Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
### Internal

- Add an internal `MonotonicTicker` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028))
- Add internal `Timestamp`, `EpochClock` and `AnchoredClock`, so related instants project from one wall-clock reading instead of each reading the clock ([#6045](https://github.com/getsentry/sentry-java/pull/6045))

## 8.55.0

Expand Down
24 changes: 24 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -7598,6 +7599,12 @@ public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys {
public fun <init> ()V
}

public final class io/sentry/time/AnchoredClock {
public static fun create (Lio/sentry/time/EpochClock;Lio/sentry/time/MonotonicTicker;)Lio/sentry/time/AnchoredClock;
public fun now ()Lio/sentry/time/Timestamp;
public fun startTime ()Lio/sentry/time/Timestamp;
}

public final class io/sentry/time/Deadline {
public static fun after (Lio/sentry/time/MonotonicTicker;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline;
public fun hasPassed ()Z
Expand All @@ -7606,6 +7613,10 @@ public final class io/sentry/time/Deadline {
public fun remaining (Ljava/util/concurrent/TimeUnit;)J
}

public abstract interface class io/sentry/time/EpochClock {
public abstract fun now ()Lio/sentry/time/Timestamp;
}

public final class io/sentry/time/JavaMonotonicTicker : io/sentry/time/MonotonicTicker {
public static fun getInstance ()Lio/sentry/time/MonotonicTicker;
public fun tickNanos ()J
Expand All @@ -7621,6 +7632,19 @@ public final class io/sentry/time/Stopwatch {
public static fun started (Lio/sentry/time/MonotonicTicker;)Lio/sentry/time/Stopwatch;
}

public final class io/sentry/time/SystemEpochClock : io/sentry/time/EpochClock {
public static fun getInstance ()Lio/sentry/time/EpochClock;
public fun now ()Lio/sentry/time/Timestamp;
}

public final class io/sentry/time/Timestamp {
public fun epochNanos ()J
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public static fun ofEpochNanos (J)Lio/sentry/time/Timestamp;
public fun toString ()Ljava/lang/String;
}

public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport {
public fun <init> (Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/RequestDetails;)V
public fun <init> (Lio/sentry/transport/QueuedThreadPoolExecutor;Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/transport/HttpConnection;)V
Expand Down
15 changes: 15 additions & 0 deletions sentry/src/main/java/io/sentry/SentryOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
import io.sentry.metrics.IMetricsBatchProcessorFactory;
import io.sentry.protocol.SdkVersion;
import io.sentry.protocol.SentryTransaction;
import io.sentry.time.EpochClock;
import io.sentry.time.JavaMonotonicTicker;
import io.sentry.time.MonotonicTicker;
import io.sentry.time.SystemEpochClock;
import io.sentry.transport.ITransport;
import io.sentry.transport.ITransportGate;
import io.sentry.transport.NoOpEnvelopeCache;
Expand Down Expand Up @@ -3061,6 +3063,19 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) {
this.dateProvider.setValue(dateProvider);
}

/**
* Returns the wall clock, for stamping an instant that will be serialized.
*
* <p>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() {
Comment thread
runningcode marked this conversation as resolved.
return SystemEpochClock.getInstance();
}

/**
* Returns the ticker used to measure elapsed time, such as rate-limit windows, cache expiry and
* ANR thresholds.
Expand Down
57 changes: 57 additions & 0 deletions sentry/src/main/java/io/sentry/time/AnchoredClock.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.sentry.time;

import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;

/**
* One wall-clock reading plus a monotonic ticker, producing timestamps that are safe to subtract
* from each other.
*
* <p>Wall clocks jump. Read the wall clock twice and the device may have changed it in between, so
* the gap between the two readings is not the time that actually passed โ€” it can come out too
* short, too long, or negative. That is how a child span ends up starting before its parent.
*
* <p>So for a group of timestamps that will be compared with each other โ€” the spans of a
* transaction, the samples of a profile chunk, the frames of a replay segment โ€” read the wall clock
* only once (the anchor) and build the rest by adding the time a {@link MonotonicTicker} has
* measured since, which only ever moves forward. Gaps between those timestamps are then real
* elapsed time. Spans need exactly that: the protocol sends a start and an end timestamp and no
* duration, so the server subtracts them.
*/
@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 wall-clock reading and one tick, back to back. */
public static @NotNull AnchoredClock create(
final @NotNull EpochClock epoch, final @NotNull MonotonicTicker ticker) {
return new AnchoredClock(ticker, epoch.now().epochNanos(), ticker.tickNanos());
}

/**
* The anchor itself: the one timestamp here that was read from the wall clock rather than built
* from a tick. Reads no clock and never changes.
*/
public @NotNull Timestamp startTime() {
return Timestamp.ofEpochNanos(epochNanos);
}

/** Now: {@link #startTime()} plus the time the ticker has measured since the anchor. */
public @NotNull Timestamp now() {
return at(ticker.tickNanos());
}

private @NotNull Timestamp at(final long tickNanos) {
return Timestamp.ofEpochNanos(epochNanos + (tickNanos - anchorTick));
}
}
20 changes: 20 additions & 0 deletions sentry/src/main/java/io/sentry/time/EpochClock.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.sentry.time;

import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;

/**
* The source of wall-clock {@link Timestamp}s (aka, instants).
*
* <p>Stamps a moment that will leave this process, an event, a breadcrumb, a session, and nothing
* else. This class should <b>not</b> be used to compute durations. For Durations, use {@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();
}
25 changes: 25 additions & 0 deletions sentry/src/main/java/io/sentry/time/InstantEpochNanos.java
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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() {
Comment thread
runningcode marked this conversation as resolved.
final Instant now = Instant.now();
// No long overflow until year 2262
return DateUtils.secondsToNanos(now.getEpochSecond()) + now.getNano();
}
}
40 changes: 40 additions & 0 deletions sentry/src/main/java/io/sentry/time/SystemEpochClock.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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()));
}
}
57 changes: 57 additions & 0 deletions sentry/src/main/java/io/sentry/time/Timestamp.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.sentry.time;

import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* An instant on the wall clock, as nanoseconds since the Unix epoch.
*
* <p>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.
*
* <p>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.
*
* <p>Nanoseconds since the epoch overflow a long in the year 2262.
*/
@ApiStatus.Internal
public final class Timestamp {

private final long epochNanos;

private Timestamp(final long epochNanos) {
this.epochNanos = epochNanos;
}

public static @NotNull Timestamp ofEpochNanos(final long epochNanos) {
return new Timestamp(epochNanos);
}

public long epochNanos() {
return epochNanos;
}

@Override
public boolean equals(final @Nullable Object other) {
if (this == other) {
return true;
}
if (!(other instanceof Timestamp)) {
return false;
}
return epochNanos == ((Timestamp) other).epochNanos;
}

@Override
public int hashCode() {
return (int) (epochNanos ^ (epochNanos >>> 32));
}

@Override
public @NotNull String toString() {
return "Timestamp{epochNanos=" + epochNanos + '}';
}
}
51 changes: 51 additions & 0 deletions sentry/src/test/java/io/sentry/time/AnchoredClockTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.sentry.time

import com.google.common.truth.Truth.assertThat
import java.util.concurrent.TimeUnit.MILLISECONDS
import java.util.concurrent.TimeUnit.SECONDS
import kotlin.test.Test

class AnchoredClockTest {
private val epoch = FixedEpochClock(SECONDS.toNanos(1_700_000_000))
private val ticker = TestMonotonicTicker(SECONDS.toNanos(5_000))
private val anchored = AnchoredClock.create(epoch, ticker)

@Test
fun `startTime is the wall-clock reading the anchor was taken at`() {
assertThat(anchored.startTime().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)
}
}
6 changes: 6 additions & 0 deletions sentry/src/test/java/io/sentry/time/FixedEpochClock.kt
Original file line number Diff line number Diff line change
@@ -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)
}
18 changes: 18 additions & 0 deletions sentry/src/test/java/io/sentry/time/SystemEpochClockTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.sentry.time

import com.google.common.truth.Truth.assertThat
import java.util.concurrent.TimeUnit.MILLISECONDS
import kotlin.test.Test

class SystemEpochClockTest {
@Test
fun `now reads the system wall clock`() {
val before = MILLISECONDS.toNanos(System.currentTimeMillis())
val now = SystemEpochClock.getInstance().now().epochNanos()
val after = MILLISECONDS.toNanos(System.currentTimeMillis())

// the bounds are millisecond-truncated, so now() may sit up to a millisecond past `after`
assertThat(now).isAtLeast(before)
assertThat(now).isAtMost(after + MILLISECONDS.toNanos(1))
}
}
22 changes: 22 additions & 0 deletions sentry/src/test/java/io/sentry/time/TimestampTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.sentry.time

import com.google.common.truth.Truth.assertThat
import kotlin.test.Test
import kotlin.test.assertNotEquals

class TimestampTest {
@Test
fun `keeps the epoch value it was given`() {
assertThat(Timestamp.ofEpochNanos(1_700_000_000_000_000_000).epochNanos())
.isEqualTo(1_700_000_000_000_000_000)
}

@Test
fun `compares by instant, whatever produced it`() {
val anchored = AnchoredClock.create(FixedEpochClock(42), TestMonotonicTicker())

assertThat(Timestamp.ofEpochNanos(42)).isEqualTo(anchored.startTime())
assertThat(Timestamp.ofEpochNanos(42).hashCode()).isEqualTo(anchored.startTime().hashCode())
assertNotEquals(Timestamp.ofEpochNanos(42), Timestamp.ofEpochNanos(43))
}
}
Loading