From d503d47e38ebef763d479a3c96dba3895c138f4b Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 11 Sep 2026 16:05:28 +0200 Subject: [PATCH 1/2] ref(core): Measure the performance-collection budget on a monotonic ticker (JAVA-579) DefaultCompositePerformanceCollector decided a transaction had been collecting for 30s by subtracting two options.getDateProvider() readings. Those are wall-clock on every platform, including Android: SentryNanotimeDate.nanoTimestamp() returns millisToNanos(unixDateMillis), not its nanoTime component, so the extra precision that type exists for never entered this comparison. A device time change therefore ended collection early or kept it running past the budget. Each CompositeData now holds a Deadline on a MonotonicTicker. The budget is not a serialized value, so this only changes when the collector stops. Two side effects worth review: the 30s boundary is now inclusive, where the old strict `>` let a sample land exactly at 30s; and addDataAndCheckTimeout no longer takes the shared clock reading, since each transaction owns its own deadline. TestMonotonicTicker's field is now volatile, as the timer thread reads a ticker the test thread advances. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/sentry/time/TestMonotonicTicker.kt | 2 +- .../DefaultCompositePerformanceCollector.java | 30 +++++++++----- ...efaultCompositePerformanceCollectorTest.kt | 40 +++++++------------ 3 files changed, 35 insertions(+), 37 deletions(-) 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 index 2471a1bc8d..e799d7520e 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt @@ -9,7 +9,7 @@ import java.util.concurrent.TimeUnit * 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 { +class TestMonotonicTicker(@Volatile private var nanos: Long = 0) : MonotonicTicker { override fun tickNanos(): Long = nanos fun advance(amount: Long, unit: TimeUnit) { diff --git a/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java b/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java index 2b5a386300..8e8de23109 100644 --- a/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java +++ b/sentry/src/main/java/io/sentry/DefaultCompositePerformanceCollector.java @@ -1,5 +1,7 @@ package io.sentry; +import io.sentry.time.Deadline; +import io.sentry.time.MonotonicTicker; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.util.ArrayList; @@ -26,10 +28,19 @@ public final class DefaultCompositePerformanceCollector implements CompositePerf private final boolean hasNoCollectors; private final @NotNull SentryOptions options; + private final @NotNull MonotonicTicker ticker; private final @NotNull AtomicBoolean isStarted = new AtomicBoolean(false); public DefaultCompositePerformanceCollector(final @NotNull SentryOptions options) { + this( + Objects.requireNonNull(options, "The options object is required."), + options.getMonotonicTicker()); + } + + DefaultCompositePerformanceCollector( + final @NotNull SentryOptions options, final @NotNull MonotonicTicker ticker) { this.options = Objects.requireNonNull(options, "The options object is required."); + this.ticker = Objects.requireNonNull(ticker, "The ticker is required."); this.snapshotCollectors = new ArrayList<>(); this.continuousCollectors = new ArrayList<>(); @@ -124,7 +135,7 @@ public void run() { // Add the enriched tempData to all transactions/profiles/objects that collect data. // Then Check if that object timed out. for (CompositeData data : compositeDataMap.values()) { - if (data.addDataAndCheckTimeout(tempData, tempData.getNanoTimestamp())) { + if (data.addDataAndCheckTimeout(tempData)) { // timed out if (data.transaction != null) { timedOutTransactions.add(data.transaction); @@ -212,33 +223,30 @@ public void close() { private class CompositeData { private final @NotNull List dataList; private final @Nullable ITransaction transaction; - private final long startTimestamp; + private final @NotNull Deadline collectUntil; private CompositeData(final @Nullable ITransaction transaction) { this.dataList = new ArrayList<>(); this.transaction = transaction; - this.startTimestamp = options.getDateProvider().now().nanoTimestamp(); + // On a ticker rather than the date provider: this is how long we have been collecting, and + // a device time change must not end a collection early or keep a finished one going. + this.collectUntil = + Deadline.after(ticker, TRANSACTION_COLLECTION_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); } /** * Adds the data to the internal list of PerformanceCollectionData. Then it checks if data * collection timed out (for transactions only). * - * @param nowNanos the timestamp of the current collection, passed in so a single clock reading - * is shared by every transaction in this collection round. * @return true if data collection timed out (for transactions only). */ - boolean addDataAndCheckTimeout( - final @NotNull PerformanceCollectionData data, final long nowNanos) { + boolean addDataAndCheckTimeout(final @NotNull PerformanceCollectionData data) { // stop() hands dataList out while this timer thread may still be writing to it, so consumers // synchronize on the list while iterating. We must hold the same monitor here. synchronized (dataList) { dataList.add(data); } - return transaction != null - && nowNanos - > startTimestamp - + TimeUnit.MILLISECONDS.toNanos(TRANSACTION_COLLECTION_TIMEOUT_MILLIS); + return transaction != null && collectUntil.hasPassed(); } } } diff --git a/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt b/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt index d259100853..46f9ca51a0 100644 --- a/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt +++ b/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt @@ -3,6 +3,7 @@ package io.sentry import io.sentry.test.getCtor import io.sentry.test.getProperty import io.sentry.test.injectForField +import io.sentry.time.TestMonotonicTicker import io.sentry.util.thread.ThreadChecker import java.util.Timer import java.util.concurrent.TimeUnit @@ -34,6 +35,7 @@ class DefaultCompositePerformanceCollectorTest { val id1 = "id1" val scopes: IScopes = mock() val options = SentryOptions() + val ticker = TestMonotonicTicker() var mockTimer: Timer? = null val mockCpuCollector: IPerformanceSnapshotCollector = @@ -65,7 +67,7 @@ class DefaultCompositePerformanceCollectorTest { optionsConfiguration.configure(options) transaction1 = SentryTracer(TransactionContext("", ""), scopes) transaction2 = SentryTracer(TransactionContext("", ""), scopes) - val collector = DefaultCompositePerformanceCollector(options) + val collector = DefaultCompositePerformanceCollector(options, ticker) val timer: Timer = collector.getProperty("timer") ?: Timer(true) mockTimer = spy(timer) collector.injectForField("timer", mockTimer) @@ -183,26 +185,19 @@ class DefaultCompositePerformanceCollectorTest { @Test fun `collector times out after 30 seconds`() { - val mockDateProvider = mock() val mockCollector = mock() - val dates = - listOf( - SentryNanotimeDate(TimeUnit.SECONDS.toMillis(100), TimeUnit.SECONDS.toNanos(100)), - SentryNanotimeDate(TimeUnit.SECONDS.toMillis(131), TimeUnit.SECONDS.toNanos(131)), - ) - whenever(mockDateProvider.now()).thenReturn(dates[0], dates[0], dates[0], dates[1]) - val collector = fixture.getSut { - it.dateProvider = mockDateProvider - it.addPerformanceCollector(mockCollector) - } + val collector = fixture.getSut { it.addPerformanceCollector(mockCollector) } collector.start(fixture.transaction1) verify(fixture.mockTimer, never())!!.cancel() + // 31 seconds of collecting have gone by + fixture.ticker.advance(31, TimeUnit.SECONDS) + // Let's sleep to make the collector get values Thread.sleep(300) - // When the collector gets the values, it checks the current date, set 31 seconds after the - // begin. This means it should stop itself + // When the collector gets the values, it checks how long it has been collecting for, which is + // now past the 30 second budget. This means it should stop itself verify(fixture.mockTimer)!!.cancel() // When the collector times out, the data collection for spans is stopped, too @@ -214,23 +209,18 @@ class DefaultCompositePerformanceCollectorTest { } @Test - fun `collector collects for 30 seconds`() { - val mockDateProvider = mock() - val dates = - listOf( - SentryNanotimeDate(TimeUnit.SECONDS.toMillis(100), TimeUnit.SECONDS.toNanos(100)), - SentryNanotimeDate(TimeUnit.SECONDS.toMillis(130), TimeUnit.SECONDS.toNanos(130)), - ) - whenever(mockDateProvider.now()).thenReturn(dates[0], dates[0], dates[0], dates[1]) - val collector = fixture.getSut { it.dateProvider = mockDateProvider } + fun `collector keeps collecting while inside the 30 second budget`() { + val collector = fixture.getSut() collector.start(fixture.transaction1) verify(fixture.mockTimer, never())!!.cancel() + // 29 seconds of collecting have gone by + fixture.ticker.advance(29, TimeUnit.SECONDS) + // Let's sleep to make the collector get values Thread.sleep(300) - // When the collector gets the values, it checks the current date, set 30 seconds after the - // begin. This means it should continue without being cancelled + // Still inside the 30 second budget, so it should continue without being cancelled verify(fixture.mockTimer, never())!!.cancel() // Data is deleted after the collector times out From e46de125344a5705e5a5e33e3b682a6717c26896 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 11 Sep 2026 16:06:44 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09622493a2..5644cd58fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Measure the 30 second performance-collection budget on a monotonic ticker, so that a device time change no longer ends collection early or extends it past the budget ([#6101](https://github.com/getsentry/sentry-java/pull/6101)) + ## 8.56.0 ### Behavioral Changes