Skip to content

fix(android): Anchor the frame-metrics time projection (JAVA-579) - #6098

Draft
runningcode wants to merge 2 commits into
mainfrom
no/java-579-c5-frame-metrics-anchor
Draft

fix(android): Anchor the frame-metrics time projection (JAVA-579)#6098
runningcode wants to merge 2 commits into
mainfrom
no/java-579-c5-frame-metrics-anchor

Conversation

@runningcode

@runningcode runningcode commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

📜 Description

SpanFrameMetricsCollector matches spans against frames, and the frame timeline is stamped by Choreographer on System.nanoTime(). A span carrying a SentryNanotimeDate is already on that timeline and needs no conversion. A wall-stamped span — an app start span built from TimeSpan, which is a SentryLongDate — has to be projected onto it, and toNanoTime did that by reading the offset between the wall clock and System.nanoTime() at the moment the span finished:

long nowUnixInNanos = DateUtils.millisToNanos(System.currentTimeMillis());
long shiftInNanos = nowUnixInNanos - date.nanoTimestamp();
return System.nanoTime() - shiftInNanos;

That offset is not a constant. A wall-clock step (NTP, carrier, user) moves one clock, and time spent suspended moves the other, because System.nanoTime() stops in suspend on Android while the wall clock keeps going. Reading it at finish time therefore charged the span for every step and every suspend that happened since it started, placing it — and the frames_slow / frames_frozen / frames_delay data derived from it — away from the frames it actually overlapped. An app start span that outlives a doze is off by the length of the doze.

This takes one wall reading and one tick at construction and projects through that fixed anchor instead, so a span lands where it did when the SDK started rather than where the accumulated drift puts it.

Note the anchor deliberately uses JavaMonotonicTicker (System.nanoTime()) rather than options.getMonotonicTicker(), which is elapsedRealtimeNanos() on Android. The frame timeline is nanoTime, so the anchor has to be on the same base; there is a comment saying so at the call site.

💡 Motivation and Context

Audit finding §C5 from the clock-usage audit.

💚 How did you test it?

A unit test in SpanFrameMetricsCollectorTest drives a wall-stamped (SentryLongDate) span with one slow frame inside it, advances the frame ticker by an hour between the span starting and finishing, and asserts the frame is still attributed to the span. Verified that this test fails against the old toNanoTime body and passes with the anchor. The existing 16 tests in the class are unchanged and still pass — they all use SentryNanotimeDate spans, which take the untouched fast path.

The projection is now injectable (a package-private constructor taking an EpochClock and a MonotonicTicker), which is what makes the drift testable at all — previously both clock reads were static calls inside the method.

📝 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.

🔗 Relationship to #6055

#6055 rewrites this same method, so the two will conflict textually — but they fix different branches, and this one is not superseded by it.

#6055 splits toNanoTime on whether the span has an anchor. Anchored spans invert the anchor exactly; everything else falls through to the old wall-clock projection, kept verbatim, live offset read and all. Its own comment names "an app-start projection" as a case that lands in that fallback, which is precisely the SentryLongDate-from-TimeSpan span this PR is about. So after #6055 the bug fixed here is still present, on the only path app-start spans take.

The other direction matters too: #6055's anchored branch inverts a tick on MonotonicTicker, which is CLOCK_BOOTTIME on Android, while frames are CLOCK_MONOTONIC — a new timebase mismatch that it documents as an unbridged TODO. This PR avoids that by anchoring on JavaMonotonicTicker (System.nanoTime()), the same timebase as the frames.

Suggested order: this lands first (targets main, changes no serialized value, mergeable now), and #6055 — a DO-NOT-MERGE demonstrator with no 9.x branch to target yet — carries the frozen anchor into its fallback branch when it rebases.

Worth noting that #6055's TODO argues a span that crossed deep sleep should be skipped rather than shifted, since there are no frames during sleep. That is a reasonable position, and a different one from this PR's, which narrows the error rather than eliminating it: the frozen anchor puts the span's start where it belongs, but the projected end of a doze-crossing span still overshoots by the length of the doze. Dropping such launches outright is JAVA-642's call.

🔮 Next steps

This does not change any serialized timestamp — only which frames a span is credited with — so it is not gated behind the v9 work. §C4 (TimeSpan back-projection), which produces the wall-stamped dates this code has to project, does change serialized app-start timestamps and is v9-gated.

🤖 Generated with Claude Code

runningcode and others added 2 commits September 11, 2026 15:52
SpanFrameMetricsCollector places a span on the frame timeline, which is
stamped by Choreographer on System.nanoTime(). Spans carrying a
SentryNanotimeDate are already on that timeline, but a wall-stamped one
— an app start span built from TimeSpan — has to be projected onto it,
and toNanoTime read the offset between the two clocks at the moment the
span finished.

That offset is not a constant. A wall-clock step moves one clock, and
time spent suspended moves the other, since System.nanoTime() stops in
suspend and the wall clock does not. So the projection charged the span
for every step and every suspend since it started, and an app start span
could be placed far away from the frames it actually overlapped, taking
its frames_slow/frozen/delay data with it.

Take one wall reading and one tick at construction and project through
that fixed anchor instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 11, 2026

Copy link
Copy Markdown

JAVA-579

@sentry

sentry Bot commented Sep 11, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

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

⚙️ sentry-android Build Distribution Settings

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.

1 participant