Skip to content

fix(replay): Keep the replay timeline anchored across wall-clock jumps (JAVA-829) - #6542

Draft
runningcode wants to merge 5 commits into
mainfrom
no/java-829-replay-anchored-clock
Draft

runningcode wants to merge 5 commits into
mainfrom
no/java-829-replay-anchored-clock

Conversation

@runningcode

@runningcode runningcode commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

📜 Description

Session Replay read the wall clock for every frame stamp, segment boundary, buffer trim and gesture stamp, so a device clock change mid-recording broke it:

  • Backward: frames were dropped until the clock caught up.
  • Forward: every frame cut a 5s segment of one frozen frame until the segment start caught up.
  • Buffer mode: negative durations, or the whole buffer rotated out.

Each recording now stamps on one AnchoredClock, held by a new internal ReplayClock in capture/. On every captured frame the strategy compares a fresh wall-clock reading with the timeline. Within 1s nothing changes. Past that:

  • The current segment closes where the old timeline ended. Session mode captures it; buffer mode buffers it.
  • The next segment starts on a new anchor at the wall clock. Breadcrumbs, errors and spans, which the rest of the SDK still stamps from the wall clock, line up with the video again.
  • Touch events stamped on the old timeline go into the closing segment.

The jump shows once in the replay: as a gap when the clock moved forward, as an overlap when it moved back. Stamps stay epoch millis, so the frame cache on disk and crash recovery work as before.

Without a jump, every serialized value is what the wall clock gives today. A value can come out up to 1ms early, because the anchor drops its sub-millisecond part.

Details worth a look

  • Forward jump longer than sessionDuration:
    • Session mode does not follow it. The recording stays on its timeline and stops the way it does at the 1h cap. The existing test does not stop replay when the wall clock jumps past the deadline is replaced by one that expects the stop.
    • Buffer mode follows it, but drops everything recorded before it: the frames, the touch events and the segments already buffered, whose videos are deleted. Otherwise an error soon after would send one replay spanning hours or years. Recording continues, so a later error sends only video from after the jump, starting at segment 0.
  • Segment requests (pause, flush, stop, configuration change, error flush) read their times when they are made, so a busy executor doesn't stretch the segment. SegmentRequest in BaseCaptureStrategy keeps those reads, unless a frame moved the recording onto a new timeline in between. In that case the task reads them again on the timeline its queued work is on.
    • This relies on executorTimeline, the timeline the replay executor's work is on. The task of the frame that followed a jump moves it onto the new timeline, as the last thing it does.
    • A request is re-read when the clock was already ahead of executorTimeline at request time, or when executorTimeline moved before the task ran. Without a jump nothing is re-read, so every value stays the request-time read it is today.
    • This closes two races: a session request queued behind the jump's task (after a backward jump it produced a frozen segment minutes long), and a buffer request made before the jump's frame task ran (after a forward jump it stretched one frame over the whole jump).
  • resume() no longer re-anchors. Otherwise every request made until its queued task ran would look like it raced a jump and be re-read. A jump while paused is followed by the first frame after resume, like any other jump; the segment it closes is empty in a real cache.
  • convert() hands the buffer's timeline to the session strategy. The flushed buffer segments and the session's first segment share one timeline, and a jump no frame has seen yet is still caught by the session's first frame.
  • Buffered segments age on the timeline they were stamped on, so they expire after errorReplayDuration of elapsed time whichever way the clock jumped.
  • Buffer trim cutoff for frames is computed at capture, because a later frame can re-anchor before the task runs. It differs from today only when the executor has a backlog.

