Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -24,7 +25,9 @@ internal class ReplayGestureConverter(
}

private val currentPositions = LinkedHashMap<Int, ArrayList<Position>>(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
Expand All @@ -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)

Expand All @@ -51,21 +57,21 @@ internal class ReplayGestureConverter(
}

// idk why but rrweb does it like dis
if (touchMoveBaseline == 0L) {
touchMoveBaseline = now
Comment thread
runningcode marked this conversation as resolved.
Resolved
if (moveBatch == null) {
moveBatch = Stopwatch.started(ticker)
}

currentPositions[pId]!! +=
Position().apply {
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<RRWebInteractionMoveEvent>(currentPositions.size)
for ((pointerId, positions) in currentPositions) {
if (positions.isNotEmpty()) {
Expand All @@ -81,7 +87,7 @@ internal class ReplayGestureConverter(
currentPositions[pointerId]!!.clear()
}
}
touchMoveBaseline = 0L
moveBatch = null
moveEvents
} else {
null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading