From 6dc71f17ebf8c65bf06d4e69b97b1ecfa61f3aa5 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 18:04:09 +0200 Subject: [PATCH 1/7] test(android): Fix flaky AnrV2 scope persistence assertion The final assertions of the AnrV2 enrichment test read transaction.json and release.json straight off disk, but both are written from the Sentry executor and nothing in the test joins it. The awaited beforeSend callback runs on the ANR processing task, which is queued ahead of the scope flush, so it flipping says nothing about whether the writes have landed. Awaitility's 100ms poll interval normally hides this; on a loaded CI runner it does not, and the read returns the value prefilled by the test setup instead of the new one. Poll for the persisted values rather than reading them once. --- .../sentry/android/core/SentryAndroidTest.kt | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 6dbc3a1fea..f08da9f06a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -464,17 +464,23 @@ class SentryAndroidTest { // Execute all posted tasks Shadows.shadowOf(Looper.getMainLooper()).idle() - // assert that persisted values have changed - assertEquals( - "TestActivity", - options - .findPersistingScopeObserver() - ?.read(options, TRANSACTION_FILENAME, String::class.java), - ) - assertEquals( - "io.sentry.sample@1.1.0+220", - PersistingOptionsObserver.read(options, RELEASE_FILENAME, String::class.java), - ) + // Both values are persisted from the Sentry executor, and nothing above joins it: the + // beforeSend callback we awaited runs on an earlier task in the same queue, so it flipping + // says nothing about the writes. Poll instead of reading once and racing them. + await + .withAlias("Persisted scope and options values are written from the Sentry executor") + .untilAsserted { + assertEquals( + "TestActivity", + options + .findPersistingScopeObserver() + ?.read(options, TRANSACTION_FILENAME, String::class.java), + ) + assertEquals( + "io.sentry.sample@1.1.0+220", + PersistingOptionsObserver.read(options, RELEASE_FILENAME, String::class.java), + ) + } } @Test From ec7a432e3a6d5e06f4f15a931fd79eb1cc4caccf Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 18:12:30 +0200 Subject: [PATCH 2/7] test(android): Cap the AnrV2 persistence poll at 5 seconds Awaitility's 10 second default is the wait a genuine regression now pays before the test reports it. By this point the only work left in the executor queue is two short file writes: the long ANR processing task has already run, which is what let the awaited beforeSend callback flip. Scheduler starvation on a loaded runner is the only plausible source of delay, so 5 seconds leaves ample headroom while halving the failure wait. --- .../src/test/java/io/sentry/android/core/SentryAndroidTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index f08da9f06a..eef26c2a1f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -469,6 +469,7 @@ class SentryAndroidTest { // says nothing about the writes. Poll instead of reading once and racing them. await .withAlias("Persisted scope and options values are written from the Sentry executor") + .atMost(5, TimeUnit.SECONDS) .untilAsserted { assertEquals( "TestActivity", From 62e16537d2249d2c9edcf4c2708e6fa7f7d0bf5e Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 18:13:47 +0200 Subject: [PATCH 3/7] test(android): Lower the AnrV2 persistence poll cap to 2 seconds The remaining executor work is two short file writes, so the cap only has to cover scheduler starvation on a loaded runner. 2 seconds still leaves an order of magnitude of headroom and shortens the wait a genuine regression pays before the test reports it. --- .../src/test/java/io/sentry/android/core/SentryAndroidTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index eef26c2a1f..87f1b9b63b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -469,7 +469,7 @@ class SentryAndroidTest { // says nothing about the writes. Poll instead of reading once and racing them. await .withAlias("Persisted scope and options values are written from the Sentry executor") - .atMost(5, TimeUnit.SECONDS) + .atMost(2, TimeUnit.SECONDS) .untilAsserted { assertEquals( "TestActivity", From 0edb41444186db0a7697e079c547b1b1693bd45e Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 1 Sep 2026 08:40:04 +0200 Subject: [PATCH 4/7] fix(core): Clear the persisted replay id when resetting the scope cache resetCache() cleared every other persisted scope value on init but left replay.json in place, so a replay id written by a previous process could still be attached to events from the current one. The reset already runs after the integrations that consume those values, so deleting it here is safe. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + .../sentry/cache/PersistingScopeObserver.java | 3 +++ .../PersistingScopeObserverBatchingTest.kt | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 823c43f992..c19d1c62ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixes - Keep dropped tombstone and ANR events dropped, instead of reporting the same app exit again at every app start ([#6002](https://github.com/getsentry/sentry-java/pull/6002)) +- Clear the persisted replay id on SDK init, so a replay id from a previous process is no longer attached to ANR events from the current one ([#6031](https://github.com/getsentry/sentry-java/pull/6031)) - Apply `Sentry.withScope` and `Sentry.withIsolationScope` data to events captured inside the callback when `globalHubMode` is enabled ([#6004](https://github.com/getsentry/sentry-java/pull/6004)) - `globalHubMode` is enabled by default on Android, where tags, extras, contexts and level set inside the callback were silently dropped - Scopes that are explicitly made current, e.g. via `Sentry.setCurrentScopes` or the `SentryContext` coroutine integration, are now also honoured when `globalHubMode` is enabled diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index d6137ae051..03b9f17c65 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -378,5 +378,8 @@ public void resetCache() { delete(TAGS_FILENAME); delete(TRACE_FILENAME); delete(TRANSACTION_FILENAME); + // the replay this id points at belongs to the previous process, so it must not be attached to + // events from this one; the replay integration writes a fresh id once it starts recording + delete(REPLAY_FILENAME); } } diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverBatchingTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverBatchingTest.kt index eec38e151e..d893e52d4c 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverBatchingTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverBatchingTest.kt @@ -6,7 +6,9 @@ import io.sentry.ISentryExecutorService import io.sentry.ISerializer import io.sentry.SentryOptions import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME +import io.sentry.protocol.SentryId import io.sentry.test.DeferredExecutorService import java.io.Writer import java.util.concurrent.atomic.AtomicBoolean @@ -28,6 +30,9 @@ class PersistingScopeObserverBatchingTest { private fun PersistingScopeObserver.readTransaction(): String? = read(options, TRANSACTION_FILENAME, String::class.java) + private fun PersistingScopeObserver.readReplayId(): String? = + read(options, REPLAY_FILENAME, String::class.java) + @Suppress("UNCHECKED_CAST") private fun PersistingScopeObserver.readBreadcrumbs(): List = read(options, BREADCRUMBS_FILENAME, List::class.java) as List @@ -118,6 +123,20 @@ class PersistingScopeObserverBatchingTest { } } + @Test + fun `resetCache clears the replay id left behind by the previous process`() { + val executor = DeferredExecutorService() + val sut = getSut(executor) + + sut.setReplayId(SentryId("afcb46b1140ade5187c4bbb5daa804df")) + executor.runAll() + assertThat(sut.readReplayId()).isEqualTo("afcb46b1140ade5187c4bbb5daa804df") + + sut.resetCache() + + assertThat(sut.readReplayId()).isNull() + } + @Test fun `resetCache keeps pending mutations from the current process`() { val executor = DeferredExecutorService() From 4ea33cf684fe1af4b6be1aefd5da316a7ff758ef Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 1 Sep 2026 08:40:08 +0200 Subject: [PATCH 5/7] test(android): Observe the scope-reset assertions instead of dropping them The reset assertions were submitted to the Sentry executor and their Future discarded, so an AssertionError never reached the test runner and the test passed regardless of what they found. Joining the Future revives them; the replay expectation is corrected to assertNull to match what resetCache does. Co-Authored-By: Claude Opus 5 (1M context) --- .../sentry/android/core/SentryAndroidTest.kt | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 87f1b9b63b..e394db2620 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -46,7 +46,6 @@ import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME import io.sentry.cache.tape.QueueFile import io.sentry.protocol.Contexts -import io.sentry.protocol.SentryId import io.sentry.spotlight.SpotlightIntegration import io.sentry.test.applyTestOptions import io.sentry.transport.NoOpEnvelopeCache @@ -54,6 +53,7 @@ import io.sentry.util.StringUtils import java.io.ByteArrayOutputStream import java.io.File import java.nio.file.Files +import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.io.path.absolutePathString @@ -437,20 +437,17 @@ class SentryAndroidTest { it.environment = "debug" options = it } - options.executorService.submit { - // verify we reset the persisted scope values after the init bg tasks have run to ensure - // clean state for a new process. - assertEquals( - emptyList(), - options - .findPersistingScopeObserver() - ?.read(options, BREADCRUMBS_FILENAME, List::class.java), - ) - assertEquals( - SentryId.EMPTY_ID.toString(), - options.findPersistingScopeObserver()?.read(options, REPLAY_FILENAME, String::class.java), - ) - } + val scopeObserver = assertNotNull(options.findPersistingScopeObserver()) + val resetAssertions = + options.executorService.submit { + // verify we reset the persisted scope values after the init bg tasks have run to ensure + // clean state for a new process. + assertEquals( + emptyList(), + scopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java), + ) + assertNull(scopeObserver.read(options, REPLAY_FILENAME, String::class.java)) + } Sentry.configureScope { it.setTransaction("TestActivity") it.addBreadcrumb(Breadcrumb.error("Error!")) @@ -464,6 +461,14 @@ class SentryAndroidTest { // Execute all posted tasks Shadows.shadowOf(Looper.getMainLooper()).idle() + // the reset assertions run on the executor, so nothing reports their AssertionError unless we + // observe the Future: without this the test would pass no matter what they found + try { + resetAssertions.get(30, TimeUnit.SECONDS) + } catch (e: ExecutionException) { + throw requireNotNull(e.cause) + } + // Both values are persisted from the Sentry executor, and nothing above joins it: the // beforeSend callback we awaited runs on an earlier task in the same queue, so it flipping // says nothing about the writes. Poll instead of reading once and racing them. @@ -473,9 +478,7 @@ class SentryAndroidTest { .untilAsserted { assertEquals( "TestActivity", - options - .findPersistingScopeObserver() - ?.read(options, TRANSACTION_FILENAME, String::class.java), + scopeObserver.read(options, TRANSACTION_FILENAME, String::class.java), ) assertEquals( "io.sentry.sample@1.1.0+220", From 7f517aba76819af035785bd9764628d406484c62 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 1 Sep 2026 08:43:52 +0200 Subject: [PATCH 6/7] test(android): Lower the scope-reset join timeout to 2 seconds The join only guards against a wedged executor; the task is expected to be long finished by then. 30 seconds was arbitrary and 15x the cap the sibling assertion in this test uses, so a hang burned CI time for no added coverage. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/test/java/io/sentry/android/core/SentryAndroidTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index e394db2620..596584b2ae 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -464,7 +464,7 @@ class SentryAndroidTest { // the reset assertions run on the executor, so nothing reports their AssertionError unless we // observe the Future: without this the test would pass no matter what they found try { - resetAssertions.get(30, TimeUnit.SECONDS) + resetAssertions.get(2, TimeUnit.SECONDS) } catch (e: ExecutionException) { throw requireNotNull(e.cause) } From 540f324586e1e0f19928f01b0654327e150d4bd6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 1 Sep 2026 08:57:16 +0200 Subject: [PATCH 7/7] test(android): Move the replay id reset out of the flakiness fix The scope-reset assertion revived by the previous commit expected resetCache() to clear replay.json, which it has never done. Fixing that is a product change unrelated to the flaky assertion this branch is about, so it moves to its own PR along with its test and changelog entry. The replay expectation is dropped rather than corrected: resetCache's behaviour belongs in PersistingScopeObserverBatchingTest, not incidental to a test about ANR enrichment. The breadcrumbs assertion beside it stays and is now observed. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 - .../sentry/android/core/SentryAndroidTest.kt | 1 - .../sentry/cache/PersistingScopeObserver.java | 3 --- .../PersistingScopeObserverBatchingTest.kt | 19 ------------------- 4 files changed, 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c19d1c62ed..823c43f992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ ### Fixes - Keep dropped tombstone and ANR events dropped, instead of reporting the same app exit again at every app start ([#6002](https://github.com/getsentry/sentry-java/pull/6002)) -- Clear the persisted replay id on SDK init, so a replay id from a previous process is no longer attached to ANR events from the current one ([#6031](https://github.com/getsentry/sentry-java/pull/6031)) - Apply `Sentry.withScope` and `Sentry.withIsolationScope` data to events captured inside the callback when `globalHubMode` is enabled ([#6004](https://github.com/getsentry/sentry-java/pull/6004)) - `globalHubMode` is enabled by default on Android, where tags, extras, contexts and level set inside the callback were silently dropped - Scopes that are explicitly made current, e.g. via `Sentry.setCurrentScopes` or the `SentryContext` coroutine integration, are now also honoured when `globalHubMode` is enabled diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 596584b2ae..725a5a6112 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -446,7 +446,6 @@ class SentryAndroidTest { emptyList(), scopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java), ) - assertNull(scopeObserver.read(options, REPLAY_FILENAME, String::class.java)) } Sentry.configureScope { it.setTransaction("TestActivity") diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 03b9f17c65..d6137ae051 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -378,8 +378,5 @@ public void resetCache() { delete(TAGS_FILENAME); delete(TRACE_FILENAME); delete(TRANSACTION_FILENAME); - // the replay this id points at belongs to the previous process, so it must not be attached to - // events from this one; the replay integration writes a fresh id once it starts recording - delete(REPLAY_FILENAME); } } diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverBatchingTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverBatchingTest.kt index d893e52d4c..eec38e151e 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverBatchingTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverBatchingTest.kt @@ -6,9 +6,7 @@ import io.sentry.ISentryExecutorService import io.sentry.ISerializer import io.sentry.SentryOptions import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME -import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME -import io.sentry.protocol.SentryId import io.sentry.test.DeferredExecutorService import java.io.Writer import java.util.concurrent.atomic.AtomicBoolean @@ -30,9 +28,6 @@ class PersistingScopeObserverBatchingTest { private fun PersistingScopeObserver.readTransaction(): String? = read(options, TRANSACTION_FILENAME, String::class.java) - private fun PersistingScopeObserver.readReplayId(): String? = - read(options, REPLAY_FILENAME, String::class.java) - @Suppress("UNCHECKED_CAST") private fun PersistingScopeObserver.readBreadcrumbs(): List = read(options, BREADCRUMBS_FILENAME, List::class.java) as List @@ -123,20 +118,6 @@ class PersistingScopeObserverBatchingTest { } } - @Test - fun `resetCache clears the replay id left behind by the previous process`() { - val executor = DeferredExecutorService() - val sut = getSut(executor) - - sut.setReplayId(SentryId("afcb46b1140ade5187c4bbb5daa804df")) - executor.runAll() - assertThat(sut.readReplayId()).isEqualTo("afcb46b1140ade5187c4bbb5daa804df") - - sut.resetCache() - - assertThat(sut.readReplayId()).isNull() - } - @Test fun `resetCache keeps pending mutations from the current process`() { val executor = DeferredExecutorService()