Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d9f5693
feat(time): Add uptime and elapsed-real-time clock abstractions (JAVA…
runningcode Aug 31, 2026
7f0c83e
changelog
runningcode Aug 31, 2026
249b093
ref(time): Drop the uptime clock options seam (JAVA-571)
runningcode Aug 31, 2026
fcc54a0
ref(time): Trim comments that restate the type name (JAVA-571)
runningcode Aug 31, 2026
8b407d9
feat(android): Back the elapsed-real-time clock with SystemClock (JAV…
runningcode Aug 31, 2026
dfe3c9e
test(time): Drop the accessor and singleton clock tests (JAVA-571)
runningcode Aug 31, 2026
6056068
ref(android): Override the elapsed-real-time clock instead of setting it
runningcode Aug 31, 2026
859443f
ref(android): Make AndroidElapsedRealtimeClock a singleton
runningcode Aug 31, 2026
6eecee6
test(android): Drop the elapsed-real-time clock options test (JAVA-571)
runningcode Aug 31, 2026
036aec4
ref(time): Rename Deadline.in to Deadline.after (JAVA-571)
runningcode Aug 31, 2026
1947fb5
ref(time): Update API dump for the Deadline.after rename (JAVA-571)
runningcode Sep 1, 2026
ab5fba1
ref(time): Collapse the clocks into a single MonotonicClock (JAVA-571)
runningcode Sep 3, 2026
655adfb
feat(time): Add Timestamp, EpochClock and AnchoredClock (JAVA-572)
runningcode Sep 4, 2026
4a7fa54
test(android): Make the DISCONNECTED test mock a disconnected network
runningcode Sep 3, 2026
41c0d52
fix(android): Treat an unpopulated connection cache as stale (JAVA-717)
runningcode Sep 3, 2026
c45669e
Add changelog entry for JAVA-717
runningcode Sep 3, 2026
b971599
ref(transport): Measure rate-limit backoff on a monotonic clock (JAVA…
runningcode Sep 3, 2026
2168b35
Add changelog entries for JAVA-574
runningcode Sep 3, 2026
b35959c
ref(checkin): Measure check-in durations with Stopwatch (JAVA-576)
runningcode Sep 3, 2026
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## Unreleased

### Behavioral Changes

- Measure HTTP rate-limit backoff on a monotonic clock instead of the wall clock, so that a device time change no longer lifts or extends an active rate limit ([#6030](https://github.com/getsentry/sentry-java/pull/6030))
- Measure check-in durations on the monotonic clock, so a cron job that spans device sleep reports the time a user would measure instead of the time the CPU was awake ([#6032](https://github.com/getsentry/sentry-java/pull/6032))

### Fixes

- 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 `MonotonicClock` 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))
- Deprecate `RateLimiter(ICurrentDateProvider, SentryOptions)` in favour of `RateLimiter(MonotonicClock, RateLimiterConfig)` ([#6030](https://github.com/getsentry/sentry-java/pull/6030))

## 8.55.0

### Features
Expand Down
1 change: 1 addition & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 getMonotonicClock ()Lio/sentry/time/MonotonicClock;
public fun getNativeSdkName ()Ljava/lang/String;
public fun getNdkAppHangTimeoutIntervalMillis ()J
public fun getNdkHandlerStrategy ()I
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.getMonotonicClock()));
}

if (options.getCacheDirPath() != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.SpanStatus;
import io.sentry.android.core.internal.time.AndroidMonotonicClock;
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.MonotonicClock;
import io.sentry.util.SampleRateUtils;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
Expand Down Expand Up @@ -889,6 +891,12 @@ public void setEnableAnrFingerprinting(final boolean enableAnrFingerprinting) {
this.enableAnrFingerprinting = enableAnrFingerprinting;
}

@Override
@ApiStatus.Internal
public @NotNull MonotonicClock getMonotonicClock() {
return AndroidMonotonicClock.getInstance();
}

static class AndroidUserFeedbackFormHandler implements SentryFeedbackOptions.IFormHandler {
@Override
public void showForm(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.sentry.android.core.internal.time;

import android.os.SystemClock;
import io.sentry.time.MonotonicClock;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;

/**
* {@link MonotonicClock} backed by {@link SystemClock#elapsedRealtimeNanos()}.
*
* <p>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 AndroidMonotonicClock implements MonotonicClock {

private static final AndroidMonotonicClock instance = new AndroidMonotonicClock();

public static @NotNull MonotonicClock getInstance() {
return instance;
}

private AndroidMonotonicClock() {}

@Override
public long tickNanos() {
return SystemClock.elapsedRealtimeNanos();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.MonotonicClock;
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;
Expand All @@ -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 MonotonicClock clock;
private final @NotNull List<IConnectionStatusObserver> connectionStatusObservers;
private final @Nullable Handler handler;
private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock();
Expand All @@ -66,29 +68,30 @@ 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 MonotonicClock clock) {
this(context, options, buildInfoProvider, clock, null);
}

@SuppressLint("InlinedApi")
public AndroidConnectionStatusProvider(
@NotNull Context context,
@NotNull SentryOptions options,
@NotNull BuildInfoProvider buildInfoProvider,
@NotNull ICurrentDateProvider timeProvider,
@NotNull MonotonicClock clock,
@Nullable Handler handler) {
this.context = ContextUtils.getApplicationContext(context);
this.options = options;
this.buildInfoProvider = buildInfoProvider;
this.timeProvider = timeProvider;
this.clock = clock;
this.cacheFreshUntil = Deadline.passed(clock);
this.handler = handler;
this.connectionStatusObservers = new ArrayList<>();

Expand Down Expand Up @@ -231,7 +234,7 @@ private void clearCacheAndNotifyObservers() {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
cachedNetworkCapabilities = null;
currentNetwork = null;
lastCacheUpdateTime = timeProvider.getCurrentTimeMillis();
cacheFreshUntil = Deadline.after(clock, CACHE_TTL_MINUTES, TimeUnit.MINUTES);

options
.getLogger()
Expand Down Expand Up @@ -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(clock, CACHE_TTL_MINUTES, TimeUnit.MINUTES);
return;
}

if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.M) {
cachedNetworkCapabilities = null;
lastCacheUpdateTime = timeProvider.getCurrentTimeMillis();
cacheFreshUntil = Deadline.after(clock, CACHE_TTL_MINUTES, TimeUnit.MINUTES);
return;
}

Expand All @@ -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(clock, CACHE_TTL_MINUTES, TimeUnit.MINUTES);

options
.getLogger()
Expand All @@ -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(clock, CACHE_TTL_MINUTES, TimeUnit.MINUTES);
}
}
}

private boolean isCacheValid() {
return (timeProvider.getCurrentTimeMillis() - lastCacheUpdateTime) < CACHE_TTL_MS;
return !cacheFreshUntil.hasPassed();
}

@Override
Expand Down Expand Up @@ -459,7 +462,7 @@ private void unregisterNetworkCallback(final boolean clearObservers) {
// Clear cached state
cachedNetworkCapabilities = null;
currentNetwork = null;
lastCacheUpdateTime = 0;
cacheFreshUntil = Deadline.passed(clock);
}
options.getLogger().log(SentryLevel.DEBUG, "Network callback unregistered");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.TestMonotonicClock
import java.util.concurrent.TimeUnit.MINUTES
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
Expand Down Expand Up @@ -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 clock: TestMonotonicClock
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<ContextUtils>

private var currentTime = 1000L

@BeforeTest
fun beforeTest() {
contextMock = mock()
Expand All @@ -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 }
clock = TestMonotonicClock()

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
Expand All @@ -120,7 +115,7 @@ class AndroidConnectionStatusProviderTest {
AppState.getInstance().registerLifecycleObserver(options)

connectionStatusProvider =
AndroidConnectionStatusProvider(contextMock, options, buildInfo, timeProvider)
AndroidConnectionStatusProvider(contextMock, options, buildInfo, clock)
}

@AfterTest
Expand All @@ -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,
Expand Down Expand Up @@ -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, clock)

assertEquals(
IConnectionStatusProvider.ConnectionStatus.UNKNOWN,
Expand Down Expand Up @@ -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, TestMonotonicClock())

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
Expand All @@ -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
clock.advance(1, MINUTES)

// Second call should use cache - no additional calls to getActiveNetwork
val secondResult = connectionStatusProvider.connectionStatus
Expand All @@ -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
clock.advance(2, MINUTES)

// Third call should refresh cache - should make new calls to getActiveNetwork
val thirdResult = connectionStatusProvider.connectionStatus
Expand Down Expand Up @@ -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
clock.advance(3, MINUTES)

// Should return CONNECTED for good capabilities
assertEquals(
Expand All @@ -560,7 +580,7 @@ class AndroidConnectionStatusProviderTest {
whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(unvalidatedCaps)

// Force cache invalidation again
currentTime += 3 * 60 * 1000L
clock.advance(3, MINUTES)

assertEquals(
IConnectionStatusProvider.ConnectionStatus.DISCONNECTED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import io.sentry.ScopesAdapter;
import io.sentry.SentryLevel;
import io.sentry.protocol.SentryId;
import io.sentry.time.Stopwatch;
import io.sentry.util.Objects;
import io.sentry.util.TracingUtils;
import java.lang.reflect.Method;
Expand Down Expand Up @@ -91,7 +92,8 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl
TracingUtils.startNewTrace(scopes);

@Nullable SentryId checkInId = null;
final long startTime = System.nanoTime();
final @NotNull Stopwatch stopwatch =
Stopwatch.started(scopes.getOptions().getMonotonicClock());
boolean didError = false;

try {
Expand All @@ -105,7 +107,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl
} finally {
final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK;
CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status);
checkIn.setDuration(DateUtils.nanosToSeconds(System.nanoTime() - startTime));
checkIn.setDuration(DateUtils.nanosToSeconds(stopwatch.elapsedNanos()));
scopes.captureCheckIn(checkIn);
}
}
Expand Down
Loading
Loading