Concurrency

  • Threads: frames arrive on the screenshot threads: the background handler, the main thread for repeated frames, or the replay executor for PixelCopy. Touches arrive on main, and segment work runs on replayExecutor.
  • timelineLock (AutoClosableReentrantLock in BaseCaptureStrategy) makes the jump check, the re-anchor and the drain of currentEvents one step. Touch events are stamped and appended under it too. So two frames can't both follow one jump, and each touch lands on exactly one side of it.
  • Never held across submit: ReplayExecutorService.submit runs inline on the executor thread, which would mean holding the lock across segment capture. The lock is also never held across an SDK call or the logger.
  • Lock-free reads: reading the timeline is a volatile read of an immutable AnchoredClock.

Compatibility

  • No public API change; sentry-android-replay.api is unchanged.
  • Every constructor keeps its ICurrentDateProvider (sentry-dart calls the 4-arg ReplayIntegration constructor). A small adapter makes it the anchor's wall clock.
  • The TODO [v9] now names what is left: removing ICurrentDateProvider (JAVA-827).
  • ReplayGestureConverter is untouched because fix(replay): Measure gesture batch durations on a monotonic clock (JAVA-830) #6538 changes it. Its ICurrentDateProvider is now backed by the replay clock.

Not addressed here

  • The player lags on the overlap after a backward jump (a separate frontend request).
  • Buffer-mode crash recovery after a jump only gets the frames after it.
  • Caller-supplied stamps in onScreenshotRecorded(File, Long) are unchanged.
  • Frames from two screenshot threads can still be queued out of order, as they can today.

💡 Motivation and Context

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.

🔮 Next steps

  • Remove ICurrentDateProvider from the replay strategies in v9 (JAVA-827).

🤖 Generated with Claude Code

runningcode and others added 2 commits November 10, 2026 15:42
Session Replay stamped frames, cut segments, trimmed the buffer and
stamped gestures from repeated wall-clock reads. A device clock change
mid-recording then dropped frames (backward) or sent one frozen 5s
segment per frame until the segment start caught up (forward); buffer
mode got negative durations or rotated out the whole buffer.

Each recording now stamps on an AnchoredClock (ReplayClock). Every
captured frame compares it with the wall clock; past a 1s tolerance the
current segment closes where the old timeline ended and the next one
starts on a new anchor, so the jump shows once as a gap or an overlap
and breadcrumbs, errors and spans stay aligned with the video. Touch
events stamped on the old timeline go into the closing segment. A
forward jump longer than sessionDuration stops a session recording the
way the cap does. Buffered segments age on the timeline they were
stamped on. Without a jump, every serialized value matches the wall
clock as before.

Fixes GH-6522
Fixes JAVA-829

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Nov 10, 2026

Copy link
Copy Markdown

JAVA-829

runningcode and others added 3 commits November 10, 2026 16:09
…ent requests

Buffer mode now drops what it recorded before a forward wall-clock jump
longer than sessionDuration: the pre-jump frames, touch events and
already buffered segments. It keeps recording on the new timeline, so an
error soon after no longer sends one replay spanning hours or years.
Session mode already stops at the same jump.

Segment requests (pause, flush, stop, configuration change, error flush)
read their times when made, so a busy executor doesn't stretch the
segment. If a frame moved the recording onto a new timeline in between,
those times and the frames the task finds were on different timelines:
a backward jump could produce a frozen segment minutes long, and a
buffer flush right after a forward jump could stretch one frame over the
whole jump. The strategies now track the timeline their queued work is
on, and a request's task reads its times again on that timeline when it
differs from the one the request read. Without a jump nothing is re-read,
so every value stays what it was.

resume() no longer re-anchors the clock. Otherwise every request until
its queued task runs would look like it raced a jump. A jump while
paused is followed by the first frame after resume, like any other.

Refs GH-6522
Refs JAVA-829

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The function references and generateSequence pulled in
kotlin.jvm.internal.FunctionReferenceImpl and kotlin.sequences.SequencesKt,
which R8 reports as missing in the SDK size app. A lambda and a plain loop
keep replay's Kotlin stdlib footprint as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sentry

sentry Bot commented Nov 10, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.63.0 (1) release

⚙️ sentry-android Build Distribution Settings

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant