Skip to content

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

Draft
runningcode wants to merge 1 commit into
no/java-571-clock-abstractionsfrom
no/java-572-timestamp-timing
Draft

feat(time): Add Timestamp, EpochClock and AnchoredClock (JAVA-572)#6045
runningcode wants to merge 1 commit into
no/java-571-clock-abstractionsfrom
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

SentryDate is 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
(SentryLongDate, from OTel and the app-start projection). Nothing in the type separates them, so
what you get depends on the runtime class of both operands:

public long diff(final @NotNull SentryDate otherDate) {
  if (otherDate instanceof SentryNanotimeDate) {
    return nanos - ((SentryNanotimeDate) otherDate).nanos;   // monotonic
  }
  return super.diff(otherDate);                              // wall subtraction
}

What that costs today:

  • Durations mean different things per platform. SentryAutoDateProvider picks
    SentryInstantDate on JVM 9+, which has no monotonic component at all, while Android forces the
    nanotime provider. Span durations are monotonic on Android and wall-derived on the JVM, where a
    clock step mid-span can make one negative.
  • The hidden tick leaks out by sentinel hack. SpanFrameMetricsCollector recovers it with
    date.diff(new SentryNanotimeDate(0, 0)), and DriverSpans.computeNanoStartTimestampForChild
    returns null — dropping SQLite sub-span nesting — whenever the date isn't a SentryNanotimeDate.
  • The wrong clock reaches control flow. QueuedThreadPoolExecutor.didRejectRecently() is
    dateProvider.now().diff(lastReject): monotonic on Android, wall clock on the JVM.

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.

driftNanos() exists because the one real hazard here is anchor staleness: a projection reports what
the clock said when the anchor was taken plus measured time, so a step afterwards is invisible to it.

  • 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 655adfb

@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

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 projects the rest through
the monotonic clock:

  Timestamp      an epoch instant, plus the anchor that projected it, or
                 null when it was read or stated directly. No arithmetic
                 between instants; equality is by instant.
  EpochClock     the wall clock, for stamping a moment that leaves the
                 process. Deliberately cannot report a duration.
  AnchoredClock  one epoch reading pinned to one tick. now() and at(tick)
                 project, tickOf() inverts exactly, driftNanos() reports how
                 far the projection has fallen behind the wall clock.

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.

tickOf() refusing an instant it did not project is what makes this safer
rather than merely tidier: mixing domains becomes an exception instead of a
plausible-looking wrong number, the same guard Deadline.isAfter applies to
clocks.

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 686cc80 to 655adfb 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