ref(time): Deprecate the legacy date and clock providers (JAVA-571) - #6043
Draft
runningcode wants to merge 28 commits into
Draft
ref(time): Deprecate the legacy date and clock providers (JAVA-571)#6043runningcode wants to merge 28 commits into
runningcode wants to merge 28 commits into
Conversation
This was referenced Sep 2, 2026
📲 Install BuildsAndroid
|
9 tasks
runningcode
force-pushed
the
no/java-579-anr-uptime-clock
branch
from
September 2, 2026 13:35
cabb812 to
77eb8c9
Compare
runningcode
force-pushed
the
no/java-571-deprecate-date-providers
branch
2 times, most recently
from
September 2, 2026 13:37
4f8f1ff to
d35ab7c
Compare
runningcode
force-pushed
the
no/java-579-anr-uptime-clock
branch
from
September 2, 2026 13:37
77eb8c9 to
2065803
Compare
9 tasks
runningcode
force-pushed
the
no/java-579-anr-uptime-clock
branch
from
September 3, 2026 15:11
2065803 to
498932f
Compare
runningcode
force-pushed
the
no/java-571-deprecate-date-providers
branch
from
September 3, 2026 15:11
d35ab7c to
f375021
Compare
runningcode
force-pushed
the
no/java-579-anr-uptime-clock
branch
from
September 3, 2026 15:41
498932f to
ae9d046
Compare
runningcode
force-pushed
the
no/java-571-deprecate-date-providers
branch
from
September 3, 2026 15:41
f375021 to
86b6e5e
Compare
runningcode
force-pushed
the
no/java-579-anr-uptime-clock
branch
from
September 4, 2026 08:58
ae9d046 to
46f3c83
Compare
runningcode
force-pushed
the
no/java-571-deprecate-date-providers
branch
from
September 4, 2026 08:58
86b6e5e to
727a95b
Compare
runningcode
force-pushed
the
no/java-579-anr-uptime-clock
branch
from
September 4, 2026 15:11
46f3c83 to
2c9adb8
Compare
runningcode
force-pushed
the
no/java-571-deprecate-date-providers
branch
from
September 4, 2026 15:11
727a95b to
edcf5cd
Compare
…-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.
Two interfaces existed so that a call site could name which suspend behaviour it needed, but only ANR detection wanted the one that excludes deep sleep, and neither ANR path can be fooled by a suspend: ANRWatchDog reports only once ActivityManager confirms NOT_RESPONDING, and AnrProfilingIntegration parks its thread while backgrounded and resets its baseline on wake. That leaves one guarantee worth naming. Ticker, UptimeClock and JavaUptimeClock are gone, and ElapsedRealtimeClock becomes MonotonicClock, backed by elapsedRealtimeNanos() on Android and nanoTime() on the JVM.
The type promises less than "clock" suggests: a tick carries no unit and no epoch, and only differences between two ticks from the same instance mean anything. "Ticker" keeps it from reading like a source of wall-clock time. Deadline.after now rejects a negative amount, since a deadline that starts out in the past is a sign error at the call site and passed() already expresses that case deliberately. Also drops the "state that has not been populated yet" framing from the Deadline.passed javadoc, covers isAfter on two equal deadlines, and rewrites the tick-zero test comment to name the last-updated-timestamp pattern it is about, rather than leaving "sentinel of 0" to be read as a deadline of 0.
runningcode
force-pushed
the
no/java-579-anr-uptime-clock
branch
from
September 8, 2026 13:32
2c9adb8 to
d570570
Compare
runningcode
force-pushed
the
no/java-571-deprecate-date-providers
branch
from
September 8, 2026 13:33
edcf5cd to
dfe0f15
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>
`When network is active but not connected with permission, return DISCONNECTED` mocked an active network reporting isConnected=false alongside NetworkCapabilities describing a validated WiFi link. Those describe opposite worlds. It passes today only because the empty connection cache reads as fresh for the first two minutes of every boot (JAVA-717), which forces the legacy activeNetworkInfo path where the capability mocks are never consulted. buildInfo reports API 24, so once that bug is fixed the provider reads capabilities and the test would fail for a reason that has nothing to do with what it is named after. Fixing the mocks first keeps that failure from being buried in the commit that fixes the cache. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lastCacheUpdateTime used 0 for "never populated" while being compared against SystemClock.uptimeMillis(), which starts at 0 at boot. For the first two minutes of every boot the empty cache therefore read as fresh, so getConnectionStatus() skipped updateCache() and fell through to the legacy activeNetworkInfo path instead of reading NetworkCapabilities. The window reopens after every unregisterNetworkCallback(), which reset the field to 0. Switching clocks does not fix this on its own: elapsedRealtimeNanos() also starts at 0 at boot. Any 0-means-unset long compared against a boot-relative clock has the same flaw; only epoch millis made it safe, because there 0 is 1970. The cache now holds a Deadline, so "never populated" is expired by construction and has no numeric value to get wrong. The provider takes an MonotonicClock in place of ICurrentDateProvider, which is what a two-minute TTL wants: it must keep counting while the device sleeps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-574) Retry-after limits were java.util.Date values derived from System.currentTimeMillis(). A wall clock is the wrong instrument for a backoff window: it steps when the device syncs time, so an NTP correction could lift a 60 second rate limit early or extend it by however far the clock jumped. The limits now live on the monotonic clock, which counts forward at a steady rate and keeps counting while the device sleeps, which is what a server-dictated wait means. Storing Deadline rather than a timestamp also removes the duplicated parameter on applyRetryAfterOnlyIfLonger, which took both an absolute deadline and the delay needed to reach it, and lets three JdkObsolete and JavaUtilDate suppressions go with the Dates. RateLimiter also took the whole SentryOptions while reading exactly three methods from it. It now depends on RateLimiterConfig, declared next to its consumer, so what a rate limiter touches is three lines to read rather than three hundred. SentryOptions implements it with no new methods, so every existing caller compiles unchanged. Both existing constructors stay, so the .api diff is additions only. The ICurrentDateProvider one is deprecated and adapts the injected provider rather than ignoring it, since a custom ITransportFactory may be passing one. One boundary moves by a nanosecond: a limit used to be active while `now <= deadline` and is now active while `now < deadline`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All four check-in paths kept a `long startTime = System.nanoTime()` and subtracted it in a finally block: CheckInUtils and the SentryCheckInAdvice in sentry-spring, sentry-spring-jakarta and sentry-spring-7. The clock now comes from the options, so a check-in measures on whatever the SDK measures on: System.nanoTime() on the JVM, unchanged to the bit, and SystemClock.elapsedRealtimeNanos() on Android. A cron job that spans a suspend therefore reports the duration a user would measure rather than the time the CPU happened to be awake, which is what a check-in duration is meant to mean. An uninitialised SDK still reaches a clock: NoOpScopes.getOptions() hands back empty options, whose clock is the JVM one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ANR tests advance the ticker from the test thread while the watchdog thread reads it, which without volatile is a data race that can leave the watchdog looking at a stale tick forever.
The watchdog took its readings from an ICurrentDateProvider lambda over SystemClock.uptimeMillis(). The type named no clock, so a call site could not tell what it was measuring, and the arithmetic -- now minus the last tick, compared against a threshold -- was spelled out inline. MonotonicClock and Deadline replace both: the clock is a named type, and the watchdog asks the question it actually cares about, which is whether the main thread has missed its window. The clock counts deep sleep, which uptimeMillis() did not, so a suspend between posting the ticker and checking it now looks like a missed window. It cannot fabricate an ANR: the watchdog reports only once ActivityManager confirms the process is NOT_RESPONDING, and on resume the main thread runs the ticker that is already queued.
Same reasoning as the watchdog: the suspicion and ANR thresholds are now read from a named clock rather than SystemClock, and injecting it lets the tests drive it directly instead of going through Robolectric's shadow clock. Deep sleep cannot inflate the measurement here either, because the polling thread parks itself while the app is backgrounded and resets the baseline when it wakes.
runningcode
force-pushed
the
no/java-579-anr-uptime-clock
branch
from
September 8, 2026 15:10
d570570 to
51e0083
Compare
It reads the wall clock into a java.util.Date at millisecond resolution. SentryDateProvider already owns wall time, is configurable, stubbable in tests and resolves finer, so there is no reason for new code to reach for the static. The class itself is not deprecated: its other fourteen statics are pure conversion and formatting helpers with no clock in them. Every caller keeps working and gets a suppression, because -Xlint:all -Werror turns the warning into a build failure. Each suppression carries a TODO [MAJOR] naming the replacement, since a suppressed warning is no longer a checklist entry — and nearly all of these callers are frozen until the next major anyway, as they stamp serialized timestamps.
One interface carried two incompatible clocks: CurrentDateProvider is System.currentTimeMillis() and AndroidCurrentDateProvider is SystemClock.uptimeMillis(). Nothing in the name or the type said which, which is the defect JAVA-571 is about — a field declared ICurrentDateProvider accepts either, and the two disagree by however long the device has been suspended. MonotonicClock names the guarantee it gives, and SentryDateProvider covers wall time. The annotation goes on the members rather than the types: the Android modules compile at Java 8, where javac still warns on imports of a deprecated type, and an import declaration cannot carry a @SuppressWarnings. Deprecating getCurrentTimeMillis() and getInstance() warns any caller just the same, and every warning it produces lands somewhere that can be suppressed. Each suppression names the replacement the site should take at the next major. Several of these must stay on the wall clock: AnrV2Integration, TombstoneIntegration and ApplicationExitInfoHistoryDispatcher compare against epoch ApplicationExitInfo timestamps, and LifecycleWatcher against Session.getStarted().
…VA-571) Its own javadoc already told callers to prefer options.getDateProvider(); this makes the compiler say so. The static holder cannot be configured or stubbed, which is the whole reason the note was there. Annotated on the method rather than the class, for the same import reason as the current-date providers.
runningcode
force-pushed
the
no/java-571-deprecate-date-providers
branch
from
September 8, 2026 15:10
dfe0f15 to
b50dc01
Compare
runningcode
force-pushed
the
no/java-579-anr-uptime-clock
branch
from
September 9, 2026 15:51
51e0083 to
a2620aa
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
Marks the legacy date and clock surface as deprecated, now that the internal consumers that could
move have moved (#6029, #6030, #6032, #6041). Nothing is migrated here and nothing is removed — the
point is to start the warning cycle before the next major, so users and the
9.x.xbranch get afull release of notice.
ICurrentDateProvider.getCurrentTimeMillis()MonotonicClockfor an interval,SentryDateProviderfor a timestampCurrentDateProvider.getInstance()SentryDateProviderfor a timestamp,MonotonicClockfor an intervalAndroidCurrentDateProvider.getInstance()MonotonicClock(this one wasSystemClock.uptimeMillis()all along)DateUtils.getCurrentDateTime()options.getDateProvider().now()AndroidDateUtils.getCurrentSentryDateTime()options.getDateProvider()The
DateUtilsclass is deliberately not deprecated: onlygetCurrentDateTime()reads a clock,while its other fourteen statics are pure conversion and formatting helpers used all over the SDK.
Two deviations from the plan, both forced
1. The annotations sit on members, not on the types. The plan called for deprecating
ICurrentDateProvider,CurrentDateProviderandAndroidCurrentDateProvideras types. That cannotcompile here: the Android modules build at Java 8, where javac still emits a deprecation warning for
an
importof a deprecated type (JEP 211 elides those only from source 9 on),-Xlint:all -Werrorturns it into an error, and an import declaration cannot carry a
@SuppressWarnings— a class-levelsuppression does not cover it either. I verified both halves of that empirically. Thirteen Java files
in
sentry-android-coreimport these types; the alternative was writingio.sentry.transport.ICurrentDateProviderinline at ~40 use sites until the next major.Deprecating
getCurrentTimeMillis()andgetInstance()warns any caller just as loudly, and everywarning it produces lands somewhere a suppression can go. The type-level javadoc still names the
replacement, so IDEs and Javadoc readers see it.
2. Kotlin call sites are left warning, not suppressed.
-Werrorapplies toJavaCompileonly,so the 17 warnings in
sentry-android-replayandsentry-okhttpdo not break anything. They are thelive migration list for JAVA-575; suppressing them would trade a checklist for a comment. That module
already carries other deprecation warnings, so this is not a new kind of noise. Say the word if you'd
rather have them suppressed.
💡 Motivation and Context
ICurrentDateProvideris the defect JAVA-571 was filed about. One interface carried twoincompatible clocks —
CurrentDateProviderisSystem.currentTimeMillis(),AndroidCurrentDateProvideris
SystemClock.uptimeMillis()— and nothing in the name or the type said which. A field declaredICurrentDateProvideraccepts either, and the two disagree by however long the device has beensuspended.
Each of the 21 Java suppressions carries a
// TODO [MAJOR]naming the replacement that site shouldtake, because a suppressed warning stops being a checklist entry. Nearly all of them are frozen until
the next major because they produce serialized timestamps —
SentryEvent,Breadcrumb,Session,SentryReplayEvent,ProfilingTraceData, both profilers, the activity-lifecycle span helpers. OnlyClientReportRecorderandEnvelopeCacheare internal. That is expected: this PR marks the surface,it does not migrate it.
Four sites must stay on the wall clock and say so:
AnrV2Integration,TombstoneIntegrationandApplicationExitInfoHistoryDispatchercompare against epochApplicationExitInfotimestamps, andLifecycleWatcheragainstSession.getStarted().💚 How did you test it?
No behaviour changes, so no new tests — this is annotations plus suppressions.
./gradlew :sentry:test :sentry:apiCheck :sentry-android-core:testReleaseUnitTest :sentry-android-core:apiCheck :sentry-android-replay:testReleaseUnitTest :sentry-okhttp:test :sentry-apache-http-client-5:test— green../gradlew spotlessApply apiDumpproduces no.apidiff: BCV does not record annotations.📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps
The removals, and the serialized-value migrations behind every
// TODO [MAJOR]here, belong to the9.x.xbranch: JAVA-572 (span durations), JAVA-575 (replay timings), JAVA-577 (session durations),JAVA-578 (profiler re-anchoring), JAVA-642 (app-start spans).