Skip to content

Commit 66f67ec

Browse files
runningcodeclaude
andauthored
feat(time): Add Timestamp, EpochClock and AnchoredClock (JAVA-572) (#6045)
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 builds the rest from the monotonic ticker: Timestamp an epoch instant. No arithmetic between instants; equality is by instant. EpochClock the wall clock, for stamping a moment that leaves the process. Not for computing durations. AnchoredClock one wall-clock reading pinned to one tick, from which now() builds. 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. Only what a caller needs today is exposed. Placing an externally measured tick on the anchor's timeline, and recovering the tick a timestamp was built from, are both exact and both trivial to add, but neither is reachable until spans carry an anchor. They belong to the change that migrates them rather than to this one, where they would arrive with no caller. 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) <noreply@anthropic.com>
1 parent 643066d commit 66f67ec

12 files changed

Lines changed: 336 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
### Internal
1212

1313
- Add an internal `MonotonicTicker` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028))
14+
- 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))
1415

1516
### Dependencies
1617

‎sentry/api/sentry.api‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3707,6 +3707,7 @@ public class io/sentry/SentryOptions {
37073707
public fun getEnvelopeDiskCache ()Lio/sentry/cache/IEnvelopeCache;
37083708
public fun getEnvelopeReader ()Lio/sentry/IEnvelopeReader;
37093709
public fun getEnvironment ()Ljava/lang/String;
3710+
public fun getEpochClock ()Lio/sentry/time/EpochClock;
37103711
public fun getEventProcessors ()Ljava/util/List;
37113712
public fun getExecutorService ()Lio/sentry/ISentryExecutorService;
37123713
public fun getExperimental ()Lio/sentry/ExperimentalOptions;
@@ -7598,6 +7599,12 @@ public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys {
75987599
public fun <init> ()V
75997600
}
76007601

7602+
public final class io/sentry/time/AnchoredClock {
7603+
public static fun create (Lio/sentry/time/EpochClock;Lio/sentry/time/MonotonicTicker;)Lio/sentry/time/AnchoredClock;
7604+
public fun now ()Lio/sentry/time/Timestamp;
7605+
public fun startTime ()Lio/sentry/time/Timestamp;
7606+
}
7607+
76017608
public final class io/sentry/time/Deadline {
76027609
public static fun after (Lio/sentry/time/MonotonicTicker;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline;
76037610
public fun hasPassed ()Z
@@ -7606,6 +7613,10 @@ public final class io/sentry/time/Deadline {
76067613
public fun remaining (Ljava/util/concurrent/TimeUnit;)J
76077614
}
76087615

7616+
public abstract interface class io/sentry/time/EpochClock {
7617+
public abstract fun now ()Lio/sentry/time/Timestamp;
7618+
}
7619+
76097620
public final class io/sentry/time/JavaMonotonicTicker : io/sentry/time/MonotonicTicker {
76107621
public static fun getInstance ()Lio/sentry/time/MonotonicTicker;
76117622
public fun tickNanos ()J
@@ -7621,6 +7632,19 @@ public final class io/sentry/time/Stopwatch {
76217632
public static fun started (Lio/sentry/time/MonotonicTicker;)Lio/sentry/time/Stopwatch;
76227633
}
76237634

7635+
public final class io/sentry/time/SystemEpochClock : io/sentry/time/EpochClock {
7636+
public static fun getInstance ()Lio/sentry/time/EpochClock;
7637+
public fun now ()Lio/sentry/time/Timestamp;
7638+
}
7639+
7640+
public final class io/sentry/time/Timestamp {
7641+
public fun epochNanos ()J
7642+
public fun equals (Ljava/lang/Object;)Z
7643+
public fun hashCode ()I
7644+
public static fun ofEpochNanos (J)Lio/sentry/time/Timestamp;
7645+
public fun toString ()Ljava/lang/String;
7646+
}
7647+
76247648
public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport {
76257649
public fun <init> (Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/RequestDetails;)V
76267650
public fun <init> (Lio/sentry/transport/QueuedThreadPoolExecutor;Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/transport/HttpConnection;)V

‎sentry/src/main/java/io/sentry/SentryOptions.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
import io.sentry.metrics.IMetricsBatchProcessorFactory;
2222
import io.sentry.protocol.SdkVersion;
2323
import io.sentry.protocol.SentryTransaction;
24+
import io.sentry.time.EpochClock;
2425
import io.sentry.time.JavaMonotonicTicker;
2526
import io.sentry.time.MonotonicTicker;
27+
import io.sentry.time.SystemEpochClock;
2628
import io.sentry.transport.ITransport;
2729
import io.sentry.transport.ITransportGate;
2830
import io.sentry.transport.NoOpEnvelopeCache;
@@ -3061,6 +3063,19 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) {
30613063
this.dateProvider.setValue(dateProvider);
30623064
}
30633065

3066+
/**
3067+
* Returns the wall clock, for stamping an instant that will be serialized.
3068+
*
3069+
* <p>Reports the same epoch as {@link #getDateProvider()}, but a {@link io.sentry.time.Timestamp}
3070+
* carries no {@link System#nanoTime()} tick of its own the way a {@link SentryNanotimeDate} does.
3071+
* Instants that will be subtracted from each other come from an {@link
3072+
* io.sentry.time.AnchoredClock} built on this and {@link #getMonotonicTicker()}.
3073+
*/
3074+
@ApiStatus.Internal
3075+
public @NotNull EpochClock getEpochClock() {
3076+
return SystemEpochClock.getInstance();
3077+
}
3078+
30643079
/**
30653080
* Returns the ticker used to measure elapsed time, such as rate-limit windows, cache expiry and
30663081
* ANR thresholds.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.sentry.time;
2+
3+
import org.jetbrains.annotations.ApiStatus;
4+
import org.jetbrains.annotations.NotNull;
5+
6+
/**
7+
* One wall-clock reading plus a monotonic ticker, producing timestamps that are safe to subtract
8+
* from each other.
9+
*
10+
* <p>Wall clocks jump. Read the wall clock twice and the device may have changed it in between, so
11+
* the gap between the two readings is not the time that actually passed — it can come out too
12+
* short, too long, or negative. That is how a child span ends up starting before its parent.
13+
*
14+
* <p>So for a group of timestamps that will be compared with each other — the spans of a
15+
* transaction, the samples of a profile chunk, the frames of a replay segment — read the wall clock
16+
* only once (the anchor) and build the rest by adding the time a {@link MonotonicTicker} has
17+
* measured since, which only ever moves forward. Gaps between those timestamps are then real
18+
* elapsed time. Spans need exactly that: the protocol sends a start and an end timestamp and no
19+
* duration, so the server subtracts them.
20+
*/
21+
@ApiStatus.Internal
22+
public final class AnchoredClock {
23+
24+
private final @NotNull MonotonicTicker ticker;
25+
private final long epochNanos;
26+
private final long anchorTick;
27+
28+
private AnchoredClock(
29+
final @NotNull MonotonicTicker ticker, final long epochNanos, final long anchorTick) {
30+
this.ticker = ticker;
31+
this.epochNanos = epochNanos;
32+
this.anchorTick = anchorTick;
33+
}
34+
35+
/** Takes the anchor now: one wall-clock reading and one tick, back to back. */
36+
public static @NotNull AnchoredClock create(
37+
final @NotNull EpochClock epoch, final @NotNull MonotonicTicker ticker) {
38+
return new AnchoredClock(ticker, epoch.now().epochNanos(), ticker.tickNanos());
39+
}
40+
41+
/**
42+
* The anchor itself: the one timestamp here that was read from the wall clock rather than built
43+
* from a tick. Reads no clock and never changes.
44+
*/
45+
public @NotNull Timestamp startTime() {
46+
return Timestamp.ofEpochNanos(epochNanos);
47+
}
48+
49+
/** Now: {@link #startTime()} plus the time the ticker has measured since the anchor. */
50+
public @NotNull Timestamp now() {
51+
return at(ticker.tickNanos());
52+
}
53+
54+
private @NotNull Timestamp at(final long tickNanos) {
55+
return Timestamp.ofEpochNanos(epochNanos + (tickNanos - anchorTick));
56+
}
57+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.sentry.time;
2+
3+
import org.jetbrains.annotations.ApiStatus;
4+
import org.jetbrains.annotations.NotNull;
5+
6+
/**
7+
* The source of wall-clock {@link Timestamp}s (aka, instants).
8+
*
9+
* <p>Stamps a moment that will leave this process, an event, a breadcrumb, a session, and nothing
10+
* else. This class should <b>not</b> be used to compute durations. For Durations, use {@link
11+
* Stopwatch}, and a group of instants that will be subtracted from each other belongs to an {@link
12+
* AnchoredClock}, which reads this once and projects the rest.
13+
*/
14+
@ApiStatus.Internal
15+
public interface EpochClock {
16+
17+
/** The current instant. Serialize it; do not subtract it from another one. */
18+
@NotNull
19+
Timestamp now();
20+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.sentry.time;
2+
3+
import io.sentry.DateUtils;
4+
import java.time.Instant;
5+
import org.jetbrains.annotations.ApiStatus;
6+
7+
/**
8+
* Reads the epoch from {@link Instant}.
9+
*
10+
* <p>A class of its own so the reference to {@code java.time} is loaded only where {@link
11+
* SystemEpochClock} decided to use it. Android's minSdk is below the API 26 that introduced {@code
12+
* Instant}.
13+
*/
14+
@ApiStatus.Internal
15+
@SuppressWarnings("NewApi")
16+
final class InstantEpochNanos {
17+
18+
private InstantEpochNanos() {}
19+
20+
static long read() {
21+
final Instant now = Instant.now();
22+
// No long overflow until year 2262
23+
return DateUtils.secondsToNanos(now.getEpochSecond()) + now.getNano();
24+
}
25+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package io.sentry.time;
2+
3+
import io.sentry.DateUtils;
4+
import io.sentry.util.Platform;
5+
import org.jetbrains.annotations.ApiStatus;
6+
import org.jetbrains.annotations.NotNull;
7+
8+
/**
9+
* The {@link EpochClock} backed by the system wall clock.
10+
*
11+
* <p>Reads the epoch at the best precision the platform offers: {@link java.time.Instant} where it
12+
* is sub-millisecond, {@link System#currentTimeMillis()} everywhere else. Android is always the
13+
* latter — {@code Instant} is millisecond-granular there whether or not the build desugars it, see
14+
* https://github.com/getsentry/sentry-java/pull/2451.
15+
*
16+
* <p>A millisecond anchor loses less than it looks: an {@link AnchoredClock} adds nanosecond ticks
17+
* to one anchor, so only the anchor is coarse.
18+
*/
19+
@ApiStatus.Internal
20+
public final class SystemEpochClock implements EpochClock {
21+
22+
private static final boolean INSTANT_IS_SUB_MILLISECOND =
23+
Platform.isJvm() && Platform.isJavaNinePlus();
24+
25+
private static final SystemEpochClock instance = new SystemEpochClock();
26+
27+
public static @NotNull EpochClock getInstance() {
28+
return instance;
29+
}
30+
31+
private SystemEpochClock() {}
32+
33+
@Override
34+
public @NotNull Timestamp now() {
35+
return Timestamp.ofEpochNanos(
36+
INSTANT_IS_SUB_MILLISECOND
37+
? InstantEpochNanos.read()
38+
: DateUtils.millisToNanos(System.currentTimeMillis()));
39+
}
40+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.sentry.time;
2+
3+
import org.jetbrains.annotations.ApiStatus;
4+
import org.jetbrains.annotations.NotNull;
5+
import org.jetbrains.annotations.Nullable;
6+
7+
/**
8+
* An instant on the wall clock, as nanoseconds since the Unix epoch.
9+
*
10+
* <p>Unlike a {@link MonotonicTicker} tick, a timestamp means something outside this process: it
11+
* can be serialized, stored, and compared against a value from another machine.
12+
*
13+
* <p>It deliberately offers no arithmetic between instants. Subtracting two independent wall-clock
14+
* readings gives a duration the device's clock can lengthen, shorten or make negative. Durations
15+
* come from a {@link Stopwatch}, or from two instants an {@link AnchoredClock} projected from the
16+
* same tick.
17+
*
18+
* <p>Nanoseconds since the epoch overflow a long in the year 2262.
19+
*/
20+
@ApiStatus.Internal
21+
public final class Timestamp {
22+
23+
private final long epochNanos;
24+
25+
private Timestamp(final long epochNanos) {
26+
this.epochNanos = epochNanos;
27+
}
28+
29+
public static @NotNull Timestamp ofEpochNanos(final long epochNanos) {
30+
return new Timestamp(epochNanos);
31+
}
32+
33+
public long epochNanos() {
34+
return epochNanos;
35+
}
36+
37+
@Override
38+
public boolean equals(final @Nullable Object other) {
39+
if (this == other) {
40+
return true;
41+
}
42+
if (!(other instanceof Timestamp)) {
43+
return false;
44+
}
45+
return epochNanos == ((Timestamp) other).epochNanos;
46+
}
47+
48+
@Override
49+
public int hashCode() {
50+
return (int) (epochNanos ^ (epochNanos >>> 32));
51+
}
52+
53+
@Override
54+
public @NotNull String toString() {
55+
return "Timestamp{epochNanos=" + epochNanos + '}';
56+
}
57+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package io.sentry.time
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import java.util.concurrent.TimeUnit.MILLISECONDS
5+
import java.util.concurrent.TimeUnit.SECONDS
6+
import kotlin.test.Test
7+
8+
class AnchoredClockTest {
9+
private val epoch = FixedEpochClock(SECONDS.toNanos(1_700_000_000))
10+
private val ticker = TestMonotonicTicker(SECONDS.toNanos(5_000))
11+
private val anchored = AnchoredClock.create(epoch, ticker)
12+
13+
@Test
14+
fun `startTime is the wall-clock reading the anchor was taken at`() {
15+
assertThat(anchored.startTime().epochNanos()).isEqualTo(SECONDS.toNanos(1_700_000_000))
16+
}
17+
18+
@Test
19+
fun `now is the anchor plus the time measured since`() {
20+
ticker.advance(120, MILLISECONDS)
21+
22+
assertThat(anchored.now().epochNanos())
23+
.isEqualTo(SECONDS.toNanos(1_700_000_000) + MILLISECONDS.toNanos(120))
24+
}
25+
26+
@Test
27+
fun `a wall-clock step does not move a projected instant`() {
28+
ticker.advance(120, MILLISECONDS)
29+
epoch.epochNanos -= SECONDS.toNanos(30)
30+
31+
assertThat(anchored.now().epochNanos())
32+
.isEqualTo(SECONDS.toNanos(1_700_000_000) + MILLISECONDS.toNanos(120))
33+
}
34+
35+
@Test
36+
fun `two projected instants differ by measured time, across a wall-clock step`() {
37+
val start = anchored.now()
38+
epoch.epochNanos += SECONDS.toNanos(30)
39+
ticker.advance(750, MILLISECONDS)
40+
val end = anchored.now()
41+
42+
assertThat(end.epochNanos() - start.epochNanos()).isEqualTo(MILLISECONDS.toNanos(750))
43+
}
44+
45+
@Test
46+
fun `a millisecond anchor still projects nanoseconds`() {
47+
ticker.advance(1_234, java.util.concurrent.TimeUnit.NANOSECONDS)
48+
49+
assertThat(anchored.now().epochNanos()).isEqualTo(SECONDS.toNanos(1_700_000_000) + 1_234)
50+
}
51+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.sentry.time
2+
3+
/** An [EpochClock] whose instant only moves when a test moves it. */
4+
internal class FixedEpochClock(var epochNanos: Long = 0) : EpochClock {
5+
override fun now(): Timestamp = Timestamp.ofEpochNanos(epochNanos)
6+
}

0 commit comments

Comments
 (0)