Skip to content

feat(time): Add uptime and elapsed-real-time clock abstractions (JAVA-571) - #6028

Open
runningcode wants to merge 10 commits into
mainfrom
no/java-571-clock-abstractions
Open

feat(time): Add uptime and elapsed-real-time clock abstractions (JAVA-571)#6028
runningcode wants to merge 10 commits into
mainfrom
no/java-571-clock-abstractions

Conversation

@runningcode

@runningcode runningcode commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📜 Description

Adds a clock abstraction to io.sentry.time. This just adds the new APIs. They are never called.

Here's a list of what is added (indents for class hierarchy).

Ticker                    long tickNanos()  — base; "a nanosecond counter with an arbitrary origin"
  UptimeClock             excludes deep sleep  (CLOCK_MONOTONIC / System.nanoTime())
  ElapsedRealtimeClock    includes deep sleep  (CLOCK_BOOTTIME on Android)
JavaUptimeClock, JavaElapsedRealtimeClock     singletons
AndroidElapsedRealtimeClock                   SystemClock.elapsedRealtimeNanos()
Deadline, Stopwatch                           take a Ticker

We also add TestTicker in sentry-test-support that advances by an amount and a unit to make testing easier.

To help review the new APIs with concrete use cases, of these new APIs that aren't called in this PR, I created draft PRs. Please don't review these yet, since I haven't fully reviewed it myself but here they are so you can see the new APIs:

There is deliberately nothing platform-specific for UptimeClock: System.nanoTime() is already CLOCK_MONOTONIC on Android and SystemClock.uptimeNanos() is API 34 against a minSdk of 21, so there is no second implementation to write. Call sites resolve JavaUptimeClock.getInstance() directly.

Elapsed real time is the one that differs per platform — RateLimiter lives in the core module but needs CLOCK_BOOTTIME on Android, which only sentry-android-core can supply. That arrives as an override on SentryAndroidOptions rather than a setter, so there is no mutation point on an internal option, and Android is correct from construction rather than from the moment AndroidOptionsInitializer runs. Consumers that take a clock in their constructor, as RateLimiter will, keep their own injection point for tests. The Android clock ships here rather than with its first consumer so the abstraction is not inert on the platform that motivated it; nothing reads it yet, so it changes no behavior.

Everything is @ApiStatus.Internal, so nothing here is a public contract.

💡 Motivation and Context

ICurrentDateProvider.getCurrentTimeMillis() has two implementations that mean different things: CurrentDateProvider returns wall time, AndroidCurrentDateProvider returns SystemClock.uptimeMillis(), which is monotonic and pauses in deep sleep. Every consumer hand-picks the one matching whatever it compares against; a wrong pairing compiles silently, and the tests inject fakes so nothing catches it.

Naming the guarantee is the fix. A class declaring UptimeClock can no longer be handed an elapsed-real-time one, and vice versa. The distinction is not academic: ANR detection compares against uptimeMillis() precisely so that a suspended device does not look like a blocked main thread on CLOCK_BOOTTIME, a 30 s suspend would fabricate a 30 s ANR.

Deadline and Stopwatch exist so callers never do arithmetic on raw ticks. Three decisions worth reviewing:

  • Deadline.passed() gives "not populated yet" a representation outside the numeric range — 0 is a real and very recent instant on any boot-relative clock.
  • hasPassed() subtracts rather than compares, so it holds for a negative or wrapping origin.
  • remaining() rounds up, so a caller scheduling work for it never wakes to find the deadline still standing.
  • resolves: JAVA-571 (partially — this is the API, not the migration)

💚 How did you test it?

Unit tests!

📝 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

This is the first of six pre-v9 PRs; the rest convert call sites whose current semantics are preserved:

  1. This PR — the clock types and the options seam
  2. Android connection-status cache → the clock added here (fixes JAVA-717, where 0 meant "unset" on a boot-relative clock)
  3. RateLimiterElapsedRealtimeClock (JAVA-574)
  4. All four check-in duration paths → UptimeClock (JAVA-576)
  5. ANR thresholds → UptimeClock (JAVA-579)
  6. Deprecate ICurrentDateProvider, DateUtils.getCurrentDateTime() and AndroidDateUtils (JAVA-571)

Everything that changes a serialized measurement — span and transaction durations, session durations, app-start spans, replay and profiler timings — is deliberately held for v9.

…-571)

`ICurrentDateProvider.getCurrentTimeMillis()` has two implementations that
mean different things: `CurrentDateProvider` returns wall time, while
`AndroidCurrentDateProvider` returns `SystemClock.uptimeMillis()`, which is
monotonic and pauses in deep sleep. Every consumer has to hand-pick the one
matching whatever it compares against, a wrong pairing compiles silently, and
the tests inject fakes so nothing catches it.

Name the guarantee instead. UptimeClock excludes time the device spent
suspended and is what ANR detection needs, since counting suspended time
reports a responsive main thread as blocked. ElapsedRealtimeClock includes it
and is what a rate-limit window or a cache TTL needs. A call site declaring
which one it wants can no longer be handed the other.

