From 159796b046c5ba985e0c967a885ba117527608bf Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 10 Nov 2026 11:09:49 +0100 Subject: [PATCH 1/2] fix(replay): Measure gesture batch durations on a monotonic clock Touch moves are batched and sent as one RRWebInteractionMoveEvent every 500ms. Both the batch timer and each position's timeOffset subtracted wall-clock readings, so a clock step corrupted them: a backward step held the batch back until the clock caught up, and dropped it if the finger lifted first; a forward step sent it at once with offsets stretched by the step. Both now come from one Stopwatch on the converter's MonotonicTicker, started with the first batched position. Without a clock step the output is unchanged. The event timestamps stay on the wall clock; they move to an AnchoredClock in v9. Fixes JAVA-830 Fixes #6523 Co-Authored-By: Claude Opus 5.5 --- .../replay/gestures/ReplayGestureConverter.kt | 20 +++++--- .../gestures/ReplayGestureConverterTest.kt | 50 +++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt index c348178e9d2..91a849627ea 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt @@ -9,6 +9,7 @@ import io.sentry.rrweb.RRWebInteractionMoveEvent import io.sentry.rrweb.RRWebInteractionMoveEvent.Position import io.sentry.time.Deadline import io.sentry.time.MonotonicTicker +import io.sentry.time.Stopwatch import io.sentry.transport.ICurrentDateProvider import java.util.concurrent.TimeUnit.MILLISECONDS @@ -24,7 +25,9 @@ internal class ReplayGestureConverter( } private val currentPositions = LinkedHashMap>(10) - private var touchMoveBaseline = 0L + + /** Started with the first position in [currentPositions], or `null` while none is batched. */ + private var moveBatch: Stopwatch? = null /** When the next move event may be captured, or `null` before the first one. */ private var moveDebounce: Deadline? = null @@ -42,6 +45,9 @@ internal class ReplayGestureConverter( } moveDebounce = Deadline.after(ticker, TOUCH_MOVE_DEBOUNCE_THRESHOLD, MILLISECONDS) + // read once, so all pointers of this event share one offset + val totalOffset = moveBatch?.elapsed(MILLISECONDS) ?: 0L + currentPositions.keys.forEach { pId -> val pIndex = event.findPointerIndex(pId) @@ -51,8 +57,8 @@ internal class ReplayGestureConverter( } // idk why but rrweb does it like dis - if (touchMoveBaseline == 0L) { - touchMoveBaseline = now + if (moveBatch == null) { + moveBatch = Stopwatch.started(ticker) } currentPositions[pId]!! += @@ -60,12 +66,12 @@ internal class ReplayGestureConverter( x = event.getX(pIndex) * recorderConfig.scaleFactorX y = event.getY(pIndex) * recorderConfig.scaleFactorY id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE - timeOffset = now - touchMoveBaseline + timeOffset = totalOffset } } - val totalOffset = now - touchMoveBaseline - return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { + // with nothing batched there is nothing to wait for + return if (moveBatch == null || totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { val moveEvents = ArrayList(currentPositions.size) for ((pointerId, positions) in currentPositions) { if (positions.isNotEmpty()) { @@ -81,7 +87,7 @@ internal class ReplayGestureConverter( currentPositions[pointerId]!!.clear() } } - touchMoveBaseline = 0L + moveBatch = null moveEvents } else { null diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt index a396a62f93a..9e8c65bc75a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt @@ -2,6 +2,7 @@ package io.sentry.android.replay.gestures import android.view.MotionEvent import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.rrweb.RRWebInteractionEvent import io.sentry.rrweb.RRWebInteractionMoveEvent @@ -141,6 +142,55 @@ class ReplayGestureConverterTest { moveEvent.recycle() } + @Test + fun `wall clock stepping backwards during a swipe does not hold back the batch`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val downEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0) + val moveEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 110f, 210f, 0) + sut.convert(downEvent, recorderConfig) + assertThat(sut.convert(moveEvent, recorderConfig)).isNull() + + // an NTP correction drags the wall clock back mid-swipe + fixture.now -= 1_000 + fixture.advance(300) + assertThat(sut.convert(moveEvent, recorderConfig)).isNull() + + fixture.advance(300) + val result = sut.convert(moveEvent, recorderConfig) + + assertThat(result).hasSize(1) + val batch = result!!.single() as RRWebInteractionMoveEvent + assertThat(batch.positions!!.map { it.timeOffset }).containsExactly(-600L, -300L, 0L).inOrder() + + downEvent.recycle() + moveEvent.recycle() + } + + @Test + fun `wall clock stepping forwards during a swipe does not send the batch early`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val downEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0) + val moveEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 110f, 210f, 0) + sut.convert(downEvent, recorderConfig) + assertThat(sut.convert(moveEvent, recorderConfig)).isNull() + + fixture.now += 60_000 + fixture.advance(300) + assertThat(sut.convert(moveEvent, recorderConfig)).isNull() + + fixture.advance(300) + val result = sut.convert(moveEvent, recorderConfig) + + assertThat(result).hasSize(1) + val batch = result!!.single() as RRWebInteractionMoveEvent + assertThat(batch.positions!!.map { it.timeOffset }).containsExactly(-600L, -300L, 0L).inOrder() + + downEvent.recycle() + moveEvent.recycle() + } + @Test fun `convert ACTION_UP event`() { val sut = fixture.getSut() From 384b02f3584a2d35937ed871625aff949643b9a7 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 10 Nov 2026 11:10:40 +0100 Subject: [PATCH 2/2] changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ddc5b5d7bf..e632f77d443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Stop blocking the main thread while the frame metrics thread starts when the first activity transaction begins ([#6500](https://github.com/getsentry/sentry-java/pull/6500)) - On a busy device during cold start this could stall the launcher activity's `onCreate` for hundreds of milliseconds. - Do not inspect the response body of streaming Apollo 3 and 4 responses (`multipart/mixed`, `text/event-stream`) for failed requests, which blocked `@defer` and `@stream` ([#6432](https://github.com/getsentry/sentry-java/pull/6432)) +- Session Replay: Measure how long touch moves are batched on a monotonic clock, so a device time change during a swipe no longer drops its positions, delays them, or stretches their timing ([#6538](https://github.com/getsentry/sentry-java/pull/6538)) ### Dependencies