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 connectionStatusObservers; private final @Nullable Handler handler; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @@ -66,16 +68,16 @@ public final class AndroidConnectionStatusProvider private volatile @Nullable NetworkCapabilities cachedNetworkCapabilities; private volatile @Nullable Network currentNetwork; - private volatile long lastCacheUpdateTime = 0; - private static final long CACHE_TTL_MS = 2 * 60 * 1000L; // 2 minutes + private volatile @NotNull Deadline cacheFreshUntil; + private static final long CACHE_TTL_MINUTES = 2; private final @NotNull AtomicBoolean isConnected = new AtomicBoolean(false); public AndroidConnectionStatusProvider( @NotNull Context context, @NotNull SentryOptions options, @NotNull BuildInfoProvider buildInfoProvider, - @NotNull ICurrentDateProvider timeProvider) { - this(context, options, buildInfoProvider, timeProvider, null); + @NotNull MonotonicTicker ticker) { + this(context, options, buildInfoProvider, ticker, null); } @SuppressLint("InlinedApi") @@ -83,12 +85,13 @@ public AndroidConnectionStatusProvider( @NotNull Context context, @NotNull SentryOptions options, @NotNull BuildInfoProvider buildInfoProvider, - @NotNull ICurrentDateProvider timeProvider, + @NotNull MonotonicTicker ticker, @Nullable Handler handler) { this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; - this.timeProvider = timeProvider; + this.ticker = ticker; + this.cacheFreshUntil = Deadline.passed(ticker); this.handler = handler; this.connectionStatusObservers = new ArrayList<>(); @@ -231,7 +234,7 @@ private void clearCacheAndNotifyObservers() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { cachedNetworkCapabilities = null; currentNetwork = null; - lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + cacheFreshUntil = Deadline.after(ticker, CACHE_TTL_MINUTES, TimeUnit.MINUTES); options .getLogger() @@ -362,13 +365,13 @@ private void updateCache(@Nullable NetworkCapabilities networkCapabilities) { SentryLevel.INFO, "No permission (ACCESS_NETWORK_STATE) to check network status."); cachedNetworkCapabilities = null; - lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + cacheFreshUntil = Deadline.after(ticker, CACHE_TTL_MINUTES, TimeUnit.MINUTES); return; } if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.M) { cachedNetworkCapabilities = null; - lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + cacheFreshUntil = Deadline.after(ticker, CACHE_TTL_MINUTES, TimeUnit.MINUTES); return; } @@ -387,7 +390,7 @@ private void updateCache(@Nullable NetworkCapabilities networkCapabilities) { null; // Clear cached capabilities if connectivity manager is null } } - lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + cacheFreshUntil = Deadline.after(ticker, CACHE_TTL_MINUTES, TimeUnit.MINUTES); options .getLogger() @@ -400,13 +403,13 @@ private void updateCache(@Nullable NetworkCapabilities networkCapabilities) { } catch (Throwable t) { options.getLogger().log(SentryLevel.WARNING, "Failed to update connection status cache", t); cachedNetworkCapabilities = null; - lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + cacheFreshUntil = Deadline.after(ticker, CACHE_TTL_MINUTES, TimeUnit.MINUTES); } } } private boolean isCacheValid() { - return (timeProvider.getCurrentTimeMillis() - lastCacheUpdateTime) < CACHE_TTL_MS; + return !cacheFreshUntil.hasPassed(); } @Override @@ -459,7 +462,7 @@ private void unregisterNetworkCallback(final boolean clearObservers) { // Clear cached state cachedNetworkCapabilities = null; currentNetwork = null; - lastCacheUpdateTime = 0; + cacheFreshUntil = Deadline.passed(ticker); } options.getLogger().log(SentryLevel.DEBUG, "Network callback unregistered"); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt index 4dd8062464..8ed3531781 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt @@ -26,7 +26,8 @@ import io.sentry.android.core.BuildInfoProvider import io.sentry.android.core.ContextUtils import io.sentry.android.core.SystemEventsBreadcrumbsIntegration import io.sentry.test.ImmediateExecutorService -import io.sentry.transport.ICurrentDateProvider +import io.sentry.time.TestMonotonicTicker +import java.util.concurrent.TimeUnit.MINUTES import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -61,15 +62,13 @@ class AndroidConnectionStatusProviderTest { private lateinit var connectivityManager: ConnectivityManager private lateinit var networkInfo: NetworkInfo private lateinit var buildInfo: BuildInfoProvider - private lateinit var timeProvider: ICurrentDateProvider + private lateinit var ticker: TestMonotonicTicker private lateinit var options: SentryOptions private lateinit var network: Network private lateinit var networkCapabilities: NetworkCapabilities private lateinit var logger: ILogger private lateinit var contextUtilsStaticMock: MockedStatic - private var currentTime = 1000L - @BeforeTest fun beforeTest() { contextMock = mock() @@ -96,17 +95,13 @@ class AndroidConnectionStatusProviderTest { whenever(networkCapabilities.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) whenever(networkCapabilities.hasTransport(TRANSPORT_WIFI)).thenReturn(true) - timeProvider = mock() - whenever(timeProvider.currentTimeMillis).thenAnswer { currentTime } + ticker = TestMonotonicTicker() logger = mock() options = SentryOptions() options.setLogger(logger) options.executorService = ImmediateExecutorService() - // Reset current time for each test to ensure cache isolation - currentTime = 1000L - // Mock ContextUtils to return foreground importance contextUtilsStaticMock = mockStatic(ContextUtils::class.java) contextUtilsStaticMock @@ -120,7 +115,7 @@ class AndroidConnectionStatusProviderTest { AppState.getInstance().registerLifecycleObserver(options) connectionStatusProvider = - AndroidConnectionStatusProvider(contextMock, options, buildInfo, timeProvider) + AndroidConnectionStatusProvider(contextMock, options, buildInfo, ticker) } @AfterTest @@ -144,6 +139,10 @@ class AndroidConnectionStatusProviderTest { @Test fun `When network is active but not connected with permission, return DISCONNECTED for isConnected`() { whenever(networkInfo.isConnected).thenReturn(false) + // buildInfo reports API 24, so the provider reads NetworkCapabilities rather than the legacy + // activeNetworkInfo. The active network has to report it cannot reach the internet too. + whenever(networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(false) + whenever(networkCapabilities.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(false) assertEquals( IConnectionStatusProvider.ConnectionStatus.DISCONNECTED, @@ -195,7 +194,7 @@ class AndroidConnectionStatusProviderTest { // Create a new provider with the null connectivity manager val providerWithNullConnectivity = - AndroidConnectionStatusProvider(nullConnectivityContext, options, buildInfo, timeProvider) + AndroidConnectionStatusProvider(nullConnectivityContext, options, buildInfo, ticker) assertEquals( IConnectionStatusProvider.ConnectionStatus.UNKNOWN, @@ -306,6 +305,27 @@ class AndroidConnectionStatusProviderTest { assertTrue(connectionStatusProvider.statusObservers.isEmpty()) } + @Test + fun `an unpopulated cache is not treated as fresh shortly after boot`() { + whenever(networkInfo.isConnected).thenReturn(true) + + // elapsedRealtimeNanos() counts from boot, so a provider created moments after boot sees a + // tick near zero. The cache is still empty and must not be read as up to date. + val provider = + AndroidConnectionStatusProvider(contextMock, options, buildInfo, TestMonotonicTicker()) + + val callsBefore = + mockingDetails(connectivityManager).invocations.count { it.method.name == "getActiveNetwork" } + + assertEquals(IConnectionStatusProvider.ConnectionStatus.CONNECTED, provider.connectionStatus) + + val callsAfter = + mockingDetails(connectivityManager).invocations.count { it.method.name == "getActiveNetwork" } + assertTrue(callsAfter > callsBefore, "An empty cache must be populated before it is read") + + provider.close() + } + @Test fun `cache TTL works correctly`() { // Setup: Mock network info to return connected @@ -323,7 +343,7 @@ class AndroidConnectionStatusProviderTest { mockingDetails(connectivityManager).invocations.count { it.method.name == "getActiveNetwork" } // Advance time by 1 minute (less than 2 minute TTL) - currentTime += 60 * 1000L + ticker.advance(1, MINUTES) // Second call should use cache - no additional calls to getActiveNetwork val secondResult = connectionStatusProvider.connectionStatus @@ -336,7 +356,7 @@ class AndroidConnectionStatusProviderTest { assertEquals(initialCallCount, callCountAfterSecond, "Second call should use cache") // Advance time beyond TTL (total 3 minutes) - currentTime += 2 * 60 * 1000L + ticker.advance(2, MINUTES) // Third call should refresh cache - should make new calls to getActiveNetwork val thirdResult = connectionStatusProvider.connectionStatus @@ -543,7 +563,7 @@ class AndroidConnectionStatusProviderTest { whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(goodCaps) // Force cache invalidation by advancing time beyond TTL - currentTime += 3 * 60 * 1000L // 3 minutes + ticker.advance(3, MINUTES) // Should return CONNECTED for good capabilities assertEquals( @@ -560,7 +580,7 @@ class AndroidConnectionStatusProviderTest { whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(unvalidatedCaps) // Force cache invalidation again - currentTime += 3 * 60 * 1000L + ticker.advance(3, MINUTES) assertEquals( IConnectionStatusProvider.ConnectionStatus.DISCONNECTED, diff --git a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt new file mode 100644 index 0000000000..2471a1bc8d --- /dev/null +++ b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt @@ -0,0 +1,18 @@ +package io.sentry.time + +import java.util.concurrent.TimeUnit + +/** + * A [MonotonicTicker] that only moves when a test tells it to. + * + * Advancing by an amount *and a unit* is the point: a stubbed `thenReturn(1001)` against a + * nanosecond ticker is off by a factor of a million and still compiles, whereas `advance(1001, + * MILLISECONDS)` cannot be. + */ +class TestMonotonicTicker(private var nanos: Long = 0) : MonotonicTicker { + override fun tickNanos(): Long = nanos + + fun advance(amount: Long, unit: TimeUnit) { + nanos += unit.toNanos(amount) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9f44481fd9..46284e2bda 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; @@ -3740,6 +3741,7 @@ public class io/sentry/SentryOptions { public fun getMaxTraceFileSize ()J public fun getMetrics ()Lio/sentry/SentryOptions$Metrics; public fun getModulesLoader ()Lio/sentry/internal/modules/IModulesLoader; + public fun getMonotonicTicker ()Lio/sentry/time/MonotonicTicker; public fun getOnDiscard ()Lio/sentry/SentryOptions$OnDiscardCallback; public fun getOnOversizedEvent ()Lio/sentry/SentryOptions$OnOversizedEventCallback; public fun getOpenTelemetryMode ()Lio/sentry/SentryOpenTelemetryMode; @@ -7597,6 +7599,54 @@ 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/MonotonicTicker;)Lio/sentry/time/AnchoredClock; + public fun now ()Lio/sentry/time/Timestamp; + public fun origin ()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/MonotonicTicker;JLjava/util/concurrent/TimeUnit;)Lio/sentry/time/Deadline; + public fun hasPassed ()Z + public fun isAfter (Lio/sentry/time/Deadline;)Z + public static fun passed (Lio/sentry/time/MonotonicTicker;)Lio/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 +} + +public abstract interface class io/sentry/time/MonotonicTicker { + public abstract fun tickNanos ()J +} + +public final class io/sentry/time/Stopwatch { + public fun elapsed (Ljava/util/concurrent/TimeUnit;)J + public fun elapsedNanos ()J + 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 (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 d7a16d4ee2..1a1fbf738c 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -21,6 +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; @@ -3059,6 +3063,31 @@ 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 #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 { + anchored.tickOf(Timestamp.ofEpochNanos(SECONDS.toNanos(1_700_000_000))) + } + } + + @Test + fun `tickOf rejects an instant from another anchor`() { + val other = AnchoredClock.create(epoch, ticker) + + assertFailsWith { anchored.tickOf(other.now()) } + } +} diff --git a/sentry/src/test/java/io/sentry/time/DeadlineTest.kt b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt new file mode 100644 index 0000000000..2cf4804f39 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/DeadlineTest.kt @@ -0,0 +1,104 @@ +package io.sentry.time + +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.MINUTES +import java.util.concurrent.TimeUnit.SECONDS +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DeadlineTest { + @Test + fun `has not passed before the deadline`() { + val ticker = TestMonotonicTicker() + val deadline = Deadline.after(ticker, 2, MINUTES) + + ticker.advance(119, SECONDS) + + assertFalse(deadline.hasPassed()) + } + + @Test + fun `has passed once the deadline is reached`() { + val ticker = TestMonotonicTicker() + val deadline = Deadline.after(ticker, 2, MINUTES) + + ticker.advance(2, MINUTES) + + assertTrue(deadline.hasPassed()) + } + + @Test + fun `a passed deadline has passed even when the ticker is at zero`() { + // The pattern this replaces stored the last-updated tick and compared `now - lastUpdated` + // against a TTL, with 0 standing in for "never updated". Tick 0 is a real instant though — + // the moment the device booted — so for the first TTL of every boot, never-updated state + // read as freshly updated. A passed deadline has no such value to misread. + assertTrue(Deadline.passed(TestMonotonicTicker()).hasPassed()) + } + + @Test + fun `remaining counts down and floors at zero`() { + val ticker = TestMonotonicTicker() + val deadline = Deadline.after(ticker, 1000, MILLISECONDS) + + assertEquals(1000, deadline.remaining(MILLISECONDS)) + + ticker.advance(400, MILLISECONDS) + assertEquals(600, deadline.remaining(MILLISECONDS)) + + ticker.advance(10, MINUTES) + assertEquals(0, deadline.remaining(MILLISECONDS)) + } + + @Test + fun `remaining rounds up so callers never wake before the deadline`() { + val ticker = TestMonotonicTicker() + val deadline = Deadline.after(ticker, 1000, MILLISECONDS) + + // half a millisecond in: 999.5ms left, which must not report as 999 + ticker.advance(500, java.util.concurrent.TimeUnit.MICROSECONDS) + + assertEquals(1000, deadline.remaining(MILLISECONDS)) + } + + @Test + fun `after rejects a negative amount`() { + assertFailsWith { + Deadline.after(TestMonotonicTicker(), -1, SECONDS) + } + } + + @Test + fun `isAfter compares two deadlines`() { + val ticker = TestMonotonicTicker() + val shorter = Deadline.after(ticker, 1, SECONDS) + val alsoShorter = Deadline.after(ticker, 1, SECONDS) + val longer = Deadline.after(ticker, 5, SECONDS) + + assertTrue(longer.isAfter(shorter)) + assertFalse(shorter.isAfter(longer)) + assertFalse(shorter.isAfter(alsoShorter)) + } + + @Test + fun `isAfter rejects deadlines from different tickers`() { + val deadline = Deadline.after(TestMonotonicTicker(), 1, SECONDS) + val fromAnotherTicker = Deadline.after(TestMonotonicTicker(), 1, SECONDS) + + assertFailsWith { deadline.isAfter(fromAnotherTicker) } + } + + @Test + fun `comparisons hold when the tick origin is negative`() { + // System.nanoTime() may start negative; only differences are meaningful. + val ticker = TestMonotonicTicker(Long.MIN_VALUE + 1) + val deadline = Deadline.after(ticker, 1, SECONDS) + + assertFalse(deadline.hasPassed()) + ticker.advance(1, SECONDS) + assertTrue(deadline.hasPassed()) + } +} 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/StopwatchTest.kt b/sentry/src/test/java/io/sentry/time/StopwatchTest.kt new file mode 100644 index 0000000000..c185cf1522 --- /dev/null +++ b/sentry/src/test/java/io/sentry/time/StopwatchTest.kt @@ -0,0 +1,38 @@ +package io.sentry.time + +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.NANOSECONDS +import java.util.concurrent.TimeUnit.SECONDS +import kotlin.test.Test +import kotlin.test.assertEquals + +class StopwatchTest { + @Test + fun `starts at zero`() { + assertEquals(0, Stopwatch.started(TestMonotonicTicker()).elapsedNanos()) + } + + @Test + fun `reports elapsed time in the requested unit`() { + val ticker = TestMonotonicTicker() + val stopwatch = Stopwatch.started(ticker) + + ticker.advance(1500, MILLISECONDS) + + assertEquals(1, stopwatch.elapsed(SECONDS)) + assertEquals(1500, stopwatch.elapsed(MILLISECONDS)) + assertEquals(MILLISECONDS.toNanos(1500), stopwatch.elapsed(NANOSECONDS)) + } + + @Test + fun `keeps running across reads`() { + val ticker = TestMonotonicTicker() + val stopwatch = Stopwatch.started(ticker) + + ticker.advance(1, SECONDS) + assertEquals(1, stopwatch.elapsed(SECONDS)) + + ticker.advance(2, SECONDS) + assertEquals(3, stopwatch.elapsed(SECONDS)) + } +} 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..041717776d --- /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(), TestMonotonicTicker()) + + assertThat(anchored.now().anchor()).isSameInstanceAs(anchored) + } + + @Test + fun `compares by instant, whatever produced it`() { + val anchored = AnchoredClock.create(FixedEpochClock(42), TestMonotonicTicker()) + + assertThat(Timestamp.ofEpochNanos(42)).isEqualTo(anchored.origin()) + assertThat(Timestamp.ofEpochNanos(42).hashCode()).isEqualTo(anchored.origin().hashCode()) + assertNotEquals(Timestamp.ofEpochNanos(42), Timestamp.ofEpochNanos(43)) + } +}