Both extend Ticker, which promises only "a nanosecond counter with an
arbitrary origin" so that Deadline and Stopwatch can be written once. That
minimalism is deliberate: a name promising a guarantee it does not keep is
the bug being fixed here.

Deadline and Stopwatch exist so callers never do arithmetic on raw ticks. A
tick carries no unit and no epoch, so `now - then < ttl` spelled out at each
call site is where unit mix-ups, sentinels that happen to mean "boot", and
wrap-unsafe comparisons come from. Deadline.passed() gives "not populated
yet" a representation outside the numeric range, hasPassed() subtracts rather
than compares so it holds for any origin, and remaining() rounds up so a
caller scheduling work for it never wakes to find the deadline still standing.

No call site is converted and no behaviour changes. Only the elapsed-real-time
clock will need an Android implementation: `SystemClock.uptimeNanos()` is API
34 against a minSdk of 21, and `System.nanoTime()` is already CLOCK_MONOTONIC
on Android, so it serves as the uptime clock on both platforms.
@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

JAVA-571

JAVA-717

@github-actions

github-actions Bot commented Aug 31, 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 058d5c4

@sentry

sentry Bot commented Aug 31, 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 6 commits August 31, 2026 15:03
System.nanoTime() is CLOCK_MONOTONIC on Android too, and
SystemClock.uptimeNanos() is API 34 against minSdk 21, so there is no
platform-specific uptime implementation to install. The setter had no
production caller and its only test was a test of itself, while still
occupying binary-compatibility surface in sentry.api.

ElapsedRealtimeClock keeps its seam: RateLimiter lives in the core
module but needs SystemClock.elapsedRealtimeNanos() on Android, which
only sentry-android-core can supply.

UptimeClock and JavaUptimeClock remain; call sites that want the
guarantee named in their type resolve the singleton directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The elapsed-real-time field carried a comment repeating its own type,
and both the setter and JavaElapsedRealtimeClock restated what the
ElapsedRealtimeClock javadoc already says at length.

The setter javadoc now answers the question a reader actually has when
they find a setter on an internal option: which platform installs one,
and why the core module cannot construct it itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…A-571)

Installs AndroidElapsedRealtimeClock in AndroidOptionsInitializer, beside
the existing SentryAndroidDateProvider. Nothing reads the clock yet, so
this changes no behaviour.

Without it the options seam added in this PR is inert on Android: the
default resolves to System.nanoTime(), which is CLOCK_MONOTONIC and stops
in deep sleep, so a reviewer sees a setter with no caller and Android
silently gets the guarantee the type says it does not provide. That was
the flaw in the previous attempt at this abstraction, where the Android
clock was built into a local and never installed.

io.sentry.android.core.internal is in apiValidation.ignoredPackages, so
there is no .api diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SentryOptionsClockTest asserted that a LazyEvaluator-backed getter
returns what its setter was given; AndroidOptionsInitializerTest already
covers the setter for real, on the one caller that uses it.
JavaClocksTest asserted singleton identity and that a nanosecond counter
does not run backwards.

Neither can fail without the language failing first. DeadlineTest and
StopwatchTest, which cover the arithmetic this package exists to
centralise, are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SentryAndroidOptions now returns AndroidElapsedRealtimeClock from an
override, so SentryOptions no longer needs a setter and the core default
collapses to the singleton it always returned.

Three things get better. The setter was a mutation point on an option
nobody should swap, and it is gone from sentry.api. Android is correct
from construction rather than from the moment AndroidOptionsInitializer
runs, closing the window where a reader saw System.nanoTime(). And
consumers that take a clock in their constructor, as RateLimiter will,
keep their own injection point for tests, so nothing lost a seam.

The cost is that this is the only getter SentryAndroidOptions overrides;
every other platform swap is installed in AndroidOptionsInitializer.
Those are user-replaceable options, though, and this one is internal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It took a public constructor to match `new SentryAndroidDateProvider()`
on the line beside it in AndroidOptionsInitializer. That line is gone
now that SentryAndroidOptions overrides the getter, so the odd one out
was the clock rather than the neighbour.

With getInstance() it matches the two JVM clocks, and the field it was
stored in disappears: both overrides are now the same single line
returning a singleton.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`in` is a Kotlin hard keyword, so every Kotlin call site had to spell it
`Deadline.`in`(...)`. Tests in this repo are Kotlin, and Kotlin callers
are expected in the Android and Kotlin integration modules, so the
backticks would have spread rather than stayed in one test file.

`after` is a plain identifier in both languages and pairs with the
existing `hasPassed()` and `isAfter()` vocabulary.
@runningcode
runningcode marked this pull request as ready for review August 31, 2026 16:33
@runningcode runningcode added the deep-dive PR needs a thorough review of design, behavior, and edge cases label Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deep-dive PR needs a thorough review of design, behavior, and edge cases

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant