diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3c5677dc..f46dbcec85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Add `Session.State.Unhandled` for unhandled errors that do not terminate the process ([#5919](https://github.com/getsentry/sentry-java/pull/5919)) +### Internal + +- Add internal `UptimeClock` and `ElapsedRealtimeClock` abstractions with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) + ### Fixes - Keep dropped tombstone and ANR events dropped, instead of reporting the same app exit again at every app start ([#6002](https://github.com/getsentry/sentry-java/pull/6002)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 4b8b41d41c..66252d82c6 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -410,6 +410,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/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 615db97a28..83fcd098b1 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; @@ -864,6 +866,12 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) { this.enableAnrFingerprinting = enableAnrFingerprinting; } + @Override + @ApiStatus.Internal + public @NotNull ElapsedRealtimeClock getElapsedRealtimeClock() { + return AndroidElapsedRealtimeClock.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/AndroidElapsedRealtimeClock.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java new file mode 100644 index 0000000000..b550e3ecf2 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidElapsedRealtimeClock.java @@ -0,0 +1,29 @@ +package io.sentry.android.core.internal.time; + +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()}. + * + *
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 {
+
+ private static final AndroidElapsedRealtimeClock instance = new AndroidElapsedRealtimeClock();
+
+ public static @NotNull ElapsedRealtimeClock getInstance() {
+ return instance;
+ }
+
+ private AndroidElapsedRealtimeClock() {}
+
+ @Override
+ public long tickNanos() {
+ return SystemClock.elapsedRealtimeNanos();
+ }
+}
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 713fb13cf9..0841b7c1cc 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -3698,6 +3698,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;
@@ -7582,6 +7583,40 @@ public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys {
public fun 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();
+ }
+
/**
* 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..1a63dc5eb5
--- /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 after(
+ 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..a24c548920
--- /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()}.
+ *
+ * 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/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..acc623a796
--- /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.after(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.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 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.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 = TestTicker()
+ 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 = TestTicker()
+ 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(TestTicker(), 1, SECONDS)
+ val fromAnotherClock = Deadline.after(TestTicker(), 5, SECONDS)
+
+ assertFailsWith