Skip to content
Draft
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixes

- Measure Session Replay's 1h recording cap and its touch-move debounce on a monotonic clock, so a device time change no longer stops a healthy recording early, keeps a finished one alive, or suppresses gesture events ([#6090](https://github.com/getsentry/sentry-java/pull/6090))

## 8.56.0

### Behavioral Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ public class ReplayIntegration(
options,
scopes,
dateProvider,
options.monotonicTicker,
replayExecutor,
persistingExecutor,
replayCacheProvider,
Expand All @@ -232,6 +233,7 @@ public class ReplayIntegration(
options,
scopes,
dateProvider,
options.monotonicTicker,
replayExecutor,
persistingExecutor,
replayCacheProvider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ import io.sentry.android.replay.gestures.ReplayGestureConverter
import io.sentry.android.replay.util.ReplayRunnable
import io.sentry.protocol.SentryId
import io.sentry.rrweb.RRWebEvent
import io.sentry.time.Deadline
import io.sentry.time.MonotonicTicker
import io.sentry.transport.ICurrentDateProvider
import java.io.File
import java.util.Date
import java.util.Deque
import java.util.concurrent.ConcurrentLinkedDeque
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit.MILLISECONDS
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
Expand All @@ -46,7 +48,9 @@ import kotlin.reflect.KProperty
internal abstract class BaseCaptureStrategy(
private val options: SentryOptions,
private val scopes: IScopes?,
private val dateProvider: ICurrentDateProvider,
// TODO [v9]: We should consider replacing this with AnchoredClock in V9.
dateProvider: ICurrentDateProvider,
private val ticker: MonotonicTicker,
protected val replayExecutor: ScheduledExecutorService,
protected val persistingExecutor: ScheduledExecutorService,
private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null,
Expand All @@ -57,7 +61,7 @@ internal abstract class BaseCaptureStrategy(
private const val MAX_CONTEXT_VALUES = 100
}

private val gestureConverter = ReplayGestureConverter(dateProvider)
private val gestureConverter = ReplayGestureConverter(dateProvider, ticker)

protected val isTerminating = AtomicBoolean(false)
protected var cache: ReplayCache? = null
Expand All @@ -79,7 +83,11 @@ internal abstract class BaseCaptureStrategy(
if (newValue == null) null else DateUtils.getTimestamp(newValue),
)
}
protected val replayStartTimestamp = AtomicLong()
/**
* When the recording reaches its `sessionDuration` cap, or `null` while nothing is being
* recorded. Only [SessionCaptureStrategy] enforces a cap; buffer mode records indefinitely.
*/
@Volatile protected var replayDeadline: Deadline? = null
protected var screenAtStart by
persistableAtomicNullable<String>(propertyName = SEGMENT_KEY_REPLAY_SCREEN_AT_START)
override var currentReplayId: SentryId by
Expand Down Expand Up @@ -109,7 +117,7 @@ internal abstract class BaseCaptureStrategy(
this.replayType = replayType ?: (if (this is SessionCaptureStrategy) SESSION else BUFFER)

segmentTimestamp = DateUtils.getCurrentDateTime()
replayStartTimestamp.set(dateProvider.currentTimeMillis)
replayDeadline = Deadline.after(ticker, options.sessionReplay.sessionDuration, MILLISECONDS)
}

override fun resume() {
Expand All @@ -125,7 +133,7 @@ internal abstract class BaseCaptureStrategy(
replayExecutor.submit(
ReplayRunnable("$TAG.stop") {
cache?.close()
replayStartTimestamp.set(0)
replayDeadline = null
segmentTimestamp = null
currentReplayId = SentryId.EMPTY_ID
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment
import io.sentry.android.replay.util.ReplayRunnable
import io.sentry.clientreport.DiscardReason.RATELIMIT_BACKOFF
import io.sentry.protocol.SentryId
import io.sentry.time.MonotonicTicker
import io.sentry.transport.ICurrentDateProvider
import io.sentry.util.FileUtils
import java.io.File
Expand All @@ -44,6 +45,7 @@ internal class BufferCaptureStrategy(
private val options: SentryOptions,
private val scopes: IScopes?,
private val dateProvider: ICurrentDateProvider,
private val ticker: MonotonicTicker,
executor: ScheduledExecutorService,
persistingExecutor: ScheduledExecutorService,
replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null,
Expand All @@ -52,6 +54,7 @@ internal class BufferCaptureStrategy(
options,
scopes,
dateProvider,
ticker,
executor,
persistingExecutor,
replayCacheProvider = replayCacheProvider,
Expand Down Expand Up @@ -173,7 +176,14 @@ internal class BufferCaptureStrategy(
// we hand over replayExecutor and persistingExecutor to the new strategy to preserve order of
// execution
val captureStrategy =
SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor, persistingExecutor)
SessionCaptureStrategy(
options,
scopes,
dateProvider,
ticker,
replayExecutor,
persistingExecutor,
)
captureStrategy.recorderConfig = recorderConfig
captureStrategy.start(
segmentId = currentSegment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.sentry.android.replay.ScreenshotRecorderConfig
import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment
import io.sentry.android.replay.util.ReplayRunnable
import io.sentry.protocol.SentryId
import io.sentry.time.MonotonicTicker
import io.sentry.transport.ICurrentDateProvider
import io.sentry.util.FileUtils
import java.util.Date
Expand All @@ -32,6 +33,7 @@ internal class SessionCaptureStrategy(
private val options: SentryOptions,
private val scopes: IScopes?,
private val dateProvider: ICurrentDateProvider,
ticker: MonotonicTicker,
executor: ScheduledExecutorService,
persistingExecutor: ScheduledExecutorService,
replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null,
Expand All @@ -40,6 +42,7 @@ internal class SessionCaptureStrategy(
options,
scopes,
dateProvider,
ticker,
executor,
persistingExecutor,
replayCacheProvider,
Expand Down Expand Up @@ -111,6 +114,7 @@ internal class SessionCaptureStrategy(
// reflecting the exact time of when it was captured
val currentConfig = recorderConfig
val frameTimestamp = dateProvider.currentTimeMillis
val deadlineExceeded = replayDeadline?.hasPassed() == true
replayExecutor.submit(
ReplayRunnable("$TAG.add_frame") {
cache?.store(frameTimestamp)
Expand Down Expand Up @@ -158,7 +162,7 @@ internal class SessionCaptureStrategy(
}
}

if (frameTimestamp - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration) {
if (deadlineExceeded) {
options.replayController.stop()
options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,27 @@ import io.sentry.rrweb.RRWebInteractionEvent
import io.sentry.rrweb.RRWebInteractionEvent.InteractionType
import io.sentry.rrweb.RRWebInteractionMoveEvent
import io.sentry.rrweb.RRWebInteractionMoveEvent.Position
import io.sentry.time.Deadline
import io.sentry.time.MonotonicTicker
import io.sentry.transport.ICurrentDateProvider
import java.util.concurrent.TimeUnit.MILLISECONDS

internal class ReplayGestureConverter(private val dateProvider: ICurrentDateProvider) {
internal class ReplayGestureConverter(
// TODO [v9]: Replace this with AnchoredClock in V9
private val dateProvider: ICurrentDateProvider,
private val ticker: MonotonicTicker,
) {
internal companion object {
// rrweb values
private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50
private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50L
private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500
}

private val currentPositions = LinkedHashMap<Int, ArrayList<Position>>(10)
private var touchMoveBaseline = 0L
private var lastCapturedMoveEvent = 0L

/** When the next move event may be captured, or `null` before the first one. */
private var moveDebounce: Deadline? = null

fun convert(
event: MotionEvent,
Expand All @@ -28,12 +37,10 @@ internal class ReplayGestureConverter(private val dateProvider: ICurrentDateProv
MotionEvent.ACTION_MOVE -> {
// we only throttle move events as those can be overwhelming
val now = dateProvider.currentTimeMillis
if (
lastCapturedMoveEvent != 0L && lastCapturedMoveEvent + TOUCH_MOVE_DEBOUNCE_THRESHOLD > now
) {
if (moveDebounce?.hasPassed() == false) {
return null
}
lastCapturedMoveEvent = now
moveDebounce = Deadline.after(ticker, TOUCH_MOVE_DEBOUNCE_THRESHOLD, MILLISECONDS)

currentPositions.keys.forEach { pId ->
val pIndex = event.findPointerIndex(pId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,7 @@ class ReplayIntegrationTest {
ICurrentDateProvider {
System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration
},
fixture.options.monotonicTicker,
// run tasks synchronously in tests
mock {
whenever(mock.submit(any<Runnable>())).doAnswer {
Expand Down Expand Up @@ -1638,6 +1639,7 @@ class ReplayIntegrationTest {
options,
null,
CurrentDateProvider.getInstance(),
options.monotonicTicker,
executor =
mock {
whenever(mock.submit(any<Runnable>())).doAnswer {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import io.sentry.android.replay.capture.BufferCaptureStrategyTest.Fixture.Compan
import io.sentry.clientreport.DiscardReason
import io.sentry.clientreport.DiscardedEvent
import io.sentry.protocol.SentryId
import io.sentry.time.TestMonotonicTicker
import io.sentry.transport.CurrentDateProvider
import io.sentry.transport.ICurrentDateProvider
import io.sentry.transport.RateLimiter
Expand Down Expand Up @@ -108,6 +109,8 @@ class BufferCaptureStrategyTest {
?.discardedEvents
.orEmpty()

val ticker = TestMonotonicTicker()

fun getSut(
dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(),
replayCacheDir: File? = null,
Expand All @@ -117,6 +120,7 @@ class BufferCaptureStrategyTest {
options,
scopes,
dateProvider,
ticker,
mock {
whenever(it.submit(any<Runnable>())).doAnswer { invocation ->
(invocation.arguments[0] as Runnable).run()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@ import io.sentry.protocol.SentryId
import io.sentry.rrweb.RRWebBreadcrumbEvent
import io.sentry.rrweb.RRWebMetaEvent
import io.sentry.rrweb.RRWebOptionsEvent
import io.sentry.time.TestMonotonicTicker
import io.sentry.transport.CurrentDateProvider
import io.sentry.transport.ICurrentDateProvider
import java.io.File
import java.util.Date
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit.MILLISECONDS
import java.util.concurrent.TimeUnit.SECONDS
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
Expand Down Expand Up @@ -108,6 +111,8 @@ class SessionCaptureStrategyTest {
bitRate = 20_000,
)

val ticker = TestMonotonicTicker()

fun getSut(
dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(),
replayCacheDir: File? = null,
Expand All @@ -125,6 +130,7 @@ class SessionCaptureStrategyTest {
options,
scopes,
dateProvider,
ticker,
replayExecutor,
mock {
doAnswer { invocation ->
Expand Down Expand Up @@ -447,6 +453,37 @@ class SessionCaptureStrategyTest {
strategy.start()
strategy.onConfigurationChanged(mock<ScreenshotRecorderConfig>())
now += fixture.options.sessionReplay.sessionDuration * 2
fixture.ticker.advance(fixture.options.sessionReplay.sessionDuration * 2, MILLISECONDS)

strategy.onScreenshotRecorded(mock<Bitmap>()) {}

verify(fixture.options.replayController).stop()
}

@Test
fun `onScreenshotRecorded does not stop replay when the wall clock jumps past the deadline`() {
var now = System.currentTimeMillis()
val strategy = fixture.getSut(dateProvider = { now })
strategy.start()
strategy.onConfigurationChanged(mock<ScreenshotRecorderConfig>())
// the device corrects its clock two hours forwards a second into the recording
now += fixture.options.sessionReplay.sessionDuration * 2
fixture.ticker.advance(1, SECONDS)

strategy.onScreenshotRecorded(mock<Bitmap>()) {}

verify(fixture.options.replayController, never()).stop()
}

@Test
fun `onScreenshotRecorded stops replay when the wall clock steps backwards past the deadline`() {
var now = System.currentTimeMillis()
val strategy = fixture.getSut(dateProvider = { now })
strategy.start()
strategy.onConfigurationChanged(mock<ScreenshotRecorderConfig>())
// an hour of recording, during which the device drags its clock back to before the start
fixture.ticker.advance(fixture.options.sessionReplay.sessionDuration, MILLISECONDS)
now -= 1000

strategy.onScreenshotRecorded(mock<Bitmap>()) {}

Expand Down
Loading
Loading