diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4b065228..be877a61a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ - Update `SentryTraced` so that it now honors `options.setIgnoredSpanOrigins` ([#6058](https://github.com/getsentry/sentry-java/pull/6058)) - `SentryTraced` now checks for its owning transaction dynamically rather than once per app process. The latter caused `SentryTraced` spans to be dropped process-wide once the original transaction finished ([#6057](https://github.com/getsentry/sentry-java/pull/6057)) - Fix typos in Spring GraphQL integration names (`GrahQL` to `GraphQL`) ([#6061](https://github.com/getsentry/sentry-java/pull/6061)) +- Populate the Android connection status cache during the first two minutes after boot, instead of treating the empty cache as up to date ([#6029](https://github.com/getsentry/sentry-java/pull/6029)) + +### 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-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 8d56c36514..fa417bee07 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -414,6 +414,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getBeforeViewHierarchyCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback; public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader; public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector; + public fun getMonotonicTicker ()Lio/sentry/time/MonotonicTicker; public fun getNativeSdkName ()Ljava/lang/String; public fun getNdkAppHangTimeoutIntervalMillis ()J public fun getNdkHandlerStrategy ()I diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index c7c590d624..85bfd8b5ac 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -35,7 +35,6 @@ import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; import io.sentry.android.core.internal.modules.AssetsModulesLoader; import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; -import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; @@ -178,7 +177,7 @@ static void initializeIntegrationsAndProcessors( if (options.getConnectionStatusProvider() instanceof NoOpConnectionStatusProvider) { options.setConnectionStatusProvider( new AndroidConnectionStatusProvider( - context, options, buildInfoProvider, AndroidCurrentDateProvider.getInstance())); + context, options, buildInfoProvider, options.getMonotonicTicker())); } if (options.getCacheDirPath() != null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 66a3700d38..202d779d61 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -12,11 +12,13 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SpanStatus; +import io.sentry.android.core.internal.time.AndroidMonotonicTicker; import io.sentry.android.core.internal.util.RootChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.Mechanism; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; +import io.sentry.time.MonotonicTicker; import io.sentry.util.SampleRateUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -889,6 +891,12 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) { this.enableAnrFingerprinting = enableAnrFingerprinting; } + @Override + @ApiStatus.Internal + public @NotNull MonotonicTicker getMonotonicTicker() { + return AndroidMonotonicTicker.getInstance(); + } + static class AndroidUserFeedbackFormHandler implements SentryFeedbackOptions.IFormHandler { @Override public void showForm( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicTicker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicTicker.java new file mode 100644 index 0000000000..22973b0d93 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/time/AndroidMonotonicTicker.java @@ -0,0 +1,29 @@ +package io.sentry.android.core.internal.time; + +import android.os.SystemClock; +import io.sentry.time.MonotonicTicker; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * {@link MonotonicTicker} backed by {@link SystemClock#elapsedRealtimeNanos()}. + * + *
That is {@code CLOCK_BOOTTIME}, so it keeps counting while the device is suspended — unlike
+ * {@link System#nanoTime()}, which the core module falls back to and which stops in deep sleep.
+ */
+@ApiStatus.Internal
+public final class AndroidMonotonicTicker implements MonotonicTicker {
+
+ private static final AndroidMonotonicTicker instance = new AndroidMonotonicTicker();
+
+ public static @NotNull MonotonicTicker getInstance() {
+ return instance;
+ }
+
+ private AndroidMonotonicTicker() {}
+
+ @Override
+ public long tickNanos() {
+ return SystemClock.elapsedRealtimeNanos();
+ }
+}
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java
index 3f05beeceb..e908f392e1 100644
--- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java
@@ -19,10 +19,12 @@
import io.sentry.android.core.AppState;
import io.sentry.android.core.BuildInfoProvider;
import io.sentry.android.core.ContextUtils;
-import io.sentry.transport.ICurrentDateProvider;
+import io.sentry.time.Deadline;
+import io.sentry.time.MonotonicTicker;
import io.sentry.util.AutoClosableReentrantLock;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
@@ -41,7 +43,7 @@ public final class AndroidConnectionStatusProvider
private final @NotNull Context context;
private final @NotNull SentryOptions options;
private final @NotNull BuildInfoProvider buildInfoProvider;
- private final @NotNull ICurrentDateProvider timeProvider;
+ private final @NotNull MonotonicTicker ticker;
private final @NotNull List 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.
+ *
+ * Android overrides this with a {@code SystemClock.elapsedRealtimeNanos()}-backed ticker,
+ * which this module cannot reference. On the JVM there is no suspend state to account for.
+ */
+ @ApiStatus.Internal
+ public @NotNull MonotonicTicker getMonotonicTicker() {
+ return JavaMonotonicTicker.getInstance();
+ }
+
/**
* Adds a ICollector.
*
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..79bcb80981
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/time/AnchoredClock.java
@@ -0,0 +1,86 @@
+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.anchoredAt(epochNanos, this);
+ }
+
+ /** 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.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);
+ }
+}
diff --git a/sentry/src/main/java/io/sentry/time/Deadline.java b/sentry/src/main/java/io/sentry/time/Deadline.java
new file mode 100644
index 0000000000..036469383c
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/time/Deadline.java
@@ -0,0 +1,89 @@
+package io.sentry.time;
+
+import java.util.concurrent.TimeUnit;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A point in the future, measured on a {@link MonotonicTicker}.
+ *
+ * Exists so that callers never do arithmetic on raw ticks. A tick carries no unit and no epoch,
+ * so spelling out {@code now - then < ttl} at every call site is where unit mix-ups, sentinels that
+ * happen to mean "boot", and wrap-unsafe {@code <} comparisons come from. Each of those is decided
+ * once, here.
+ */
+@ApiStatus.Internal
+public final class Deadline {
+
+ private final @NotNull MonotonicTicker ticker;
+ private final long deadlineNanos;
+
+ private Deadline(final @NotNull MonotonicTicker ticker, final long deadlineNanos) {
+ this.ticker = ticker;
+ this.deadlineNanos = deadlineNanos;
+ }
+
+ /**
+ * A deadline {@code amount} of {@code unit} from now.
+ *
+ * @throws IllegalArgumentException if {@code amount} is negative. A deadline that starts out in
+ * the past is a sign error at the call site; {@link #passed} says it deliberately.
+ */
+ public static @NotNull Deadline after(
+ final @NotNull MonotonicTicker ticker, final long amount, final @NotNull TimeUnit unit) {
+ if (amount < 0) {
+ throw new IllegalArgumentException("Deadline amount must not be negative, but was " + amount);
+ }
+ return new Deadline(ticker, ticker.tickNanos() + unit.toNanos(amount));
+ }
+
+ /**
+ * A deadline that has already passed.
+ *
+ * Saves callers from reserving a tick value to mean "not set yet": {@code 0} is a real and
+ * very recent instant on a boot-relative ticker, so a field left at {@code 0} reads as freshly
+ * set rather than as unset.
+ */
+ public static @NotNull Deadline passed(final @NotNull MonotonicTicker ticker) {
+ return new Deadline(ticker, ticker.tickNanos());
+ }
+
+ public boolean hasPassed() {
+ // Subtraction rather than `<`: a tick origin is arbitrary, may be negative, and may wrap.
+ return ticker.tickNanos() - deadlineNanos >= 0;
+ }
+
+ /**
+ * How much time is left, rounded up, or zero once the deadline has passed.
+ *
+ * Rounding up matters: callers schedule work for {@code remaining()} and then re-check {@link
+ * #hasPassed()}. Truncating would wake them a fraction early, to find the deadline still
+ * standing.
+ */
+ public long remaining(final @NotNull TimeUnit unit) {
+ final long remainingNanos = deadlineNanos - ticker.tickNanos();
+ if (remainingNanos <= 0) {
+ return 0;
+ }
+ final long unitNanos = unit.toNanos(1);
+ final long whole = remainingNanos / unitNanos;
+ return remainingNanos % unitNanos == 0 ? whole : whole + 1;
+ }
+
+ /**
+ * Whether this deadline falls after {@code other}.
+ *
+ * @throws IllegalArgumentException if the two were created from different tickers, whose origins
+ * are unrelated and whose ticks are therefore not comparable.
+ */
+ public boolean isAfter(final @NotNull Deadline other) {
+ if (ticker != other.ticker) {
+ throw new IllegalArgumentException(
+ "Cannot compare deadlines from different tickers: "
+ + ticker.getClass().getName()
+ + " and "
+ + other.ticker.getClass().getName());
+ }
+ return deadlineNanos - other.deadlineNanos > 0;
+ }
+}
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/JavaMonotonicTicker.java b/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java
new file mode 100644
index 0000000000..b5b000f280
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java
@@ -0,0 +1,22 @@
+package io.sentry.time;
+
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+/** {@link MonotonicTicker} backed by {@link System#nanoTime()}. */
+@ApiStatus.Internal
+public final class JavaMonotonicTicker implements MonotonicTicker {
+
+ private static final JavaMonotonicTicker instance = new JavaMonotonicTicker();
+
+ public static @NotNull MonotonicTicker getInstance() {
+ return instance;
+ }
+
+ private JavaMonotonicTicker() {}
+
+ @Override
+ public long tickNanos() {
+ return System.nanoTime();
+ }
+}
diff --git a/sentry/src/main/java/io/sentry/time/MonotonicTicker.java b/sentry/src/main/java/io/sentry/time/MonotonicTicker.java
new file mode 100644
index 0000000000..f3e87e3992
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/time/MonotonicTicker.java
@@ -0,0 +1,22 @@
+package io.sentry.time;
+
+import org.jetbrains.annotations.ApiStatus;
+
+/**
+ * A monotonically increasing nanosecond counter, including time the device spent suspended in deep
+ * sleep.
+ *
+ * This type deliberately promises very little: a tick is a number that does not go backwards,
+ * measured from an origin that is arbitrary and may be negative. Only differences between
+ * two ticks from the same instance are meaningful, and a tick must never be persisted, serialized,
+ * or compared against a value from another ticker.
+ *
+ * On Android this is {@code CLOCK_BOOTTIME}, via {@code SystemClock.elapsedRealtimeNanos()}, so
+ * an interval measured across a suspend reports the real time that passed rather than only the time
+ * the CPU was awake. On the JVM there is no comparable suspend state, so {@link System#nanoTime()}
+ * is equivalent.
+ */
+@ApiStatus.Internal
+public interface MonotonicTicker {
+ long tickNanos();
+}
diff --git a/sentry/src/main/java/io/sentry/time/Stopwatch.java b/sentry/src/main/java/io/sentry/time/Stopwatch.java
new file mode 100644
index 0000000000..083b5c6906
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/time/Stopwatch.java
@@ -0,0 +1,35 @@
+package io.sentry.time;
+
+import java.util.concurrent.TimeUnit;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Measures how long something took, on a {@link MonotonicTicker}.
+ *
+ * The counterpart to {@link Deadline}: it keeps the start tick and the unit conversion in one
+ * place, so call sites stop repeating {@code System.nanoTime() - startTime}.
+ */
+@ApiStatus.Internal
+public final class Stopwatch {
+
+ private final @NotNull MonotonicTicker ticker;
+ private final long startNanos;
+
+ private Stopwatch(final @NotNull MonotonicTicker ticker) {
+ this.ticker = ticker;
+ this.startNanos = ticker.tickNanos();
+ }
+
+ public static @NotNull Stopwatch started(final @NotNull MonotonicTicker ticker) {
+ return new Stopwatch(ticker);
+ }
+
+ public long elapsedNanos() {
+ return ticker.tickNanos() - startNanos;
+ }
+
+ public long elapsed(final @NotNull TimeUnit unit) {
+ return unit.convert(elapsedNanos(), TimeUnit.NANOSECONDS);
+ }
+}
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..68ce67dc98
--- /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 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.
+ *
+ * {@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..d3ca87ebcc
--- /dev/null
+++ b/sentry/src/test/java/io/sentry/time/AnchoredClockTest.kt
@@ -0,0 +1,82 @@
+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 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))
+ }
+
+ @Test
+ fun `tickOf recovers the tick a projection came from`() {
+ ticker.advance(120, MILLISECONDS)
+ val now = anchored.now()
+
+ assertThat(anchored.tickOf(now)).isEqualTo(ticker.tickNanos())
+ }
+
+ @Test
+ fun `tickOf rejects an instant read straight from a wall clock`() {
+ assertFailsWith