From d1adfe24533bb60fc4f4f93de0b0ae3ec3720521 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 16:39:31 +0200 Subject: [PATCH 1/3] feat(time): Add Timestamp, Timing and EpochClock (JAVA-572) SentryDate is asked to be four things at once: an epoch instant to serialize, one endpoint of a monotonic interval, a carrier of a hidden System.nanoTime() reading, and an opaque foreign timestamp. Nothing in the type separates them, so the guarantees are decided by the runtime class of both operands -- SentryNanotimeDate.diff() is monotonic only when the other date is also a SentryNanotimeDate, and falls back to subtracting two wall-clock readings otherwise, silently. These three types split that apart along the line that matters, which is whether a value means anything outside this process: Timestamp an epoch instant. Serialize it, compare it against another machine's value -- but it offers no arithmetic between instants, because subtracting two wall-clock readings gives a duration the device's clock can lengthen or reverse. Timing a Timestamp anchor plus a Stopwatch, captured together. end() is the anchor plus the measured duration, which is what the span protocol needs: it carries two instants and no duration field, so the server subtracts them. EpochClock now() to stamp a moment, start() to measure something. A call site has to say which it is doing, and neither result can do the other's job. Timing composes Stopwatch rather than repeating it, so there is one implementation of a monotonic delta. Both measure on MonotonicClock, which counts deep sleep: an interval that excluded it, paired with wall-clock anchors, would produce an end() that falls further behind real time the longer the device sleeps. Nothing calls any of it yet. --- sentry/api/sentry.api | 22 +++++++ .../io/sentry/DateProviderEpochClock.java | 43 +++++++++++++ .../main/java/io/sentry/SentryOptions.java | 16 +++++ .../main/java/io/sentry/time/EpochClock.java | 28 ++++++++ .../main/java/io/sentry/time/Timestamp.java | 64 +++++++++++++++++++ .../src/main/java/io/sentry/time/Timing.java | 55 ++++++++++++++++ .../io/sentry/DateProviderEpochClockTest.kt | 48 ++++++++++++++ .../test/java/io/sentry/time/TimestampTest.kt | 32 ++++++++++ .../test/java/io/sentry/time/TimingTest.kt | 64 +++++++++++++++++++ 9 files changed, 372 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/DateProviderEpochClock.java create mode 100644 sentry/src/main/java/io/sentry/time/EpochClock.java create mode 100644 sentry/src/main/java/io/sentry/time/Timestamp.java create mode 100644 sentry/src/main/java/io/sentry/time/Timing.java create mode 100644 sentry/src/test/java/io/sentry/DateProviderEpochClockTest.kt create mode 100644 sentry/src/test/java/io/sentry/time/TimestampTest.kt create mode 100644 sentry/src/test/java/io/sentry/time/TimingTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 6afc681752..400f2c29dd 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3707,6 +3707,7 @@ public class io/sentry/SentryOptions { public fun getEnvelopeDiskCache ()Lio/sentry/cache/IEnvelopeCache; public fun getEnvelopeReader ()Lio/sentry/IEnvelopeReader; public fun getEnvironment ()Ljava/lang/String; + public fun getEpochClock ()Lio/sentry/time/EpochClock; public fun getEventProcessors ()Ljava/util/List; public fun getExecutorService ()Lio/sentry/ISentryExecutorService; public fun getExperimental ()Lio/sentry/ExperimentalOptions; @@ -7606,6 +7607,11 @@ public final class io/sentry/time/Deadline { public fun remaining (Ljava/util/concurrent/TimeUnit;)J } +public abstract interface class io/sentry/time/EpochClock { + public abstract fun now ()Lio/sentry/time/Timestamp; + public abstract fun start ()Lio/sentry/time/Timing; +} + public final class io/sentry/time/JavaMonotonicClock : io/sentry/time/MonotonicClock { public static fun getInstance ()Lio/sentry/time/MonotonicClock; public fun tickNanos ()J @@ -7621,6 +7627,22 @@ public final class io/sentry/time/Stopwatch { public static fun started (Lio/sentry/time/MonotonicClock;)Lio/sentry/time/Stopwatch; } +public final class io/sentry/time/Timestamp { + public fun epochNanos ()J + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public static fun ofEpochNanos (J)Lio/sentry/time/Timestamp; + public fun plusNanos (J)Lio/sentry/time/Timestamp; + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/time/Timing { + public fun durationNanos ()J + public fun end ()Lio/sentry/time/Timestamp; + public fun start ()Lio/sentry/time/Timestamp; + public static fun started (Lio/sentry/time/Timestamp;Lio/sentry/time/MonotonicClock;)Lio/sentry/time/Timing; +} + 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/DateProviderEpochClock.java b/sentry/src/main/java/io/sentry/DateProviderEpochClock.java new file mode 100644 index 0000000000..135a41f727 --- /dev/null +++ b/sentry/src/main/java/io/sentry/DateProviderEpochClock.java @@ -0,0 +1,43 @@ +package io.sentry; + +import io.sentry.time.EpochClock; +import io.sentry.time.MonotonicClock; +import io.sentry.time.Timestamp; +import io.sentry.time.Timing; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * An {@link EpochClock} that takes its anchor from the configured {@link SentryDateProvider} and + * measures durations on the monotonic clock. + * + *

