diff --git a/CHANGELOG.md b/CHANGELOG.md index e89e7e07fd..90ae53294a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Internal - Add an internal `MonotonicTicker` 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 7897fe2ccb..dad91a2419 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,13 @@ 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/MonotonicTicker;)Lio/sentry/time/AnchoredClock; + public fun now ()Lio/sentry/time/Timestamp; + public fun origin ()Lio/sentry/time/Timestamp; +} + public final class io/sentry/time/Deadline { public static fun after (Lio/sentry/time/MonotonicTicker;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline; public fun hasPassed ()Z @@ -7606,6 +7614,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/JavaMonotonicTicker : io/sentry/time/MonotonicTicker { public static fun getInstance ()Lio/sentry/time/MonotonicTicker; public fun tickNanos ()J @@ -7621,6 +7633,19 @@ public final class io/sentry/time/Stopwatch { public static fun started (Lio/sentry/time/MonotonicTicker;)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 93806f4d6f..1a1fbf738c 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.JavaMonotonicTicker; import io.sentry.time.MonotonicTicker; +import io.sentry.time.SystemEpochClock; import io.sentry.transport.ITransport; import io.sentry.transport.ITransportGate; import io.sentry.transport.NoOpEnvelopeCache; @@ -3061,6 +3063,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 #getMonotonicTicker()}. + */ + @ApiStatus.Internal + public @NotNull EpochClock getEpochClock() { + return SystemEpochClock.getInstance(); + } + /** * Returns the ticker 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..3bd86cae3e --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/AnchoredClock.java @@ -0,0 +1,70 @@ +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 + * MonotonicTicker} makes every instant 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. That is the workaround {@link io.sentry.SentryNanotimeDate} describes, + * applied once per group rather than to every reading. OpenTelemetry's SDK anchors per local root + * span for the same two reasons. + * + *

The cost is that a projection ages: it reports what the wall clock said when the anchor was + * taken plus the time measured since, so a later correction to the device's clock — an NTP sync, or + * the user setting the time — never reaches it. Anchor something short-lived. + */ +@ApiStatus.Internal +public final class AnchoredClock { + + private final @NotNull MonotonicTicker ticker; + private final long epochNanos; + private final long anchorTick; + + private AnchoredClock( + final @NotNull MonotonicTicker ticker, final long epochNanos, final long anchorTick) { + this.ticker = ticker; + 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 MonotonicTicker ticker) { + return new AnchoredClock(ticker, epoch.now().epochNanos(), ticker.tickNanos()); + } + + /** + * The instant the anchor was taken — the one instant here that was read rather than projected. + * + *

Reads no clock and never changes. Every other instant this class returns is this one plus + * measured time. + */ + public @NotNull Timestamp origin() { + return Timestamp.ofEpochNanos(epochNanos); + } + + /** The current instant: {@link #origin()} plus the time the ticker has measured since. */ + public @NotNull Timestamp now() { + return at(ticker.tickNanos()); + } + + /** + * The instant a tick corresponds to, for placing something already measured on this ticker — a + * frame, a profiler sample — on the same timeline as the instants projected here. + */ + public @NotNull Timestamp at(final long tickNanos) { + return Timestamp.ofEpochNanos(epochNanos + (tickNanos - anchorTick)); + } +} 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..d703cfb9c9 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/Timestamp.java @@ -0,0 +1,57 @@ +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 MonotonicTicker} 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. + * + *

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; + } + + @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..9caf1b3125 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/AnchoredClockTest.kt @@ -0,0 +1,59 @@ +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 + +class AnchoredClockTest { + private val epoch = FixedEpochClock(SECONDS.toNanos(1_700_000_000)) + private val ticker = TestMonotonicTicker(SECONDS.toNanos(5_000)) + private val anchored = AnchoredClock.create(epoch, ticker) + + @Test + fun `origin is the epoch reading the anchor was taken at`() { + assertThat(anchored.origin().epochNanos()).isEqualTo(SECONDS.toNanos(1_700_000_000)) + } + + @Test + fun `now is the anchor plus the time measured since`() { + ticker.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`() { + ticker.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) + ticker.advance(750, MILLISECONDS) + val end = anchored.now() + + assertThat(end.epochNanos() - start.epochNanos()).isEqualTo(MILLISECONDS.toNanos(750)) + } + + @Test + fun `a millisecond anchor still projects nanoseconds`() { + ticker.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 = ticker.tickNanos() + MILLISECONDS.toNanos(8) + + assertThat(anchored.at(tick).epochNanos()) + .isEqualTo(SECONDS.toNanos(1_700_000_000) + MILLISECONDS.toNanos(8)) + } +} 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..ee6391ac90 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/SystemEpochClockTest.kt @@ -0,0 +1,18 @@ +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)) + } +} 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..49cb0be471 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/TimestampTest.kt @@ -0,0 +1,22 @@ +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 `compares by instant, whatever produced it`() { + val anchored = AnchoredClock.create(FixedEpochClock(42), TestMonotonicTicker()) + + assertThat(Timestamp.ofEpochNanos(42)).isEqualTo(anchored.origin()) + assertThat(Timestamp.ofEpochNanos(42).hashCode()).isEqualTo(anchored.origin().hashCode()) + assertNotEquals(Timestamp.ofEpochNanos(42), Timestamp.ofEpochNanos(43)) + } +}