Skip to content

Commit 4cc93b7

Browse files
runningcodeclaude
andauthored
feat(time): Add a monotonic clock abstraction (JAVA-571) (#6028)
* feat(time): Add uptime and elapsed-real-time clock abstractions (JAVA-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. * changelog * ref(time): Drop the uptime clock options seam (JAVA-571) 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> * ref(time): Trim comments that restate the type name (JAVA-571) 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> * feat(android): Back the elapsed-real-time clock with SystemClock (JAVA-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> * test(time): Drop the accessor and singleton clock tests (JAVA-571) 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> * ref(android): Override the elapsed-real-time clock instead of setting it 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> * ref(android): Make AndroidElapsedRealtimeClock a singleton 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> * test(android): Drop the elapsed-real-time clock options test (JAVA-571) * ref(time): Rename Deadline.in to Deadline.after (JAVA-571) `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. * ref(time): Update API dump for the Deadline.after rename (JAVA-571) The Deadline.in to Deadline.after rename did not regenerate sentry/api/sentry.api, so :sentry:apiCheck failed on CI. * ref(time): Collapse the clocks into a single MonotonicClock (JAVA-571) 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. * ref(time): Rename MonotonicClock to MonotonicTicker (JAVA-571) 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. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 38be688 commit 4cc93b7

13 files changed

Lines changed: 408 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
- `SentryTraced` now checks for its owning transaction dynamically rather than once per app process. The latter caused `SentryTraced` spans to be dropped process-wide once the original transaction finished ([#6057](https://github.com/getsentry/sentry-java/pull/6057))
99
- Fix typos in Spring GraphQL integration names (`GrahQL` to `GraphQL`) ([#6061](https://github.com/getsentry/sentry-java/pull/6061))
1010

11+
### Internal
12+
13+
- Add an internal `MonotonicTicker` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028))
14+
1115
## 8.55.0
1216

1317
### Features

‎sentry-android-core/api/sentry-android-core.api‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
414414
public fun getBeforeViewHierarchyCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;
415415
public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader;
416416
public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;
417+
public fun getMonotonicTicker ()Lio/sentry/time/MonotonicTicker;
417418
public fun getNativeSdkName ()Ljava/lang/String;
418419
public fun getNdkAppHangTimeoutIntervalMillis ()J
419420
public fun getNdkHandlerStrategy ()I

‎sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
import io.sentry.SentryLevel;
1313
import io.sentry.SentryOptions;
1414
import io.sentry.SpanStatus;
15+
import io.sentry.android.core.internal.time.AndroidMonotonicTicker;
1516
import io.sentry.android.core.internal.util.RootChecker;
1617
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
1718
import io.sentry.protocol.Mechanism;
1819
import io.sentry.protocol.SdkVersion;
1920
import io.sentry.protocol.SentryId;
21+
import io.sentry.time.MonotonicTicker;
2022
import io.sentry.util.SampleRateUtils;
2123
import org.jetbrains.annotations.ApiStatus;
2224
import org.jetbrains.annotations.NotNull;
@@ -889,6 +891,12 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) {
889891
this.enableAnrFingerprinting = enableAnrFingerprinting;
890892
}
891893

894+
@Override
895+
@ApiStatus.Internal
896+
public @NotNull MonotonicTicker getMonotonicTicker() {
897+
return AndroidMonotonicTicker.getInstance();
898+
}
899+
892900
static class AndroidUserFeedbackFormHandler implements SentryFeedbackOptions.IFormHandler {
893901
@Override
894902
public void showForm(
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package io.sentry.android.core.internal.time;
2+
3+
import android.os.SystemClock;
4+
import io.sentry.time.MonotonicTicker;
5+
import org.jetbrains.annotations.ApiStatus;
6+
import org.jetbrains.annotations.NotNull;
7+
8+
/**
9+
* {@link MonotonicTicker} backed by {@link SystemClock#elapsedRealtimeNanos()}.
10+
*
11+
* <p>That is {@code CLOCK_BOOTTIME}, so it keeps counting while the device is suspended — unlike
12+
* {@link System#nanoTime()}, which the core module falls back to and which stops in deep sleep.
13+
*/
14+
@ApiStatus.Internal
15+
public final class AndroidMonotonicTicker implements MonotonicTicker {
16+
17+
private static final AndroidMonotonicTicker instance = new AndroidMonotonicTicker();
18+
19+
public static @NotNull MonotonicTicker getInstance() {
20+
return instance;
21+
}
22+
23+
private AndroidMonotonicTicker() {}
24+
25+
@Override
26+
public long tickNanos() {
27+
return SystemClock.elapsedRealtimeNanos();
28+
}
29+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.sentry.time
2+
3+
import java.util.concurrent.TimeUnit
4+
5+
/**
6+
* A [MonotonicTicker] that only moves when a test tells it to.
7+
*
8+
* Advancing by an amount *and a unit* is the point: a stubbed `thenReturn(1001)` against a
9+
* nanosecond ticker is off by a factor of a million and still compiles, whereas `advance(1001,
10+
* MILLISECONDS)` cannot be.
11+
*/
12+
class TestMonotonicTicker(private var nanos: Long = 0) : MonotonicTicker {
13+
override fun tickNanos(): Long = nanos
14+
15+
fun advance(amount: Long, unit: TimeUnit) {
16+
nanos += unit.toNanos(amount)
17+
}
18+
}

‎sentry/api/sentry.api‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3740,6 +3740,7 @@ public class io/sentry/SentryOptions {
37403740
public fun getMaxTraceFileSize ()J
37413741
public fun getMetrics ()Lio/sentry/SentryOptions$Metrics;
37423742
public fun getModulesLoader ()Lio/sentry/internal/modules/IModulesLoader;
3743+
public fun getMonotonicTicker ()Lio/sentry/time/MonotonicTicker;
37433744
public fun getOnDiscard ()Lio/sentry/SentryOptions$OnDiscardCallback;
37443745
public fun getOnOversizedEvent ()Lio/sentry/SentryOptions$OnOversizedEventCallback;
37453746
public fun getOpenTelemetryMode ()Lio/sentry/SentryOpenTelemetryMode;
@@ -7597,6 +7598,29 @@ public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys {
75977598
public fun <init> ()V
75987599
}
75997600

7601+
public final class io/sentry/time/Deadline {
7602+
public static fun after (Lio/sentry/time/MonotonicTicker;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline;
7603+
public fun hasPassed ()Z
7604+
public fun isAfter (Lio/sentry/time/Deadline;)Z
7605+
public static fun passed (Lio/sentry/time/MonotonicTicker;)Lio/sentry/time/Deadline;
7606+
public fun remaining (Ljava/util/concurrent/TimeUnit;)J
7607+
}
7608+
7609+
public final class io/sentry/time/JavaMonotonicTicker : io/sentry/time/MonotonicTicker {
7610+
public static fun getInstance ()Lio/sentry/time/MonotonicTicker;
7611+
public fun tickNanos ()J
7612+
}
7613+
7614+
public abstract interface class io/sentry/time/MonotonicTicker {
7615+
public abstract fun tickNanos ()J
7616+
}
7617+
7618+
public final class io/sentry/time/Stopwatch {
7619+
public fun elapsed (Ljava/util/concurrent/TimeUnit;)J
7620+
public fun elapsedNanos ()J
7621+
public static fun started (Lio/sentry/time/MonotonicTicker;)Lio/sentry/time/Stopwatch;
7622+
}
7623+
76007624
public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport {
76017625
public fun <init> (Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/RequestDetails;)V
76027626
public fun <init> (Lio/sentry/transport/QueuedThreadPoolExecutor;Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/transport/HttpConnection;)V

‎sentry/src/main/java/io/sentry/SentryOptions.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import io.sentry.metrics.IMetricsBatchProcessorFactory;
2222
import io.sentry.protocol.SdkVersion;
2323
import io.sentry.protocol.SentryTransaction;
24+
import io.sentry.time.JavaMonotonicTicker;
25+
import io.sentry.time.MonotonicTicker;
2426
import io.sentry.transport.ITransport;
2527
import io.sentry.transport.ITransportGate;
2628
import io.sentry.transport.NoOpEnvelopeCache;
@@ -3059,6 +3061,18 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) {
30593061
this.dateProvider.setValue(dateProvider);
30603062
}
30613063

3064+
/**
3065+
* Returns the ticker used to measure elapsed time, such as rate-limit windows, cache expiry and
3066+
* ANR thresholds.
3067+
*
3068+
* <p>Android overrides this with a {@code SystemClock.elapsedRealtimeNanos()}-backed ticker,
3069+
* which this module cannot reference. On the JVM there is no suspend state to account for.
3070+
*/
3071+
@ApiStatus.Internal
3072+
public @NotNull MonotonicTicker getMonotonicTicker() {
3073+
return JavaMonotonicTicker.getInstance();
3074+
}
3075+
30623076
/**
30633077
* Adds a ICollector.
30643078
*
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package io.sentry.time;
2+
3+
import java.util.concurrent.TimeUnit;
4+
import org.jetbrains.annotations.ApiStatus;
5+
import org.jetbrains.annotations.NotNull;
6+
7+
/**
8+
* A point in the future, measured on a {@link MonotonicTicker}.
9+
*
10+
* <p>Exists so that callers never do arithmetic on raw ticks. A tick carries no unit and no epoch,
11+
* so spelling out {@code now - then < ttl} at every call site is where unit mix-ups, sentinels that
12+
* happen to mean "boot", and wrap-unsafe {@code <} comparisons come from. Each of those is decided
13+
* once, here.
14+
*/
15+
@ApiStatus.Internal
16+
public final class Deadline {
17+
18+
private final @NotNull MonotonicTicker ticker;
19+
private final long deadlineNanos;
20+
21+
private Deadline(final @NotNull MonotonicTicker ticker, final long deadlineNanos) {
22+
this.ticker = ticker;
23+
this.deadlineNanos = deadlineNanos;
24+
}
25+
26+
/**
27+
* A deadline {@code amount} of {@code unit} from now.
28+
*
29+
* @throws IllegalArgumentException if {@code amount} is negative. A deadline that starts out in
30+
* the past is a sign error at the call site; {@link #passed} says it deliberately.
31+
*/
32+
public static @NotNull Deadline after(
33+
final @NotNull MonotonicTicker ticker, final long amount, final @NotNull TimeUnit unit) {
34+
if (amount < 0) {
35+
throw new IllegalArgumentException("Deadline amount must not be negative, but was " + amount);
36+
}
37+
return new Deadline(ticker, ticker.tickNanos() + unit.toNanos(amount));
38+
}
39+
40+
/**
41+
* A deadline that has already passed.
42+
*
43+
* <p>Saves callers from reserving a tick value to mean "not set yet": {@code 0} is a real and
44+
* very recent instant on a boot-relative ticker, so a field left at {@code 0} reads as freshly
45+
* set rather than as unset.
46+
*/
47+
public static @NotNull Deadline passed(final @NotNull MonotonicTicker ticker) {
48+
return new Deadline(ticker, ticker.tickNanos());
49+
}
50+
51+
public boolean hasPassed() {
52+
// Subtraction rather than `<`: a tick origin is arbitrary, may be negative, and may wrap.
53+
return ticker.tickNanos() - deadlineNanos >= 0;
54+
}
55+
56+
/**
57+
* How much time is left, rounded up, or zero once the deadline has passed.
58+
*
59+
* <p>Rounding up matters: callers schedule work for {@code remaining()} and then re-check {@link
60+
* #hasPassed()}. Truncating would wake them a fraction early, to find the deadline still
61+
* standing.
62+
*/
63+
public long remaining(final @NotNull TimeUnit unit) {
64+
final long remainingNanos = deadlineNanos - ticker.tickNanos();
65+
if (remainingNanos <= 0) {
66+
return 0;
67+
}
68+
final long unitNanos = unit.toNanos(1);
69+
final long whole = remainingNanos / unitNanos;
70+
return remainingNanos % unitNanos == 0 ? whole : whole + 1;
71+
}
72+
73+
/**
74+
* Whether this deadline falls after {@code other}.
75+
*
76+
* @throws IllegalArgumentException if the two were created from different tickers, whose origins
77+
* are unrelated and whose ticks are therefore not comparable.
78+
*/
79+
public boolean isAfter(final @NotNull Deadline other) {
80+
if (ticker != other.ticker) {
81+
throw new IllegalArgumentException(
82+
"Cannot compare deadlines from different tickers: "
83+
+ ticker.getClass().getName()
84+
+ " and "
85+
+ other.ticker.getClass().getName());
86+
}
87+
return deadlineNanos - other.deadlineNanos > 0;
88+
}
89+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package io.sentry.time;
2+
3+
import org.jetbrains.annotations.ApiStatus;
4+
import org.jetbrains.annotations.NotNull;
5+
6+
/** {@link MonotonicTicker} backed by {@link System#nanoTime()}. */
7+
@ApiStatus.Internal
8+
public final class JavaMonotonicTicker implements MonotonicTicker {
9+
10+
private static final JavaMonotonicTicker instance = new JavaMonotonicTicker();
11+
12+
public static @NotNull MonotonicTicker getInstance() {
13+
return instance;
14+
}
15+
16+
private JavaMonotonicTicker() {}
17+
18+
@Override
19+
public long tickNanos() {
20+
return System.nanoTime();
21+
}
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package io.sentry.time;
2+
3+
import org.jetbrains.annotations.ApiStatus;
4+
5+
/**
6+
* A monotonically increasing nanosecond counter, including time the device spent suspended in deep
7+
* sleep.
8+
*
9+
* <p>This type deliberately promises very little: a tick is a number that does not go backwards,
10+
* measured from an origin that is arbitrary and may be negative. Only <em>differences</em> between
11+
* two ticks from the same instance are meaningful, and a tick must never be persisted, serialized,
12+
* or compared against a value from another ticker.
13+
*
14+
* <p>On Android this is {@code CLOCK_BOOTTIME}, via {@code SystemClock.elapsedRealtimeNanos()}, so
15+
* an interval measured across a suspend reports the real time that passed rather than only the time
16+
* the CPU was awake. On the JVM there is no comparable suspend state, so {@link System#nanoTime()}
17+
* is equivalent.
18+
*/
19+
@ApiStatus.Internal
20+
public interface MonotonicTicker {
21+
long tickNanos();
22+
}

0 commit comments

Comments
 (0)