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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
- 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 no longer drops or freezes video when the device clock changes mid-recording ([#6542](https://github.com/getsentry/sentry-java/pull/6542))
- The replay shows the change once, as a gap or an overlap, and breadcrumbs and errors stay aligned with the video.
- A forward change longer than the session duration (1h) ends a full-session recording, as reaching that duration does. When recording only for errors, what was recorded before such a change is dropped, so the replay of a later error starts after it.

### Dependencies

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ 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.AnchoredClock
import io.sentry.time.Deadline
import io.sentry.time.MonotonicTicker
import io.sentry.time.Timestamp
import io.sentry.transport.ICurrentDateProvider
import io.sentry.util.AutoClosableReentrantLock
import java.io.File
import java.util.ArrayDeque
import java.util.Date
import java.util.Deque
import java.util.concurrent.ConcurrentLinkedDeque
Expand All @@ -56,20 +60,45 @@ internal abstract class BaseCaptureStrategy
constructor(
private val options: SentryOptions,
private val scopes: IScopes?,
// TODO [v9]: We should consider replacing this with AnchoredClock in V9.
// TODO [v9]: Remove ICurrentDateProvider and anchor on options.epochClock (JAVA-827).
dateProvider: ICurrentDateProvider,
private val ticker: MonotonicTicker,
protected val replayExecutor: ScheduledExecutorService,
protected val persistingExecutor: ScheduledExecutorService,
private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null,
/** The timeline of the recording this strategy takes over, if it continues one. */
continuedTimeline: AnchoredClock? = null,
) : CaptureStrategy {
internal companion object {
private const val TAG = "CaptureStrategy"
// https://github.com/getsentry/sentry-javascript/blob/30eb68fff5077211c30c61ba74625e66ab514870/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts#L41
private const val MAX_CONTEXT_VALUES = 100
}

private val gestureConverter = ReplayGestureConverter(dateProvider, ticker)
protected val clock =
ReplayClock(
{ Timestamp.ofEpochNanos(DateUtils.millisToNanos(dateProvider.currentTimeMillis)) },
ticker,
continuedTimeline,
)

/**
* Makes moving onto a new timeline atomic: checking the wall clock, restarting the clock and
* draining [currentEvents] for the segment that closes the old timeline happen together. Touch
* events are stamped and added under it too. So two frames can't both follow one jump, and every
* touch event stamped on the old timeline is drained while every one stamped on the new one
* stays.
*/
private val timelineLock = AutoClosableReentrantLock()

/**
* The timeline the work queued on [replayExecutor] is on: the frames it stored and the segment it
* is building. Only [stampFrame] restarts the clock, and that frame's task moves this onto the
* new timeline, so the two differ only while such a task is queued.
*/
@Volatile protected var executorTimeline: AnchoredClock = clock.timeline

private val gestureConverter = ReplayGestureConverter({ clock.nowMillis() }, ticker)

protected val isTerminating = AtomicBoolean(false)
protected var cache: ReplayCache? = null
Expand Down Expand Up @@ -132,7 +161,7 @@ constructor(
this.currentSegment = segmentId
this.replayType = replayType ?: (if (this is SessionCaptureStrategy) SESSION else BUFFER)

segmentTimestamp = DateUtils.getCurrentDateTime()
segmentTimestamp = DateUtils.getDateTime(clock.nowMillis())
replayDeadline = Deadline.after(ticker, options.sessionReplay.sessionDuration, MILLISECONDS)
// the same values MainEventProcessor applies to current-run segments
recordingMetadata =
Expand All @@ -150,7 +179,9 @@ constructor(
}

override fun resume() {
val resumedAt = DateUtils.getCurrentDateTime()
// not re-anchored here: that would make every segment request until the task below runs look
// like it raced a jump. The first frame after a jump while paused follows it like any other.
val resumedAt = DateUtils.getDateTime(clock.nowMillis())
// Keep the timeline update behind queued frames and pause/flush segment creation.
replayExecutor.submit(ReplayRunnable("$TAG.resume") { segmentTimestamp = resumedAt })
}
Expand Down Expand Up @@ -216,11 +247,38 @@ constructor(
this.recorderConfig = recorderConfig
}

/**
* Stamps a frame captured now. If the wall clock jumped since the previous frame, the recording
* first moves onto a new timeline anchored on it, unless the jump is forward by more than
* [maxForwardJumpMillis].
*/
protected fun stampFrame(maxForwardJumpMillis: Long = Long.MAX_VALUE): StampedFrame =
timelineLock.acquire().use {
val jumpMillis = clock.wallClockJumpMillis()
if (jumpMillis == 0L || jumpMillis > maxForwardJumpMillis) {
StampedFrame(clock.nowMillis(), jumpedTooFar = jumpMillis > maxForwardJumpMillis)
} else {
val oldTimeline = clock.timeline
val oldTimelineEnd = oldTimeline.nowMillis()
val newTimeline = clock.restart()
val oldEvents = ArrayDeque<RRWebEvent>()
var event = currentEvents.pollFirst()
while (event != null) {
oldEvents.add(event)
event = currentEvents.pollFirst()
}
val jump = TimelineJump(oldTimeline, oldTimelineEnd, newTimeline, oldEvents)
StampedFrame(jump.newTimelineStart, jump)
}
}

override fun onTouchEvent(event: MotionEvent) {
recorderConfig?.let { config ->
val rrwebEvents = gestureConverter.convert(event, config)
if (rrwebEvents != null) {
currentEvents += rrwebEvents
timelineLock.acquire().use {
val rrwebEvents = gestureConverter.convert(event, config)
if (rrwebEvents != null) {
currentEvents += rrwebEvents
}
}
}
}
Expand All @@ -245,6 +303,46 @@ constructor(
}
}

protected class StampedFrame(
val timestamp: Long,
/** Set when the recording moved onto a new timeline at this frame. */
val jump: TimelineJump? = null,
/** The wall clock jumped forward too far to follow, so the frame stayed on the old timeline. */
val jumpedTooFar: Boolean = false,
)

/**
* Where the old timeline ended and the new one starts, and the touch events stamped on the old
* one, which belong to the segment that closes it.
*/
protected class TimelineJump(
val oldTimeline: AnchoredClock,
val oldTimelineEnd: Long,
val newTimeline: AnchoredClock,
val oldEvents: Deque<RRWebEvent>,
) {
val newTimelineStart: Long = newTimeline.startTime().epochMillis
}

/**
* The times a segment request reads when it is made, so a busy executor doesn't stretch the
* segment. If a frame moved the recording onto a new timeline in between, what the request read
* and the frames its task finds are on different timelines, so the task reads again with [read]
* on the timeline the queued work is on.
*/
protected inner class SegmentRequest<T : Any>(private val read: (AnchoredClock) -> T) {
// null while the task of a frame that followed a jump is still queued: the clock is on the new
// timeline already, the frames are not
private val timeline = clock.timeline.takeIf { it === executorTimeline }
private val requested = timeline?.let(read)

/** Called from the request's task: the times to use, and the timeline they are on. */
fun resolve(): Pair<T, AnchoredClock> {
val current = executorTimeline
return (requested?.takeIf { timeline === current } ?: read(current)) to current
}
}

private inline fun <T> persistableAtomicNullable(
initialValue: T? = null,
propertyName: String,
Expand Down
Loading
Loading