From 6e876813fcb758509ef4a98c19be03913daf50a5 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 14:47:37 +0200 Subject: [PATCH 01/13] 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. --- .../main/kotlin/io/sentry/time/TestTicker.kt | 22 +++++ sentry/api/sentry.api | 38 ++++++++ .../main/java/io/sentry/SentryOptions.java | 44 +++++++++ .../main/java/io/sentry/time/Deadline.java | 79 ++++++++++++++++ .../io/sentry/time/ElapsedRealtimeClock.java | 16 ++++ .../sentry/time/JavaElapsedRealtimeClock.java | 28 ++++++ .../java/io/sentry/time/JavaUptimeClock.java | 22 +++++ .../main/java/io/sentry/time/Stopwatch.java | 35 +++++++ .../src/main/java/io/sentry/time/Ticker.java | 20 ++++ .../main/java/io/sentry/time/UptimeClock.java | 16 ++++ .../test/java/io/sentry/time/DeadlineTest.kt | 93 +++++++++++++++++++ .../io/sentry/time/SentryOptionsClockTest.kt | 34 +++++++ .../test/java/io/sentry/time/StopwatchTest.kt | 38 ++++++++ 13 files changed, 485 insertions(+) create mode 100644 sentry-test-support/src/main/kotlin/io/sentry/time/TestTicker.kt create mode 100644 sentry/src/main/java/io/sentry/time/Deadline.java create mode 100644 sentry/src/main/java/io/sentry/time/ElapsedRealtimeClock.java create mode 100644 sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java create mode 100644 sentry/src/main/java/io/sentry/time/JavaUptimeClock.java create mode 100644 sentry/src/main/java/io/sentry/time/Stopwatch.java create mode 100644 sentry/src/main/java/io/sentry/time/Ticker.java create mode 100644 sentry/src/main/java/io/sentry/time/UptimeClock.java create mode 100644 sentry/src/test/java/io/sentry/time/DeadlineTest.kt create mode 100644 sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt create mode 100644 sentry/src/test/java/io/sentry/time/StopwatchTest.kt diff --git a/sentry-test-support/src/main/kotlin/io/sentry/time/TestTicker.kt b/sentry-test-support/src/main/kotlin/io/sentry/time/TestTicker.kt new file mode 100644 index 0000000000..1b9277be4c --- /dev/null +++ b/sentry-test-support/src/main/kotlin/io/sentry/time/TestTicker.kt @@ -0,0 +1,22 @@ +package io.sentry.time + +import java.util.concurrent.TimeUnit + +/** + * A [Ticker] that only moves when a test tells it to. + * + * Advancing by an amount *and a unit* is the point: a stubbed `thenReturn(1001)` against a + * nanosecond clock is off by a factor of a million and still compiles, whereas `advance(1001, + * MILLISECONDS)` cannot be. + * + * Implements both clock guarantees so a test can inject it wherever either is declared. Production + * code must never do this — the whole purpose of the two interfaces is that one object cannot + * honestly promise both. + */ +class TestTicker(private var nanos: Long = 0) : UptimeClock, ElapsedRealtimeClock { + override fun tickNanos(): Long = nanos + + fun advance(amount: Long, unit: TimeUnit) { + nanos += unit.toNanos(amount) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9f44481fd9..d94998d233 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3704,6 +3704,7 @@ public class io/sentry/SentryOptions { public fun getDistributionController ()Lio/sentry/IDistributionApi; public fun getDsn ()Ljava/lang/String; public fun getEffectiveOrgId ()Ljava/lang/String; + public fun getElapsedRealtimeClock ()Lio/sentry/time/ElapsedRealtimeClock; public fun getEnvelopeDiskCache ()Lio/sentry/cache/IEnvelopeCache; public fun getEnvelopeReader ()Lio/sentry/IEnvelopeReader; public fun getEnvironment ()Ljava/lang/String; @@ -3783,6 +3784,7 @@ public class io/sentry/SentryOptions { public fun getTransactionProfiler ()Lio/sentry/ITransactionProfiler; public fun getTransportFactory ()Lio/sentry/ITransportFactory; public fun getTransportGate ()Lio/sentry/transport/ITransportGate; + public fun getUptimeClock ()Lio/sentry/time/UptimeClock; public fun getVersionDetector ()Lio/sentry/IVersionDetector; public final fun getViewHierarchyExporters ()Ljava/util/List; public fun isAttachServerName ()Z @@ -3854,6 +3856,7 @@ public class io/sentry/SentryOptions { public fun setDistribution (Lio/sentry/SentryOptions$DistributionOptions;)V public fun setDistributionController (Lio/sentry/IDistributionApi;)V public fun setDsn (Ljava/lang/String;)V + public fun setElapsedRealtimeClock (Lio/sentry/time/ElapsedRealtimeClock;)V public fun setEnableAppStartProfiling (Z)V public fun setEnableAutoSessionTracking (Z)V public fun setEnableBackpressureHandling (Z)V @@ -3953,6 +3956,7 @@ public class io/sentry/SentryOptions { public fun setTransactionProfiler (Lio/sentry/ITransactionProfiler;)V public fun setTransportFactory (Lio/sentry/ITransportFactory;)V public fun setTransportGate (Lio/sentry/transport/ITransportGate;)V + public fun setUptimeClock (Lio/sentry/time/UptimeClock;)V public fun setVersionDetector (Lio/sentry/IVersionDetector;)V public fun setViewHierarchyExporters (Ljava/util/List;)V } @@ -7597,6 +7601,40 @@ public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys { public fun ()V } +public final class io/sentry/time/Deadline { + public fun hasPassed ()Z + public static fun in (Lio/sentry/time/Ticker;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline; + public fun isAfter (Lio/sentry/time/Deadline;)Z + public static fun passed (Lio/sentry/time/Ticker;)Lio/sentry/time/Deadline; + public fun remaining (Ljava/util/concurrent/TimeUnit;)J +} + +public abstract interface class io/sentry/time/ElapsedRealtimeClock : io/sentry/time/Ticker { +} + +public final class io/sentry/time/JavaElapsedRealtimeClock : io/sentry/time/ElapsedRealtimeClock { + public static fun getInstance ()Lio/sentry/time/ElapsedRealtimeClock; + public fun tickNanos ()J +} + +public final class io/sentry/time/JavaUptimeClock : io/sentry/time/UptimeClock { + public static fun getInstance ()Lio/sentry/time/UptimeClock; + public fun tickNanos ()J +} + +public final class io/sentry/time/Stopwatch { + public fun elapsed (Ljava/util/concurrent/TimeUnit;)J + public fun elapsedNanos ()J + public static fun started (Lio/sentry/time/Ticker;)Lio/sentry/time/Stopwatch; +} + +public abstract interface class io/sentry/time/Ticker { + public abstract fun tickNanos ()J +} + +public abstract interface class io/sentry/time/UptimeClock : io/sentry/time/Ticker { +} + public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport { public fun (Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/RequestDetails;)V public fun (Lio/sentry/transport/QueuedThreadPoolExecutor;Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/transport/HttpConnection;)V diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index d7a16d4ee2..2f8f63287a 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -21,6 +21,10 @@ import io.sentry.metrics.IMetricsBatchProcessorFactory; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryTransaction; +import io.sentry.time.ElapsedRealtimeClock; +import io.sentry.time.JavaElapsedRealtimeClock; +import io.sentry.time.JavaUptimeClock; +import io.sentry.time.UptimeClock; import io.sentry.transport.ITransport; import io.sentry.transport.ITransportGate; import io.sentry.transport.NoOpEnvelopeCache; @@ -525,6 +529,16 @@ public class SentryOptions { private final @NotNull LazyEvaluator dateProvider = new LazyEvaluator<>(() -> new SentryAutoDateProvider()); + /** Clock for measuring intervals that must exclude time the device spent in deep sleep. */ + @ApiStatus.Internal + private final @NotNull LazyEvaluator uptimeClock = + new LazyEvaluator<>(() -> JavaUptimeClock.getInstance()); + + /** Clock for measuring intervals that must include time the device spent in deep sleep. */ + @ApiStatus.Internal + private final @NotNull LazyEvaluator elapsedRealtimeClock = + new LazyEvaluator<>(() -> JavaElapsedRealtimeClock.getInstance()); + private final @NotNull List performanceCollectors = new ArrayList<>(); /** Performance collector that collect performance stats while transactions run. */ @@ -3059,6 +3073,36 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { this.dateProvider.setValue(dateProvider); } + /** + * Returns the clock used to measure intervals that must exclude deep sleep, such as ANR + * thresholds. + */ + @ApiStatus.Internal + public @NotNull UptimeClock getUptimeClock() { + return uptimeClock.getValue(); + } + + /** Sets the clock used to measure intervals that must exclude deep sleep. */ + @ApiStatus.Internal + public void setUptimeClock(final @NotNull UptimeClock uptimeClock) { + this.uptimeClock.setValue(uptimeClock); + } + + /** + * Returns the clock used to measure intervals that must include deep sleep, such as rate-limit + * windows and cache expiry. + */ + @ApiStatus.Internal + public @NotNull ElapsedRealtimeClock getElapsedRealtimeClock() { + return elapsedRealtimeClock.getValue(); + } + + /** Sets the clock used to measure intervals that must include deep sleep. */ + @ApiStatus.Internal + public void setElapsedRealtimeClock(final @NotNull ElapsedRealtimeClock elapsedRealtimeClock) { + this.elapsedRealtimeClock.setValue(elapsedRealtimeClock); + } + /** * Adds a ICollector. * diff --git a/sentry/src/main/java/io/sentry/time/Deadline.java b/sentry/src/main/java/io/sentry/time/Deadline.java new file mode 100644 index 0000000000..b3db5e6adf --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/Deadline.java @@ -0,0 +1,79 @@ +package io.sentry.time; + +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * A point in the future, measured on a {@link Ticker}. + * + *

Exists so that callers never do arithmetic on raw ticks. A tick carries no unit and no epoch, + * so spelling out {@code now - then < ttl} at every call site is where unit mix-ups, sentinels that + * happen to mean "boot", and wrap-unsafe {@code <} comparisons come from. Each of those is decided + * once, here. + */ +@ApiStatus.Internal +public final class Deadline { + + private final @NotNull Ticker clock; + private final long deadlineNanos; + + private Deadline(final @NotNull Ticker clock, final long deadlineNanos) { + this.clock = clock; + this.deadlineNanos = deadlineNanos; + } + + /** A deadline {@code amount} of {@code unit} from now. */ + public static @NotNull Deadline in( + final @NotNull Ticker clock, final long amount, final @NotNull TimeUnit unit) { + return new Deadline(clock, clock.tickNanos() + unit.toNanos(amount)); + } + + /** + * A deadline that has already passed. Use for state that has not been populated yet, so that + * "never set" needs no numeric sentinel and cannot be mistaken for fresh — {@code 0} is a real + * and very recent instant on any boot-relative clock. + */ + public static @NotNull Deadline passed(final @NotNull Ticker clock) { + return new Deadline(clock, clock.tickNanos()); + } + + public boolean hasPassed() { + // Subtraction rather than `<`: a tick origin is arbitrary, may be negative, and may wrap. + return clock.tickNanos() - deadlineNanos >= 0; + } + + /** + * How much time is left, rounded up, or zero once the deadline has passed. + * + *

Rounding up matters: callers schedule work for {@code remaining()} and then re-check {@link + * #hasPassed()}. Truncating would wake them a fraction early, to find the deadline still + * standing. + */ + public long remaining(final @NotNull TimeUnit unit) { + final long remainingNanos = deadlineNanos - clock.tickNanos(); + if (remainingNanos <= 0) { + return 0; + } + final long unitNanos = unit.toNanos(1); + final long whole = remainingNanos / unitNanos; + return remainingNanos % unitNanos == 0 ? whole : whole + 1; + } + + /** + * Whether this deadline falls after {@code other}. + * + * @throws IllegalArgumentException if the two were created from different clocks, whose origins + * are unrelated and whose ticks are therefore not comparable. + */ + public boolean isAfter(final @NotNull Deadline other) { + if (clock != other.clock) { + throw new IllegalArgumentException( + "Cannot compare deadlines from different clocks: " + + clock.getClass().getName() + + " and " + + other.clock.getClass().getName()); + } + return deadlineNanos - other.deadlineNanos > 0; + } +} diff --git a/sentry/src/main/java/io/sentry/time/ElapsedRealtimeClock.java b/sentry/src/main/java/io/sentry/time/ElapsedRealtimeClock.java new file mode 100644 index 0000000000..dce672e621 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/ElapsedRealtimeClock.java @@ -0,0 +1,16 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; + +/** + * A {@link Ticker} that includes time the device spent suspended in deep sleep. + * + *

This is the clock for anything expressed in real elapsed time regardless of what the device + * was doing — a rate-limit window the server asked us to wait out, or a cache entry that should go + * stale on a wall-clock schedule. + * + *

On Android this is {@code CLOCK_BOOTTIME}, via {@code SystemClock.elapsedRealtimeNanos()}. On + * the JVM there is no comparable suspend state, so uptime and elapsed real time coincide. + */ +@ApiStatus.Internal +public interface ElapsedRealtimeClock extends Ticker {} diff --git a/sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java b/sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java new file mode 100644 index 0000000000..29dd1b3223 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java @@ -0,0 +1,28 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * {@link ElapsedRealtimeClock} backed by {@link System#nanoTime()}. + * + *

A JVM has no equivalent of Android's deep sleep that it can observe, so this is the same + * source as {@link JavaUptimeClock}. The two are distinct types anyway, so that a call site + * declaring which guarantee it needs keeps documenting that intent on every platform. + */ +@ApiStatus.Internal +public final class JavaElapsedRealtimeClock implements ElapsedRealtimeClock { + + private static final JavaElapsedRealtimeClock instance = new JavaElapsedRealtimeClock(); + + public static @NotNull ElapsedRealtimeClock getInstance() { + return instance; + } + + private JavaElapsedRealtimeClock() {} + + @Override + public long tickNanos() { + return System.nanoTime(); + } +} diff --git a/sentry/src/main/java/io/sentry/time/JavaUptimeClock.java b/sentry/src/main/java/io/sentry/time/JavaUptimeClock.java new file mode 100644 index 0000000000..101eaef7d1 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/JavaUptimeClock.java @@ -0,0 +1,22 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** {@link UptimeClock} backed by {@link System#nanoTime()}. */ +@ApiStatus.Internal +public final class JavaUptimeClock implements UptimeClock { + + private static final JavaUptimeClock instance = new JavaUptimeClock(); + + public static @NotNull UptimeClock getInstance() { + return instance; + } + + private JavaUptimeClock() {} + + @Override + public long tickNanos() { + return System.nanoTime(); + } +} diff --git a/sentry/src/main/java/io/sentry/time/Stopwatch.java b/sentry/src/main/java/io/sentry/time/Stopwatch.java new file mode 100644 index 0000000000..249a73041c --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/Stopwatch.java @@ -0,0 +1,35 @@ +package io.sentry.time; + +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Measures how long something took, on a {@link Ticker}. + * + *

The counterpart to {@link Deadline}: it keeps the start tick and the unit conversion in one + * place, so call sites stop repeating {@code System.nanoTime() - startTime}. + */ +@ApiStatus.Internal +public final class Stopwatch { + + private final @NotNull Ticker clock; + private final long startNanos; + + private Stopwatch(final @NotNull Ticker clock) { + this.clock = clock; + this.startNanos = clock.tickNanos(); + } + + public static @NotNull Stopwatch started(final @NotNull Ticker clock) { + return new Stopwatch(clock); + } + + public long elapsedNanos() { + return clock.tickNanos() - startNanos; + } + + public long elapsed(final @NotNull TimeUnit unit) { + return unit.convert(elapsedNanos(), TimeUnit.NANOSECONDS); + } +} diff --git a/sentry/src/main/java/io/sentry/time/Ticker.java b/sentry/src/main/java/io/sentry/time/Ticker.java new file mode 100644 index 0000000000..a56ce0fe79 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/Ticker.java @@ -0,0 +1,20 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; + +/** + * A monotonically increasing nanosecond counter. + * + *

This type deliberately promises very little: a tick is a number that does not go backwards, + * measured from an origin that is arbitrary and may be negative. Only differences between + * two ticks from the same instance are meaningful, and a tick must never be persisted, serialized, + * or compared against a value from another clock. + * + *

Do not implement or depend on {@code Ticker} directly. It exists so that {@link Deadline} and + * {@link Stopwatch} can be written once; callers declare {@link UptimeClock} or {@link + * ElapsedRealtimeClock}, whose names state which guarantee they provide. + */ +@ApiStatus.Internal +public interface Ticker { + long tickNanos(); +} diff --git a/sentry/src/main/java/io/sentry/time/UptimeClock.java b/sentry/src/main/java/io/sentry/time/UptimeClock.java new file mode 100644 index 0000000000..992c729e63 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/UptimeClock.java @@ -0,0 +1,16 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; + +/** + * A {@link Ticker} that excludes time the device spent suspended in deep sleep. + * + *

This is the clock for measuring how long the CPU was actually available — most importantly ANR + * detection, where counting suspended time would report a responsive main thread as blocked. + * + *

On Android this is {@code CLOCK_MONOTONIC}, the same clock behind {@code + * SystemClock.uptimeMillis()}. On the JVM there is no comparable suspend state, so uptime and + * elapsed real time coincide. + */ +@ApiStatus.Internal +public interface UptimeClock extends Ticker {} diff --git a/sentry/src/test/java/io/sentry/time/DeadlineTest.kt b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt new file mode 100644 index 0000000000..9f3e47aef2 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt @@ -0,0 +1,93 @@ +package io.sentry.time + +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.MINUTES +import java.util.concurrent.TimeUnit.SECONDS +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DeadlineTest { + @Test + fun `has not passed before the deadline`() { + val clock = TestTicker() + val deadline = Deadline.`in`(clock, 2, MINUTES) + + clock.advance(119, SECONDS) + + assertFalse(deadline.hasPassed()) + } + + @Test + fun `has passed once the deadline is reached`() { + val clock = TestTicker() + val deadline = Deadline.`in`(clock, 2, MINUTES) + + clock.advance(2, MINUTES) + + assertTrue(deadline.hasPassed()) + } + + @Test + fun `a passed deadline is never fresh, even at tick zero`() { + // Regression guard: elapsedRealtimeNanos and uptimeMillis both start at 0 on boot, so a + // numeric sentinel of 0 would read as fresh for a whole TTL after every boot. + assertTrue(Deadline.passed(TestTicker()).hasPassed()) + } + + @Test + fun `remaining counts down and floors at zero`() { + val clock = TestTicker() + val deadline = Deadline.`in`(clock, 1000, MILLISECONDS) + + assertEquals(1000, deadline.remaining(MILLISECONDS)) + + clock.advance(400, MILLISECONDS) + assertEquals(600, deadline.remaining(MILLISECONDS)) + + clock.advance(10, MINUTES) + assertEquals(0, deadline.remaining(MILLISECONDS)) + } + + @Test + fun `remaining rounds up so callers never wake before the deadline`() { + val clock = TestTicker() + val deadline = Deadline.`in`(clock, 1000, MILLISECONDS) + + // half a millisecond in: 999.5ms left, which must not report as 999 + clock.advance(500, java.util.concurrent.TimeUnit.MICROSECONDS) + + assertEquals(1000, deadline.remaining(MILLISECONDS)) + } + + @Test + fun `isAfter compares two deadlines`() { + val clock = TestTicker() + val shorter = Deadline.`in`(clock, 1, SECONDS) + val longer = Deadline.`in`(clock, 5, SECONDS) + + assertTrue(longer.isAfter(shorter)) + assertFalse(shorter.isAfter(longer)) + } + + @Test + fun `isAfter rejects deadlines from different clocks`() { + val deadline = Deadline.`in`(TestTicker(), 1, SECONDS) + val fromAnotherClock = Deadline.`in`(TestTicker(), 5, SECONDS) + + assertFailsWith { deadline.isAfter(fromAnotherClock) } + } + + @Test + fun `comparisons hold when the tick origin is negative`() { + // System.nanoTime() may start negative; only differences are meaningful. + val clock = TestTicker(Long.MIN_VALUE + 1) + val deadline = Deadline.`in`(clock, 1, SECONDS) + + assertFalse(deadline.hasPassed()) + clock.advance(1, SECONDS) + assertTrue(deadline.hasPassed()) + } +} diff --git a/sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt b/sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt new file mode 100644 index 0000000000..31de24d0de --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt @@ -0,0 +1,34 @@ +package io.sentry.time + +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryOptions +import kotlin.test.Test + +class SentryOptionsClockTest { + @Test + fun `defaults to the JVM clocks`() { + val options = SentryOptions() + + assertThat(options.uptimeClock).isInstanceOf(JavaUptimeClock::class.java) + assertThat(options.elapsedRealtimeClock).isInstanceOf(JavaElapsedRealtimeClock::class.java) + } + + @Test + fun `the default clocks are singletons`() { + assertThat(SentryOptions().uptimeClock).isSameInstanceAs(JavaUptimeClock.getInstance()) + assertThat(SentryOptions().elapsedRealtimeClock) + .isSameInstanceAs(JavaElapsedRealtimeClock.getInstance()) + } + + @Test + fun `a platform can replace either clock`() { + val options = SentryOptions() + val ticker = TestTicker() + + options.uptimeClock = ticker + options.elapsedRealtimeClock = ticker + + assertThat(options.uptimeClock).isSameInstanceAs(ticker) + assertThat(options.elapsedRealtimeClock).isSameInstanceAs(ticker) + } +} diff --git a/sentry/src/test/java/io/sentry/time/StopwatchTest.kt b/sentry/src/test/java/io/sentry/time/StopwatchTest.kt new file mode 100644 index 0000000000..bfcd924fd1 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/StopwatchTest.kt @@ -0,0 +1,38 @@ +package io.sentry.time + +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.NANOSECONDS +import java.util.concurrent.TimeUnit.SECONDS +import kotlin.test.Test +import kotlin.test.assertEquals + +class StopwatchTest { + @Test + fun `starts at zero`() { + assertEquals(0, Stopwatch.started(TestTicker()).elapsedNanos()) + } + + @Test + fun `reports elapsed time in the requested unit`() { + val clock = TestTicker() + val stopwatch = Stopwatch.started(clock) + + clock.advance(1500, MILLISECONDS) + + assertEquals(1, stopwatch.elapsed(SECONDS)) + assertEquals(1500, stopwatch.elapsed(MILLISECONDS)) + assertEquals(MILLISECONDS.toNanos(1500), stopwatch.elapsed(NANOSECONDS)) + } + + @Test + fun `keeps running across reads`() { + val clock = TestTicker() + val stopwatch = Stopwatch.started(clock) + + clock.advance(1, SECONDS) + assertEquals(1, stopwatch.elapsed(SECONDS)) + + clock.advance(2, SECONDS) + assertEquals(3, stopwatch.elapsed(SECONDS)) + } +} From 532cc5ff702485b3eaaeb750c37c62c8bff2fb9f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 14:48:40 +0200 Subject: [PATCH 02/13] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4b065228..de2b0784b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - `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)) - Fix typos in Spring GraphQL integration names (`GrahQL` to `GraphQL`) ([#6061](https://github.com/getsentry/sentry-java/pull/6061)) +### Internal + +- Add an internal `UptimeClock` and `ElapsedRealtimeClock` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) + ## 8.55.0 ### Features From 78707e1a8cac4dbf241808ef8bd79eb0b61dfdb7 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 15:03:29 +0200 Subject: [PATCH 03/13] 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) --- sentry/api/sentry.api | 2 -- .../main/java/io/sentry/SentryOptions.java | 22 ------------------- .../java/io/sentry/time/JavaClocksTest.kt | 20 +++++++++++++++++ .../io/sentry/time/SentryOptionsClockTest.kt | 15 +++++-------- 4 files changed, 25 insertions(+), 34 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/time/JavaClocksTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d94998d233..deb33c50ce 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3784,7 +3784,6 @@ public class io/sentry/SentryOptions { public fun getTransactionProfiler ()Lio/sentry/ITransactionProfiler; public fun getTransportFactory ()Lio/sentry/ITransportFactory; public fun getTransportGate ()Lio/sentry/transport/ITransportGate; - public fun getUptimeClock ()Lio/sentry/time/UptimeClock; public fun getVersionDetector ()Lio/sentry/IVersionDetector; public final fun getViewHierarchyExporters ()Ljava/util/List; public fun isAttachServerName ()Z @@ -3956,7 +3955,6 @@ public class io/sentry/SentryOptions { public fun setTransactionProfiler (Lio/sentry/ITransactionProfiler;)V public fun setTransportFactory (Lio/sentry/ITransportFactory;)V public fun setTransportGate (Lio/sentry/transport/ITransportGate;)V - public fun setUptimeClock (Lio/sentry/time/UptimeClock;)V public fun setVersionDetector (Lio/sentry/IVersionDetector;)V public fun setViewHierarchyExporters (Ljava/util/List;)V } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 2f8f63287a..8e847cc6e9 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -23,8 +23,6 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.time.ElapsedRealtimeClock; import io.sentry.time.JavaElapsedRealtimeClock; -import io.sentry.time.JavaUptimeClock; -import io.sentry.time.UptimeClock; import io.sentry.transport.ITransport; import io.sentry.transport.ITransportGate; import io.sentry.transport.NoOpEnvelopeCache; @@ -529,11 +527,6 @@ public class SentryOptions { private final @NotNull LazyEvaluator dateProvider = new LazyEvaluator<>(() -> new SentryAutoDateProvider()); - /** Clock for measuring intervals that must exclude time the device spent in deep sleep. */ - @ApiStatus.Internal - private final @NotNull LazyEvaluator uptimeClock = - new LazyEvaluator<>(() -> JavaUptimeClock.getInstance()); - /** Clock for measuring intervals that must include time the device spent in deep sleep. */ @ApiStatus.Internal private final @NotNull LazyEvaluator elapsedRealtimeClock = @@ -3073,21 +3066,6 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { this.dateProvider.setValue(dateProvider); } - /** - * Returns the clock used to measure intervals that must exclude deep sleep, such as ANR - * thresholds. - */ - @ApiStatus.Internal - public @NotNull UptimeClock getUptimeClock() { - return uptimeClock.getValue(); - } - - /** Sets the clock used to measure intervals that must exclude deep sleep. */ - @ApiStatus.Internal - public void setUptimeClock(final @NotNull UptimeClock uptimeClock) { - this.uptimeClock.setValue(uptimeClock); - } - /** * Returns the clock used to measure intervals that must include deep sleep, such as rate-limit * windows and cache expiry. diff --git a/sentry/src/test/java/io/sentry/time/JavaClocksTest.kt b/sentry/src/test/java/io/sentry/time/JavaClocksTest.kt new file mode 100644 index 0000000000..1a5a2ac893 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/JavaClocksTest.kt @@ -0,0 +1,20 @@ +package io.sentry.time + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test + +class JavaClocksTest { + @Test + fun `each clock is a singleton`() { + assertThat(JavaUptimeClock.getInstance()).isSameInstanceAs(JavaUptimeClock.getInstance()) + assertThat(JavaElapsedRealtimeClock.getInstance()) + .isSameInstanceAs(JavaElapsedRealtimeClock.getInstance()) + } + + @Test + fun `ticks do not go backwards`() { + for (clock in listOf(JavaUptimeClock.getInstance(), JavaElapsedRealtimeClock.getInstance())) { + assertThat(clock.tickNanos()).isAtMost(clock.tickNanos()) + } + } +} diff --git a/sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt b/sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt index 31de24d0de..4785f8761d 100644 --- a/sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt +++ b/sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt @@ -6,29 +6,24 @@ import kotlin.test.Test class SentryOptionsClockTest { @Test - fun `defaults to the JVM clocks`() { - val options = SentryOptions() - - assertThat(options.uptimeClock).isInstanceOf(JavaUptimeClock::class.java) - assertThat(options.elapsedRealtimeClock).isInstanceOf(JavaElapsedRealtimeClock::class.java) + fun `defaults to the JVM clock`() { + assertThat(SentryOptions().elapsedRealtimeClock) + .isInstanceOf(JavaElapsedRealtimeClock::class.java) } @Test - fun `the default clocks are singletons`() { - assertThat(SentryOptions().uptimeClock).isSameInstanceAs(JavaUptimeClock.getInstance()) + fun `the default clock is a singleton`() { assertThat(SentryOptions().elapsedRealtimeClock) .isSameInstanceAs(JavaElapsedRealtimeClock.getInstance()) } @Test - fun `a platform can replace either clock`() { + fun `a platform can replace the clock`() { val options = SentryOptions() val ticker = TestTicker() - options.uptimeClock = ticker options.elapsedRealtimeClock = ticker - assertThat(options.uptimeClock).isSameInstanceAs(ticker) assertThat(options.elapsedRealtimeClock).isSameInstanceAs(ticker) } } From 6da48db16de07fdba3086a01c27c8c17004da624 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 15:08:23 +0200 Subject: [PATCH 04/13] 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) --- sentry/src/main/java/io/sentry/SentryOptions.java | 6 ++++-- .../main/java/io/sentry/time/JavaElapsedRealtimeClock.java | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 8e847cc6e9..5175f61102 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -527,7 +527,6 @@ public class SentryOptions { private final @NotNull LazyEvaluator dateProvider = new LazyEvaluator<>(() -> new SentryAutoDateProvider()); - /** Clock for measuring intervals that must include time the device spent in deep sleep. */ @ApiStatus.Internal private final @NotNull LazyEvaluator elapsedRealtimeClock = new LazyEvaluator<>(() -> JavaElapsedRealtimeClock.getInstance()); @@ -3075,7 +3074,10 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { return elapsedRealtimeClock.getValue(); } - /** Sets the clock used to measure intervals that must include deep sleep. */ + /** + * Sets the clock. Android installs one backed by {@code SystemClock.elapsedRealtimeNanos()}, + * which this module cannot reference. + */ @ApiStatus.Internal public void setElapsedRealtimeClock(final @NotNull ElapsedRealtimeClock elapsedRealtimeClock) { this.elapsedRealtimeClock.setValue(elapsedRealtimeClock); diff --git a/sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java b/sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java index 29dd1b3223..a24c548920 100644 --- a/sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java +++ b/sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java @@ -6,9 +6,9 @@ /** * {@link ElapsedRealtimeClock} backed by {@link System#nanoTime()}. * - *

A JVM has no equivalent of Android's deep sleep that it can observe, so this is the same - * source as {@link JavaUptimeClock}. The two are distinct types anyway, so that a call site - * declaring which guarantee it needs keeps documenting that intent on every platform. + *

Identical to {@link JavaUptimeClock} — a JVM cannot observe deep sleep — but kept a distinct + * type so that a call site declaring which guarantee it needs documents that intent on every + * platform. */ @ApiStatus.Internal public final class JavaElapsedRealtimeClock implements ElapsedRealtimeClock { From 9051e40559dbb94a030613977a4631637aebd28a Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 17:02:05 +0200 Subject: [PATCH 05/13] 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) --- .../core/AndroidOptionsInitializer.java | 2 ++ .../time/AndroidElapsedRealtimeClock.java | 20 +++++++++++++++++++ .../core/AndroidOptionsInitializerTest.kt | 8 ++++++++ 3 files changed, 30 insertions(+) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index c7c590d624..3be7b67773 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -34,6 +34,7 @@ import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; import io.sentry.android.core.internal.modules.AssetsModulesLoader; +import io.sentry.android.core.internal.time.AndroidElapsedRealtimeClock; import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.AndroidThreadChecker; @@ -126,6 +127,7 @@ static void loadDefaultAndMetadataOptions( options.setDefaultScopeType(ScopeType.CURRENT); options.setOpenTelemetryMode(SentryOpenTelemetryMode.OFF); options.setDateProvider(new SentryAndroidDateProvider()); + options.setElapsedRealtimeClock(new AndroidElapsedRealtimeClock()); options.getLogs().setLoggerBatchProcessorFactory(new AndroidLoggerBatchProcessorFactory()); options.getMetrics().setMetricsBatchProcessorFactory(new AndroidMetricsBatchProcessorFactory()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java new file mode 100644 index 0000000000..742f54408b --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java @@ -0,0 +1,20 @@ +package io.sentry.android.core.internal.time; + +import android.os.SystemClock; +import io.sentry.time.ElapsedRealtimeClock; +import org.jetbrains.annotations.ApiStatus; + +/** + * {@link ElapsedRealtimeClock} backed by {@link SystemClock#elapsedRealtimeNanos()}. + * + *

That is {@code CLOCK_BOOTTIME}, so it keeps counting while the device is suspended — unlike + * {@link System#nanoTime()}, which the core module falls back to and which stops in deep sleep. + */ +@ApiStatus.Internal +public final class AndroidElapsedRealtimeClock implements ElapsedRealtimeClock { + + @Override + public long tickNanos() { + return SystemClock.elapsedRealtimeNanos(); + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 85fba36f7d..15753dd68c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -24,6 +24,7 @@ import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator import io.sentry.android.core.internal.modules.AssetsModulesLoader +import io.sentry.android.core.internal.time.AndroidElapsedRealtimeClock import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider import io.sentry.android.core.internal.util.AndroidThreadChecker import io.sentry.android.core.performance.AppStartMetrics @@ -748,6 +749,13 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.envelopeDiskCache is AndroidEnvelopeCache } } + @Test + fun `AndroidElapsedRealtimeClock is set to options`() { + fixture.initSut() + + assertTrue { fixture.sentryOptions.elapsedRealtimeClock is AndroidElapsedRealtimeClock } + } + @Test fun `When Activity Frames Tracking is enabled, the Activity Frames Tracker should be unavailable`() { fixture.initSut( From 5359ea117150297185e2c1f51a8ac481cc2fd2c6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 17:08:59 +0200 Subject: [PATCH 06/13] 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) --- .../java/io/sentry/time/JavaClocksTest.kt | 20 ------------- .../io/sentry/time/SentryOptionsClockTest.kt | 29 ------------------- 2 files changed, 49 deletions(-) delete mode 100644 sentry/src/test/java/io/sentry/time/JavaClocksTest.kt delete mode 100644 sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt diff --git a/sentry/src/test/java/io/sentry/time/JavaClocksTest.kt b/sentry/src/test/java/io/sentry/time/JavaClocksTest.kt deleted file mode 100644 index 1a5a2ac893..0000000000 --- a/sentry/src/test/java/io/sentry/time/JavaClocksTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.sentry.time - -import com.google.common.truth.Truth.assertThat -import kotlin.test.Test - -class JavaClocksTest { - @Test - fun `each clock is a singleton`() { - assertThat(JavaUptimeClock.getInstance()).isSameInstanceAs(JavaUptimeClock.getInstance()) - assertThat(JavaElapsedRealtimeClock.getInstance()) - .isSameInstanceAs(JavaElapsedRealtimeClock.getInstance()) - } - - @Test - fun `ticks do not go backwards`() { - for (clock in listOf(JavaUptimeClock.getInstance(), JavaElapsedRealtimeClock.getInstance())) { - assertThat(clock.tickNanos()).isAtMost(clock.tickNanos()) - } - } -} diff --git a/sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt b/sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt deleted file mode 100644 index 4785f8761d..0000000000 --- a/sentry/src/test/java/io/sentry/time/SentryOptionsClockTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package io.sentry.time - -import com.google.common.truth.Truth.assertThat -import io.sentry.SentryOptions -import kotlin.test.Test - -class SentryOptionsClockTest { - @Test - fun `defaults to the JVM clock`() { - assertThat(SentryOptions().elapsedRealtimeClock) - .isInstanceOf(JavaElapsedRealtimeClock::class.java) - } - - @Test - fun `the default clock is a singleton`() { - assertThat(SentryOptions().elapsedRealtimeClock) - .isSameInstanceAs(JavaElapsedRealtimeClock.getInstance()) - } - - @Test - fun `a platform can replace the clock`() { - val options = SentryOptions() - val ticker = TestTicker() - - options.elapsedRealtimeClock = ticker - - assertThat(options.elapsedRealtimeClock).isSameInstanceAs(ticker) - } -} From 873e406d72254730775ed8d3c01a61b5a888dc86 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 17:11:54 +0200 Subject: [PATCH 07/13] 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) --- .../api/sentry-android-core.api | 1 + .../core/AndroidOptionsInitializer.java | 2 -- .../android/core/SentryAndroidOptions.java | 11 +++++++++++ .../core/AndroidOptionsInitializerTest.kt | 8 -------- .../android/core/SentryAndroidOptionsTest.kt | 6 ++++++ sentry/api/sentry.api | 1 - .../src/main/java/io/sentry/SentryOptions.java | 18 ++++-------------- 7 files changed, 22 insertions(+), 25 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 8d56c36514..9e08227fd4 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -413,6 +413,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getBeforeScreenshotCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback; public fun getBeforeViewHierarchyCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback; public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader; + public fun getElapsedRealtimeClock ()Lio/sentry/time/ElapsedRealtimeClock; public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector; public fun getNativeSdkName ()Ljava/lang/String; public fun getNdkAppHangTimeoutIntervalMillis ()J diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 3be7b67773..c7c590d624 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -34,7 +34,6 @@ import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; import io.sentry.android.core.internal.modules.AssetsModulesLoader; -import io.sentry.android.core.internal.time.AndroidElapsedRealtimeClock; import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.AndroidThreadChecker; @@ -127,7 +126,6 @@ static void loadDefaultAndMetadataOptions( options.setDefaultScopeType(ScopeType.CURRENT); options.setOpenTelemetryMode(SentryOpenTelemetryMode.OFF); options.setDateProvider(new SentryAndroidDateProvider()); - options.setElapsedRealtimeClock(new AndroidElapsedRealtimeClock()); options.getLogs().setLoggerBatchProcessorFactory(new AndroidLoggerBatchProcessorFactory()); options.getMetrics().setMetricsBatchProcessorFactory(new AndroidMetricsBatchProcessorFactory()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 66a3700d38..30acace2cf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -12,11 +12,13 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SpanStatus; +import io.sentry.android.core.internal.time.AndroidElapsedRealtimeClock; import io.sentry.android.core.internal.util.RootChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.Mechanism; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; +import io.sentry.time.ElapsedRealtimeClock; import io.sentry.util.SampleRateUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -285,6 +287,9 @@ public interface BeforeCaptureCallback { private boolean enableAnrFingerprinting = true; + private final @NotNull ElapsedRealtimeClock elapsedRealtimeClock = + new AndroidElapsedRealtimeClock(); + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -889,6 +894,12 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) { this.enableAnrFingerprinting = enableAnrFingerprinting; } + @Override + @ApiStatus.Internal + public @NotNull ElapsedRealtimeClock getElapsedRealtimeClock() { + return elapsedRealtimeClock; + } + static class AndroidUserFeedbackFormHandler implements SentryFeedbackOptions.IFormHandler { @Override public void showForm( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 15753dd68c..85fba36f7d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -24,7 +24,6 @@ import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator import io.sentry.android.core.internal.modules.AssetsModulesLoader -import io.sentry.android.core.internal.time.AndroidElapsedRealtimeClock import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider import io.sentry.android.core.internal.util.AndroidThreadChecker import io.sentry.android.core.performance.AppStartMetrics @@ -749,13 +748,6 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.envelopeDiskCache is AndroidEnvelopeCache } } - @Test - fun `AndroidElapsedRealtimeClock is set to options`() { - fixture.initSut() - - assertTrue { fixture.sentryOptions.elapsedRealtimeClock is AndroidElapsedRealtimeClock } - } - @Test fun `When Activity Frames Tracking is enabled, the Activity Frames Tracker should be unavailable`() { fixture.initSut( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 94857b9105..40c17ea6e5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -2,6 +2,7 @@ package io.sentry.android.core import io.sentry.ITransactionProfiler import io.sentry.NoOpTransactionProfiler +import io.sentry.android.core.internal.time.AndroidElapsedRealtimeClock import io.sentry.protocol.DebugImage import kotlin.test.Test import kotlin.test.assertEquals @@ -12,6 +13,11 @@ import kotlin.test.assertTrue import org.mockito.kotlin.mock class SentryAndroidOptionsTest { + @Test + fun `elapsed real-time clock counts through deep sleep`() { + assertTrue(SentryAndroidOptions().elapsedRealtimeClock is AndroidElapsedRealtimeClock) + } + @Test fun `init should set clientName`() { val sentryOptions = SentryAndroidOptions() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index deb33c50ce..9081d56307 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3855,7 +3855,6 @@ public class io/sentry/SentryOptions { public fun setDistribution (Lio/sentry/SentryOptions$DistributionOptions;)V public fun setDistributionController (Lio/sentry/IDistributionApi;)V public fun setDsn (Ljava/lang/String;)V - public fun setElapsedRealtimeClock (Lio/sentry/time/ElapsedRealtimeClock;)V public fun setEnableAppStartProfiling (Z)V public fun setEnableAutoSessionTracking (Z)V public fun setEnableBackpressureHandling (Z)V diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 5175f61102..54f19ba93d 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -527,10 +527,6 @@ public class SentryOptions { private final @NotNull LazyEvaluator dateProvider = new LazyEvaluator<>(() -> new SentryAutoDateProvider()); - @ApiStatus.Internal - private final @NotNull LazyEvaluator elapsedRealtimeClock = - new LazyEvaluator<>(() -> JavaElapsedRealtimeClock.getInstance()); - private final @NotNull List performanceCollectors = new ArrayList<>(); /** Performance collector that collect performance stats while transactions run. */ @@ -3068,19 +3064,13 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { /** * Returns the clock used to measure intervals that must include deep sleep, such as rate-limit * windows and cache expiry. + * + *

Android overrides this with a {@code SystemClock.elapsedRealtimeNanos()}-backed clock, which + * this module cannot reference. On the JVM there is no suspend state to account for. */ @ApiStatus.Internal public @NotNull ElapsedRealtimeClock getElapsedRealtimeClock() { - return elapsedRealtimeClock.getValue(); - } - - /** - * Sets the clock. Android installs one backed by {@code SystemClock.elapsedRealtimeNanos()}, - * which this module cannot reference. - */ - @ApiStatus.Internal - public void setElapsedRealtimeClock(final @NotNull ElapsedRealtimeClock elapsedRealtimeClock) { - this.elapsedRealtimeClock.setValue(elapsedRealtimeClock); + return JavaElapsedRealtimeClock.getInstance(); } /** From d0760ca2b96aa5191bb1205beaae52d44b6697f7 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 17:14:49 +0200 Subject: [PATCH 08/13] 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) --- .../io/sentry/android/core/SentryAndroidOptions.java | 5 +---- .../core/internal/time/AndroidElapsedRealtimeClock.java | 9 +++++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 30acace2cf..bfc8c717ed 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -287,9 +287,6 @@ public interface BeforeCaptureCallback { private boolean enableAnrFingerprinting = true; - private final @NotNull ElapsedRealtimeClock elapsedRealtimeClock = - new AndroidElapsedRealtimeClock(); - public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -897,7 +894,7 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) { @Override @ApiStatus.Internal public @NotNull ElapsedRealtimeClock getElapsedRealtimeClock() { - return elapsedRealtimeClock; + return AndroidElapsedRealtimeClock.getInstance(); } static class AndroidUserFeedbackFormHandler implements SentryFeedbackOptions.IFormHandler { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java index 742f54408b..b550e3ecf2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java @@ -3,6 +3,7 @@ import android.os.SystemClock; import io.sentry.time.ElapsedRealtimeClock; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; /** * {@link ElapsedRealtimeClock} backed by {@link SystemClock#elapsedRealtimeNanos()}. @@ -13,6 +14,14 @@ @ApiStatus.Internal public final class AndroidElapsedRealtimeClock implements ElapsedRealtimeClock { + private static final AndroidElapsedRealtimeClock instance = new AndroidElapsedRealtimeClock(); + + public static @NotNull ElapsedRealtimeClock getInstance() { + return instance; + } + + private AndroidElapsedRealtimeClock() {} + @Override public long tickNanos() { return SystemClock.elapsedRealtimeNanos(); From 8078fa99b64ecaab0bb644550fa28c2a980e8f63 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 18:29:51 +0200 Subject: [PATCH 09/13] test(android): Drop the elapsed-real-time clock options test (JAVA-571) --- .../java/io/sentry/android/core/SentryAndroidOptionsTest.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 40c17ea6e5..94857b9105 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -2,7 +2,6 @@ package io.sentry.android.core import io.sentry.ITransactionProfiler import io.sentry.NoOpTransactionProfiler -import io.sentry.android.core.internal.time.AndroidElapsedRealtimeClock import io.sentry.protocol.DebugImage import kotlin.test.Test import kotlin.test.assertEquals @@ -13,11 +12,6 @@ import kotlin.test.assertTrue import org.mockito.kotlin.mock class SentryAndroidOptionsTest { - @Test - fun `elapsed real-time clock counts through deep sleep`() { - assertTrue(SentryAndroidOptions().elapsedRealtimeClock is AndroidElapsedRealtimeClock) - } - @Test fun `init should set clientName`() { val sentryOptions = SentryAndroidOptions() From c0b1e923a4ad4b80541f67ae3c4ade84014f0e9e Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 18:32:32 +0200 Subject: [PATCH 10/13] 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. --- .../src/main/java/io/sentry/time/Deadline.java | 2 +- .../test/java/io/sentry/time/DeadlineTest.kt | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sentry/src/main/java/io/sentry/time/Deadline.java b/sentry/src/main/java/io/sentry/time/Deadline.java index b3db5e6adf..1a63dc5eb5 100644 --- a/sentry/src/main/java/io/sentry/time/Deadline.java +++ b/sentry/src/main/java/io/sentry/time/Deadline.java @@ -24,7 +24,7 @@ private Deadline(final @NotNull Ticker clock, final long deadlineNanos) { } /** A deadline {@code amount} of {@code unit} from now. */ - public static @NotNull Deadline in( + public static @NotNull Deadline after( final @NotNull Ticker clock, final long amount, final @NotNull TimeUnit unit) { return new Deadline(clock, clock.tickNanos() + unit.toNanos(amount)); } diff --git a/sentry/src/test/java/io/sentry/time/DeadlineTest.kt b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt index 9f3e47aef2..acc623a796 100644 --- a/sentry/src/test/java/io/sentry/time/DeadlineTest.kt +++ b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt @@ -13,7 +13,7 @@ class DeadlineTest { @Test fun `has not passed before the deadline`() { val clock = TestTicker() - val deadline = Deadline.`in`(clock, 2, MINUTES) + val deadline = Deadline.after(clock, 2, MINUTES) clock.advance(119, SECONDS) @@ -23,7 +23,7 @@ class DeadlineTest { @Test fun `has passed once the deadline is reached`() { val clock = TestTicker() - val deadline = Deadline.`in`(clock, 2, MINUTES) + val deadline = Deadline.after(clock, 2, MINUTES) clock.advance(2, MINUTES) @@ -40,7 +40,7 @@ class DeadlineTest { @Test fun `remaining counts down and floors at zero`() { val clock = TestTicker() - val deadline = Deadline.`in`(clock, 1000, MILLISECONDS) + val deadline = Deadline.after(clock, 1000, MILLISECONDS) assertEquals(1000, deadline.remaining(MILLISECONDS)) @@ -54,7 +54,7 @@ class DeadlineTest { @Test fun `remaining rounds up so callers never wake before the deadline`() { val clock = TestTicker() - val deadline = Deadline.`in`(clock, 1000, MILLISECONDS) + val deadline = Deadline.after(clock, 1000, MILLISECONDS) // half a millisecond in: 999.5ms left, which must not report as 999 clock.advance(500, java.util.concurrent.TimeUnit.MICROSECONDS) @@ -65,8 +65,8 @@ class DeadlineTest { @Test fun `isAfter compares two deadlines`() { val clock = TestTicker() - val shorter = Deadline.`in`(clock, 1, SECONDS) - val longer = Deadline.`in`(clock, 5, SECONDS) + val shorter = Deadline.after(clock, 1, SECONDS) + val longer = Deadline.after(clock, 5, SECONDS) assertTrue(longer.isAfter(shorter)) assertFalse(shorter.isAfter(longer)) @@ -74,8 +74,8 @@ class DeadlineTest { @Test fun `isAfter rejects deadlines from different clocks`() { - val deadline = Deadline.`in`(TestTicker(), 1, SECONDS) - val fromAnotherClock = Deadline.`in`(TestTicker(), 5, SECONDS) + val deadline = Deadline.after(TestTicker(), 1, SECONDS) + val fromAnotherClock = Deadline.after(TestTicker(), 5, SECONDS) assertFailsWith { deadline.isAfter(fromAnotherClock) } } @@ -84,7 +84,7 @@ class DeadlineTest { fun `comparisons hold when the tick origin is negative`() { // System.nanoTime() may start negative; only differences are meaningful. val clock = TestTicker(Long.MIN_VALUE + 1) - val deadline = Deadline.`in`(clock, 1, SECONDS) + val deadline = Deadline.after(clock, 1, SECONDS) assertFalse(deadline.hasPassed()) clock.advance(1, SECONDS) From 414344ba5b7032f66eef662545f22ab912b9fd8b Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 1 Sep 2026 08:57:22 +0200 Subject: [PATCH 11/13] 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. --- sentry/api/sentry.api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9081d56307..9228c9c418 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7599,8 +7599,8 @@ public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys { } public final class io/sentry/time/Deadline { + public static fun after (Lio/sentry/time/Ticker;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline; public fun hasPassed ()Z - public static fun in (Lio/sentry/time/Ticker;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline; public fun isAfter (Lio/sentry/time/Deadline;)Z public static fun passed (Lio/sentry/time/Ticker;)Lio/sentry/time/Deadline; public fun remaining (Ljava/util/concurrent/TimeUnit;)J From d27758da748f0b4df5cb0002107390420d869011 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 16:35:20 +0200 Subject: [PATCH 12/13] 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. --- CHANGELOG.md | 2 +- .../api/sentry-android-core.api | 2 +- .../android/core/SentryAndroidOptions.java | 8 +++--- ...eClock.java => AndroidMonotonicClock.java} | 12 ++++---- .../{TestTicker.kt => TestMonotonicClock.kt} | 8 ++---- sentry/api/sentry.api | 27 ++++++------------ .../main/java/io/sentry/SentryOptions.java | 12 ++++---- .../main/java/io/sentry/time/Deadline.java | 10 +++---- .../io/sentry/time/ElapsedRealtimeClock.java | 16 ----------- .../sentry/time/JavaElapsedRealtimeClock.java | 28 ------------------- .../io/sentry/time/JavaMonotonicClock.java | 22 +++++++++++++++ .../java/io/sentry/time/JavaUptimeClock.java | 22 --------------- .../time/{Ticker.java => MonotonicClock.java} | 12 ++++---- .../main/java/io/sentry/time/Stopwatch.java | 8 +++--- .../main/java/io/sentry/time/UptimeClock.java | 16 ----------- .../test/java/io/sentry/time/DeadlineTest.kt | 22 +++++++-------- .../test/java/io/sentry/time/StopwatchTest.kt | 6 ++-- 17 files changed, 80 insertions(+), 153 deletions(-) rename sentry-android-core/src/main/java/io/sentry/android/core/internal/time/{AndroidElapsedRealtimeClock.java => AndroidMonotonicClock.java} (55%) rename sentry-test-support/src/main/kotlin/io/sentry/time/{TestTicker.kt => TestMonotonicClock.kt} (53%) delete mode 100644 sentry/src/main/java/io/sentry/time/ElapsedRealtimeClock.java delete mode 100644 sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java create mode 100644 sentry/src/main/java/io/sentry/time/JavaMonotonicClock.java delete mode 100644 sentry/src/main/java/io/sentry/time/JavaUptimeClock.java rename sentry/src/main/java/io/sentry/time/{Ticker.java => MonotonicClock.java} (50%) delete mode 100644 sentry/src/main/java/io/sentry/time/UptimeClock.java diff --git a/CHANGELOG.md b/CHANGELOG.md index de2b0784b1..c42a6b1ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Internal -- Add an internal `UptimeClock` and `ElapsedRealtimeClock` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) +- Add an internal `MonotonicClock` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) ## 8.55.0 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 9e08227fd4..08fd917251 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -413,8 +413,8 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getBeforeScreenshotCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback; public fun getBeforeViewHierarchyCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback; public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader; - public fun getElapsedRealtimeClock ()Lio/sentry/time/ElapsedRealtimeClock; public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector; + public fun getMonotonicClock ()Lio/sentry/time/MonotonicClock; public fun getNativeSdkName ()Ljava/lang/String; public fun getNdkAppHangTimeoutIntervalMillis ()J public fun getNdkHandlerStrategy ()I diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index bfc8c717ed..b8b9a2e398 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -12,13 +12,13 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SpanStatus; -import io.sentry.android.core.internal.time.AndroidElapsedRealtimeClock; +import io.sentry.android.core.internal.time.AndroidMonotonicClock; import io.sentry.android.core.internal.util.RootChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.Mechanism; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; -import io.sentry.time.ElapsedRealtimeClock; +import io.sentry.time.MonotonicClock; import io.sentry.util.SampleRateUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -893,8 +893,8 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) { @Override @ApiStatus.Internal - public @NotNull ElapsedRealtimeClock getElapsedRealtimeClock() { - return AndroidElapsedRealtimeClock.getInstance(); + public @NotNull MonotonicClock getMonotonicClock() { + return AndroidMonotonicClock.getInstance(); } static class AndroidUserFeedbackFormHandler implements SentryFeedbackOptions.IFormHandler { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicClock.java similarity index 55% rename from sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java rename to sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicClock.java index b550e3ecf2..f47d81b5c4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicClock.java @@ -1,26 +1,26 @@ package io.sentry.android.core.internal.time; import android.os.SystemClock; -import io.sentry.time.ElapsedRealtimeClock; +import io.sentry.time.MonotonicClock; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; /** - * {@link ElapsedRealtimeClock} backed by {@link SystemClock#elapsedRealtimeNanos()}. + * {@link MonotonicClock} backed by {@link SystemClock#elapsedRealtimeNanos()}. * *

That is {@code CLOCK_BOOTTIME}, so it keeps counting while the device is suspended — unlike * {@link System#nanoTime()}, which the core module falls back to and which stops in deep sleep. */ @ApiStatus.Internal -public final class AndroidElapsedRealtimeClock implements ElapsedRealtimeClock { +public final class AndroidMonotonicClock implements MonotonicClock { - private static final AndroidElapsedRealtimeClock instance = new AndroidElapsedRealtimeClock(); + private static final AndroidMonotonicClock instance = new AndroidMonotonicClock(); - public static @NotNull ElapsedRealtimeClock getInstance() { + public static @NotNull MonotonicClock getInstance() { return instance; } - private AndroidElapsedRealtimeClock() {} + private AndroidMonotonicClock() {} @Override public long tickNanos() { diff --git a/sentry-test-support/src/main/kotlin/io/sentry/time/TestTicker.kt b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicClock.kt similarity index 53% rename from sentry-test-support/src/main/kotlin/io/sentry/time/TestTicker.kt rename to sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicClock.kt index 1b9277be4c..e478e7b9bd 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/time/TestTicker.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicClock.kt @@ -3,17 +3,13 @@ package io.sentry.time import java.util.concurrent.TimeUnit /** - * A [Ticker] that only moves when a test tells it to. + * A [MonotonicClock] that only moves when a test tells it to. * * Advancing by an amount *and a unit* is the point: a stubbed `thenReturn(1001)` against a * nanosecond clock is off by a factor of a million and still compiles, whereas `advance(1001, * MILLISECONDS)` cannot be. - * - * Implements both clock guarantees so a test can inject it wherever either is declared. Production - * code must never do this — the whole purpose of the two interfaces is that one object cannot - * honestly promise both. */ -class TestTicker(private var nanos: Long = 0) : UptimeClock, ElapsedRealtimeClock { +class TestMonotonicClock(private var nanos: Long = 0) : MonotonicClock { override fun tickNanos(): Long = nanos fun advance(amount: Long, unit: TimeUnit) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9228c9c418..6afc681752 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3704,7 +3704,6 @@ public class io/sentry/SentryOptions { public fun getDistributionController ()Lio/sentry/IDistributionApi; public fun getDsn ()Ljava/lang/String; public fun getEffectiveOrgId ()Ljava/lang/String; - public fun getElapsedRealtimeClock ()Lio/sentry/time/ElapsedRealtimeClock; public fun getEnvelopeDiskCache ()Lio/sentry/cache/IEnvelopeCache; public fun getEnvelopeReader ()Lio/sentry/IEnvelopeReader; public fun getEnvironment ()Ljava/lang/String; @@ -3741,6 +3740,7 @@ public class io/sentry/SentryOptions { public fun getMaxTraceFileSize ()J public fun getMetrics ()Lio/sentry/SentryOptions$Metrics; public fun getModulesLoader ()Lio/sentry/internal/modules/IModulesLoader; + public fun getMonotonicClock ()Lio/sentry/time/MonotonicClock; public fun getOnDiscard ()Lio/sentry/SentryOptions$OnDiscardCallback; public fun getOnOversizedEvent ()Lio/sentry/SentryOptions$OnOversizedEventCallback; public fun getOpenTelemetryMode ()Lio/sentry/SentryOpenTelemetryMode; @@ -7599,37 +7599,26 @@ public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys { } public final class io/sentry/time/Deadline { - public static fun after (Lio/sentry/time/Ticker;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline; + public static fun after (Lio/sentry/time/MonotonicClock;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline; public fun hasPassed ()Z public fun isAfter (Lio/sentry/time/Deadline;)Z - public static fun passed (Lio/sentry/time/Ticker;)Lio/sentry/time/Deadline; + public static fun passed (Lio/sentry/time/MonotonicClock;)Lio/sentry/time/Deadline; public fun remaining (Ljava/util/concurrent/TimeUnit;)J } -public abstract interface class io/sentry/time/ElapsedRealtimeClock : io/sentry/time/Ticker { -} - -public final class io/sentry/time/JavaElapsedRealtimeClock : io/sentry/time/ElapsedRealtimeClock { - public static fun getInstance ()Lio/sentry/time/ElapsedRealtimeClock; +public final class io/sentry/time/JavaMonotonicClock : io/sentry/time/MonotonicClock { + public static fun getInstance ()Lio/sentry/time/MonotonicClock; public fun tickNanos ()J } -public final class io/sentry/time/JavaUptimeClock : io/sentry/time/UptimeClock { - public static fun getInstance ()Lio/sentry/time/UptimeClock; - public fun tickNanos ()J +public abstract interface class io/sentry/time/MonotonicClock { + public abstract fun tickNanos ()J } public final class io/sentry/time/Stopwatch { public fun elapsed (Ljava/util/concurrent/TimeUnit;)J public fun elapsedNanos ()J - public static fun started (Lio/sentry/time/Ticker;)Lio/sentry/time/Stopwatch; -} - -public abstract interface class io/sentry/time/Ticker { - public abstract fun tickNanos ()J -} - -public abstract interface class io/sentry/time/UptimeClock : io/sentry/time/Ticker { + public static fun started (Lio/sentry/time/MonotonicClock;)Lio/sentry/time/Stopwatch; } public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport { diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 54f19ba93d..eba06124f5 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -21,8 +21,8 @@ import io.sentry.metrics.IMetricsBatchProcessorFactory; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryTransaction; -import io.sentry.time.ElapsedRealtimeClock; -import io.sentry.time.JavaElapsedRealtimeClock; +import io.sentry.time.JavaMonotonicClock; +import io.sentry.time.MonotonicClock; import io.sentry.transport.ITransport; import io.sentry.transport.ITransportGate; import io.sentry.transport.NoOpEnvelopeCache; @@ -3062,15 +3062,15 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { } /** - * Returns the clock used to measure intervals that must include deep sleep, such as rate-limit - * windows and cache expiry. + * Returns the clock used to measure elapsed time, such as rate-limit windows, cache expiry and + * ANR thresholds. * *

Android overrides this with a {@code SystemClock.elapsedRealtimeNanos()}-backed clock, which * this module cannot reference. On the JVM there is no suspend state to account for. */ @ApiStatus.Internal - public @NotNull ElapsedRealtimeClock getElapsedRealtimeClock() { - return JavaElapsedRealtimeClock.getInstance(); + public @NotNull MonotonicClock getMonotonicClock() { + return JavaMonotonicClock.getInstance(); } /** diff --git a/sentry/src/main/java/io/sentry/time/Deadline.java b/sentry/src/main/java/io/sentry/time/Deadline.java index 1a63dc5eb5..5bb12047f6 100644 --- a/sentry/src/main/java/io/sentry/time/Deadline.java +++ b/sentry/src/main/java/io/sentry/time/Deadline.java @@ -5,7 +5,7 @@ import org.jetbrains.annotations.NotNull; /** - * A point in the future, measured on a {@link Ticker}. + * A point in the future, measured on a {@link MonotonicClock}. * *

Exists so that callers never do arithmetic on raw ticks. A tick carries no unit and no epoch, * so spelling out {@code now - then < ttl} at every call site is where unit mix-ups, sentinels that @@ -15,17 +15,17 @@ @ApiStatus.Internal public final class Deadline { - private final @NotNull Ticker clock; + private final @NotNull MonotonicClock clock; private final long deadlineNanos; - private Deadline(final @NotNull Ticker clock, final long deadlineNanos) { + private Deadline(final @NotNull MonotonicClock clock, final long deadlineNanos) { this.clock = clock; this.deadlineNanos = deadlineNanos; } /** A deadline {@code amount} of {@code unit} from now. */ public static @NotNull Deadline after( - final @NotNull Ticker clock, final long amount, final @NotNull TimeUnit unit) { + final @NotNull MonotonicClock clock, final long amount, final @NotNull TimeUnit unit) { return new Deadline(clock, clock.tickNanos() + unit.toNanos(amount)); } @@ -34,7 +34,7 @@ private Deadline(final @NotNull Ticker clock, final long deadlineNanos) { * "never set" needs no numeric sentinel and cannot be mistaken for fresh — {@code 0} is a real * and very recent instant on any boot-relative clock. */ - public static @NotNull Deadline passed(final @NotNull Ticker clock) { + public static @NotNull Deadline passed(final @NotNull MonotonicClock clock) { return new Deadline(clock, clock.tickNanos()); } diff --git a/sentry/src/main/java/io/sentry/time/ElapsedRealtimeClock.java b/sentry/src/main/java/io/sentry/time/ElapsedRealtimeClock.java deleted file mode 100644 index dce672e621..0000000000 --- a/sentry/src/main/java/io/sentry/time/ElapsedRealtimeClock.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.sentry.time; - -import org.jetbrains.annotations.ApiStatus; - -/** - * A {@link Ticker} that includes time the device spent suspended in deep sleep. - * - *

This is the clock for anything expressed in real elapsed time regardless of what the device - * was doing — a rate-limit window the server asked us to wait out, or a cache entry that should go - * stale on a wall-clock schedule. - * - *

On Android this is {@code CLOCK_BOOTTIME}, via {@code SystemClock.elapsedRealtimeNanos()}. On - * the JVM there is no comparable suspend state, so uptime and elapsed real time coincide. - */ -@ApiStatus.Internal -public interface ElapsedRealtimeClock extends Ticker {} diff --git a/sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java b/sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java deleted file mode 100644 index a24c548920..0000000000 --- a/sentry/src/main/java/io/sentry/time/JavaElapsedRealtimeClock.java +++ /dev/null @@ -1,28 +0,0 @@ -package io.sentry.time; - -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -/** - * {@link ElapsedRealtimeClock} backed by {@link System#nanoTime()}. - * - *

Identical to {@link JavaUptimeClock} — a JVM cannot observe deep sleep — but kept a distinct - * type so that a call site declaring which guarantee it needs documents that intent on every - * platform. - */ -@ApiStatus.Internal -public final class JavaElapsedRealtimeClock implements ElapsedRealtimeClock { - - private static final JavaElapsedRealtimeClock instance = new JavaElapsedRealtimeClock(); - - public static @NotNull ElapsedRealtimeClock getInstance() { - return instance; - } - - private JavaElapsedRealtimeClock() {} - - @Override - public long tickNanos() { - return System.nanoTime(); - } -} diff --git a/sentry/src/main/java/io/sentry/time/JavaMonotonicClock.java b/sentry/src/main/java/io/sentry/time/JavaMonotonicClock.java new file mode 100644 index 0000000000..3475b93096 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/JavaMonotonicClock.java @@ -0,0 +1,22 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** {@link MonotonicClock} backed by {@link System#nanoTime()}. */ +@ApiStatus.Internal +public final class JavaMonotonicClock implements MonotonicClock { + + private static final JavaMonotonicClock instance = new JavaMonotonicClock(); + + public static @NotNull MonotonicClock getInstance() { + return instance; + } + + private JavaMonotonicClock() {} + + @Override + public long tickNanos() { + return System.nanoTime(); + } +} diff --git a/sentry/src/main/java/io/sentry/time/JavaUptimeClock.java b/sentry/src/main/java/io/sentry/time/JavaUptimeClock.java deleted file mode 100644 index 101eaef7d1..0000000000 --- a/sentry/src/main/java/io/sentry/time/JavaUptimeClock.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.sentry.time; - -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -/** {@link UptimeClock} backed by {@link System#nanoTime()}. */ -@ApiStatus.Internal -public final class JavaUptimeClock implements UptimeClock { - - private static final JavaUptimeClock instance = new JavaUptimeClock(); - - public static @NotNull UptimeClock getInstance() { - return instance; - } - - private JavaUptimeClock() {} - - @Override - public long tickNanos() { - return System.nanoTime(); - } -} diff --git a/sentry/src/main/java/io/sentry/time/Ticker.java b/sentry/src/main/java/io/sentry/time/MonotonicClock.java similarity index 50% rename from sentry/src/main/java/io/sentry/time/Ticker.java rename to sentry/src/main/java/io/sentry/time/MonotonicClock.java index a56ce0fe79..f2d333c1a0 100644 --- a/sentry/src/main/java/io/sentry/time/Ticker.java +++ b/sentry/src/main/java/io/sentry/time/MonotonicClock.java @@ -3,18 +3,20 @@ import org.jetbrains.annotations.ApiStatus; /** - * A monotonically increasing nanosecond counter. + * A monotonically increasing nanosecond counter, including time the device spent suspended in deep + * sleep. * *

This type deliberately promises very little: a tick is a number that does not go backwards, * measured from an origin that is arbitrary and may be negative. Only differences between * two ticks from the same instance are meaningful, and a tick must never be persisted, serialized, * or compared against a value from another clock. * - *

Do not implement or depend on {@code Ticker} directly. It exists so that {@link Deadline} and - * {@link Stopwatch} can be written once; callers declare {@link UptimeClock} or {@link - * ElapsedRealtimeClock}, whose names state which guarantee they provide. + *

On Android this is {@code CLOCK_BOOTTIME}, via {@code SystemClock.elapsedRealtimeNanos()}, so + * an interval measured across a suspend reports the real time that passed rather than only the time + * the CPU was awake. On the JVM there is no comparable suspend state, so {@link System#nanoTime()} + * is equivalent. */ @ApiStatus.Internal -public interface Ticker { +public interface MonotonicClock { long tickNanos(); } diff --git a/sentry/src/main/java/io/sentry/time/Stopwatch.java b/sentry/src/main/java/io/sentry/time/Stopwatch.java index 249a73041c..bb2e3a212d 100644 --- a/sentry/src/main/java/io/sentry/time/Stopwatch.java +++ b/sentry/src/main/java/io/sentry/time/Stopwatch.java @@ -5,7 +5,7 @@ import org.jetbrains.annotations.NotNull; /** - * Measures how long something took, on a {@link Ticker}. + * Measures how long something took, on a {@link MonotonicClock}. * *

The counterpart to {@link Deadline}: it keeps the start tick and the unit conversion in one * place, so call sites stop repeating {@code System.nanoTime() - startTime}. @@ -13,15 +13,15 @@ @ApiStatus.Internal public final class Stopwatch { - private final @NotNull Ticker clock; + private final @NotNull MonotonicClock clock; private final long startNanos; - private Stopwatch(final @NotNull Ticker clock) { + private Stopwatch(final @NotNull MonotonicClock clock) { this.clock = clock; this.startNanos = clock.tickNanos(); } - public static @NotNull Stopwatch started(final @NotNull Ticker clock) { + public static @NotNull Stopwatch started(final @NotNull MonotonicClock clock) { return new Stopwatch(clock); } diff --git a/sentry/src/main/java/io/sentry/time/UptimeClock.java b/sentry/src/main/java/io/sentry/time/UptimeClock.java deleted file mode 100644 index 992c729e63..0000000000 --- a/sentry/src/main/java/io/sentry/time/UptimeClock.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.sentry.time; - -import org.jetbrains.annotations.ApiStatus; - -/** - * A {@link Ticker} that excludes time the device spent suspended in deep sleep. - * - *

This is the clock for measuring how long the CPU was actually available — most importantly ANR - * detection, where counting suspended time would report a responsive main thread as blocked. - * - *

On Android this is {@code CLOCK_MONOTONIC}, the same clock behind {@code - * SystemClock.uptimeMillis()}. On the JVM there is no comparable suspend state, so uptime and - * elapsed real time coincide. - */ -@ApiStatus.Internal -public interface UptimeClock extends Ticker {} diff --git a/sentry/src/test/java/io/sentry/time/DeadlineTest.kt b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt index acc623a796..38e55710ea 100644 --- a/sentry/src/test/java/io/sentry/time/DeadlineTest.kt +++ b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt @@ -12,7 +12,7 @@ import kotlin.test.assertTrue class DeadlineTest { @Test fun `has not passed before the deadline`() { - val clock = TestTicker() + val clock = TestMonotonicClock() val deadline = Deadline.after(clock, 2, MINUTES) clock.advance(119, SECONDS) @@ -22,7 +22,7 @@ class DeadlineTest { @Test fun `has passed once the deadline is reached`() { - val clock = TestTicker() + val clock = TestMonotonicClock() val deadline = Deadline.after(clock, 2, MINUTES) clock.advance(2, MINUTES) @@ -32,14 +32,14 @@ class DeadlineTest { @Test fun `a passed deadline is never fresh, even at tick zero`() { - // Regression guard: elapsedRealtimeNanos and uptimeMillis both start at 0 on boot, so a - // numeric sentinel of 0 would read as fresh for a whole TTL after every boot. - assertTrue(Deadline.passed(TestTicker()).hasPassed()) + // Regression guard: elapsedRealtimeNanos() starts at 0 on boot, so a numeric sentinel of 0 + // would read as fresh for a whole TTL after every boot. + assertTrue(Deadline.passed(TestMonotonicClock()).hasPassed()) } @Test fun `remaining counts down and floors at zero`() { - val clock = TestTicker() + val clock = TestMonotonicClock() val deadline = Deadline.after(clock, 1000, MILLISECONDS) assertEquals(1000, deadline.remaining(MILLISECONDS)) @@ -53,7 +53,7 @@ class DeadlineTest { @Test fun `remaining rounds up so callers never wake before the deadline`() { - val clock = TestTicker() + val clock = TestMonotonicClock() val deadline = Deadline.after(clock, 1000, MILLISECONDS) // half a millisecond in: 999.5ms left, which must not report as 999 @@ -64,7 +64,7 @@ class DeadlineTest { @Test fun `isAfter compares two deadlines`() { - val clock = TestTicker() + val clock = TestMonotonicClock() val shorter = Deadline.after(clock, 1, SECONDS) val longer = Deadline.after(clock, 5, SECONDS) @@ -74,8 +74,8 @@ class DeadlineTest { @Test fun `isAfter rejects deadlines from different clocks`() { - val deadline = Deadline.after(TestTicker(), 1, SECONDS) - val fromAnotherClock = Deadline.after(TestTicker(), 5, SECONDS) + val deadline = Deadline.after(TestMonotonicClock(), 1, SECONDS) + val fromAnotherClock = Deadline.after(TestMonotonicClock(), 5, SECONDS) assertFailsWith { deadline.isAfter(fromAnotherClock) } } @@ -83,7 +83,7 @@ class DeadlineTest { @Test fun `comparisons hold when the tick origin is negative`() { // System.nanoTime() may start negative; only differences are meaningful. - val clock = TestTicker(Long.MIN_VALUE + 1) + val clock = TestMonotonicClock(Long.MIN_VALUE + 1) val deadline = Deadline.after(clock, 1, SECONDS) assertFalse(deadline.hasPassed()) diff --git a/sentry/src/test/java/io/sentry/time/StopwatchTest.kt b/sentry/src/test/java/io/sentry/time/StopwatchTest.kt index bfcd924fd1..a09cf3ad4f 100644 --- a/sentry/src/test/java/io/sentry/time/StopwatchTest.kt +++ b/sentry/src/test/java/io/sentry/time/StopwatchTest.kt @@ -9,12 +9,12 @@ import kotlin.test.assertEquals class StopwatchTest { @Test fun `starts at zero`() { - assertEquals(0, Stopwatch.started(TestTicker()).elapsedNanos()) + assertEquals(0, Stopwatch.started(TestMonotonicClock()).elapsedNanos()) } @Test fun `reports elapsed time in the requested unit`() { - val clock = TestTicker() + val clock = TestMonotonicClock() val stopwatch = Stopwatch.started(clock) clock.advance(1500, MILLISECONDS) @@ -26,7 +26,7 @@ class StopwatchTest { @Test fun `keeps running across reads`() { - val clock = TestTicker() + val clock = TestMonotonicClock() val stopwatch = Stopwatch.started(clock) clock.advance(1, SECONDS) From d7bc897099f09c79d1c91dc591d6c610341e1553 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 8 Sep 2026 15:18:11 +0200 Subject: [PATCH 13/13] 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. --- CHANGELOG.md | 2 +- .../api/sentry-android-core.api | 2 +- .../android/core/SentryAndroidOptions.java | 8 +-- ...Clock.java => AndroidMonotonicTicker.java} | 12 ++-- ...notonicClock.kt => TestMonotonicTicker.kt} | 6 +- sentry/api/sentry.api | 14 ++-- .../main/java/io/sentry/SentryOptions.java | 14 ++-- .../main/java/io/sentry/time/Deadline.java | 48 ++++++++------ .../io/sentry/time/JavaMonotonicClock.java | 22 ------- .../io/sentry/time/JavaMonotonicTicker.java | 22 +++++++ ...notonicClock.java => MonotonicTicker.java} | 4 +- .../main/java/io/sentry/time/Stopwatch.java | 16 ++--- .../test/java/io/sentry/time/DeadlineTest.kt | 65 +++++++++++-------- .../test/java/io/sentry/time/StopwatchTest.kt | 16 ++--- 14 files changed, 136 insertions(+), 115 deletions(-) rename sentry-android-core/src/main/java/io/sentry/android/core/internal/time/{AndroidMonotonicClock.java => AndroidMonotonicTicker.java} (57%) rename sentry-test-support/src/main/kotlin/io/sentry/time/{TestMonotonicClock.kt => TestMonotonicTicker.kt} (57%) delete mode 100644 sentry/src/main/java/io/sentry/time/JavaMonotonicClock.java create mode 100644 sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java rename sentry/src/main/java/io/sentry/time/{MonotonicClock.java => MonotonicTicker.java} (90%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c42a6b1ca3..e89e7e07fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Internal -- Add an internal `MonotonicClock` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) +- Add an internal `MonotonicTicker` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) ## 8.55.0 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 08fd917251..fa417bee07 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -414,7 +414,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getBeforeViewHierarchyCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback; public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader; public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector; - public fun getMonotonicClock ()Lio/sentry/time/MonotonicClock; + public fun getMonotonicTicker ()Lio/sentry/time/MonotonicTicker; public fun getNativeSdkName ()Ljava/lang/String; public fun getNdkAppHangTimeoutIntervalMillis ()J public fun getNdkHandlerStrategy ()I diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index b8b9a2e398..202d779d61 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -12,13 +12,13 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SpanStatus; -import io.sentry.android.core.internal.time.AndroidMonotonicClock; +import io.sentry.android.core.internal.time.AndroidMonotonicTicker; import io.sentry.android.core.internal.util.RootChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.Mechanism; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; -import io.sentry.time.MonotonicClock; +import io.sentry.time.MonotonicTicker; import io.sentry.util.SampleRateUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -893,8 +893,8 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) { @Override @ApiStatus.Internal - public @NotNull MonotonicClock getMonotonicClock() { - return AndroidMonotonicClock.getInstance(); + public @NotNull MonotonicTicker getMonotonicTicker() { + return AndroidMonotonicTicker.getInstance(); } static class AndroidUserFeedbackFormHandler implements SentryFeedbackOptions.IFormHandler { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicClock.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicTicker.java similarity index 57% rename from sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicClock.java rename to sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicTicker.java index f47d81b5c4..22973b0d93 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicClock.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicTicker.java @@ -1,26 +1,26 @@ package io.sentry.android.core.internal.time; import android.os.SystemClock; -import io.sentry.time.MonotonicClock; +import io.sentry.time.MonotonicTicker; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; /** - * {@link MonotonicClock} backed by {@link SystemClock#elapsedRealtimeNanos()}. + * {@link MonotonicTicker} backed by {@link SystemClock#elapsedRealtimeNanos()}. * *

That is {@code CLOCK_BOOTTIME}, so it keeps counting while the device is suspended — unlike * {@link System#nanoTime()}, which the core module falls back to and which stops in deep sleep. */ @ApiStatus.Internal -public final class AndroidMonotonicClock implements MonotonicClock { +public final class AndroidMonotonicTicker implements MonotonicTicker { - private static final AndroidMonotonicClock instance = new AndroidMonotonicClock(); + private static final AndroidMonotonicTicker instance = new AndroidMonotonicTicker(); - public static @NotNull MonotonicClock getInstance() { + public static @NotNull MonotonicTicker getInstance() { return instance; } - private AndroidMonotonicClock() {} + private AndroidMonotonicTicker() {} @Override public long tickNanos() { diff --git a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicClock.kt b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt similarity index 57% rename from sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicClock.kt rename to sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt index e478e7b9bd..2471a1bc8d 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicClock.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt @@ -3,13 +3,13 @@ package io.sentry.time import java.util.concurrent.TimeUnit /** - * A [MonotonicClock] that only moves when a test tells it to. + * A [MonotonicTicker] that only moves when a test tells it to. * * Advancing by an amount *and a unit* is the point: a stubbed `thenReturn(1001)` against a - * nanosecond clock is off by a factor of a million and still compiles, whereas `advance(1001, + * nanosecond ticker is off by a factor of a million and still compiles, whereas `advance(1001, * MILLISECONDS)` cannot be. */ -class TestMonotonicClock(private var nanos: Long = 0) : MonotonicClock { +class TestMonotonicTicker(private var nanos: Long = 0) : MonotonicTicker { override fun tickNanos(): Long = nanos fun advance(amount: Long, unit: TimeUnit) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 6afc681752..7897fe2ccb 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3740,7 +3740,7 @@ public class io/sentry/SentryOptions { public fun getMaxTraceFileSize ()J public fun getMetrics ()Lio/sentry/SentryOptions$Metrics; public fun getModulesLoader ()Lio/sentry/internal/modules/IModulesLoader; - public fun getMonotonicClock ()Lio/sentry/time/MonotonicClock; + public fun getMonotonicTicker ()Lio/sentry/time/MonotonicTicker; public fun getOnDiscard ()Lio/sentry/SentryOptions$OnDiscardCallback; public fun getOnOversizedEvent ()Lio/sentry/SentryOptions$OnOversizedEventCallback; public fun getOpenTelemetryMode ()Lio/sentry/SentryOpenTelemetryMode; @@ -7599,26 +7599,26 @@ public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys { } public final class io/sentry/time/Deadline { - public static fun after (Lio/sentry/time/MonotonicClock;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline; + public static fun after (Lio/sentry/time/MonotonicTicker;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline; public fun hasPassed ()Z public fun isAfter (Lio/sentry/time/Deadline;)Z - public static fun passed (Lio/sentry/time/MonotonicClock;)Lio/sentry/time/Deadline; + public static fun passed (Lio/sentry/time/MonotonicTicker;)Lio/sentry/time/Deadline; public fun remaining (Ljava/util/concurrent/TimeUnit;)J } -public final class io/sentry/time/JavaMonotonicClock : io/sentry/time/MonotonicClock { - public static fun getInstance ()Lio/sentry/time/MonotonicClock; +public final class io/sentry/time/JavaMonotonicTicker : io/sentry/time/MonotonicTicker { + public static fun getInstance ()Lio/sentry/time/MonotonicTicker; public fun tickNanos ()J } -public abstract interface class io/sentry/time/MonotonicClock { +public abstract interface class io/sentry/time/MonotonicTicker { public abstract fun tickNanos ()J } public final class io/sentry/time/Stopwatch { public fun elapsed (Ljava/util/concurrent/TimeUnit;)J public fun elapsedNanos ()J - public static fun started (Lio/sentry/time/MonotonicClock;)Lio/sentry/time/Stopwatch; + public static fun started (Lio/sentry/time/MonotonicTicker;)Lio/sentry/time/Stopwatch; } public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport { diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index eba06124f5..93806f4d6f 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -21,8 +21,8 @@ import io.sentry.metrics.IMetricsBatchProcessorFactory; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryTransaction; -import io.sentry.time.JavaMonotonicClock; -import io.sentry.time.MonotonicClock; +import io.sentry.time.JavaMonotonicTicker; +import io.sentry.time.MonotonicTicker; import io.sentry.transport.ITransport; import io.sentry.transport.ITransportGate; import io.sentry.transport.NoOpEnvelopeCache; @@ -3062,15 +3062,15 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { } /** - * Returns the clock used to measure elapsed time, such as rate-limit windows, cache expiry and + * Returns the ticker used to measure elapsed time, such as rate-limit windows, cache expiry and * ANR thresholds. * - *

Android overrides this with a {@code SystemClock.elapsedRealtimeNanos()}-backed clock, which - * this module cannot reference. On the JVM there is no suspend state to account for. + *

Android overrides this with a {@code SystemClock.elapsedRealtimeNanos()}-backed ticker, + * which this module cannot reference. On the JVM there is no suspend state to account for. */ @ApiStatus.Internal - public @NotNull MonotonicClock getMonotonicClock() { - return JavaMonotonicClock.getInstance(); + public @NotNull MonotonicTicker getMonotonicTicker() { + return JavaMonotonicTicker.getInstance(); } /** diff --git a/sentry/src/main/java/io/sentry/time/Deadline.java b/sentry/src/main/java/io/sentry/time/Deadline.java index 5bb12047f6..036469383c 100644 --- a/sentry/src/main/java/io/sentry/time/Deadline.java +++ b/sentry/src/main/java/io/sentry/time/Deadline.java @@ -5,7 +5,7 @@ import org.jetbrains.annotations.NotNull; /** - * A point in the future, measured on a {@link MonotonicClock}. + * A point in the future, measured on a {@link MonotonicTicker}. * *

Exists so that callers never do arithmetic on raw ticks. A tick carries no unit and no epoch, * so spelling out {@code now - then < ttl} at every call site is where unit mix-ups, sentinels that @@ -15,32 +15,42 @@ @ApiStatus.Internal public final class Deadline { - private final @NotNull MonotonicClock clock; + private final @NotNull MonotonicTicker ticker; private final long deadlineNanos; - private Deadline(final @NotNull MonotonicClock clock, final long deadlineNanos) { - this.clock = clock; + private Deadline(final @NotNull MonotonicTicker ticker, final long deadlineNanos) { + this.ticker = ticker; this.deadlineNanos = deadlineNanos; } - /** A deadline {@code amount} of {@code unit} from now. */ + /** + * A deadline {@code amount} of {@code unit} from now. + * + * @throws IllegalArgumentException if {@code amount} is negative. A deadline that starts out in + * the past is a sign error at the call site; {@link #passed} says it deliberately. + */ public static @NotNull Deadline after( - final @NotNull MonotonicClock clock, final long amount, final @NotNull TimeUnit unit) { - return new Deadline(clock, clock.tickNanos() + unit.toNanos(amount)); + final @NotNull MonotonicTicker ticker, final long amount, final @NotNull TimeUnit unit) { + if (amount < 0) { + throw new IllegalArgumentException("Deadline amount must not be negative, but was " + amount); + } + return new Deadline(ticker, ticker.tickNanos() + unit.toNanos(amount)); } /** - * A deadline that has already passed. Use for state that has not been populated yet, so that - * "never set" needs no numeric sentinel and cannot be mistaken for fresh — {@code 0} is a real - * and very recent instant on any boot-relative clock. + * A deadline that has already passed. + * + *

Saves callers from reserving a tick value to mean "not set yet": {@code 0} is a real and + * very recent instant on a boot-relative ticker, so a field left at {@code 0} reads as freshly + * set rather than as unset. */ - public static @NotNull Deadline passed(final @NotNull MonotonicClock clock) { - return new Deadline(clock, clock.tickNanos()); + public static @NotNull Deadline passed(final @NotNull MonotonicTicker ticker) { + return new Deadline(ticker, ticker.tickNanos()); } public boolean hasPassed() { // Subtraction rather than `<`: a tick origin is arbitrary, may be negative, and may wrap. - return clock.tickNanos() - deadlineNanos >= 0; + return ticker.tickNanos() - deadlineNanos >= 0; } /** @@ -51,7 +61,7 @@ public boolean hasPassed() { * standing. */ public long remaining(final @NotNull TimeUnit unit) { - final long remainingNanos = deadlineNanos - clock.tickNanos(); + final long remainingNanos = deadlineNanos - ticker.tickNanos(); if (remainingNanos <= 0) { return 0; } @@ -63,16 +73,16 @@ public long remaining(final @NotNull TimeUnit unit) { /** * Whether this deadline falls after {@code other}. * - * @throws IllegalArgumentException if the two were created from different clocks, whose origins + * @throws IllegalArgumentException if the two were created from different tickers, whose origins * are unrelated and whose ticks are therefore not comparable. */ public boolean isAfter(final @NotNull Deadline other) { - if (clock != other.clock) { + if (ticker != other.ticker) { throw new IllegalArgumentException( - "Cannot compare deadlines from different clocks: " - + clock.getClass().getName() + "Cannot compare deadlines from different tickers: " + + ticker.getClass().getName() + " and " - + other.clock.getClass().getName()); + + other.ticker.getClass().getName()); } return deadlineNanos - other.deadlineNanos > 0; } diff --git a/sentry/src/main/java/io/sentry/time/JavaMonotonicClock.java b/sentry/src/main/java/io/sentry/time/JavaMonotonicClock.java deleted file mode 100644 index 3475b93096..0000000000 --- a/sentry/src/main/java/io/sentry/time/JavaMonotonicClock.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.sentry.time; - -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -/** {@link MonotonicClock} backed by {@link System#nanoTime()}. */ -@ApiStatus.Internal -public final class JavaMonotonicClock implements MonotonicClock { - - private static final JavaMonotonicClock instance = new JavaMonotonicClock(); - - public static @NotNull MonotonicClock getInstance() { - return instance; - } - - private JavaMonotonicClock() {} - - @Override - public long tickNanos() { - return System.nanoTime(); - } -} diff --git a/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java b/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java new file mode 100644 index 0000000000..b5b000f280 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java @@ -0,0 +1,22 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** {@link MonotonicTicker} backed by {@link System#nanoTime()}. */ +@ApiStatus.Internal +public final class JavaMonotonicTicker implements MonotonicTicker { + + private static final JavaMonotonicTicker instance = new JavaMonotonicTicker(); + + public static @NotNull MonotonicTicker getInstance() { + return instance; + } + + private JavaMonotonicTicker() {} + + @Override + public long tickNanos() { + return System.nanoTime(); + } +} diff --git a/sentry/src/main/java/io/sentry/time/MonotonicClock.java b/sentry/src/main/java/io/sentry/time/MonotonicTicker.java similarity index 90% rename from sentry/src/main/java/io/sentry/time/MonotonicClock.java rename to sentry/src/main/java/io/sentry/time/MonotonicTicker.java index f2d333c1a0..f3e87e3992 100644 --- a/sentry/src/main/java/io/sentry/time/MonotonicClock.java +++ b/sentry/src/main/java/io/sentry/time/MonotonicTicker.java @@ -9,7 +9,7 @@ *

This type deliberately promises very little: a tick is a number that does not go backwards, * measured from an origin that is arbitrary and may be negative. Only differences between * two ticks from the same instance are meaningful, and a tick must never be persisted, serialized, - * or compared against a value from another clock. + * or compared against a value from another ticker. * *

On Android this is {@code CLOCK_BOOTTIME}, via {@code SystemClock.elapsedRealtimeNanos()}, so * an interval measured across a suspend reports the real time that passed rather than only the time @@ -17,6 +17,6 @@ * is equivalent. */ @ApiStatus.Internal -public interface MonotonicClock { +public interface MonotonicTicker { long tickNanos(); } diff --git a/sentry/src/main/java/io/sentry/time/Stopwatch.java b/sentry/src/main/java/io/sentry/time/Stopwatch.java index bb2e3a212d..083b5c6906 100644 --- a/sentry/src/main/java/io/sentry/time/Stopwatch.java +++ b/sentry/src/main/java/io/sentry/time/Stopwatch.java @@ -5,7 +5,7 @@ import org.jetbrains.annotations.NotNull; /** - * Measures how long something took, on a {@link MonotonicClock}. + * Measures how long something took, on a {@link MonotonicTicker}. * *

The counterpart to {@link Deadline}: it keeps the start tick and the unit conversion in one * place, so call sites stop repeating {@code System.nanoTime() - startTime}. @@ -13,20 +13,20 @@ @ApiStatus.Internal public final class Stopwatch { - private final @NotNull MonotonicClock clock; + private final @NotNull MonotonicTicker ticker; private final long startNanos; - private Stopwatch(final @NotNull MonotonicClock clock) { - this.clock = clock; - this.startNanos = clock.tickNanos(); + private Stopwatch(final @NotNull MonotonicTicker ticker) { + this.ticker = ticker; + this.startNanos = ticker.tickNanos(); } - public static @NotNull Stopwatch started(final @NotNull MonotonicClock clock) { - return new Stopwatch(clock); + public static @NotNull Stopwatch started(final @NotNull MonotonicTicker ticker) { + return new Stopwatch(ticker); } public long elapsedNanos() { - return clock.tickNanos() - startNanos; + return ticker.tickNanos() - startNanos; } public long elapsed(final @NotNull TimeUnit unit) { diff --git a/sentry/src/test/java/io/sentry/time/DeadlineTest.kt b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt index 38e55710ea..2cf4804f39 100644 --- a/sentry/src/test/java/io/sentry/time/DeadlineTest.kt +++ b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt @@ -12,82 +12,93 @@ import kotlin.test.assertTrue class DeadlineTest { @Test fun `has not passed before the deadline`() { - val clock = TestMonotonicClock() - val deadline = Deadline.after(clock, 2, MINUTES) + val ticker = TestMonotonicTicker() + val deadline = Deadline.after(ticker, 2, MINUTES) - clock.advance(119, SECONDS) + ticker.advance(119, SECONDS) assertFalse(deadline.hasPassed()) } @Test fun `has passed once the deadline is reached`() { - val clock = TestMonotonicClock() - val deadline = Deadline.after(clock, 2, MINUTES) + val ticker = TestMonotonicTicker() + val deadline = Deadline.after(ticker, 2, MINUTES) - clock.advance(2, MINUTES) + ticker.advance(2, MINUTES) assertTrue(deadline.hasPassed()) } @Test - fun `a passed deadline is never fresh, even at tick zero`() { - // Regression guard: elapsedRealtimeNanos() starts at 0 on boot, so a numeric sentinel of 0 - // would read as fresh for a whole TTL after every boot. - assertTrue(Deadline.passed(TestMonotonicClock()).hasPassed()) + fun `a passed deadline has passed even when the ticker is at zero`() { + // The pattern this replaces stored the last-updated tick and compared `now - lastUpdated` + // against a TTL, with 0 standing in for "never updated". Tick 0 is a real instant though — + // the moment the device booted — so for the first TTL of every boot, never-updated state + // read as freshly updated. A passed deadline has no such value to misread. + assertTrue(Deadline.passed(TestMonotonicTicker()).hasPassed()) } @Test fun `remaining counts down and floors at zero`() { - val clock = TestMonotonicClock() - val deadline = Deadline.after(clock, 1000, MILLISECONDS) + val ticker = TestMonotonicTicker() + val deadline = Deadline.after(ticker, 1000, MILLISECONDS) assertEquals(1000, deadline.remaining(MILLISECONDS)) - clock.advance(400, MILLISECONDS) + ticker.advance(400, MILLISECONDS) assertEquals(600, deadline.remaining(MILLISECONDS)) - clock.advance(10, MINUTES) + ticker.advance(10, MINUTES) assertEquals(0, deadline.remaining(MILLISECONDS)) } @Test fun `remaining rounds up so callers never wake before the deadline`() { - val clock = TestMonotonicClock() - val deadline = Deadline.after(clock, 1000, MILLISECONDS) + val ticker = TestMonotonicTicker() + val deadline = Deadline.after(ticker, 1000, MILLISECONDS) // half a millisecond in: 999.5ms left, which must not report as 999 - clock.advance(500, java.util.concurrent.TimeUnit.MICROSECONDS) + ticker.advance(500, java.util.concurrent.TimeUnit.MICROSECONDS) assertEquals(1000, deadline.remaining(MILLISECONDS)) } + @Test + fun `after rejects a negative amount`() { + assertFailsWith { + Deadline.after(TestMonotonicTicker(), -1, SECONDS) + } + } + @Test fun `isAfter compares two deadlines`() { - val clock = TestMonotonicClock() - val shorter = Deadline.after(clock, 1, SECONDS) - val longer = Deadline.after(clock, 5, SECONDS) + val ticker = TestMonotonicTicker() + val shorter = Deadline.after(ticker, 1, SECONDS) + val alsoShorter = Deadline.after(ticker, 1, SECONDS) + val longer = Deadline.after(ticker, 5, SECONDS) assertTrue(longer.isAfter(shorter)) assertFalse(shorter.isAfter(longer)) + assertFalse(shorter.isAfter(alsoShorter)) } @Test - fun `isAfter rejects deadlines from different clocks`() { - val deadline = Deadline.after(TestMonotonicClock(), 1, SECONDS) - val fromAnotherClock = Deadline.after(TestMonotonicClock(), 5, SECONDS) + fun `isAfter rejects deadlines from different tickers`() { + val deadline = Deadline.after(TestMonotonicTicker(), 1, SECONDS) + val fromAnotherTicker = Deadline.after(TestMonotonicTicker(), 1, SECONDS) - assertFailsWith { deadline.isAfter(fromAnotherClock) } + assertFailsWith { deadline.isAfter(fromAnotherTicker) } } @Test fun `comparisons hold when the tick origin is negative`() { // System.nanoTime() may start negative; only differences are meaningful. - val clock = TestMonotonicClock(Long.MIN_VALUE + 1) - val deadline = Deadline.after(clock, 1, SECONDS) + val ticker = TestMonotonicTicker(Long.MIN_VALUE + 1) + val deadline = Deadline.after(ticker, 1, SECONDS) assertFalse(deadline.hasPassed()) - clock.advance(1, SECONDS) + ticker.advance(1, SECONDS) assertTrue(deadline.hasPassed()) } } diff --git a/sentry/src/test/java/io/sentry/time/StopwatchTest.kt b/sentry/src/test/java/io/sentry/time/StopwatchTest.kt index a09cf3ad4f..c185cf1522 100644 --- a/sentry/src/test/java/io/sentry/time/StopwatchTest.kt +++ b/sentry/src/test/java/io/sentry/time/StopwatchTest.kt @@ -9,15 +9,15 @@ import kotlin.test.assertEquals class StopwatchTest { @Test fun `starts at zero`() { - assertEquals(0, Stopwatch.started(TestMonotonicClock()).elapsedNanos()) + assertEquals(0, Stopwatch.started(TestMonotonicTicker()).elapsedNanos()) } @Test fun `reports elapsed time in the requested unit`() { - val clock = TestMonotonicClock() - val stopwatch = Stopwatch.started(clock) + val ticker = TestMonotonicTicker() + val stopwatch = Stopwatch.started(ticker) - clock.advance(1500, MILLISECONDS) + ticker.advance(1500, MILLISECONDS) assertEquals(1, stopwatch.elapsed(SECONDS)) assertEquals(1500, stopwatch.elapsed(MILLISECONDS)) @@ -26,13 +26,13 @@ class StopwatchTest { @Test fun `keeps running across reads`() { - val clock = TestMonotonicClock() - val stopwatch = Stopwatch.started(clock) + val ticker = TestMonotonicTicker() + val stopwatch = Stopwatch.started(ticker) - clock.advance(1, SECONDS) + ticker.advance(1, SECONDS) assertEquals(1, stopwatch.elapsed(SECONDS)) - clock.advance(2, SECONDS) + ticker.advance(2, SECONDS) assertEquals(3, stopwatch.elapsed(SECONDS)) } }