feat(time): Add uptime and elapsed-real-time clock abstractions (JAVA-571) - #6028
feat(time): Add uptime and elapsed-real-time clock abstractions (JAVA-571)#6028runningcode wants to merge 11 commits into
Conversation
|
📲 Install BuildsAndroid
|
…-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.
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.
The Deadline.in to Deadline.after rename did not regenerate sentry/api/sentry.api, so :sentry:apiCheck failed on CI.
2d9f213 to
c2f7b51
Compare
| /** | ||
| * A monotonically increasing nanosecond counter. | ||
| * | ||
| * <p>This type deliberately promises very little: a tick is a number that does not go backwards, |
There was a problem hiding this comment.
in theory the number could overflow, but practically that's not happening (e.g. on Android it would take 292 years of uptime)
markushi
left a comment
There was a problem hiding this comment.
Left some early feedback (which maybe are already resolved on follow up PRs)
| * elapsed real time coincide. | ||
| */ | ||
| @ApiStatus.Internal | ||
| public interface UptimeClock extends Ticker {} |
There was a problem hiding this comment.
To be honest I would remove this one for now.
There was a problem hiding this comment.
Why is that? We will need it for ANRs.
| */ | ||
| public boolean isAfter(final @NotNull Deadline other) { | ||
| if (clock != other.clock) { | ||
| throw new IllegalArgumentException( |
| * ElapsedRealtimeClock}, whose names state which guarantee they provide. | ||
| */ | ||
| @ApiStatus.Internal | ||
| public interface Ticker { |
There was a problem hiding this comment.
What's the motivation behind using both terms "Ticker" and "Clock"?
There was a problem hiding this comment.
It comes from Guava. https://guava.dev/releases/27.1-jre/api/docs/com/google/common/base/Ticker.html
It represents a nanosecond counter with an arbitrary origin.
The Clock means that we have some sort of guarantee attached to the counter. For example if it includes time spent in deep sleep.
To be fair, most of the APIs are copied from Guava here.
| * ElapsedRealtimeClock}, whose names state which guarantee they provide. | ||
| */ | ||
| @ApiStatus.Internal | ||
| public interface Ticker { |
There was a problem hiding this comment.
(I haven't looked at PRs on top of this one)
I guess we also need some mechanism to translate the ticks to unix time, right?
There was a problem hiding this comment.
It is a very important point. Maybe this wasn't clear in the PR description but anything that extends Ticker should not be used for wall clocks or translating to wall clocks. So for translating to wall clocks I'm working on these APIs: #6045 but that's not ready yet. Main issue is interop with existing APIs like SentryDateProvider and Spans etc.
| public static @NotNull Stopwatch started(final @NotNull Ticker clock) { | ||
| return new Stopwatch(clock); | ||
| } | ||
|
|
There was a problem hiding this comment.
maybe it could be useful to have a reset() method too, which sets startNanos to the current time
There was a problem hiding this comment.
the way it is designed you need to create a new stopwatch. this way it can only measure a single interval.
do you know of a use case for the reset method where creating a new stopwatch wouldn't make sense?
|
|
||
| @Override | ||
| public long tickNanos() { | ||
| return SystemClock.elapsedRealtimeNanos(); |
There was a problem hiding this comment.
I double checked: both uptimeMillis() and elapsedRealtimeNanos() are marked as @ CriticalNative, indicating they both should be fast ™️
PR Stack (Clock semantics hardening)
📜 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.