Skip to content

[DO NOT MERGE] feat(time): Anchor span timestamps on one wall-clock reading (JAVA-572) - #6055

Draft
runningcode wants to merge 1 commit into
no/java-571-deprecate-date-providersfrom
no/span-anchored-clock-demo
Draft

[DO NOT MERGE] feat(time): Anchor span timestamps on one wall-clock reading (JAVA-572)#6055
runningcode wants to merge 1 commit into
no/java-571-deprecate-date-providersfrom
no/span-anchored-clock-demo

Conversation

@runningcode

Copy link
Copy Markdown
Contributor

Warning

Do not merge. This is a demonstrator for the v9 shape of span timing. It changes a
serialized value, so it cannot land before the major, and there is no 9.x.x branch to target
yet. It is deliberately not part of the clock PR stack — it sits on top of it so the diff
shows only the integration.

Based on #6043 (the top of the stack), which brings in #6028#6045#6029#6030#6032#6041.

📜 What this shows

#6045 adds Timestamp, EpochClock and AnchoredClock with no consumer. This is the consumer.

SentryTracer takes one epoch reading when it is constructed, pins it to one monotonic tick,
and every span below it projects from that anchor instead of reading the wall clock again:

epoch(t) = anchorEpoch + (tick(t) − anchorTick)

The projection is affine with slope 1, so subtracting any two instants in a transaction is
subtracting two ticks. Serialization stops deriving anything:

-  this.timestamp = DateUtils.nanosToSeconds(
-      span.getStartDate().laterDateNanosTimestampByDiff(span.getFinishDate()));
+  this.timestamp = DateUtils.nanosToSeconds(span.endTimestamp().epochNanos());

laterDateNanosTimestampByDiff exists only to rescue a monotonic end out of two dates whose runtime
classes happen to cooperate. With one anchor there is nothing to rescue.

💡 What it removes

  • laterDateNanosTimestampByDiff from the span path — durations are monotonic by construction
  • SpanFrameMetricsCollector.toNanoTime's instanceof + wall-clock projection (the ¯\_(ツ)_/¯ one)
  • DriverSpans.computeNanoStartTimestampForChild, which collapses to anchor().now() and no longer
    silently drops to millisecond precision when the parent's date isn't the class it hoped for
  • both SentryNanotimeDate(0, 0) sentinels
  • two SentryDate allocations per span endpoint

ISpan gains startTimestamp(), endTimestamp() and anchor(). getStartDate()/getFinishDate()
stay as epoch-only views so the public API keeps working; at the major they go, and the SentryDate
overloads of finish() and updateEndDate() go with them.

Spans whose instants come from outside the process — OTel, the app-start projection, a
caller-supplied startTimestamp — report a null anchor and keep wall-clock semantics. That case
cannot be improved, because the input carries nothing but an epoch. What changes is that it is a
named branch instead of an instanceof that falls through.

⚠️ Why it waits for the major

A child span's start_timestamp moves by sub-millisecond amounts, because it is projected rather
than read. Root spans do not move — the anchor is read at root start. Small, systematic, and a
serialized value, so it sits behind the v9 gate.

🔍 Two things building this turned up

Both left open on purpose rather than half-solved:

  1. The frame timebase. Choreographer reports frames on CLOCK_MONOTONIC; the anchor's tick is
    CLOCK_BOOTTIME on Android. Bridging costs an offset that SpanFrameMetricsCollector cannot read
    from statics without becoming untestable, and it changes on every suspend — so it also needs a
    decision about what to do with a span that spanned deep sleep. There are no frames during sleep,
    so skipping such a span is more honest than shifting it. Marked TODO [MAJOR].
  2. SentryDate has no value equality. Once getStartDate() returns a fresh view rather than the
    caller's own instance, every identity comparison breaks. That accounts for most of the test diff
    here: the assertions move to Timestamp, which does have value equality. Worth knowing before the
    real migration — it is invisible until you try it.

💚 How did you test it?

5425 tests, 0 failures (scan):

module tests
sentry 3552
sentry-android-core 1681
sentry-android-sqlite 101
sentry-opentelemetry-core 91

spotlessApply apiDump clean; the .api diff is additions only.

