feat(time): Add Timestamp, EpochClock and AnchoredClock (JAVA-572) - #6045
Draft
runningcode wants to merge 1 commit into
Draft
feat(time): Add Timestamp, EpochClock and AnchoredClock (JAVA-572)#6045runningcode wants to merge 1 commit into
runningcode wants to merge 1 commit into
Conversation
Contributor
|
📲 Install BuildsAndroid
|
This was referenced Sep 2, 2026
runningcode
force-pushed
the
no/java-572-timestamp-timing
branch
from
September 3, 2026 15:11
aa53f2e to
7255d5e
Compare
runningcode
force-pushed
the
no/java-571-clock-abstractions
branch
from
September 3, 2026 15:41
deb42e1 to
271cf66
Compare
runningcode
force-pushed
the
no/java-572-timestamp-timing
branch
2 times, most recently
from
September 4, 2026 08:17
a372da0 to
bec70fb
Compare
runningcode
force-pushed
the
no/java-571-clock-abstractions
branch
from
September 4, 2026 08:29
271cf66 to
ab5fba1
Compare
runningcode
force-pushed
the
no/java-572-timestamp-timing
branch
from
September 4, 2026 08:29
bec70fb to
686cc80
Compare
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
force-pushed
the
no/java-572-timestamp-timing
branch
from
September 4, 2026 15:11
686cc80 to
655adfb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Stack (Clock semantics hardening)
📜 Description
Adds the wall-clock half of the time API —
Timestamp,EpochClockandAnchoredClock— on top ofthe
MonotonicClock/Stopwatchprimitives from #6028. Nothing calls any of it yet, like #6028.SentryOptions.getEpochClock()is the injection point.💡 Motivation and Context
SentryDateis four things at once: an epoch instant to serialize, one endpoint of a monotonicinterval, 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, sowhat you get depends on the runtime class of both operands:
What that costs today:
SentryAutoDateProviderpicksSentryInstantDateon JVM 9+, which has no monotonic component at all, while Android forces thenanotime provider. Span durations are monotonic on Android and wall-derived on the JVM, where a
clock step mid-span can make one negative.
SpanFrameMetricsCollectorrecovers it withdate.diff(new SentryNanotimeDate(0, 0)), andDriverSpans.computeNanoStartTimestampForChildreturns
null— dropping SQLite sub-span nesting — whenever the date isn't aSentryNanotimeDate.QueuedThreadPoolExecutor.didRejectRecently()isdateProvider.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:
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 merelytidier, and it is why
Timestampreferences its anchor at all: mixing domains raises anIllegalArgumentExceptioninstead of returning a plausible-looking wrong number. An instant readstraight 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 whatthe clock said when the anchor was taken plus measured time, so a step afterwards is invisible to it.
The epoch clock does not go through SentryDateProvider
SystemEpochClockreads the wall clock directly, picking precision the waySentryAutoDateProviderdoes:
Instant.now()on JVM 9+,System.currentTimeMillis()otherwise. Android is always thelatter —
Instantis millisecond-granular there whether or not the build desugars it (#2451). Thevalues 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 aSystem.nanoTime()reading that an
EpochClocknever looks at.setDateProvidertherefore does not reach the epoch clock. Faking time means overridinggetEpochClock()onSentryOptions, the waySentryAndroidOptionsalready overridesgetMonotonicClock(). There is nosetEpochClockbecause nothing consumes it yet.💚 How did you test it?
./gradlew :sentry:test— 3551 tests, 0 failures.spotlessApply apiDumpclean; the.apidiffagainst the merge base is additions only, with no
<init>leaks.14 new tests on
AnchoredClock, including the ones that were impossible to write before:and the differences between them do not move
tickOfinverts a projection exactly, and throws for a bare instant and for another anchor's instantdriftNanos()is zero while the wall clock keeps pace, and reports the signed size of a step📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps
SentryTracercreates oneAnchoredClockper transaction, before its rootSpan, and everySpanbelow projects from that anchor.
Spanthen holds twoTimestamps instead of twoSentryDates,serialization reads
end().epochNanos()instead oflaterDateNanosTimestampByDiff, and the twosentinel hacks call
anchor.tickOf(...). That retireslaterDateNanosTimestampByDiff,SentryNanotimeDate'sdiff/compareTo/nanotimeDiffoverrides,SentryAutoDateProvider/SentryInstantDate, and twoSentryDateallocations per span endpoint.Two things that PR has to settle, flagged here so they get argued before code exists:
Choreographerhands us frame timestamps in theSystem.nanoTime()timebase, whileAndroidMonotonicClockisSystemClock.elapsedRealtimeNanos(); the two differ by accumulatedsuspend. The plan is to keep a single
MonotonicClockand put that last hop insideSpanFrameMetricsCollector, which already has anonSpanStartedhook to capture both readings andcan detect sleep by comparing the deltas — skipping attribution honestly rather than returning a
plausible-but-wrong projection.
start_timestampby sub-millisecondamounts (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
(
SentryTraceralready usesgetTimerExecutorService()), but "clamp the finish timestamp when thedeadline fires late" is still real and is the actual fix for the multi-hour
ui.loadartifact. Itwants its own ticket.
QueuedThreadPoolExecutor's wall-clock backoff is internal control flow, so itcan be fixed before the major, like #6030.