Skip to content

feat(time): Add Timestamp, EpochClock and AnchoredClock (JAVA-572) - #6045

Merged
runningcode merged 1 commit into
mainfrom
no/java-572-timestamp-timing
Sep 9, 2026
Merged

feat(time): Add Timestamp, EpochClock and AnchoredClock (JAVA-572)#6045
runningcode merged 1 commit into
mainfrom
no/java-572-timestamp-timing

Conversation

@runningcode

@runningcode runningcode commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

PR Stack (Clock semantics hardening)


📜 Description

Adds the wall-clock half of the time API — Timestamp, EpochClock and AnchoredClock — on top of
the MonotonicClock/Stopwatch primitives from #6028. Nothing calls any of it yet, like #6028.

Timestamp      // an epoch instant, plus the anchor that projected it (or null if read directly).
               // No arithmetic between instants; equality is by instant.
EpochClock     // now() stamps a moment that leaves the process. Cannot report a duration.
AnchoredClock  // one epoch reading pinned to one tick. now()/at(tick) project, tickOf() inverts
               // exactly, driftNanos() reports how far the projection trails the wall clock.

SentryOptions.getEpochClock() is the injection point.

💡 Motivation and Context

Why anchoring, rather than more careful types

The bug class is not "wall vs monotonic". It is pairwise arithmetic between two independently
produced instants
. Better types narrow that; they do not close it, because a mixed pair is still
reachable and still has to answer.

So the fix is to stop producing the instants independently. A group that will be compared against
each other — the spans of a transaction, the samples of a profile chunk, the segments of a replay —
reads the epoch once and projects the rest through the monotonic clock:

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

The projection is affine with slope 1, so subtracting two instants from one anchor is subtracting
two ticks. A duration is then monotonic by construction rather than by convention, and a clock step
cannot make a child span start before its parent. It also buys resolution the wall clock does not
have: Android's epoch is millisecond-granular, so a directly read instant is truncated while a
projected one carries nanoseconds. OpenTelemetry's SDK does the same thing, per local root span, for
the same two reasons.

tickOf() refusing an instant it did not project is what makes this safer rather than merely
tidier, and it is why Timestamp references its anchor at all: mixing domains raises an
IllegalArgumentException instead of returning a plausible-looking wrong number. An instant read
straight from the wall clock, or stated by something outside the process, has no anchor and can only
be serialized.

  • resolves: JAVA-572 (partially — this is the additive, behaviour-free half)

The epoch clock does not go through SentryDateProvider

