Skip to content

Commit 42fb799

Browse files
runningcodeclaude
andauthored
fix(replay): Measure gesture batch durations on a monotonic clock (JAVA-830) (#6538)
* 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 <noreply@anthropic.com> * changelog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 842b197 commit 42fb799

3 files changed

Lines changed: 64 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
- 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))
4040
- On a busy device during cold start this could stall the launcher activity's `onCreate` for hundreds of milliseconds.
4141
- 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))
42+
- 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))
4243

4344
### Dependencies
4445

‎sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import io.sentry.rrweb.RRWebInteractionMoveEvent
99
import io.sentry.rrweb.RRWebInteractionMoveEvent.Position
1010
import io.sentry.time.Deadline
1111
import io.sentry.time.MonotonicTicker
12+
import io.sentry.time.Stopwatch
1213
import io.sentry.transport.ICurrentDateProvider
1314
import java.util.concurrent.TimeUnit.MILLISECONDS
1415

@@ -24,7 +25,9 @@ internal class ReplayGestureConverter(
2425
}
2526

2627
private val currentPositions = LinkedHashMap<Int, ArrayList<Position>>(10)
27-
private var touchMoveBaseline = 0L
28+
29+
/** Started with the first position in [currentPositions], or `null` while none is batched. */
30+
private var moveBatch: Stopwatch? = null
2831

2932
/** When the next move event may be captured, or `null` before the first one. */
3033
private var moveDebounce: Deadline? = null
@@ -42,6 +45,9 @@ internal class ReplayGestureConverter(
4245
}
4346
moveDebounce = Deadline.after(ticker, TOUCH_MOVE_DEBOUNCE_THRESHOLD, MILLISECONDS)
4447

48+
// read once, so all pointers of this event share one offset
49+
val totalOffset = moveBatch?.elapsed(MILLISECONDS) ?: 0L
50+
4551
currentPositions.keys.forEach { pId ->
4652
val pIndex = event.findPointerIndex(pId)
4753

@@ -51,21 +57,21 @@ internal class ReplayGestureConverter(
5157
}
5258

5359
// idk why but rrweb does it like dis
54-
if (touchMoveBaseline == 0L) {
55-
touchMoveBaseline = now
60+
if (moveBatch == null) {
61+
moveBatch = Stopwatch.started(ticker)
5662
}
5763

5864
currentPositions[pId]!! +=
5965
Position().apply {
6066
x = event.getX(pIndex) * recorderConfig.scaleFactorX
6167
y = event.getY(pIndex) * recorderConfig.scaleFactorY
6268
id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE
63-
timeOffset = now - touchMoveBaseline
69+
timeOffset = totalOffset
6470
}
6571
}
6672

67-
val totalOffset = now - touchMoveBaseline
68-
return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) {
73+
// with nothing batched there is nothing to wait for
74+
return if (moveBatch == null || totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) {
6975
val moveEvents = ArrayList<RRWebInteractionMoveEvent>(currentPositions.size)
7076
for ((pointerId, positions) in currentPositions) {
7177
if (positions.isNotEmpty()) {
@@ -81,7 +87,7 @@ internal class ReplayGestureConverter(
8187
currentPositions[pointerId]!!.clear()
8288
}
8389
}
84-
touchMoveBaseline = 0L
90+
moveBatch = null
8591
moveEvents
8692
} else {
8793
null

‎sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.sentry.android.replay.gestures
22

33
import android.view.MotionEvent
44
import androidx.test.ext.junit.runners.AndroidJUnit4
5+
import com.google.common.truth.Truth.assertThat
56
import io.sentry.android.replay.ScreenshotRecorderConfig
67
import io.sentry.rrweb.RRWebInteractionEvent
78
import io.sentry.rrweb.RRWebInteractionMoveEvent
@@ -141,6 +142,55 @@ class ReplayGestureConverterTest {
141142
moveEvent.recycle()
142143
}
143144

145+
@Test
146+
fun `wall clock stepping backwards during a swipe does not hold back the batch`() {
147+
val sut = fixture.getSut()
148+
val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f)
149+
val downEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0)
150+
val moveEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 110f, 210f, 0)
151+
sut.convert(downEvent, recorderConfig)
152+
assertThat(sut.convert(moveEvent, recorderConfig)).isNull()
153+
154+
// an NTP correction drags the wall clock back mid-swipe
155+
fixture.now -= 1_000
156+
fixture.advance(300)
157+
assertThat(sut.convert(moveEvent, recorderConfig)).isNull()
158+
159+
fixture.advance(300)
160+
val result = sut.convert(moveEvent, recorderConfig)
161+
162+
assertThat(result).hasSize(1)
163+
val batch = result!!.single() as RRWebInteractionMoveEvent
164+
assertThat(batch.positions!!.map { it.timeOffset }).containsExactly(-600L, -300L, 0L).inOrder()
165+
166+
downEvent.recycle()
167+
moveEvent.recycle()
168+
}
169+
170+
@Test
171+
fun `wall clock stepping forwards during a swipe does not send the batch early`() {
172+
val sut = fixture.getSut()
173+
val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f)
174+
val downEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0)
175+
val moveEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 110f, 210f, 0)
176+
sut.convert(downEvent, recorderConfig)
177+
assertThat(sut.convert(moveEvent, recorderConfig)).isNull()
178+
179+
fixture.now += 60_000
180+
fixture.advance(300)
181+
assertThat(sut.convert(moveEvent, recorderConfig)).isNull()
182+
183+
fixture.advance(300)
184+
val result = sut.convert(moveEvent, recorderConfig)
185+
186+
assertThat(result).hasSize(1)
187+
val batch = result!!.single() as RRWebInteractionMoveEvent
188+
assertThat(batch.positions!!.map { it.timeOffset }).containsExactly(-600L, -300L, 0L).inOrder()
189+
190+
downEvent.recycle()
191+
moveEvent.recycle()
192+
}
193+
144194
@Test
145195
fun `convert ACTION_UP event`() {
146196
val sut = fixture.getSut()

0 commit comments

Comments
 (0)