Two real bugs found by the suite while writing this, both fixed here:

  • I inverted the trimStart comparison — the trim tests caught it
  • DefaultCompositePerformanceCollectorTest stubbed dateProvider.now() as a fixed call sequence,
    so it silently depended on how often unrelated code read the provider. Anchoring removes some of
    those reads. Restubbed by role — first reading anchors, later ones are ticks — rather than by count.

🔮 If this direction is accepted

  1. File the span-timing issue in the 9.0.0 milestone — nothing clock-related is in it today, and
    the plan treats that milestone as the authoritative "must wait" list
  2. Re-scope JAVA-572, whose title still describes the tracer's java.util.Timer; the surviving half
    of it is "clamp the finish timestamp when the deadline fires late", which is orthogonal to this
    and is the actual fix for the multi-hour ui.load artifact
  3. Settle the frame timebase question above
  4. Land on 9.x.x once that branch is cut

@linear-code

linear-code Bot commented Sep 4, 2026

Copy link
Copy Markdown

JAVA-572

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor
Fails
🚫 Please consider adding a changelog entry for the next release.

Instructions and example for changelog

Please add an entry to CHANGELOG.md to the "Unreleased" section. Make sure the entry includes this PR's number.

Example:

## Unreleased

### Features

- Anchor span timestamps on one wall-clock reading (JAVA-572) ([#6055](https://github.com/getsentry/sentry-java/pull/6055))

If none of the above apply, you can opt out of this check by adding #skip-changelog to the PR description or adding a skip-changelog label.

Generated by 🚫 dangerJS against c071882

@sentry

sentry Bot commented Sep 4, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

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

⚙️ sentry-android Build Distribution Settings

Demonstrates what AnchoredClock and Timestamp look like once Span and
SentryTracer actually use them. Not for merge before the major: it changes a
serialized value.

SentryTracer takes one epoch reading when it is constructed, pins it to one
monotonic tick, and every span below it projects from that anchor rather than
reading the wall clock again. Because the projection is affine with slope 1,
subtracting any two instants in a transaction is subtracting two ticks, so
serialization no longer derives anything:

  -  this.timestamp = DateUtils.nanosToSeconds(
  -      span.getStartDate().laterDateNanosTimestampByDiff(span.getFinishDate()));
  +  this.timestamp = DateUtils.nanosToSeconds(span.endTimestamp().epochNanos());

That is the whole point. laterDateNanosTimestampByDiff exists to rescue a
monotonic end out of two dates whose classes happen to cooperate; with one
anchor there is nothing to rescue. The two sites that reverse-engineered the
hidden System.nanoTime() tick out of SentryNanotimeDate ask the anchor instead,
and both sentinels -- SentryNanotimeDate(0, 0) -- are gone.
DriverSpans.computeNanoStartTimestampForChild collapses to anchor().now(), and
no longer silently drops to millisecond precision when the parent's date is not
the class it hoped for.

ISpan gains startTimestamp(), endTimestamp() and anchor(). getStartDate() and
getFinishDate() stay, as epoch-only views, so the public API still works; at the
major they go and the SentryDate overloads of finish() and updateEndDate() go
with them.

Spans whose instants come from outside the process -- OTel, an app-start
projection, a caller-supplied startTimestamp -- report a null anchor and keep
wall-clock semantics. That case cannot be improved, because the input carries
nothing but an epoch; what changes is that it is now a named branch rather than
an instanceof that falls through.

Why this waits for the major: a child span's start_timestamp moves by
sub-millisecond amounts, because it is projected rather than read. Root spans do
not move, the anchor being read at root start.

Two things this turned up, both left open on purpose:

  - The frame timebase. Choreographer reports frames on CLOCK_MONOTONIC while
    the anchor's tick is CLOCK_BOOTTIME on Android, so SpanFrameMetricsCollector
    needs an offset it cannot read from statics without becoming untestable, and
    a decision about spans that spanned deep sleep. Marked TODO [MAJOR] rather
    than half-solved.
  - SentryDate has no value equality, so getStartDate() returning a fresh view
    breaks identity comparisons. The tests here move to Timestamp, which does.

Also fixes a test that stubbed dateProvider.now() as a fixed call sequence and
so depended on how often unrelated code read the provider.
@runningcode
runningcode force-pushed the no/java-571-deprecate-date-providers branch from 727a95b to edcf5cd Compare September 4, 2026 15:11
@runningcode
runningcode force-pushed the no/span-anchored-clock-demo branch from 5ab4737 to c071882 Compare September 4, 2026 15:11
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