Skip to content

Commit bec70fb

Browse files
runningcodeclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 271cf66 commit bec70fb

12 files changed

Lines changed: 486 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Internal
66

77
- Add an internal `MonotonicClock` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028))
8+
- Add internal `Timestamp`, `EpochClock` and `AnchoredClock`, so a group of related instants is projected from one wall-clock reading instead of each reading the clock again ([#6045](https://github.com/getsentry/sentry-java/pull/6045))
89

910
## 8.55.0
1011

‎sentry/api/sentry.api‎

Lines changed: 27 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,15 @@ 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 fun at (J)Lio/sentry/time/Timestamp;
7604+
public static fun create (Lio/sentry/time/EpochClock;Lio/sentry/time/MonotonicClock;)Lio/sentry/time/AnchoredClock;
7605+
public fun driftNanos ()J
7606+
public fun now ()Lio/sentry/time/Timestamp;
7607+
public fun start ()Lio/sentry/time/Timestamp;
7608+
public fun tickOf (Lio/sentry/time/Timestamp;)J
7609+
}
7610+
76017611
public final class io/sentry/time/Deadline {
76027612
public static fun after (Lio/sentry/time/MonotonicClock;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline;
76037613
public fun hasPassed ()Z
@@ -7606,6 +7616,10 @@ public final class io/sentry/time/Deadline {
76067616
public fun remaining (Ljava/util/concurrent/TimeUnit;)J
76077617
}
76087618

7619+
public abstract interface class io/sentry/time/EpochClock {
7620+
public abstract fun now ()Lio/sentry/time/Timestamp;
7621+
}
7622+
76097623
public final class io/sentry/time/JavaMonotonicClock : io/sentry/time/MonotonicClock {
76107624
public static fun getInstance ()Lio/sentry/time/MonotonicClock;
76117625
public fun tickNanos ()J
@@ -7621,6 +7635,19 @@ public final class io/sentry/time/Stopwatch {
76217635
public static fun started (Lio/sentry/time/MonotonicClock;)Lio/sentry/time/Stopwatch;
76227636
}
76237637

7638+
public final class io/sentry/time/SystemEpochClock : io/sentry/time/EpochClock {
7639+
public static fun getInstance ()Lio/sentry/time/EpochClock;
7640+
public fun now ()Lio/sentry/time/Timestamp;
7641+
}
7642+
7643+
public final class io/sentry/time/Timestamp {
7644+
public fun epochNanos ()J
7645+
public fun equals (Ljava/lang/Object;)Z
7646+
public fun hashCode ()I
7647+
public static fun ofEpochNanos (J)Lio/sentry/time/Timestamp;
7648+
public fun toString ()Ljava/lang/String;
7649+
}
7650+
76247651
public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport {
76257652
public fun <init> (Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/RequestDetails;)V
76267653
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: 18 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.JavaMonotonicClock;
2526
import io.sentry.time.MonotonicClock;
27+
import io.sentry.time.SystemEpochClock;
2628
import io.sentry.transport.ITransport;
2729
import io.sentry.transport.ITransportGate;
2830
import io.sentry.transport.NoOpEnvelopeCache;
@@ -527,6 +529,9 @@ public class SentryOptions {
527529
private final @NotNull LazyEvaluator<SentryDateProvider> dateProvider =
528530
new LazyEvaluator<>(() -> new SentryAutoDateProvider());
529531

532+
private final @NotNull LazyEvaluator<EpochClock> epochClock =
533+
new LazyEvaluator<>(() -> SystemEpochClock.getInstance());
534+
530535
private final @NotNull List<IPerformanceCollector> performanceCollectors = new ArrayList<>();
531536

532537
/** Performance collector that collect performance stats while transactions run. */
@@ -3061,6 +3066,19 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) {
30613066
this.dateProvider.setValue(dateProvider);
30623067
}
30633068

3069+
/**
3070+
* Returns the wall clock, for stamping an instant that will be serialized.
3071+
*
3072+
* <p>Reports the same epoch as {@link #getDateProvider()} but nothing else: a {@link
3073+
* io.sentry.time.Timestamp} carries no {@link System#nanoTime()} tick of its own, the way a
3074+
* {@link SentryNanotimeDate} does. Instants that will be subtracted from each other come from an
3075+
* {@link io.sentry.time.AnchoredClock} built on this and {@link #getMonotonicClock()}.
3076+
*/
3077+
@ApiStatus.Internal
3078+
public @NotNull EpochClock getEpochClock() {
3079+
return epochClock.getValue();
3080+
}
3081+
30643082
/**
30653083
* Returns the clock used to measure elapsed time, such as rate-limit windows, cache expiry and
30663084
* ANR thresholds.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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 pinned to one monotonic tick, from which related instants are projected.
8+
*
9+
* <p>Exists because a group of instants that will be compared against each other — the spans of a
10+
* transaction, the samples of a profile chunk, the frames of a replay segment — must not each read
11+
* the wall clock. Two independent readings differ by whatever the device's clock did in between, so
12+
* a duration taken across them can shorten, lengthen or go negative, and a child can appear to
13+
* start before its parent. Reading the epoch once and projecting the rest through {@link
14+
* MonotonicClock} makes every instant in the group an image of the same tick origin, which is what
15+
* makes subtracting any two of them monotonic by construction rather than by convention.
16+
*
17+
* <p>That is what the span protocol needs. It carries a start and an end instant and no duration
18+
* field, so the server subtracts them; both endpoints coming from one anchor is the only way that
19+
* subtraction reports measured time rather than whatever the clock did.
20+
*
21+
* <p>Projection also buys resolution the wall clock does not have. On Android the epoch is
22+
* millisecond-granular, so an instant read directly is truncated, whereas one projected from a tick
23+
* carries nanoseconds. This is the workaround {@link io.sentry.SentryNanotimeDate} describes,
24+
* applied once per group instead of between each pair of readings. OpenTelemetry's SDK anchors per
25+
* local root span for the same two reasons.
26+
*
27+
* <p>The cost is that a projection drifts from real wall time as the anchor ages: it reports what
28+
* the clock said when the anchor was taken, plus measured time, so a clock step afterwards is
29+
* invisible to it. Anchor something short-lived and bounded, and use {@link #driftNanos()} to
30+
* observe the gap rather than assume it away.
31+
*/
32+
@ApiStatus.Internal
33+
public final class AnchoredClock {
34+
35+
private final @NotNull EpochClock epoch;
36+
private final @NotNull MonotonicClock clock;
37+
private final long epochNanos;
38+
private final long anchorTick;
39+
40+
private AnchoredClock(
41+
final @NotNull EpochClock epoch,
42+
final @NotNull MonotonicClock clock,
43+
final long epochNanos,
44+
final long anchorTick) {
45+
this.epoch = epoch;
46+
this.clock = clock;
47+
this.epochNanos = epochNanos;
48+
this.anchorTick = anchorTick;
49+
}
50+
51+
/** Takes the anchor now: one epoch reading, one tick, as close together as a call allows. */
52+
public static @NotNull AnchoredClock create(
53+
final @NotNull EpochClock epoch, final @NotNull MonotonicClock clock) {
54+
return new AnchoredClock(epoch, clock, epoch.now().epochNanos(), clock.tickNanos());
55+
}
56+
57+
/** The anchor itself — the one instant here that was read rather than projected. */
58+
public @NotNull Timestamp start() {
59+
return Timestamp.anchoredAt(epochNanos, this);
60+
}
61+
62+
public @NotNull Timestamp now() {
63+
return at(clock.tickNanos());
64+
}
65+
66+
/**
67+
* The instant a tick corresponds to, for placing something already measured on this clock — a
68+
* frame, a profiler sample — on the same timeline as the instants projected here.
69+
*/
70+
public @NotNull Timestamp at(final long tickNanos) {
71+
return Timestamp.anchoredAt(epochNanos + (tickNanos - anchorTick), this);
72+
}
73+
74+
/**
75+
* The tick an instant was projected from. Exact, and reads no clock: projection adds a tick
76+
* difference to a fixed epoch, so it inverts by subtraction.
77+
*
78+
* @throws IllegalArgumentException if this clock did not project the instant. Its epoch then
79+
* bears no arithmetic relation to these ticks, and converting it would silently produce a
80+
* tick derived from a wall-clock difference.
81+
*/
82+
public long tickOf(final @NotNull Timestamp timestamp) {
83+
if (timestamp.anchor() != this) {
84+
throw new IllegalArgumentException(
85+
"Timestamp was not projected by this AnchoredClock: " + timestamp);
86+
}
87+
return anchorTick + (timestamp.epochNanos() - epochNanos);
88+
}
89+
90+
/**
91+
* How far this anchor's projection has fallen behind or ahead of the wall clock, in nanoseconds.
92+
*
93+
* <p>Zero means the wall clock has advanced by exactly the time this clock measured. Anything
94+
* else is a clock step, or — where {@link MonotonicClock} and the wall clock disagree about
95+
* suspend — device sleep. Read the epoch and the tick in the same order as {@link #create} so
96+
* that the gap between the two reads biases the result the same way it biased the anchor.
97+
*/
98+
public long driftNanos() {
99+
final long wallElapsed = epoch.now().epochNanos() - epochNanos;
100+
final long measuredElapsed = clock.tickNanos() - anchorTick;
101+
return wallElapsed - measuredElapsed;
102+
}
103+
}
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 time.
8+
*
9+
* <p>Stamps a moment that will leave this process — an event, a breadcrumb, a session — and nothing
10+
* else. There is deliberately no way to ask this for a duration: measuring belongs to {@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 that 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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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, and {@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 is not the precision loss it looks like where instants are projected
17+
* rather than read: an {@link AnchoredClock} adds nanosecond ticks to one anchor, so only the
18+
* anchor is coarse.
19+
*/
20+
@ApiStatus.Internal
21+
public final class SystemEpochClock implements EpochClock {
22+
23+
private static final boolean INSTANT_IS_SUB_MILLISECOND =
24+
Platform.isJvm() && Platform.isJavaNinePlus();
25+
26+
private static final SystemEpochClock instance = new SystemEpochClock();
27+
28+
public static @NotNull EpochClock getInstance() {
29+
return instance;
30+
}
31+
32+
private SystemEpochClock() {}
33+
34+
@Override
35+
public @NotNull Timestamp now() {
36+
return Timestamp.ofEpochNanos(
37+
INSTANT_IS_SUB_MILLISECOND
38+
? InstantEpochNanos.read()
39+
: DateUtils.millisToNanos(System.currentTimeMillis()));
40+
}
41+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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>The counterpart to {@link MonotonicClock}, and its opposite in every respect that matters: a
11+
* timestamp is anchored to an epoch, so it is meaningful outside this process — it can be
12+
* serialized, stored, and compared against a value produced by another machine. A tick can do none
13+
* of those things.
14+
*
15+
* <p>What a timestamp deliberately cannot do is measure an interval. Subtracting two independent
16+
* wall-clock readings gives a duration the device's clock can lengthen, shorten or make negative,
17+
* so this type offers no arithmetic between instants. Durations come from a {@link Stopwatch}, or
18+
* from two instants that an {@link AnchoredClock} projected from the same tick origin.
19+
*
20+
* <p>Which of those it is, is what {@link #anchor()} records. An instant read straight from the
21+
* wall clock, or stated by something outside this process, has no anchor and can only be
22+
* serialized. One an {@link AnchoredClock} produced carries a reference to it, which is what lets
23+
* {@link AnchoredClock#tickOf} recover the tick it came from and refuse the instants it did not
24+
* produce.
25+
*
26+
* <p>Nanoseconds since the epoch overflow a long in the year 2262.
27+
*/
28+
@ApiStatus.Internal
29+
public final class Timestamp {
30+
31+
private final long epochNanos;
32+
private final @Nullable AnchoredClock anchor;
33+
34+
private Timestamp(final long epochNanos, final @Nullable AnchoredClock anchor) {
35+
this.epochNanos = epochNanos;
36+
this.anchor = anchor;
37+
}
38+
39+
/** An instant read straight from a wall clock, or stated by something outside this process. */
40+
public static @NotNull Timestamp ofEpochNanos(final long epochNanos) {
41+
return new Timestamp(epochNanos, null);
42+
}
43+
44+
static @NotNull Timestamp anchoredAt(final long epochNanos, final @NotNull AnchoredClock anchor) {
45+
return new Timestamp(epochNanos, anchor);
46+
}
47+
48+
public long epochNanos() {
49+
return epochNanos;
50+
}
51+
52+
/** The clock that projected this instant, or null if it was read or stated directly. */
53+
@Nullable
54+
AnchoredClock anchor() {
55+
return anchor;
56+
}
57+
58+
/**
59+
* Equality is by instant. The anchor records how this instant was obtained, not what it denotes,
60+
* so two readings of the same moment are equal whether or not they were projected.
61+
*/
62+
@Override
63+
public boolean equals(final @Nullable Object other) {
64+
if (this == other) {
65+
return true;
66+
}
67+
if (!(other instanceof Timestamp)) {
68+
return false;
69+
}
70+
return epochNanos == ((Timestamp) other).epochNanos;
71+
}
72+
73+
@Override
74+
public int hashCode() {
75+
return (int) (epochNanos ^ (epochNanos >>> 32));
76+
}
77+
78+
@Override
79+
public @NotNull String toString() {
80+
return "Timestamp{epochNanos=" + epochNanos + '}';
81+
}
82+
}

0 commit comments

Comments
 (0)