feat(time): Add uptime and elapsed-real-time clock abstractions (JAVA-571) - #6028
Open
runningcode wants to merge 10 commits into
Open
feat(time): Add uptime and elapsed-real-time clock abstractions (JAVA-571)#6028runningcode wants to merge 10 commits into
runningcode wants to merge 10 commits into
Conversation
…-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.
Contributor
|
📲 Install BuildsAndroid
|
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
marked this pull request as ready for review
August 31, 2026 16:33
runningcode
requested review from
0xadam-brown,
adinauer,
markushi and
romtsn
as code owners
August 31, 2026 16:33
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.
📜 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).
We also add
TestTickerinsentry-test-supportthat 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:
DeadlineandElapsedRealtimeClock: fix(android): Treat an unpopulated connection cache as stale (JAVA-717) #6029 and ref(transport): Measure rate-limit backoff on a monotonic clock (JAVA-574) #6030StopwatchandJavaUptimeClock: ref(checkin): Measure check-in durations with Stopwatch (JAVA-576) #6032There is deliberately nothing platform-specific for
UptimeClock:System.nanoTime()is alreadyCLOCK_MONOTONICon Android andSystemClock.uptimeNanos()is API 34 against a minSdk of 21, so there is no second implementation to write. Call sites resolveJavaUptimeClock.getInstance()directly.Elapsed real time is the one that differs per platform —
RateLimiterlives in the core module but needsCLOCK_BOOTTIMEon Android, which onlysentry-android-corecan supply. That arrives as an override onSentryAndroidOptionsrather than a setter, so there is no mutation point on an internal option, and Android is correct from construction rather than from the momentAndroidOptionsInitializerruns. Consumers that take a clock in their constructor, asRateLimiterwill, 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:CurrentDateProviderreturns wall time,AndroidCurrentDateProviderreturnsSystemClock.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
UptimeClockcan no longer be handed an elapsed-real-time one, and vice versa. The distinction is not academic: ANR detection compares againstuptimeMillis()precisely so that a suspended device does not look like a blocked main thread onCLOCK_BOOTTIME, a 30 s suspend would fabricate a 30 s ANR.DeadlineandStopwatchexist so callers never do arithmetic on raw ticks. Three decisions worth reviewing:Deadline.passed()gives "not populated yet" a representation outside the numeric range —0is 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.💚 How did you test it?
Unit tests!
📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps
This is the first of six pre-v9 PRs; the rest convert call sites whose current semantics are preserved:
0meant "unset" on a boot-relative clock)RateLimiter→ElapsedRealtimeClock(JAVA-574)UptimeClock(JAVA-576)UptimeClock(JAVA-579)ICurrentDateProvider,DateUtils.getCurrentDateTime()andAndroidDateUtils(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.