Skip to content

Session Replay segment, buffer and gesture timestamps read the wall clock #6522

Open

Description

@runningcode

Follow-up to #5578. #6090 moved the 1h session cap and the 50ms move debounce onto the monotonic clock. Everything else #5578 listed still reads the wall clock.

What still reads the wall clock

  • Segment boundary. SessionCaptureStrategy cuts a segment when frameTimestamp - segmentTimestamp reaches 5s.
  • Buffer trim. BufferCaptureStrategy keeps the last 30s with now - errorReplayDuration. It computes the buffered segment's duration as now - firstFrame.
  • Frame windows. ReplayCache.createVideoOf and rotate walk epoch-millis windows. Frame files are named by epoch millis.
  • Gesture timestamps. ReplayGestureConverter stamps touch start, end, cancel and move events with the wall clock. The gesture batch timer and timeOffset durations are split out to Session Replay gesture batching measures durations with the wall clock #6523, which lands before v9.

What goes wrong

A backward step mid-recording makes new frames look older than the segment they belong to. They fall outside the window, and rotate deletes them until the wall clock catches back up. Breadcrumbs from that stretch are dropped the same way. A forward step cuts a 5s segment of one frozen frame on every captured frame until the segment start catches up, so a 2-minute step sends about 24 frozen segments in 30s. Buffer mode fails too: a backward step makes the buffered duration negative, and a forward step rotates out the whole buffer.

Not a breaking change

Lands on main (decided on 2026-11-10), as a one-off exception to the project's rule that timestamps wait for v9. Today's behavior is a live bug, and the timestamps sent only change after a clock jump.

Checked on main on 2026-11-09:

  • Session counts. Replay doesn't start, end or time release-health sessions. LifecycleWatcher decides, and replay follows. Crash-free rate and replay counts can't move.
  • Public API. Every public replay method that carries time takes epoch millis as a Long. An AnchoredClock still produces epoch millis. The ICurrentDateProvider constructor parameter must stay, because sentry-dart calls the four-argument ReplayIntegration constructor.
  • Metrics. On Android the ticker counts deep sleep, like the wall clock. With no clock step the output is the same. After a step it gets more accurate.

Decision: one anchor per recording that re-anchors on a clock jump

Decided on 2026-11-10.

  • Anchor an AnchoredClock at start(), which reads the wall clock today, so no value changes there. Only a captured frame re-anchors: resume() reuses the current anchor, and the first frame after resume follows a jump made while paused, like any other jump. Re-anchoring at resume() would make every request queued behind it look like it raced a jump.
  • On every captured frame, compare a fresh wall-clock reading with the anchored time. If they differ by more than 1s, close the current segment at the old timeline's "now" and start the next segment on a new anchor.
  • With no jump, every value sent is the same as today, apart from rounding of up to 1ms.
  • After a jump, no frames are lost, and breadcrumbs, errors and spans stay aligned with the video to within one frame. The jump shows up once: a gap after a forward jump, an overlap after a backward one.

Rejected:

  • One anchor, never re-anchored: breadcrumbs, errors and spans stay offset for up to an hour.
  • New anchor at each segment boundary: changes values even without a jump, and the player freezes briefly every 5s.
  • Project only breadcrumbs onto the replay clock: errors and spans stay offset.
  • End the replay on a jump: splits one session into two replays.

Also decided:

  • After a backward jump the next segment overlaps the previous one, and the player's video lags its timeline until the user seeks. Accepted; the player fix is a separate frontend request.
  • A forward jump longer than the 1h session cap stops a session recording, as hitting the cap does. In buffer mode it drops the buffered video from before the jump and keeps recording, so a replay never spans a gap of hours or years.
  • Requests already queued when the clock re-anchors (pause, flush, configuration change, error) recompute from the new timeline, so no segment mixes two timelines.
  • The jump tolerance is 1s, just under the player's 1.1s "Video Unavailable" marker.
  • The jump detector stays internal to sentry-android-replay.
  • ReplayIntegration's ICurrentDateProvider stays and becomes the anchor's wall clock, so no constructor changes.
  • After a jump in buffer mode, crash recovery only gets the frames from after the jump.

Done when

Agent notes

Verified on main at 5652060246 (2026-11-09).

  • Segment boundary: SessionCaptureStrategy.kt:144. Frame stamp: :117.
  • Buffer: BufferCaptureStrategy.kt:138 (frame stamp), :143 (rotate on frame), :199 (onTouchEvent → rotateEvents), :263-269 (segment start and duration).
  • ReplayCache.kt: :81 (file name), :179-189 (createVideoOf window loop), :220 (rotate(until = from + duration)), :260 (rotate), :442 (fromDisk parses the filename into the timestamp).
  • ReplayGestureConverter.kt: :74 (move event timestamp), :105, :128, :142 (touch start, end and cancel timestamps). The batch timer and timeOffset (:39, :63, :67-68) are Session Replay gesture batching measures durations with the wall clock #6523.
  • Breadcrumb window filter: CaptureStrategy.kt:209-212. Gesture event filter: :235-236. Breadcrumb() stamps System.currentTimeMillis() (Breadcrumb.java:567). maxBreadcrumbs defaults to 100.
  • TODO [v9] markers: BaseCaptureStrategy.kt:59, ReplayGestureConverter.kt:16.
  • AnchoredClock (sentry/src/main/java/io/sentry/time/AnchoredClock.java) has create(EpochClock, MonotonicTicker), startTime(), now(). No drift check.
  • Clocks: options.monotonicTicker (Android: elapsedRealtimeNanos) is already passed to both strategies (ReplayIntegration.kt:236-247). Epoch source: options.epochClock.
  • Public timestamp APIs in sentry-android-replay.api: ReplayCache.addFrame(File, long, String), ReplayCache.createVideoOf(long, long, …), GeneratedVideo(File, int, long), ReplayIntegration.onScreenshotRecorded(File, long).
  • Hybrid callers: sentry-dart SentryFlutterPlugin.kt (around line 293) calls ReplayIntegration(ctx, dateProvider = CurrentDateProvider.getInstance(), recorderProvider, replayCacheProvider = null). android_replay_recorder.dart calls onScreenshotRecorded(bitmap), with no timestamp. sentry-react-native's Android code only imports DefaultReplayBreadcrumbConverter. Unity and KMP were not checked (GitHub code search rate limit).
  • Gotchas from fix(replay): Measure recording deadlines on a monotonic clock (JAVA-575) #6090: evaluate time checks when the frame is captured, not in the queued replayExecutor task (SessionCaptureStrategyTest: "executor delay does not trigger a segment boundary"). Gesture tests that step the clock by more than ~500ms hit CAPTURE_MOVE_EVENT_THRESHOLD instead of the debounce.
  • Code plan (2026-11-10): new internal capture/ReplayClock.kt holding a volatile AnchoredClock and a generation counter, with restart(), nowMillis() and checkForStep(): Rebase(oldNowMs, newStartMs)?. Session strategy checks at capture (:117) and, on a rebase, closes [segmentTimestamp, oldNow) before storing the frame. Buffer strategy computes the trim cutoff at capture and ages buffered segments on a Stopwatch. The gesture converter gets ICurrentDateProvider { replayClock.nowMillis() } at BaseCaptureStrategy.kt:72.

Activity

  1. self-assigned this
    on Nov 9, 2026
  2. linear-code commented on Nov 9, 2026

    @linear-code
  3. changed the title [-]Session Replay segment, buffer and gesture timing reads the wall clock[/-] [+]Session Replay segment, buffer and gesture timestamps read the wall clock[/+] on Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions