From 655adfba2fc25c271cc86ab9d0e84a8735a1db30 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 4 Sep 2026 10:17:30 +0200 Subject: [PATCH] feat(time): Add Timestamp, EpochClock and AnchoredClock (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 silently subtracts two wall-clock readings otherwise. On the JVM, where SentryAutoDateProvider picks SentryInstantDate, neither endpoint has a monotonic component and span durations are not monotonic at all. The fix is not to type the instants more carefully. It is to stop producing them independently. A group of instants that will be compared against each other -- the spans of a transaction, the samples of a profile chunk, the segments of a replay -- reads the epoch once and projects the rest through the monotonic clock: Timestamp an epoch instant, plus the anchor that projected it, or null when it was read or stated directly. No arithmetic between instants; equality is by instant. EpochClock the wall clock, for stamping a moment that leaves the process. Deliberately cannot report a duration. AnchoredClock one epoch reading pinned to one tick. now() and at(tick) project, tickOf() inverts exactly, driftNanos() reports how far the projection has fallen behind the wall clock. Subtracting two instants from one anchor is subtracting two ticks, so a duration is monotonic by construction rather than by convention, and a clock step cannot make a child span start before its parent. It also gives Android nanosecond resolution it cannot read directly, the epoch being millisecond-granular there -- the workaround 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. tickOf() refusing an instant it did not project is what makes this safer rather than merely tidier: mixing domains becomes an exception instead of a plausible-looking wrong number, the same guard Deadline.isAfter applies to clocks. Timing is dropped rather than kept. It paired one Timestamp with one Stopwatch, which is what AnchoredClock does for a whole group, and no call site would have wanted the single-interval version. Nothing calls any of it yet. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + sentry/api/sentry.api | 27 +++++ .../main/java/io/sentry/SentryOptions.java | 18 +++ .../java/io/sentry/time/AnchoredClock.java | 101 +++++++++++++++++ .../main/java/io/sentry/time/EpochClock.java | 20 ++++ .../io/sentry/time/InstantEpochNanos.java | 25 +++++ .../java/io/sentry/time/SystemEpochClock.java | 40 +++++++ .../main/java/io/sentry/time/Timestamp.java | 79 +++++++++++++ .../java/io/sentry/time/AnchoredClockTest.kt | 106 ++++++++++++++++++ .../java/io/sentry/time/FixedEpochClock.kt | 6 + .../io/sentry/time/SystemEpochClockTest.kt | 23 ++++ .../test/java/io/sentry/time/TimestampTest.kt | 34 ++++++ 12 files changed, 480 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/time/AnchoredClock.java create mode 100644 sentry/src/main/java/io/sentry/time/EpochClock.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 create mode 100644 sentry/src/main/java/io/sentry/time/Timestamp.java create mode 100644 sentry/src/test/java/io/sentry/time/AnchoredClockTest.kt create mode 100644 sentry/src/test/java/io/sentry/time/FixedEpochClock.kt create mode 100644 sentry/src/test/java/io/sentry/time/SystemEpochClockTest.kt create mode 100644 sentry/src/test/java/io/sentry/time/TimestampTest.kt 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)) + } +}