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 ()V } +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 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..54f19ba93d 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.ElapsedRealtimeClock; +import io.sentry.time.JavaElapsedRealtimeClock; 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 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 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 { 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.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..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)) + } +}