diff --git a/CHANGELOG.md b/CHANGELOG.md index d53dc1b6be..a2bd7a26d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Add an internal `MonotonicClock` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) + ## 8.55.0 ### Features diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 8d56c36514..08fd917251 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -414,6 +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 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 66a3700d38..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,11 +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.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.util.SampleRateUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -889,6 +891,12 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) { this.enableAnrFingerprinting = enableAnrFingerprinting; } + @Override + @ApiStatus.Internal + public @NotNull MonotonicClock getMonotonicClock() { + return AndroidMonotonicClock.getInstance(); + } + static class AndroidUserFeedbackFormHandler implements SentryFeedbackOptions.IFormHandler { @Override public void showForm( 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/AndroidMonotonicClock.java new file mode 100644 index 0000000000..f47d81b5c4 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicClock.java @@ -0,0 +1,29 @@ +package io.sentry.android.core.internal.time; + +import android.os.SystemClock; +import io.sentry.time.MonotonicClock; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * {@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 AndroidMonotonicClock implements MonotonicClock { + + private static final AndroidMonotonicClock instance = new AndroidMonotonicClock(); + + public static @NotNull MonotonicClock getInstance() { + return instance; + } + + private AndroidMonotonicClock() {} + + @Override + public long tickNanos() { + return SystemClock.elapsedRealtimeNanos(); + } +} diff --git a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicClock.kt b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicClock.kt new file mode 100644 index 0000000000..e478e7b9bd --- /dev/null +++ b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicClock.kt @@ -0,0 +1,18 @@ +package io.sentry.time + +import java.util.concurrent.TimeUnit + +/** + * 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. + */ +class TestMonotonicClock(private var nanos: Long = 0) : MonotonicClock { + 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..6afc681752 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3740,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; @@ -7597,6 +7598,29 @@ public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys { public fun ()V } +public final class io/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/MonotonicClock;)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 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/MonotonicClock;)Lio/sentry/time/Stopwatch; +} + 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..eba06124f5 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -21,6 +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.transport.ITransport; import io.sentry.transport.ITransportGate; import io.sentry.transport.NoOpEnvelopeCache; @@ -3059,6 +3061,18 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { this.dateProvider.setValue(dateProvider); } + /** + * 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 MonotonicClock getMonotonicClock() { + return JavaMonotonicClock.getInstance(); + } + /** * 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..5bb12047f6 --- /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 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 + * 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 MonotonicClock clock; + private 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 MonotonicClock 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 MonotonicClock 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/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/MonotonicClock.java b/sentry/src/main/java/io/sentry/time/MonotonicClock.java new file mode 100644 index 0000000000..f2d333c1a0 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/MonotonicClock.java @@ -0,0 +1,22 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; + +/** + * 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. + * + *

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 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 new file mode 100644 index 0000000000..bb2e3a212d --- /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 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}. + */ +@ApiStatus.Internal +public final class Stopwatch { + + private final @NotNull MonotonicClock clock; + private final long startNanos; + + private Stopwatch(final @NotNull MonotonicClock clock) { + this.clock = clock; + this.startNanos = clock.tickNanos(); + } + + public static @NotNull Stopwatch started(final @NotNull MonotonicClock 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/test/java/io/sentry/time/DeadlineTest.kt b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt new file mode 100644 index 0000000000..38e55710ea --- /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 = TestMonotonicClock() + val deadline = Deadline.after(clock, 2, MINUTES) + + clock.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) + + clock.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()) + } + + @Test + fun `remaining counts down and floors at zero`() { + val clock = TestMonotonicClock() + val deadline = Deadline.after(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 = TestMonotonicClock() + 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) + + assertEquals(1000, deadline.remaining(MILLISECONDS)) + } + + @Test + fun `isAfter compares two deadlines`() { + val clock = TestMonotonicClock() + val shorter = Deadline.after(clock, 1, SECONDS) + val longer = Deadline.after(clock, 5, SECONDS) + + assertTrue(longer.isAfter(shorter)) + assertFalse(shorter.isAfter(longer)) + } + + @Test + fun `isAfter rejects deadlines from different clocks`() { + val deadline = Deadline.after(TestMonotonicClock(), 1, SECONDS) + val fromAnotherClock = Deadline.after(TestMonotonicClock(), 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 = TestMonotonicClock(Long.MIN_VALUE + 1) + val deadline = Deadline.after(clock, 1, SECONDS) + + assertFalse(deadline.hasPassed()) + clock.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 new file mode 100644 index 0000000000..a09cf3ad4f --- /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(TestMonotonicClock()).elapsedNanos()) + } + + @Test + fun `reports elapsed time in the requested unit`() { + val clock = TestMonotonicClock() + 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 = TestMonotonicClock() + val stopwatch = Stopwatch.started(clock) + + clock.advance(1, SECONDS) + assertEquals(1, stopwatch.elapsed(SECONDS)) + + clock.advance(2, SECONDS) + assertEquals(3, stopwatch.elapsed(SECONDS)) + } +}