diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c2673e22..b0f1f756b6 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`, `EpochClock` and `AnchoredClock`, so related instants project from one wall-clock reading instead of each reading the clock ([#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..a1323ccf68 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; @@ -7598,6 +7599,15 @@ public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys { public fun ()V } +public final class io/sentry/time/AnchoredClock { + public fun at (J)Lio/sentry/time/Timestamp; + public static fun create (Lio/sentry/time/EpochClock;Lio/sentry/time/MonotonicClock;)Lio/sentry/time/AnchoredClock; + public fun driftNanos ()J + public fun now ()Lio/sentry/time/Timestamp; + public fun start ()Lio/sentry/time/Timestamp; + public fun tickOf (Lio/sentry/time/Timestamp;)J +} + 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 @@ -7606,6 +7616,10 @@ 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 final class io/sentry/time/JavaMonotonicClock : io/sentry/time/MonotonicClock { public static fun getInstance ()Lio/sentry/time/MonotonicClock; public fun tickNanos ()J @@ -7621,6 +7635,19 @@ 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 static fun getInstance ()Lio/sentry/time/EpochClock; + public fun now ()Lio/sentry/time/Timestamp; +} + +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 toString ()Ljava/lang/String; +} + 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..0409d718ac 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<>(() -> SystemEpochClock.getInstance()); + 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. + * + *

Reports the same epoch as {@link #getDateProvider()}, but a {@link io.sentry.time.Timestamp} + * carries no {@link System#nanoTime()} tick of its own the way a {@link SentryNanotimeDate} does. + * Instants that will be subtracted from each other come from an {@link + * io.sentry.time.AnchoredClock} built on this and {@link #getMonotonicClock()}. + */ + @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/AnchoredClock.java b/sentry/src/main/java/io/sentry/time/AnchoredClock.java new file mode 100644 index 0000000000..30d5b91047 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/AnchoredClock.java @@ -0,0 +1,101 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * One wall-clock reading pinned to one monotonic tick, from which related instants are projected. + * + *

Exists because a group of instants that will be compared against each other — the spans of a + * transaction, the samples of a profile chunk, the frames of a replay segment — must not each read + * the wall clock. Two independent readings differ by whatever the device's clock did in between, so + * a duration taken across them can shorten, lengthen or go negative, and a child can appear to + * start before its parent. Reading the epoch once and projecting the rest through {@link + * MonotonicClock} makes every instant in the group an image of the same tick, so subtracting any + * two of them reports measured time. + * + *

The span protocol needs exactly that: it carries a start and an end instant and no duration + * field, so the server subtracts them. + * + *

Projection also buys resolution the wall clock does not have. On Android the epoch is + * millisecond-granular, so an instant read directly is truncated, whereas one projected from a tick + * carries nanoseconds — the workaround {@link io.sentry.SentryNanotimeDate} describes, applied once + * per group instead of between each pair of readings. OpenTelemetry's SDK anchors per local root + * span for the same two reasons. + * + *

The cost is that a projection drifts as the anchor ages: it reports what the clock said when + * the anchor was taken, plus measured time, so a clock step afterwards is invisible to it. Anchor + * something short-lived, and use {@link #driftNanos()} to observe the gap. + */ +@ApiStatus.Internal +public final class AnchoredClock { + + private final @NotNull EpochClock epoch; + private final @NotNull MonotonicClock clock; + private final long epochNanos; + private final long anchorTick; + + private AnchoredClock( + final @NotNull EpochClock epoch, + final @NotNull MonotonicClock clock, + final long epochNanos, + final long anchorTick) { + this.epoch = epoch; + this.clock = clock; + this.epochNanos = epochNanos; + this.anchorTick = anchorTick; + } + + /** Takes the anchor now: one epoch reading, one tick, as close together as a call allows. */ + public static @NotNull AnchoredClock create( + final @NotNull EpochClock epoch, final @NotNull MonotonicClock clock) { + return new AnchoredClock(epoch, clock, epoch.now().epochNanos(), clock.tickNanos()); + } + + /** The anchor itself — the one instant here that was read rather than projected. */ + public @NotNull Timestamp start() { + return Timestamp.anchoredAt(epochNanos, this); + } + + public @NotNull Timestamp now() { + return at(clock.tickNanos()); + } + + /** + * The instant a tick corresponds to, for placing something already measured on this clock — a + * frame, a profiler sample — on the same timeline as the instants projected here. + */ + public @NotNull Timestamp at(final long tickNanos) { + return Timestamp.anchoredAt(epochNanos + (tickNanos - anchorTick), this); + } + + /** + * The tick an instant was projected from. Exact, and reads no clock: projection adds a tick + * difference to a fixed epoch, so subtraction inverts it. + * + * @throws IllegalArgumentException if this clock did not project the instant. Its epoch bears no + * arithmetic relation to these ticks, so converting it would silently produce a tick derived + * from a wall-clock difference. + */ + public long tickOf(final @NotNull Timestamp timestamp) { + if (timestamp.anchor() != this) { + throw new IllegalArgumentException( + "Timestamp was not projected by this AnchoredClock: " + timestamp); + } + return anchorTick + (timestamp.epochNanos() - epochNanos); + } + + /** + * How far this anchor's projection has fallen behind or ahead of the wall clock, in nanoseconds. + * + *

Zero means the wall clock advanced by exactly the time this clock measured. Anything else is + * a clock step, or — where {@link MonotonicClock} and the wall clock disagree about suspend — + * device sleep. Reads the epoch and the tick in the same order as {@link #create}, so the gap + * between the two reads biases the result the same way it biased the anchor. + */ + public long driftNanos() { + final long wallElapsed = epoch.now().epochNanos() - epochNanos; + final long measuredElapsed = clock.tickNanos() - anchorTick; + return wallElapsed - measuredElapsed; + } +} 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..f50c2642b0 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/EpochClock.java @@ -0,0 +1,20 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * The source of wall-clock time. + * + *

Stamps a moment that will leave this process — an event, a breadcrumb, a session — and nothing + * else. It deliberately cannot report a duration: measuring belongs to {@link Stopwatch}, and a + * group of instants that will be subtracted from each other belongs to an {@link AnchoredClock}, + * which reads this once and projects the rest. + */ +@ApiStatus.Internal +public interface EpochClock { + + /** The current instant. Serialize it; do not subtract it from another one. */ + @NotNull + Timestamp now(); +} 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..a4343da717 --- /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 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..2cb037c0e0 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/SystemEpochClock.java @@ -0,0 +1,40 @@ +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. + * + *

Reads the epoch at the best precision the platform offers: {@link java.time.Instant} where it + * is sub-millisecond, {@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. + * + *

A millisecond anchor loses less than it looks: an {@link AnchoredClock} adds nanosecond ticks + * to one anchor, so 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 static final SystemEpochClock instance = new SystemEpochClock(); + + public static @NotNull EpochClock getInstance() { + return instance; + } + + private SystemEpochClock() {} + + @Override + public @NotNull Timestamp now() { + return Timestamp.ofEpochNanos( + INSTANT_IS_SUB_MILLISECOND + ? InstantEpochNanos.read() + : DateUtils.millisToNanos(System.currentTimeMillis())); + } +} 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..0c476db7c8 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/Timestamp.java @@ -0,0 +1,79 @@ +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. + * + *

Unlike a {@link MonotonicClock} tick, a timestamp means something outside this process: it can + * be serialized, stored, and compared against a value from another machine. + * + *

It deliberately offers no arithmetic between instants. Subtracting two independent wall-clock + * readings gives a duration the device's clock can lengthen, shorten or make negative. Durations + * come from a {@link Stopwatch}, or from two instants an {@link AnchoredClock} projected from the + * same tick. + * + *

{@link #anchor()} records which of those this is. An instant read straight from the wall + * clock, or stated by something outside this process, has no anchor and can only be serialized. One + * an {@link AnchoredClock} produced references that clock, which lets {@link AnchoredClock#tickOf} + * recover the tick it came from and reject instants it did not produce. + * + *

Nanoseconds since the epoch overflow a long in the year 2262. + */ +@ApiStatus.Internal +public final class Timestamp { + + private final long epochNanos; + private final @Nullable AnchoredClock anchor; + + private Timestamp(final long epochNanos, final @Nullable AnchoredClock anchor) { + this.epochNanos = epochNanos; + this.anchor = anchor; + } + + /** An instant read straight from a wall clock, or stated by something outside this process. */ + public static @NotNull Timestamp ofEpochNanos(final long epochNanos) { + return new Timestamp(epochNanos, null); + } + + static @NotNull Timestamp anchoredAt(final long epochNanos, final @NotNull AnchoredClock anchor) { + return new Timestamp(epochNanos, anchor); + } + + public long epochNanos() { + return epochNanos; + } + + /** The clock that projected this instant, or null if it was read or stated directly. */ + @Nullable + AnchoredClock anchor() { + return anchor; + } + + /** + * Equality is by instant. The anchor records how the instant was obtained, not what it denotes, + * so two readings of the same moment are equal whether or not they were projected. + */ + @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/test/java/io/sentry/time/AnchoredClockTest.kt b/sentry/src/test/java/io/sentry/time/AnchoredClockTest.kt new file mode 100644 index 0000000000..9bf0e21528 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/AnchoredClockTest.kt @@ -0,0 +1,106 @@ +package io.sentry.time + +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.SECONDS +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class AnchoredClockTest { + private val epoch = FixedEpochClock(SECONDS.toNanos(1_700_000_000)) + private val clock = TestMonotonicClock(SECONDS.toNanos(5_000)) + private val anchored = AnchoredClock.create(epoch, clock) + + @Test + fun `start is the epoch reading the anchor was taken at`() { + assertThat(anchored.start().epochNanos()).isEqualTo(SECONDS.toNanos(1_700_000_000)) + } + + @Test + fun `now is the anchor plus the time measured since`() { + clock.advance(120, MILLISECONDS) + + assertThat(anchored.now().epochNanos()) + .isEqualTo(SECONDS.toNanos(1_700_000_000) + MILLISECONDS.toNanos(120)) + } + + @Test + fun `a wall-clock step does not move a projected instant`() { + clock.advance(120, MILLISECONDS) + epoch.epochNanos -= SECONDS.toNanos(30) + + assertThat(anchored.now().epochNanos()) + .isEqualTo(SECONDS.toNanos(1_700_000_000) + MILLISECONDS.toNanos(120)) + } + + @Test + fun `two projected instants differ by measured time, across a wall-clock step`() { + val start = anchored.now() + epoch.epochNanos += SECONDS.toNanos(30) + clock.advance(750, MILLISECONDS) + val end = anchored.now() + + assertThat(end.epochNanos() - start.epochNanos()).isEqualTo(MILLISECONDS.toNanos(750)) + } + + @Test + fun `a millisecond anchor still projects nanoseconds`() { + clock.advance(1_234, java.util.concurrent.TimeUnit.NANOSECONDS) + + assertThat(anchored.now().epochNanos()).isEqualTo(SECONDS.toNanos(1_700_000_000) + 1_234) + } + + @Test + fun `at places a tick measured elsewhere on the same timeline`() { + val tick = clock.tickNanos() + MILLISECONDS.toNanos(8) + + assertThat(anchored.at(tick).epochNanos()) + .isEqualTo(SECONDS.toNanos(1_700_000_000) + MILLISECONDS.toNanos(8)) + } + + @Test + fun `tickOf recovers the tick a projection came from`() { + clock.advance(120, MILLISECONDS) + val now = anchored.now() + + assertThat(anchored.tickOf(now)).isEqualTo(clock.tickNanos()) + } + + @Test + fun `tickOf rejects an instant read straight from a wall clock`() { + assertFailsWith { + anchored.tickOf(Timestamp.ofEpochNanos(SECONDS.toNanos(1_700_000_000))) + } + } + + @Test + fun `tickOf rejects an instant from another anchor`() { + val other = AnchoredClock.create(epoch, clock) + + assertFailsWith { anchored.tickOf(other.now()) } + } + + @Test + fun `drift is zero while the wall clock keeps pace`() { + epoch.epochNanos += MILLISECONDS.toNanos(120) + clock.advance(120, MILLISECONDS) + + assertThat(anchored.driftNanos()).isEqualTo(0) + } + + @Test + fun `drift reports how far the wall clock stepped`() { + clock.advance(1, SECONDS) + epoch.epochNanos += SECONDS.toNanos(31) + + assertThat(anchored.driftNanos()).isEqualTo(SECONDS.toNanos(30)) + } + + @Test + fun `drift is negative when the wall clock steps backwards`() { + clock.advance(1, SECONDS) + epoch.epochNanos -= SECONDS.toNanos(4) + + assertThat(anchored.driftNanos()).isEqualTo(SECONDS.toNanos(-5)) + } +} diff --git a/sentry/src/test/java/io/sentry/time/FixedEpochClock.kt b/sentry/src/test/java/io/sentry/time/FixedEpochClock.kt new file mode 100644 index 0000000000..ceb6b8d1fd --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/FixedEpochClock.kt @@ -0,0 +1,6 @@ +package io.sentry.time + +/** An [EpochClock] whose instant only moves when a test moves it. */ +internal class FixedEpochClock(var epochNanos: Long = 0) : EpochClock { + override fun now(): Timestamp = Timestamp.ofEpochNanos(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..1159e0f348 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/SystemEpochClockTest.kt @@ -0,0 +1,23 @@ +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 before = MILLISECONDS.toNanos(System.currentTimeMillis()) + val now = SystemEpochClock.getInstance().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 `an instant read from the wall clock is not anchored to anything`() { + assertThat(SystemEpochClock.getInstance().now().anchor()).isNull() + } +} 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..d4699b248c --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/TimestampTest.kt @@ -0,0 +1,34 @@ +package io.sentry.time + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import kotlin.test.assertNotEquals + +class TimestampTest { + @Test + fun `keeps the epoch value it was given`() { + assertThat(Timestamp.ofEpochNanos(1_700_000_000_000_000_000).epochNanos()) + .isEqualTo(1_700_000_000_000_000_000) + } + + @Test + fun `an instant read directly has no anchor`() { + assertThat(Timestamp.ofEpochNanos(42).anchor()).isNull() + } + + @Test + fun `an instant a clock projected carries that clock`() { + val anchored = AnchoredClock.create(SystemEpochClock.getInstance(), TestMonotonicClock()) + + assertThat(anchored.now().anchor()).isSameInstanceAs(anchored) + } + + @Test + fun `compares by instant, whatever produced it`() { + val anchored = AnchoredClock.create(FixedEpochClock(42), TestMonotonicClock()) + + assertThat(Timestamp.ofEpochNanos(42)).isEqualTo(anchored.start()) + assertThat(Timestamp.ofEpochNanos(42).hashCode()).isEqualTo(anchored.start().hashCode()) + assertNotEquals(Timestamp.ofEpochNanos(42), Timestamp.ofEpochNanos(43)) + } +}