SystemEpochClock reads the wall clock directly, picking precision the way SentryAutoDateProvider
does: Instant.now() on JVM 9+, System.currentTimeMillis() otherwise. Android is always the
latter — Instant is millisecond-granular there whether or not the build desugars it (#2451). The
values are byte-identical to what the provider returns on every platform; what changes is that
reading one no longer allocates a SentryDate, and on Android no longer takes a System.nanoTime()
reading that an EpochClock never looks at.

setDateProvider therefore does not reach the epoch clock. Faking time means overriding
getEpochClock() on SentryOptions, the way SentryAndroidOptions already overrides
getMonotonicClock(). There is no setEpochClock because nothing consumes it yet.

💚 How did you test it?

./gradlew :sentry:test — 3551 tests, 0 failures. spotlessApply apiDump clean; the .api diff
against the merge base is additions only, with no <init> leaks.

14 new tests on AnchoredClock, including the ones that were impossible to write before:

  • step the epoch clock backwards and forwards after taking the anchor, and assert projected instants
    and the differences between them do not move
  • tickOf inverts a projection exactly, and throws for a bare instant and for another anchor's instant
  • a millisecond anchor still projects nanoseconds
  • driftNanos() is zero while the wall clock keeps pace, and reports the signed size of a step

📝 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

SentryTracer creates one AnchoredClock per transaction, before its root Span, and every Span
below projects from that anchor. Span then holds two Timestamps instead of two SentryDates,
serialization reads end().epochNanos() instead of laterDateNanosTimestampByDiff, and the two
sentinel hacks call anchor.tickOf(...). That retires laterDateNanosTimestampByDiff,
SentryNanotimeDate's diff/compareTo/nanotimeDiff overrides,
SentryAutoDateProvider/SentryInstantDate, and two SentryDate allocations per span endpoint.

Two things that PR has to settle, flagged here so they get argued before code exists:

  • Clock base. Choreographer hands us frame timestamps in the System.nanoTime() timebase, while
    AndroidMonotonicClock is SystemClock.elapsedRealtimeNanos(); the two differ by accumulated
    suspend. The plan is to keep a single MonotonicClock and put that last hop inside
    SpanFrameMetricsCollector, which already has an onSpanStarted hook to capture both readings and
    can detect sleep by comparing the deltas — skipping attribution honestly rather than returning a
    plausible-but-wrong projection.
  • v9 gating. Eager projection moves every child span's start_timestamp by sub-millisecond
    amounts (root spans are unaffected — the anchor is read at root start). Under the "serialized values
    are frozen until the major" rule that puts the flip behind v9, even though it is an improvement.

Separately, JAVA-572's original subject — the tracer idle/deadline timeout — is stale on the timer half
(SentryTracer already uses getTimerExecutorService()), but "clamp the finish timestamp when the
deadline fires late" is still real and is the actual fix for the multi-hour ui.load artifact. It
wants its own ticket. QueuedThreadPoolExecutor's wall-clock backoff is internal control flow, so it
can be fixed before the major, like #6030.

⚠️ Merge this PR using a merge commit (not squash), so the rest of the stack keeps a clean history.

@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown

JAVA-572

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against d484855

@sentry

sentry Bot commented Sep 2, 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

@runningcode
runningcode force-pushed the no/java-572-timestamp-timing branch from aa53f2e to 7255d5e Compare September 3, 2026 15:11
@runningcode
runningcode force-pushed the no/java-571-clock-abstractions branch from deb42e1 to 271cf66 Compare September 3, 2026 15:41
@runningcode
runningcode force-pushed the no/java-572-timestamp-timing branch 2 times, most recently from a372da0 to bec70fb Compare September 4, 2026 08:17
@runningcode runningcode changed the title feat(time): Add Timestamp, Timing and EpochClock (JAVA-572) feat(time): Add Timestamp, EpochClock and AnchoredClock (JAVA-572) Sep 4, 2026
@runningcode
runningcode force-pushed the no/java-571-clock-abstractions branch from 271cf66 to ab5fba1 Compare September 4, 2026 08:29
@runningcode
runningcode force-pushed the no/java-572-timestamp-timing branch from bec70fb to 686cc80 Compare September 4, 2026 08:29
@runningcode
runningcode force-pushed the no/java-572-timestamp-timing branch from 686cc80 to 655adfb Compare September 4, 2026 15:11
@runningcode
runningcode marked this pull request as ready for review September 7, 2026 14:02

@0xadam-brown 0xadam-brown left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! 🥇

Some comments for your consideration. (Apologies in advance for the doc suggestions, but hope they'll be helpful in a cut-and-paste way – seems worthwhile one-up the clanker because these APIs are so important.) Looking great though 💯

Comment thread sentry/src/main/java/io/sentry/time/AnchoredClock.java Outdated
Comment thread sentry/src/main/java/io/sentry/time/AnchoredClock.java Outdated
Comment thread sentry/src/main/java/io/sentry/time/AnchoredClock.java Outdated
Comment thread sentry/src/main/java/io/sentry/time/AnchoredClock.java Outdated
Comment thread sentry/src/main/java/io/sentry/time/AnchoredClock.java Outdated
Comment thread sentry/src/main/java/io/sentry/time/EpochClock.java Outdated
Comment thread sentry/src/main/java/io/sentry/time/InstantEpochNanos.java
Comment thread sentry/src/main/java/io/sentry/time/Timestamp.java Outdated
Comment thread sentry/src/main/java/io/sentry/SentryOptions.java Outdated
Comment thread sentry/src/main/java/io/sentry/SentryOptions.java
@runningcode
runningcode force-pushed the no/java-571-clock-abstractions branch from ab5fba1 to d7bc897 Compare September 8, 2026 13:19
@runningcode
runningcode force-pushed the no/java-572-timestamp-timing branch 2 times, most recently from 0969abb to 005fa73 Compare September 8, 2026 15:09

@0xadam-brown 0xadam-brown left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Responded to your major questions here, but nothing that has to block us 👍

Base automatically changed from no/java-571-clock-abstractions to main September 9, 2026 07:26
@runningcode
runningcode force-pushed the no/java-572-timestamp-timing branch from 005fa73 to 8d4dce5 Compare September 9, 2026 07:29
@runningcode
runningcode force-pushed the no/java-572-timestamp-timing branch from 8d4dce5 to 690617d Compare September 9, 2026 08:28
SentryDate is asked to be four things at once: an epoch instant to
serialize, one endpoint of a monotonic interval, a carrier of a hidden
System.nanoTime() reading, and an opaque foreign timestamp. Nothing in the
type separates them, so the guarantees are decided by the runtime class of
both operands -- SentryNanotimeDate.diff() is monotonic only when the other
date is also a SentryNanotimeDate, and silently subtracts two wall-clock
readings otherwise. On the JVM, where SentryAutoDateProvider picks
SentryInstantDate, neither endpoint has a monotonic component and span
durations are not monotonic at all.

The fix is not to type the instants more carefully. It is to stop producing
them independently. A group of instants that will be compared against each
other -- the spans of a transaction, the samples of a profile chunk, the
segments of a replay -- reads the epoch once and builds the rest from the
monotonic ticker:

  Timestamp      an epoch instant. No arithmetic between instants; equality
                 is by instant.
  EpochClock     the wall clock, for stamping a moment that leaves the
                 process. Not for computing durations.
  AnchoredClock  one wall-clock reading pinned to one tick, from which now()
                 builds.

Subtracting two instants from one anchor is subtracting two ticks, so a
duration is monotonic by construction rather than by convention, and a clock
step cannot make a child span start before its parent. It also gives Android
nanosecond resolution it cannot read directly, the epoch being
millisecond-granular there -- the workaround SentryNanotimeDate describes,
applied once per group instead of between each pair of readings.
OpenTelemetry's SDK anchors per local root span for the same two reasons.

Only what a caller needs today is exposed. Placing an externally measured
tick on the anchor's timeline, and recovering the tick a timestamp was built
from, are both exact and both trivial to add, but neither is reachable until
spans carry an anchor. They belong to the change that migrates them rather
than to this one, where they would arrive with no caller.

Timing is dropped rather than kept. It paired one Timestamp with one
Stopwatch, which is what AnchoredClock does for a whole group, and no call
site would have wanted the single-interval version.

Nothing calls any of it yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@runningcode
runningcode force-pushed the no/java-572-timestamp-timing branch from 690617d to d484855 Compare September 9, 2026 12:38
@runningcode
runningcode enabled auto-merge (squash) September 9, 2026 12:41
@runningcode
runningcode merged commit 66f67ec into main Sep 9, 2026
68 of 69 checks passed
@runningcode
runningcode deleted the no/java-572-timestamp-timing branch September 9, 2026 12:58
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.

2 participants