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 diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 6afc681752..e7ce023db6 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,28 @@ 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 + 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/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index eba06124f5..4b0777eaa7 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -21,8 +21,10 @@ 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.time.SystemEpochClock; import io.sentry.transport.ITransport; import io.sentry.transport.ITransportGate; import io.sentry.transport.NoOpEnvelopeCache; @@ -527,6 +529,9 @@ public class SentryOptions { private final @NotNull LazyEvaluator dateProvider = new LazyEvaluator<>(() -> new SentryAutoDateProvider()); + private final @NotNull LazyEvaluator epochClock = + new LazyEvaluator<>(() -> new SystemEpochClock(getMonotonicClock())); + private final @NotNull List performanceCollectors = new ArrayList<>(); /** Performance collector that collect performance stats while transactions run. */ @@ -3061,6 +3066,19 @@ 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. + * + *

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() { + 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/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/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/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)) + } +} 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()) + } +}