Skip to content

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

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

feat(time): Add Timestamp, Timing and EpochClock (JAVA-572)#6045
runningcode wants to merge 3 commits 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, Timing and EpochClock, built on the
MonotonicClock/Stopwatch primitives from #6028. Nothing calls any of it yet, exactly like #6028.

Timestamp   // an epoch instant. No arithmetic between instants.
Timing      // a Timestamp anchor + a Stopwatch, captured together. end() = anchor + duration.
EpochClock  // now() stamps a moment; start() begins something whose duration gets reported.

Timing composes Stopwatch rather than repeating it, so there is one implementation of a
monotonic delta and Timing owns exactly one operation of its own — end(). Both measure on
MonotonicClock, which counts deep sleep: an interval that excluded it, paired with wall-clock
anchors, would produce an end() that falls further behind real time the longer the device
sleeps.

SentryOptions.getEpochClock() is the injection point. SystemEpochClock reads the epoch itself
rather than going through SentryDateProvider — see below.

💡 Motivation and Context

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 (SentryLongDate, from OTel and the app-start projection). Nothing in the type separates
them, so the guarantees are decided by 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. The default 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.

The split here is along the line that actually matters — whether a value means anything outside this
process. A MonotonicClock tick never does; a Timestamp always does. Conflating those is what put
System.nanoTime() and unixDateMillis in the same object with no way for a caller to tell which
one they were getting.

  • 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 therefore byte-identical to what the provider would have returned 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, because a Timing
measures on the monotonic clock instead.

setDateProvider consequently does not reach the epoch clock. Faking time for a future consumer
means overriding getEpochClock() on SentryOptions, the way SentryAndroidOptions already
overrides getMonotonicClock(). There is no setEpochClock because there is nothing to
configure it for yet.

The bridge still cannot run the other way. Reimplementing SentryDateProvider on top of
EpochClock would change serialized values today: SentryNanotimeDate carries System.nanoTime()
as its tick, the two sites above compare that tick against System.nanoTime() directly, and a
Timing measures on the monotonic clock, which counts deep sleep. Swapping the tick source would
change span durations across deep sleep and break both comparisons, so it waits for the major.

💚 How did you test it?

./gradlew :sentry:test :sentry:apiCheck — green. 10 new tests covering the anchor being kept, the
end tracking the clock across reads, the duration surviving a wall-clock jump, and SystemEpochClock
anchoring on the wall clock while measuring on the clock it was given.

.api diff is additions only.

📝 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

Stage 2, at the major: Span/SentryTracer hold a Timing, serialization uses start()/end()
instead of laterDateNanosTimestampByDiff, the two sentinel hacks get a supported accessor, and JVM
durations become monotonic. Stage 3 retires SentryDate.diff, SentryNanotimeDate,
SentryInstantDate, SentryAutoDateProvider and SentryLongDate — including the open question of
whether SentryDate shrinks into Timestamp rather than the two coexisting.

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 7255d5e

@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.54.0 (1) release

⚙️ sentry-android Build Distribution Settings

runningcode and others added 3 commits September 3, 2026 16:39
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 falls back to subtracting two
wall-clock readings otherwise, silently.

These three types split that apart along the line that matters, which is
whether a value means anything outside this process:

  Timestamp    an epoch instant. Serialize it, compare it against another
               machine's value -- but it offers no arithmetic between
               instants, because subtracting two wall-clock readings gives
               a duration the device's clock can lengthen or reverse.
  Timing       a Timestamp anchor plus a Stopwatch, captured together.
               end() is the anchor plus the measured duration, which is
               what the span protocol needs: it carries two instants and no
               duration field, so the server subtracts them.
  EpochClock   now() to stamp a moment, start() to measure something. A
               call site has to say which it is doing, and neither result
               can do the other's job.

Timing composes Stopwatch rather than repeating it, so there is one
implementation of a monotonic delta. Both measure on MonotonicClock, which
counts deep sleep: an interval that excluded it, paired with wall-clock
anchors, would produce an end() that falls further behind real time the
longer the device sleeps.

Nothing calls any of it yet.
…der (JAVA-572)

DateProviderEpochClock took its instant from options.getDateProvider(),
which meant allocating a SentryDate per call to read one long out of it --
and on Android that allocation also takes a System.nanoTime() reading that
an EpochClock never looks at, because a Timing measures on the
monotonic clock instead. The indirection bought compatibility with
a tick nothing here consumes.

SystemEpochClock reads the epoch itself, picking precision the way
SentryAutoDateProvider does: Instant.now() on JVM 9+, currentTimeMillis()
otherwise. Android is always the latter -- Instant is millisecond-granular
there whether or not the build desugars it. InstantEpochNanos is a class of
its own so the java.time reference is loaded only where it is used, the
same reason SentryInstantDate is kept separate.

The values are identical on every platform; the dependency is what changes.
setDateProvider no longer reaches the epoch clock, so tests override
getEpochClock() on SentryOptions, as SentryAndroidOptions already does for
getMonotonicClock().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@runningcode
runningcode force-pushed the no/java-572-timestamp-timing branch from aa53f2e to 7255d5e Compare September 3, 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