The bridge runs in this direction on purpose. Reimplementing {@link SentryDateProvider} on top + * of {@link EpochClock} would change serialized values today: a {@link SentryNanotimeDate} carries + * {@link System#nanoTime()} as its tick, {@code SpanFrameMetricsCollector} and the SQLite driver + * spans compare that tick against {@code System.nanoTime()} directly, and a {@link Timing} measures + * on the monotonic clock instead, which counts deep sleep. Swapping the tick source would change + * span durations across deep sleep and break those two comparisons, so it waits for the next major. + * + *

Reading through the options rather than capturing the provider keeps {@link + * SentryOptions#setDateProvider} working after the clock is built. + */ +@ApiStatus.Internal +final class DateProviderEpochClock implements EpochClock { + + private final @NotNull SentryOptions options; + + DateProviderEpochClock(final @NotNull SentryOptions options) { + this.options = options; + } + + @Override + public @NotNull Timestamp now() { + return Timestamp.ofEpochNanos(options.getDateProvider().now().nanoTimestamp()); + } + + @Override + public @NotNull Timing start() { + final @NotNull MonotonicClock clock = options.getMonotonicClock(); + return Timing.started(now(), clock); + } +} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index eba06124f5..401e238314 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -21,6 +21,7 @@ import io.sentry.metrics.IMetricsBatchProcessorFactory; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryTransaction; +import io.sentry.time.EpochClock; import io.sentry.time.JavaMonotonicClock; import io.sentry.time.MonotonicClock; import io.sentry.transport.ITransport; @@ -527,6 +528,9 @@ public class SentryOptions { private final @NotNull LazyEvaluator dateProvider = new LazyEvaluator<>(() -> new SentryAutoDateProvider()); + private final @NotNull LazyEvaluator epochClock = + new LazyEvaluator<>(() -> new DateProviderEpochClock(this)); + private final @NotNull List performanceCollectors = new ArrayList<>(); /** Performance collector that collect performance stats while transactions run. */ @@ -3061,6 +3065,18 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { this.dateProvider.setValue(dateProvider); } + /** + * Returns the wall clock, for stamping an instant that will be serialized or for starting + * something whose duration will be reported. + * + *

Backed by {@link #getDateProvider()}, so a provider set through {@link #setDateProvider} is + * honoured here too. + */ + @ApiStatus.Internal + public @NotNull EpochClock getEpochClock() { + return epochClock.getValue(); + } + /** * Returns the clock used to measure elapsed time, such as rate-limit windows, cache expiry and * ANR thresholds. diff --git a/sentry/src/main/java/io/sentry/time/EpochClock.java b/sentry/src/main/java/io/sentry/time/EpochClock.java new file mode 100644 index 0000000000..313808027b --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/EpochClock.java @@ -0,0 +1,28 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * The source of wall-clock time. + * + *

Two entry points, because there are two questions and they need different answers. {@link + * #now()} stamps a moment — an event, a breadcrumb, a session — and hands back an instant with no + * arithmetic on it. {@link #start()} begins something whose duration will be reported, and hands + * back a {@link Timing} that measures on a monotonic clock. + * + *

The split is the point. A call site has to say which it is doing, and neither result can do + * the other's job, so a duration cannot quietly end up being the difference between two wall-clock + * readings. + */ +@ApiStatus.Internal +public interface EpochClock { + + /** The current instant. Serialize it; do not subtract it from another one. */ + @NotNull + Timestamp now(); + + /** Starts measuring now. */ + @NotNull + Timing start(); +} diff --git a/sentry/src/main/java/io/sentry/time/Timestamp.java b/sentry/src/main/java/io/sentry/time/Timestamp.java new file mode 100644 index 0000000000..416abdf562 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/Timestamp.java @@ -0,0 +1,64 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * An instant on the wall clock, as nanoseconds since the Unix epoch. + * + *

The counterpart to {@link MonotonicClock}, and its opposite in every respect that matters: a + * timestamp is anchored to an epoch, so it is meaningful outside this process — it can be + * serialized, stored, and compared against a value produced by another machine. A tick can do none + * of those things. + * + *

What a timestamp deliberately cannot do is measure an interval. Subtracting two wall-clock + * readings gives a duration that the device's clock can lengthen, shorten or make negative, so this + * type offers no arithmetic between instants. Pair it with a {@link Stopwatch} — which is what + * {@link Timing} is — and the duration comes from a monotonic clock instead. + * + *

Nanoseconds since the epoch overflow a long in the year 2262. + */ +@ApiStatus.Internal +public final class Timestamp { + + private final long epochNanos; + + private Timestamp(final long epochNanos) { + this.epochNanos = epochNanos; + } + + public static @NotNull Timestamp ofEpochNanos(final long epochNanos) { + return new Timestamp(epochNanos); + } + + public long epochNanos() { + return epochNanos; + } + + /** This instant moved forward by {@code nanos}, for deriving an end from a measured duration. */ + public @NotNull Timestamp plusNanos(final long nanos) { + return new Timestamp(epochNanos + nanos); + } + + @Override + public boolean equals(final @Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Timestamp)) { + return false; + } + return epochNanos == ((Timestamp) other).epochNanos; + } + + @Override + public int hashCode() { + return (int) (epochNanos ^ (epochNanos >>> 32)); + } + + @Override + public @NotNull String toString() { + return "Timestamp{epochNanos=" + epochNanos + '}'; + } +} diff --git a/sentry/src/main/java/io/sentry/time/Timing.java b/sentry/src/main/java/io/sentry/time/Timing.java new file mode 100644 index 0000000000..5d96de11ca --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/Timing.java @@ -0,0 +1,55 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Something whose start instant is reported and whose duration is measured. + * + *

A span, a profile or a replay segment needs two facts that no single clock can supply: when it + * began, in epoch terms, so it can be lined up with events from other systems; and how long it + * took, measured on a clock that cannot jump. So this holds both — a {@link Timestamp} anchor and a + * {@link Stopwatch} — captured together, and derives the end from them. + * + *

That derivation is the reason the type exists. The span protocol carries a start and an end + * instant and no duration, so the server subtracts them; emitting a separately-read wall-clock end + * would throw away the monotonic measurement and report whatever the device's clock did in between. + * {@link #end()} is therefore the anchor plus the measured duration, computed in one place instead + * of at every call site. + */ +@ApiStatus.Internal +public final class Timing { + + private final @NotNull Timestamp start; + private final @NotNull Stopwatch stopwatch; + + private Timing(final @NotNull Timestamp start, final @NotNull Stopwatch stopwatch) { + this.start = start; + this.stopwatch = stopwatch; + } + + /** + * Starts measuring, anchored at {@code start}. + * + *

{@link MonotonicClock} counts deep sleep, which is what pairing with a wall-clock anchor + * requires: an interval that excluded it would produce an {@link #end()} that falls further + * behind real time the longer the device sleeps. + */ + public static @NotNull Timing started( + final @NotNull Timestamp start, final @NotNull MonotonicClock clock) { + return new Timing(start, Stopwatch.started(clock)); + } + + public @NotNull Timestamp start() { + return start; + } + + public long durationNanos() { + return stopwatch.elapsedNanos(); + } + + /** The anchor plus the duration measured so far. */ + public @NotNull Timestamp end() { + return start.plusNanos(durationNanos()); + } +} diff --git a/sentry/src/test/java/io/sentry/DateProviderEpochClockTest.kt b/sentry/src/test/java/io/sentry/DateProviderEpochClockTest.kt new file mode 100644 index 0000000000..f729f570b2 --- /dev/null +++ b/sentry/src/test/java/io/sentry/DateProviderEpochClockTest.kt @@ -0,0 +1,48 @@ +package io.sentry + +import io.sentry.time.MonotonicClock +import io.sentry.time.TestMonotonicClock +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.SECONDS +import kotlin.test.Test +import kotlin.test.assertEquals + +class DateProviderEpochClockTest { + private val fixedNanos = SECONDS.toNanos(1_700_000_000) + + private fun options(nanos: Long = fixedNanos) = + SentryOptions().apply { setDateProvider { SentryLongDate(nanos) } } + + @Test + fun `now reads the configured date provider`() { + assertEquals(fixedNanos, options().epochClock.now().epochNanos()) + } + + @Test + fun `now follows a date provider set after the clock was built`() { + val options = options() + val clock = options.epochClock + + options.setDateProvider { SentryLongDate(1) } + + assertEquals(1, clock.now().epochNanos()) + } + + @Test + fun `start anchors on the date provider and measures on the elapsed real-time clock`() { + val ticker = TestMonotonicClock() + // the elapsed-real-time clock is overridden rather than set, the way SentryAndroidOptions does + val options = + object : SentryOptions() { + override fun getMonotonicClock(): MonotonicClock = ticker + } + options.setDateProvider { SentryLongDate(fixedNanos) } + + val timing = options.epochClock.start() + ticker.advance(120, MILLISECONDS) + + assertEquals(fixedNanos, timing.start().epochNanos()) + assertEquals(MILLISECONDS.toNanos(120), timing.durationNanos()) + assertEquals(fixedNanos + MILLISECONDS.toNanos(120), timing.end().epochNanos()) + } +} diff --git a/sentry/src/test/java/io/sentry/time/TimestampTest.kt b/sentry/src/test/java/io/sentry/time/TimestampTest.kt new file mode 100644 index 0000000000..6c84084016 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/TimestampTest.kt @@ -0,0 +1,32 @@ +package io.sentry.time + +import java.util.concurrent.TimeUnit.MILLISECONDS +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class TimestampTest { + @Test + fun `keeps the epoch value it was given`() { + assertEquals( + 1_700_000_000_000_000_000, + Timestamp.ofEpochNanos(1_700_000_000_000_000_000).epochNanos(), + ) + } + + @Test + fun `moves forward without mutating the original`() { + val start = Timestamp.ofEpochNanos(1_000) + val later = start.plusNanos(MILLISECONDS.toNanos(5)) + + assertEquals(1_000, start.epochNanos()) + assertEquals(1_000 + MILLISECONDS.toNanos(5), later.epochNanos()) + } + + @Test + fun `compares by value`() { + assertEquals(Timestamp.ofEpochNanos(42), Timestamp.ofEpochNanos(42)) + assertEquals(Timestamp.ofEpochNanos(42).hashCode(), Timestamp.ofEpochNanos(42).hashCode()) + assertNotEquals(Timestamp.ofEpochNanos(42), Timestamp.ofEpochNanos(43)) + } +} diff --git a/sentry/src/test/java/io/sentry/time/TimingTest.kt b/sentry/src/test/java/io/sentry/time/TimingTest.kt new file mode 100644 index 0000000000..5797b0e17d --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/TimingTest.kt @@ -0,0 +1,64 @@ +package io.sentry.time + +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.SECONDS +import kotlin.test.Test +import kotlin.test.assertEquals + +class TimingTest { + private val anchor = Timestamp.ofEpochNanos(SECONDS.toNanos(1_700_000_000)) + + @Test + fun `starts with a zero duration and an end equal to the anchor`() { + val timing = Timing.started(anchor, TestMonotonicClock()) + + assertEquals(0, timing.durationNanos()) + assertEquals(anchor, timing.end()) + } + + @Test + fun `keeps the anchor it was given`() { + val clock = TestMonotonicClock() + val timing = Timing.started(anchor, clock) + + clock.advance(30, SECONDS) + + assertEquals(anchor, timing.start()) + } + + @Test + fun `derives the end from the measured duration`() { + val clock = TestMonotonicClock() + val timing = Timing.started(anchor, clock) + + clock.advance(250, MILLISECONDS) + + assertEquals(MILLISECONDS.toNanos(250), timing.durationNanos()) + assertEquals(anchor.plusNanos(MILLISECONDS.toNanos(250)), timing.end()) + } + + @Test + fun `end tracks the clock across reads`() { + val clock = TestMonotonicClock() + val timing = Timing.started(anchor, clock) + + clock.advance(1, SECONDS) + assertEquals(anchor.plusNanos(SECONDS.toNanos(1)), timing.end()) + + clock.advance(2, SECONDS) + assertEquals(anchor.plusNanos(SECONDS.toNanos(3)), timing.end()) + } + + @Test + fun `a wall clock jump does not change the duration`() { + // the anchor is captured once; only the ticker feeds the duration, so nothing the device's + // clock does afterwards can lengthen, shorten or reverse it + val clock = TestMonotonicClock() + val timing = Timing.started(anchor, clock) + + clock.advance(500, MILLISECONDS) + + assertEquals(MILLISECONDS.toNanos(500), timing.durationNanos()) + assertEquals(anchor.plusNanos(MILLISECONDS.toNanos(500)), timing.end()) + } +} From 440f5c6eada1890a3c15e462847cdc3c89022cb0 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 2 Sep 2026 15:36:03 +0200 Subject: [PATCH 2/3] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2bd7a26d2..0b8b298bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Internal - Add an internal `MonotonicClock` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) +- Add internal `Timestamp`, `Timing` and `EpochClock`, separating a serialized wall-clock instant from a monotonically measured duration ([#6045](https://github.com/getsentry/sentry-java/pull/6045)) ## 8.55.0 From a372da06805d0f671486595c7dc5bd75fb4152a9 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 10:39:02 +0200 Subject: [PATCH 3/3] ref(time): Read the epoch directly instead of through SentryDateProvider (JAVA-572) DateProviderEpochClock took its instant from options.getDateProvider(), which meant allocating a SentryDate per call to read one long out of it -- and on Android that allocation also takes a System.nanoTime() reading that an EpochClock never looks at, because a Timing measures on the monotonic clock instead. The indirection bought compatibility with a tick nothing here consumes. SystemEpochClock reads the epoch itself, picking precision the way SentryAutoDateProvider does: Instant.now() on JVM 9+, currentTimeMillis() otherwise. Android is always the latter -- Instant is millisecond-granular there whether or not the build desugars it. InstantEpochNanos is a class of its own so the java.time reference is loaded only where it is used, the same reason SentryInstantDate is kept separate. The values are identical on every platform; the dependency is what changes. setDateProvider no longer reaches the epoch clock, so tests override getEpochClock() on SentryOptions, as SentryAndroidOptions already does for getMonotonicClock(). Co-Authored-By: Claude Opus 5 (1M context) --- sentry/api/sentry.api | 6 +++ .../io/sentry/DateProviderEpochClock.java | 43 ----------------- .../main/java/io/sentry/SentryOptions.java | 8 ++-- .../io/sentry/time/InstantEpochNanos.java | 25 ++++++++++ .../java/io/sentry/time/SystemEpochClock.java | 44 +++++++++++++++++ .../io/sentry/DateProviderEpochClockTest.kt | 48 ------------------- .../io/sentry/time/SystemEpochClockTest.kt | 34 +++++++++++++ 7 files changed, 114 insertions(+), 94 deletions(-) delete mode 100644 sentry/src/main/java/io/sentry/DateProviderEpochClock.java create mode 100644 sentry/src/main/java/io/sentry/time/InstantEpochNanos.java create mode 100644 sentry/src/main/java/io/sentry/time/SystemEpochClock.java delete mode 100644 sentry/src/test/java/io/sentry/DateProviderEpochClockTest.kt create mode 100644 sentry/src/test/java/io/sentry/time/SystemEpochClockTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 400f2c29dd..e7ce023db6 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7627,6 +7627,12 @@ public final class io/sentry/time/Stopwatch { public static fun started (Lio/sentry/time/MonotonicClock;)Lio/sentry/time/Stopwatch; } +public final class io/sentry/time/SystemEpochClock : io/sentry/time/EpochClock { + public fun (Lio/sentry/time/MonotonicClock;)V + public fun now ()Lio/sentry/time/Timestamp; + public fun start ()Lio/sentry/time/Timing; +} + public final class io/sentry/time/Timestamp { public fun epochNanos ()J public fun equals (Ljava/lang/Object;)Z diff --git a/sentry/src/main/java/io/sentry/DateProviderEpochClock.java b/sentry/src/main/java/io/sentry/DateProviderEpochClock.java deleted file mode 100644 index 135a41f727..0000000000 --- a/sentry/src/main/java/io/sentry/DateProviderEpochClock.java +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry; - -import io.sentry.time.EpochClock; -import io.sentry.time.MonotonicClock; -import io.sentry.time.Timestamp; -import io.sentry.time.Timing; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -/** - * An {@link EpochClock} that takes its anchor from the configured {@link SentryDateProvider} and - * measures durations on the monotonic clock. - * - *

The bridge runs in this direction on purpose. Reimplementing {@link SentryDateProvider} on top - * of {@link EpochClock} would change serialized values today: a {@link SentryNanotimeDate} carries - * {@link System#nanoTime()} as its tick, {@code SpanFrameMetricsCollector} and the SQLite driver - * spans compare that tick against {@code System.nanoTime()} directly, and a {@link Timing} measures - * on the monotonic clock instead, which counts deep sleep. Swapping the tick source would change - * span durations across deep sleep and break those two comparisons, so it waits for the next major. - * - *

Reading through the options rather than capturing the provider keeps {@link - * SentryOptions#setDateProvider} working after the clock is built. - */ -@ApiStatus.Internal -final class DateProviderEpochClock implements EpochClock { - - private final @NotNull SentryOptions options; - - DateProviderEpochClock(final @NotNull SentryOptions options) { - this.options = options; - } - - @Override - public @NotNull Timestamp now() { - return Timestamp.ofEpochNanos(options.getDateProvider().now().nanoTimestamp()); - } - - @Override - public @NotNull Timing start() { - final @NotNull MonotonicClock clock = options.getMonotonicClock(); - return Timing.started(now(), clock); - } -} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 401e238314..4b0777eaa7 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -24,6 +24,7 @@ import io.sentry.time.EpochClock; import io.sentry.time.JavaMonotonicClock; import io.sentry.time.MonotonicClock; +import io.sentry.time.SystemEpochClock; import io.sentry.transport.ITransport; import io.sentry.transport.ITransportGate; import io.sentry.transport.NoOpEnvelopeCache; @@ -529,7 +530,7 @@ public class SentryOptions { new LazyEvaluator<>(() -> new SentryAutoDateProvider()); private final @NotNull LazyEvaluator epochClock = - new LazyEvaluator<>(() -> new DateProviderEpochClock(this)); + new LazyEvaluator<>(() -> new SystemEpochClock(getMonotonicClock())); private final @NotNull List performanceCollectors = new ArrayList<>(); @@ -3069,8 +3070,9 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { * Returns the wall clock, for stamping an instant that will be serialized or for starting * something whose duration will be reported. * - *

Backed by {@link #getDateProvider()}, so a provider set through {@link #setDateProvider} is - * honoured here too. + *

Independent of {@link #getDateProvider()}: the epoch values are the same, but an {@link + * io.sentry.time.Timing} measures on {@link #getMonotonicClock()} rather than on the {@link + * System#nanoTime()} tick a {@link SentryNanotimeDate} carries. */ @ApiStatus.Internal public @NotNull EpochClock getEpochClock() { diff --git a/sentry/src/main/java/io/sentry/time/InstantEpochNanos.java b/sentry/src/main/java/io/sentry/time/InstantEpochNanos.java new file mode 100644 index 0000000000..65cf3d9bd8 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/InstantEpochNanos.java @@ -0,0 +1,25 @@ +package io.sentry.time; + +import io.sentry.DateUtils; +import java.time.Instant; +import org.jetbrains.annotations.ApiStatus; + +/** + * Reads the epoch from {@link Instant}. + * + *

A class of its own so that the reference to {@code java.time} is loaded only where {@link + * SystemEpochClock} decided to use it. Android's minSdk is below the API 26 that introduced {@code + * Instant}. + */ +@ApiStatus.Internal +@SuppressWarnings("NewApi") +final class InstantEpochNanos { + + private InstantEpochNanos() {} + + static long read() { + final Instant now = Instant.now(); + // No long overflow until year 2262 + return DateUtils.secondsToNanos(now.getEpochSecond()) + now.getNano(); + } +} diff --git a/sentry/src/main/java/io/sentry/time/SystemEpochClock.java b/sentry/src/main/java/io/sentry/time/SystemEpochClock.java new file mode 100644 index 0000000000..0e3136e819 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/SystemEpochClock.java @@ -0,0 +1,44 @@ +package io.sentry.time; + +import io.sentry.DateUtils; +import io.sentry.util.Platform; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * The {@link EpochClock} backed by the system wall clock. + * + *

{@link #now()} reads the epoch at the best precision the platform offers: {@link + * java.time.Instant} where it is sub-millisecond, and {@link System#currentTimeMillis()} everywhere + * else. Android is always the latter — {@code Instant} is millisecond-granular there whether or not + * the build desugars it, see https://github.com/getsentry/sentry-java/pull/2451. + * + *

Millisecond anchors are not the precision loss they look like, because nothing here measures + * with them. A {@link Timing} reports its duration from a {@link Stopwatch}, so the nanosecond + * precision that matters comes off the monotonic clock and only the anchor is coarse. + */ +@ApiStatus.Internal +public final class SystemEpochClock implements EpochClock { + + private static final boolean INSTANT_IS_SUB_MILLISECOND = + Platform.isJvm() && Platform.isJavaNinePlus(); + + private final @NotNull MonotonicClock clock; + + public SystemEpochClock(final @NotNull MonotonicClock clock) { + this.clock = clock; + } + + @Override + public @NotNull Timestamp now() { + return Timestamp.ofEpochNanos( + INSTANT_IS_SUB_MILLISECOND + ? InstantEpochNanos.read() + : DateUtils.millisToNanos(System.currentTimeMillis())); + } + + @Override + public @NotNull Timing start() { + return Timing.started(now(), clock); + } +} diff --git a/sentry/src/test/java/io/sentry/DateProviderEpochClockTest.kt b/sentry/src/test/java/io/sentry/DateProviderEpochClockTest.kt deleted file mode 100644 index f729f570b2..0000000000 --- a/sentry/src/test/java/io/sentry/DateProviderEpochClockTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package io.sentry - -import io.sentry.time.MonotonicClock -import io.sentry.time.TestMonotonicClock -import java.util.concurrent.TimeUnit.MILLISECONDS -import java.util.concurrent.TimeUnit.SECONDS -import kotlin.test.Test -import kotlin.test.assertEquals - -class DateProviderEpochClockTest { - private val fixedNanos = SECONDS.toNanos(1_700_000_000) - - private fun options(nanos: Long = fixedNanos) = - SentryOptions().apply { setDateProvider { SentryLongDate(nanos) } } - - @Test - fun `now reads the configured date provider`() { - assertEquals(fixedNanos, options().epochClock.now().epochNanos()) - } - - @Test - fun `now follows a date provider set after the clock was built`() { - val options = options() - val clock = options.epochClock - - options.setDateProvider { SentryLongDate(1) } - - assertEquals(1, clock.now().epochNanos()) - } - - @Test - fun `start anchors on the date provider and measures on the elapsed real-time clock`() { - val ticker = TestMonotonicClock() - // the elapsed-real-time clock is overridden rather than set, the way SentryAndroidOptions does - val options = - object : SentryOptions() { - override fun getMonotonicClock(): MonotonicClock = ticker - } - options.setDateProvider { SentryLongDate(fixedNanos) } - - val timing = options.epochClock.start() - ticker.advance(120, MILLISECONDS) - - assertEquals(fixedNanos, timing.start().epochNanos()) - assertEquals(MILLISECONDS.toNanos(120), timing.durationNanos()) - assertEquals(fixedNanos + MILLISECONDS.toNanos(120), timing.end().epochNanos()) - } -} diff --git a/sentry/src/test/java/io/sentry/time/SystemEpochClockTest.kt b/sentry/src/test/java/io/sentry/time/SystemEpochClockTest.kt new file mode 100644 index 0000000000..61ccfd18c6 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/SystemEpochClockTest.kt @@ -0,0 +1,34 @@ +package io.sentry.time + +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.TimeUnit.MILLISECONDS +import kotlin.test.Test + +class SystemEpochClockTest { + @Test + fun `now reads the system wall clock`() { + val clock = SystemEpochClock(TestMonotonicClock()) + val before = MILLISECONDS.toNanos(System.currentTimeMillis()) + val now = clock.now().epochNanos() + val after = MILLISECONDS.toNanos(System.currentTimeMillis()) + + // the bounds are millisecond-truncated, so now() may sit up to a millisecond past `after` + assertThat(now).isAtLeast(before) + assertThat(now).isAtMost(after + MILLISECONDS.toNanos(1)) + } + + @Test + fun `start anchors on the wall clock and measures on the supplied clock`() { + val ticker = TestMonotonicClock() + val clock = SystemEpochClock(ticker) + val before = MILLISECONDS.toNanos(System.currentTimeMillis()) + + val timing = clock.start() + ticker.advance(120, MILLISECONDS) + + assertThat(timing.start().epochNanos()).isAtLeast(before) + assertThat(timing.durationNanos()).isEqualTo(MILLISECONDS.toNanos(120)) + assertThat(timing.end().epochNanos()) + .isEqualTo(timing.start().epochNanos() + MILLISECONDS.toNanos(120)) + } +}