From e21f0468a019dc9bba4140ec65ad8eb95cbc3346 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 14 Jul 2026 22:10:09 +0200 Subject: [PATCH 01/15] fix(android): Backfill exit options when app is unchanged Use current options when persisted values are missing and the app has not been updated since the exit. Avoid attributing historical crashes to a newer app version. --- .../ApplicationExitInfoEventProcessor.java | 82 ++++++++++---- .../ApplicationExitInfoEventProcessorTest.kt | 100 +++++++++++++++++- 2 files changed, 159 insertions(+), 23 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 2eca0e68b5b..1860a1bdb81 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -51,6 +51,7 @@ import io.sentry.exception.ExceptionMechanismException; import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; +import io.sentry.hints.NativeCrashExit; import io.sentry.protocol.App; import io.sentry.protocol.Contexts; import io.sentry.protocol.DebugImage; @@ -161,7 +162,12 @@ public ApplicationExitInfoEventProcessor( mergeOS(event); setDevice(event); + final boolean canUseCurrentOptions = isAppNotUpdated(backfillable); + if (!backfillable.shouldEnrich()) { + setRelease(event, canUseCurrentOptions); + setEnvironment(event, canUseCurrentOptions); + setDist(event, canUseCurrentOptions); options .getLogger() .log( @@ -172,7 +178,7 @@ public ApplicationExitInfoEventProcessor( backfillScope(event); - backfillOptions(event); + backfillOptions(event, canUseCurrentOptions); setStaticValues(event); @@ -393,17 +399,18 @@ private void setRequest(final @NotNull SentryBaseEvent event) { // endregion // region options persisted values - private void backfillOptions(final @NotNull SentryEvent event) { - setRelease(event); - setEnvironment(event); - setDist(event); + private void backfillOptions( + final @NotNull SentryEvent event, final boolean canUseCurrentOptions) { + setRelease(event, canUseCurrentOptions); + setEnvironment(event, canUseCurrentOptions); + setDist(event, canUseCurrentOptions); setDebugMeta(event); setSdk(event); - setApp(event); + setApp(event, canUseCurrentOptions); setOptionsTags(event); } - private void setApp(final @NotNull SentryBaseEvent event) { + private void setApp(final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) { App app = event.getContexts().getApp(); if (app == null) { app = new App(); @@ -415,11 +422,14 @@ private void setApp(final @NotNull SentryBaseEvent event) { app.setAppIdentifier(packageInfo.packageName); } - // backfill versionName and versionCode from the persisted release string - final String release = - event.getRelease() != null - ? event.getRelease() - : PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); + // backfill versionName and versionCode from the release string + String release = event.getRelease(); + if (release == null) { + release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); + } + if (release == null && canUseCurrentOptions) { + release = options.getRelease(); + } if (release != null) { try { final String versionName = @@ -450,19 +460,26 @@ private void setApp(final @NotNull SentryBaseEvent event) { event.getContexts().setApp(app); } - private void setRelease(final @NotNull SentryBaseEvent event) { + private void setRelease( + final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) { if (event.getRelease() == null) { - final String release = - PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); + String release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); + if (release == null && canUseCurrentOptions) { + release = options.getRelease(); + } event.setRelease(release); } } - private void setEnvironment(final @NotNull SentryBaseEvent event) { + private void setEnvironment( + final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) { if (event.getEnvironment() == null) { final String environment = PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String.class); - event.setEnvironment(environment != null ? environment : options.getEnvironment()); + event.setEnvironment( + environment != null + ? environment + : canUseCurrentOptions ? options.getEnvironment() : null); } } @@ -490,15 +507,17 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) { } } - private void setDist(final @NotNull SentryBaseEvent event) { + private void setDist(final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) { if (event.getDist() == null) { - final String dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String.class); + String dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String.class); + if (dist == null && canUseCurrentOptions) { + dist = options.getDist(); + } event.setDist(dist); } - // if there's no user-set dist, fall back to versionCode from the persisted release string + // if there's no user-set dist, fall back to versionCode from the release string if (event.getDist() == null) { - final String release = - PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); + final String release = event.getRelease(); if (release != null) { try { final String versionCode = release.substring(release.indexOf('+') + 1); @@ -512,6 +531,25 @@ private void setDist(final @NotNull SentryBaseEvent event) { } } + private boolean isAppNotUpdated(final @NotNull Backfillable hint) { + final @Nullable Long timestamp; + if (hint instanceof AbnormalExit) { + timestamp = ((AbnormalExit) hint).timestamp(); + } else if (hint instanceof NativeCrashExit) { + timestamp = ((NativeCrashExit) hint).timestamp(); + } else { + timestamp = null; + } + if (timestamp == null) { + return true; + } + + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); + return packageInfo != null + && packageInfo.lastUpdateTime > 0 + && packageInfo.lastUpdateTime <= timestamp; + } + private void setSdk(final @NotNull SentryBaseEvent event) { if (event.getSdk() == null) { final SdkVersion sdkVersion = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index e7583429910..797a4408086 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -198,6 +198,7 @@ class ApplicationExitInfoEventProcessorTest { @BeforeTest fun `set up`() { DeviceInfoUtil.resetInstance() + ContextUtils.resetInstance() fixture.context = ApplicationProvider.getApplicationContext() } @@ -398,6 +399,97 @@ class ApplicationExitInfoEventProcessorTest { assertEquals("release", processed.environment) } + @Test + fun `if release is not persisted and app was not updated, uses release from options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.2.0+232", processed.release) + } + + @Test + fun `if release is not persisted and app was updated, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if last update time is invalid, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(-1) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + + @Test + fun `if dist is not persisted and app was not updated, uses version code from options release`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("232", processed.dist) + } + + @Test + fun `if app version is not persisted and app was not updated, uses options release`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("1.2.0", processed.contexts.app!!.appVersion) + assertEquals("232", processed.contexts.app!!.appBuild) + } + + @Test + fun `historical event uses current options when app was not updated`() { + val hint = + HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false, timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + fixture.options.environment = "production" + fixture.options.dist = "custom-dist" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.2.0+232", processed.release) + assertEquals("production", processed.environment) + assertEquals("custom-dist", processed.dist) + } + + @Test + fun `historical event leaves release empty when app was updated`() { + val hint = + HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false, timestamp = 1_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + @Test fun `if dist is not persisted, backfills it from release`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -938,15 +1030,21 @@ class ApplicationExitInfoEventProcessorTest { return processor.process(original, hint)!! } + private fun setLastUpdateTime(lastUpdateTime: Long) { + ContextUtils.getPackageInfo(fixture.context, fixture.buildInfo)!!.lastUpdateTime = + lastUpdateTime + } + internal class AbnormalExitHint( val mechanism: String? = null, private val shouldEnrich: Boolean = true, + private val timestamp: Long? = null, ) : AbnormalExit, Backfillable { override fun mechanism(): String? = mechanism override fun ignoreCurrentThread(): Boolean = false - override fun timestamp(): Long? = null + override fun timestamp(): Long? = timestamp override fun shouldEnrich(): Boolean = shouldEnrich } From 3f338ac7a2c95f5e72b2328a6f50bf63476223d2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 14 Jul 2026 22:17:50 +0200 Subject: [PATCH 02/15] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c57c75fe6a7..19d87607adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ ### Fixes +- Backfill release, environment, distribution, and app version/build for ANR and native crash events that occurred before SDK initialization, provided the app has not since been updated ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - Session Replay: Fix first recording segment missing for replays in `buffer` mode ([#5753](https://github.com/getsentry/sentry-java/pull/5753)) - Session Replay: Fix error-to-replay linkage in `buffer` mode ([#5754](https://github.com/getsentry/sentry-java/pull/5754)) - Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756)) From c51d5088a0051e356da2c4dac248c37399b31109 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 15 Jul 2026 11:22:34 +0200 Subject: [PATCH 03/15] fix(android): Backfill app context for historical exits Populate app version and build for historical ANR and native crash events when the current package metadata is safe to use. Co-Authored-By: Codex --- .../sentry/android/core/ApplicationExitInfoEventProcessor.java | 3 +++ .../android/core/ApplicationExitInfoEventProcessorTest.kt | 2 ++ 2 files changed, 5 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 1860a1bdb81..ddce5fdd964 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -168,6 +168,9 @@ public ApplicationExitInfoEventProcessor( setRelease(event, canUseCurrentOptions); setEnvironment(event, canUseCurrentOptions); setDist(event, canUseCurrentOptions); + if (canUseCurrentOptions) { + setApp(event, true); + } options .getLogger() .log( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index 797a4408086..59698a89978 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -475,6 +475,8 @@ class ApplicationExitInfoEventProcessorTest { assertEquals("io.sentry.samples@1.2.0+232", processed.release) assertEquals("production", processed.environment) assertEquals("custom-dist", processed.dist) + assertEquals("1.2.0", processed.contexts.app!!.appVersion) + assertEquals("232", processed.contexts.app!!.appBuild) } @Test From 6279c130328f57ec3bcc54fbbcda15c321240e80 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 15 Jul 2026 11:30:47 +0200 Subject: [PATCH 04/15] fix(android): Limit historical app metadata backfill Backfill only app version and build for historical exits. Avoid attaching current localized app names, identifiers, or split APK state to older events. Co-Authored-By: Codex --- .../ApplicationExitInfoEventProcessor.java | 44 +++++++++++-------- .../ApplicationExitInfoEventProcessorTest.kt | 8 +++- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index ddce5fdd964..5d1b33fcbdf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -168,9 +168,7 @@ public ApplicationExitInfoEventProcessor( setRelease(event, canUseCurrentOptions); setEnvironment(event, canUseCurrentOptions); setDist(event, canUseCurrentOptions); - if (canUseCurrentOptions) { - setApp(event, true); - } + setAppVersionAndBuild(event, canUseCurrentOptions); options .getLogger() .log( @@ -425,7 +423,25 @@ private void setApp(final @NotNull SentryBaseEvent event, final boolean canUseCu app.setAppIdentifier(packageInfo.packageName); } - // backfill versionName and versionCode from the release string + try { + final ContextUtils.SplitApksInfo splitApksInfo = + DeviceInfoUtil.getInstance(context, options).getSplitApksInfo(); + if (splitApksInfo != null) { + app.setSplitApks(splitApksInfo.isSplitApks()); + if (splitApksInfo.getSplitNames() != null) { + app.setSplitNames(Arrays.asList(splitApksInfo.getSplitNames())); + } + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting split apks info.", e); + } + + event.getContexts().setApp(app); + setAppVersionAndBuild(event, canUseCurrentOptions); + } + + private void setAppVersionAndBuild( + final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) { String release = event.getRelease(); if (release == null) { release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); @@ -435,32 +451,22 @@ private void setApp(final @NotNull SentryBaseEvent event, final boolean canUseCu } if (release != null) { try { + App app = event.getContexts().getApp(); + if (app == null) { + app = new App(); + } final String versionName = release.substring(release.indexOf('@') + 1, release.indexOf('+')); final String versionCode = release.substring(release.indexOf('+') + 1); app.setAppVersion(versionName); app.setAppBuild(versionCode); + event.getContexts().setApp(app); } catch (Throwable e) { options .getLogger() .log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release); } } - - try { - final ContextUtils.SplitApksInfo splitApksInfo = - DeviceInfoUtil.getInstance(context, options).getSplitApksInfo(); - if (splitApksInfo != null) { - app.setSplitApks(splitApksInfo.isSplitApks()); - if (splitApksInfo.getSplitNames() != null) { - app.setSplitNames(Arrays.asList(splitApksInfo.getSplitNames())); - } - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting split apks info.", e); - } - - event.getContexts().setApp(app); } private void setRelease( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index 59698a89978..a5c4a1876bc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -475,8 +475,11 @@ class ApplicationExitInfoEventProcessorTest { assertEquals("io.sentry.samples@1.2.0+232", processed.release) assertEquals("production", processed.environment) assertEquals("custom-dist", processed.dist) - assertEquals("1.2.0", processed.contexts.app!!.appVersion) - assertEquals("232", processed.contexts.app!!.appBuild) + val app = processed.contexts.app!! + assertEquals("1.2.0", app.appVersion) + assertEquals("232", app.appBuild) + assertNull(app.appName) + assertNull(app.appIdentifier) } @Test @@ -490,6 +493,7 @@ class ApplicationExitInfoEventProcessorTest { val processed = processor.process(SentryEvent(), hint)!! assertNull(processed.release) + assertNull(processed.contexts.app) } @Test From ad691cafbd49e7ee39b59afa1f510bfa19448bb4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 10:21:03 +0200 Subject: [PATCH 05/15] fix(android): Reject unknown exit timestamps Do not use current SDK options when an exit timestamp is unavailable because an intervening app update cannot be ruled out. Co-Authored-By: Codex --- .../core/ApplicationExitInfoEventProcessor.java | 2 +- .../ApplicationExitInfoEventProcessorTest.kt | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 5d1b33fcbdf..c54d9185ad2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -550,7 +550,7 @@ private boolean isAppNotUpdated(final @NotNull Backfillable hint) { timestamp = null; } if (timestamp == null) { - return true; + return false; } final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index a5c4a1876bc..13b899eea48 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -391,8 +391,9 @@ class ApplicationExitInfoEventProcessorTest { } @Test - fun `if environment is not persisted, uses environment from options`() { - val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + fun `if environment is not persisted and app was not updated, uses environment from options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + setLastUpdateTime(1_000) val processed = processEvent(hint) @@ -423,6 +424,18 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.release) } + @Test + fun `if exit timestamp is unknown, leaves release empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2.0+232" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + } + @Test fun `if last update time is invalid, leaves release empty`() { val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 1_000)) From aaab0135d7ac5b0b4ccc9d099a44b9818857000a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 11:11:11 +0200 Subject: [PATCH 06/15] fix(android): Validate persisted options cache generation Persist the app update timestamp after writing the options snapshot. Trust cached release, environment, and dist only when the marker identifies the current app installation, preserving launch-specific values without leaking stale metadata across app updates. Co-Authored-By: Codex --- .../core/AndroidOptionsInitializer.java | 5 + .../ApplicationExitInfoEventProcessor.java | 105 +++++++++++------- ...sistingOptionsCacheGenerationObserver.java | 81 ++++++++++++++ .../core/AndroidOptionsInitializerTest.kt | 15 +++ .../ApplicationExitInfoEventProcessorTest.kt | 40 +++++++ 5 files changed, 207 insertions(+), 39 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java 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 9cc5cb3df0f..434dfc73d3e 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 @@ -182,6 +182,11 @@ static void initializeIntegrationsAndProcessors( if (options.getCacheDirPath() != null) { options.addScopeObserver(new PersistingScopeObserver(options)); options.addOptionsObserver(new PersistingOptionsObserver(options)); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); + if (packageInfo != null && packageInfo.lastUpdateTime > 0) { + options.addOptionsObserver( + new PersistingOptionsCacheGenerationObserver(options, packageInfo.lastUpdateTime)); + } } options.addEventProcessor(new DeduplicateMultithreadedEventProcessor(options)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index c54d9185ad2..d0a97a516f5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -163,12 +163,13 @@ public ApplicationExitInfoEventProcessor( setDevice(event); final boolean canUseCurrentOptions = isAppNotUpdated(backfillable); + final boolean optionsCacheForCurrentApp = isOptionsCacheForCurrentApp(); if (!backfillable.shouldEnrich()) { - setRelease(event, canUseCurrentOptions); - setEnvironment(event, canUseCurrentOptions); - setDist(event, canUseCurrentOptions); - setAppVersionAndBuild(event, canUseCurrentOptions); + setRelease(event, canUseCurrentOptions, optionsCacheForCurrentApp); + setEnvironment(event, canUseCurrentOptions, optionsCacheForCurrentApp); + setDist(event, canUseCurrentOptions, optionsCacheForCurrentApp); + setAppVersionAndBuild(event); options .getLogger() .log( @@ -179,7 +180,7 @@ public ApplicationExitInfoEventProcessor( backfillScope(event); - backfillOptions(event, canUseCurrentOptions); + backfillOptions(event, canUseCurrentOptions, optionsCacheForCurrentApp); setStaticValues(event); @@ -401,17 +402,19 @@ private void setRequest(final @NotNull SentryBaseEvent event) { // region options persisted values private void backfillOptions( - final @NotNull SentryEvent event, final boolean canUseCurrentOptions) { - setRelease(event, canUseCurrentOptions); - setEnvironment(event, canUseCurrentOptions); - setDist(event, canUseCurrentOptions); + final @NotNull SentryEvent event, + final boolean canUseCurrentOptions, + final boolean optionsCacheForCurrentApp) { + setRelease(event, canUseCurrentOptions, optionsCacheForCurrentApp); + setEnvironment(event, canUseCurrentOptions, optionsCacheForCurrentApp); + setDist(event, canUseCurrentOptions, optionsCacheForCurrentApp); setDebugMeta(event); setSdk(event); - setApp(event, canUseCurrentOptions); + setApp(event); setOptionsTags(event); } - private void setApp(final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) { + private void setApp(final @NotNull SentryBaseEvent event) { App app = event.getContexts().getApp(); if (app == null) { app = new App(); @@ -437,18 +440,11 @@ private void setApp(final @NotNull SentryBaseEvent event, final boolean canUseCu } event.getContexts().setApp(app); - setAppVersionAndBuild(event, canUseCurrentOptions); + setAppVersionAndBuild(event); } - private void setAppVersionAndBuild( - final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) { - String release = event.getRelease(); - if (release == null) { - release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); - } - if (release == null && canUseCurrentOptions) { - release = options.getRelease(); - } + private void setAppVersionAndBuild(final @NotNull SentryBaseEvent event) { + final String release = event.getRelease(); if (release != null) { try { App app = event.getContexts().getApp(); @@ -470,25 +466,30 @@ private void setAppVersionAndBuild( } private void setRelease( - final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) { + final @NotNull SentryBaseEvent event, + final boolean canUseCurrentOptions, + final boolean optionsCacheForCurrentApp) { if (event.getRelease() == null) { - String release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); - if (release == null && canUseCurrentOptions) { - release = options.getRelease(); - } - event.setRelease(release); + event.setRelease( + getOption( + RELEASE_FILENAME, + options.getRelease(), + canUseCurrentOptions, + optionsCacheForCurrentApp)); } } private void setEnvironment( - final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) { + final @NotNull SentryBaseEvent event, + final boolean canUseCurrentOptions, + final boolean optionsCacheForCurrentApp) { if (event.getEnvironment() == null) { - final String environment = - PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String.class); event.setEnvironment( - environment != null - ? environment - : canUseCurrentOptions ? options.getEnvironment() : null); + getOption( + ENVIRONMENT_FILENAME, + options.getEnvironment(), + canUseCurrentOptions, + optionsCacheForCurrentApp)); } } @@ -516,13 +517,14 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) { } } - private void setDist(final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) { + private void setDist( + final @NotNull SentryBaseEvent event, + final boolean canUseCurrentOptions, + final boolean optionsCacheForCurrentApp) { if (event.getDist() == null) { - String dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String.class); - if (dist == null && canUseCurrentOptions) { - dist = options.getDist(); - } - event.setDist(dist); + event.setDist( + getOption( + DIST_FILENAME, options.getDist(), canUseCurrentOptions, optionsCacheForCurrentApp)); } // if there's no user-set dist, fall back to versionCode from the release string if (event.getDist() == null) { @@ -540,6 +542,22 @@ private void setDist(final @NotNull SentryBaseEvent event, final boolean canUseC } } + private @Nullable String getOption( + final @NotNull String fileName, + final @Nullable String currentValue, + final boolean canUseCurrentOptions, + final boolean optionsCacheForCurrentApp) { + if (canUseCurrentOptions && !optionsCacheForCurrentApp) { + return currentValue; + } + if (!canUseCurrentOptions && optionsCacheForCurrentApp) { + return null; + } + + final String persistedValue = PersistingOptionsObserver.read(options, fileName, String.class); + return persistedValue != null ? persistedValue : canUseCurrentOptions ? currentValue : null; + } + private boolean isAppNotUpdated(final @NotNull Backfillable hint) { final @Nullable Long timestamp; if (hint instanceof AbnormalExit) { @@ -559,6 +577,15 @@ private boolean isAppNotUpdated(final @NotNull Backfillable hint) { && packageInfo.lastUpdateTime <= timestamp; } + private boolean isOptionsCacheForCurrentApp() { + final Long cachedLastUpdateTime = PersistingOptionsCacheGenerationObserver.read(options); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); + return cachedLastUpdateTime != null + && packageInfo != null + && packageInfo.lastUpdateTime > 0 + && cachedLastUpdateTime == packageInfo.lastUpdateTime; + } + private void setSdk(final @NotNull SentryBaseEvent event) { if (event.getSdk() == null) { final SdkVersion sdkVersion = diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java new file mode 100644 index 00000000000..9f993bef902 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -0,0 +1,81 @@ +package io.sentry.android.core; + +import static io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE; + +import io.sentry.IOptionsObserver; +import io.sentry.SentryOptions; +import io.sentry.protocol.SdkVersion; +import io.sentry.util.FileUtils; +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver { + static final String APP_LAST_UPDATE_TIME_FILENAME = "app-last-update-time.json"; + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + private final @NotNull SentryOptions options; + private final long lastUpdateTime; + + PersistingOptionsCacheGenerationObserver( + final @NotNull SentryOptions options, final long lastUpdateTime) { + this.options = options; + this.lastUpdateTime = lastUpdateTime; + } + + @Override + public void setRelease(final @Nullable String release) { + final File cacheDir = new File(options.getCacheDirPath(), OPTIONS_CACHE); + cacheDir.mkdirs(); + try (final OutputStream stream = + new FileOutputStream(new File(cacheDir, APP_LAST_UPDATE_TIME_FILENAME))) { + stream.write(Long.toString(lastUpdateTime).getBytes(UTF_8)); + } catch (Throwable e) { + options + .getLogger() + .log(io.sentry.SentryLevel.ERROR, e, "Failed to persist options cache generation."); + } + } + + static @Nullable Long read(final @NotNull SentryOptions options) { + if (options.getCacheDirPath() == null) { + return null; + } + try { + final String value = + FileUtils.readText( + new File( + new File(options.getCacheDirPath(), OPTIONS_CACHE), + APP_LAST_UPDATE_TIME_FILENAME)); + return value == null ? null : Long.valueOf(value); + } catch (Throwable e) { + options + .getLogger() + .log(io.sentry.SentryLevel.ERROR, e, "Failed to read options cache generation."); + return null; + } + } + + @Override + public void setProguardUuid(final @Nullable String proguardUuid) {} + + @Override + public void setSdkVersion(final @Nullable SdkVersion sdkVersion) {} + + @Override + public void setEnvironment(final @Nullable String environment) {} + + @Override + public void setDist(final @Nullable String dist) {} + + @Override + public void setTags(final @NotNull Map tags) {} + + @Override + public void setReplayErrorSampleRate(final @Nullable Double replayErrorSampleRate) {} +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index f8724d286f8..cbe42faa103 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -843,6 +843,21 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.optionsObservers.any { it is PersistingOptionsObserver } } } + @Test + fun `options cache generation observer is set when app update time is valid`() { + val buildInfo = mock() + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.LOLLIPOP) + ContextUtils.getPackageInfo(fixture.context, buildInfo)!!.lastUpdateTime = 1_000L + + fixture.initSut(useRealContext = true) + + assertTrue { + fixture.sentryOptions.optionsObservers.any { + it is PersistingOptionsCacheGenerationObserver + } + } + } + @Test fun `when cacheDir is not set, persisting observers are not set to options`() { fixture.initSut(configureOptions = { cacheDirPath = null }) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index 13b899eea48..a05b98312c3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -495,6 +495,46 @@ class ApplicationExitInfoEventProcessorTest { assertNull(app.appIdentifier) } + @Test + fun `if options cache is from an older app update, uses current options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 3_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@2.0.0+300" + fixture.options.environment = "current-user" + fixture.options.dist = "current-dist" + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") + fixture.persistOptions(ENVIRONMENT_FILENAME, "previous-user") + fixture.persistOptions(DIST_FILENAME, "previous-dist") + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@2.0.0+300", processed.release) + assertEquals("current-user", processed.environment) + assertEquals("current-dist", processed.dist) + } + + @Test + fun `if options cache is from current app update, uses persisted options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.0.0+100" + fixture.options.environment = "current-user" + fixture.options.dist = "current-dist" + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") + fixture.persistOptions(ENVIRONMENT_FILENAME, "crashed-user") + fixture.persistOptions(DIST_FILENAME, "crashed-dist") + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.0.0+100", processed.release) + assertEquals("crashed-user", processed.environment) + assertEquals("crashed-dist", processed.dist) + } + @Test fun `historical event leaves release empty when app was updated`() { val hint = From 83014c99841beb1d5a640183abdd6ac0883b2ba0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 11:54:34 +0200 Subject: [PATCH 07/15] fix(android): Validate launch-specific cached options Apply the app-generation marker when selecting option tags and the replay-on-error sample rate. Preserve values from the crashed launch within one app version while rejecting stale values after an update. Co-Authored-By: Codex --- .../ApplicationExitInfoEventProcessor.java | 61 ++++++++++++++----- .../ApplicationExitInfoEventProcessorTest.kt | 42 ++++++++++++- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index d0a97a516f5..6874a7b2aa3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -178,7 +178,7 @@ public ApplicationExitInfoEventProcessor( return event; } - backfillScope(event); + backfillScope(event, canUseCurrentOptions, optionsCacheForCurrentApp); backfillOptions(event, canUseCurrentOptions, optionsCacheForCurrentApp); @@ -192,7 +192,10 @@ public ApplicationExitInfoEventProcessor( } // region scope persisted values - private void backfillScope(final @NotNull SentryEvent event) { + private void backfillScope( + final @NotNull SentryEvent event, + final boolean canUseCurrentOptions, + final boolean optionsCacheForCurrentApp) { setRequest(event); setUser(event); setScopeTags(event); @@ -203,19 +206,28 @@ private void backfillScope(final @NotNull SentryEvent event) { setFingerprints(event); setLevel(event); setTrace(event); - setReplayId(event); + setReplayId(event, canUseCurrentOptions, optionsCacheForCurrentApp); } - private boolean sampleReplay(final @NotNull SentryEvent event) { + private boolean sampleReplay( + final @NotNull SentryEvent event, + final boolean canUseCurrentOptions, + final boolean optionsCacheForCurrentApp) { + final @Nullable Double currentSampleRate = options.getSessionReplay().getOnErrorSampleRate(); final @Nullable String replayErrorSampleRate = - PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class); + getOption( + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + String.class, + currentSampleRate == null ? null : currentSampleRate.toString(), + canUseCurrentOptions, + optionsCacheForCurrentApp); if (replayErrorSampleRate == null) { return false; } try { - // we have to sample here with the old sample rate, because it may change between app launches + // Sample with the rate from the relevant launch because it may change between launches. final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); if (replayErrorSampleRateDouble < SentryRandom.current().nextDouble()) { options @@ -234,7 +246,10 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { return true; } - private void setReplayId(final @NotNull SentryEvent event) { + private void setReplayId( + final @NotNull SentryEvent event, + final boolean canUseCurrentOptions, + final boolean optionsCacheForCurrentApp) { @Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class); @Nullable String cacheDirPath = options.getCacheDirPath(); if (cacheDirPath == null) { @@ -242,7 +257,7 @@ private void setReplayId(final @NotNull SentryEvent event) { } final @NotNull File replayFolder = new File(cacheDirPath, "replay_" + persistedReplayId); if (!replayFolder.exists()) { - if (!sampleReplay(event)) { + if (!sampleReplay(event, canUseCurrentOptions, optionsCacheForCurrentApp)) { return; } // if the replay folder does not exist (e.g. running in buffer mode), we need to find the @@ -411,7 +426,7 @@ private void backfillOptions( setDebugMeta(event); setSdk(event); setApp(event); - setOptionsTags(event); + setOptionsTags(event, canUseCurrentOptions, optionsCacheForCurrentApp); } private void setApp(final @NotNull SentryBaseEvent event) { @@ -473,6 +488,7 @@ private void setRelease( event.setRelease( getOption( RELEASE_FILENAME, + String.class, options.getRelease(), canUseCurrentOptions, optionsCacheForCurrentApp)); @@ -487,6 +503,7 @@ private void setEnvironment( event.setEnvironment( getOption( ENVIRONMENT_FILENAME, + String.class, options.getEnvironment(), canUseCurrentOptions, optionsCacheForCurrentApp)); @@ -524,7 +541,11 @@ private void setDist( if (event.getDist() == null) { event.setDist( getOption( - DIST_FILENAME, options.getDist(), canUseCurrentOptions, optionsCacheForCurrentApp)); + DIST_FILENAME, + String.class, + options.getDist(), + canUseCurrentOptions, + optionsCacheForCurrentApp)); } // if there's no user-set dist, fall back to versionCode from the release string if (event.getDist() == null) { @@ -542,9 +563,10 @@ private void setDist( } } - private @Nullable String getOption( + private @Nullable T getOption( final @NotNull String fileName, - final @Nullable String currentValue, + final @NotNull Class clazz, + final @Nullable T currentValue, final boolean canUseCurrentOptions, final boolean optionsCacheForCurrentApp) { if (canUseCurrentOptions && !optionsCacheForCurrentApp) { @@ -554,7 +576,7 @@ private void setDist( return null; } - final String persistedValue = PersistingOptionsObserver.read(options, fileName, String.class); + final T persistedValue = PersistingOptionsObserver.read(options, fileName, clazz); return persistedValue != null ? persistedValue : canUseCurrentOptions ? currentValue : null; } @@ -595,11 +617,18 @@ private void setSdk(final @NotNull SentryBaseEvent event) { } @SuppressWarnings("unchecked") - private void setOptionsTags(final @NotNull SentryBaseEvent event) { + private void setOptionsTags( + final @NotNull SentryBaseEvent event, + final boolean canUseCurrentOptions, + final boolean optionsCacheForCurrentApp) { final Map tags = (Map) - PersistingOptionsObserver.read( - options, PersistingOptionsObserver.TAGS_FILENAME, Map.class); + getOption( + PersistingOptionsObserver.TAGS_FILENAME, + Map.class, + options.getTags(), + canUseCurrentOptions, + optionsCacheForCurrentApp); if (tags == null) { return; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index a05b98312c3..ac1a6cffc92 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -27,6 +27,7 @@ import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME +import io.sentry.cache.PersistingOptionsObserver.TAGS_FILENAME as OPTIONS_TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME @@ -152,7 +153,7 @@ class ApplicationExitInfoEventProcessorTest { persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0")) persistOptions(DIST_FILENAME, "232") persistOptions(ENVIRONMENT_FILENAME, "debug") - persistOptions(TAGS_FILENAME, mapOf("option" to "tag")) + persistOptions(OPTIONS_TAGS_FILENAME, mapOf("option" to "tag")) replayErrorSampleRate?.let { persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, it.toString()) } @@ -502,9 +503,11 @@ class ApplicationExitInfoEventProcessorTest { fixture.options.release = "io.sentry.samples@2.0.0+300" fixture.options.environment = "current-user" fixture.options.dist = "current-dist" + fixture.options.setTag("account", "current-tag") fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") fixture.persistOptions(ENVIRONMENT_FILENAME, "previous-user") fixture.persistOptions(DIST_FILENAME, "previous-dist") + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "previous-tag")) PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) setLastUpdateTime(2_000) @@ -513,6 +516,7 @@ class ApplicationExitInfoEventProcessorTest { assertEquals("io.sentry.samples@2.0.0+300", processed.release) assertEquals("current-user", processed.environment) assertEquals("current-dist", processed.dist) + assertEquals("current-tag", processed.tags!!["account"]) } @Test @@ -522,9 +526,11 @@ class ApplicationExitInfoEventProcessorTest { fixture.options.release = "io.sentry.samples@1.0.0+100" fixture.options.environment = "current-user" fixture.options.dist = "current-dist" + fixture.options.setTag("account", "current-tag") fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") fixture.persistOptions(ENVIRONMENT_FILENAME, "crashed-user") fixture.persistOptions(DIST_FILENAME, "crashed-dist") + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "crashed-tag")) PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) setLastUpdateTime(1_000) @@ -533,6 +539,7 @@ class ApplicationExitInfoEventProcessorTest { assertEquals("io.sentry.samples@1.0.0+100", processed.release) assertEquals("crashed-user", processed.environment) assertEquals("crashed-dist", processed.dist) + assertEquals("crashed-tag", processed.tags!!["account"]) } @Test @@ -1041,6 +1048,39 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.contexts[Contexts.REPLAY_ID]) } + @Test + fun `if options cache is current, uses persisted replay error sample rate`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + fixture.options.sessionReplay.onErrorSampleRate = 1.0 + fixture.persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, "0.0") + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `if options cache is stale, uses current replay error sample rate`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 3_000)) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + fixture.options.sessionReplay.onErrorSampleRate = 1.0 + fixture.persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, "0.0") + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(2_000) + val replayId = SentryId() + File(fixture.options.cacheDirPath, "replay_$replayId").also { + it.mkdirs() + it.setLastModified(1_000) + } + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(replayId.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) + } + @Test fun `set replayId of the last modified folder`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) From df59d98089c74da25a3984505733ad4ba4a46d3d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 12:14:11 +0200 Subject: [PATCH 08/15] docs(android): Explain options cache marker ordering Document why the generation observer uses its release callback only after the options cache has been fully persisted. Co-Authored-By: Codex --- .../android/core/PersistingOptionsCacheGenerationObserver.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java index 9f993bef902..4193b2f8539 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -30,6 +30,8 @@ final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver @Override public void setRelease(final @Nullable String release) { + // This observer is registered after PersistingOptionsObserver, so its first callback runs only + // after all option cache files have been persisted. final File cacheDir = new File(options.getCacheDirPath(), OPTIONS_CACHE); cacheDir.mkdirs(); try (final OutputStream stream = From 50dd281385efa8776cebc1b9713647361db24c3f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 13:53:46 +0200 Subject: [PATCH 09/15] fix(android): Validate exit option cache sources Reject option caches created after an exit and keep immutable build metadata aligned with the event's app generation. This prevents intermediate releases and stale ProGuard or SDK metadata from being attached to historical exits. Co-Authored-By: Codex --- .../ApplicationExitInfoEventProcessor.java | 173 +++++++++--------- .../ApplicationExitInfoEventProcessorTest.kt | 29 +++ 2 files changed, 111 insertions(+), 91 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 6874a7b2aa3..fe69d52ecf0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -162,13 +162,12 @@ public ApplicationExitInfoEventProcessor( mergeOS(event); setDevice(event); - final boolean canUseCurrentOptions = isAppNotUpdated(backfillable); - final boolean optionsCacheForCurrentApp = isOptionsCacheForCurrentApp(); + final OptionsSource optionsSource = getOptionsSource(backfillable); if (!backfillable.shouldEnrich()) { - setRelease(event, canUseCurrentOptions, optionsCacheForCurrentApp); - setEnvironment(event, canUseCurrentOptions, optionsCacheForCurrentApp); - setDist(event, canUseCurrentOptions, optionsCacheForCurrentApp); + setRelease(event, optionsSource); + setEnvironment(event, optionsSource); + setDist(event, optionsSource); setAppVersionAndBuild(event); options .getLogger() @@ -178,9 +177,9 @@ public ApplicationExitInfoEventProcessor( return event; } - backfillScope(event, canUseCurrentOptions, optionsCacheForCurrentApp); + backfillScope(event, optionsSource); - backfillOptions(event, canUseCurrentOptions, optionsCacheForCurrentApp); + backfillOptions(event, optionsSource); setStaticValues(event); @@ -193,9 +192,7 @@ public ApplicationExitInfoEventProcessor( // region scope persisted values private void backfillScope( - final @NotNull SentryEvent event, - final boolean canUseCurrentOptions, - final boolean optionsCacheForCurrentApp) { + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { setRequest(event); setUser(event); setScopeTags(event); @@ -206,21 +203,18 @@ private void backfillScope( setFingerprints(event); setLevel(event); setTrace(event); - setReplayId(event, canUseCurrentOptions, optionsCacheForCurrentApp); + setReplayId(event, optionsSource); } private boolean sampleReplay( - final @NotNull SentryEvent event, - final boolean canUseCurrentOptions, - final boolean optionsCacheForCurrentApp) { + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { final @Nullable Double currentSampleRate = options.getSessionReplay().getOnErrorSampleRate(); final @Nullable String replayErrorSampleRate = - getOption( + getLaunchOption( REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class, currentSampleRate == null ? null : currentSampleRate.toString(), - canUseCurrentOptions, - optionsCacheForCurrentApp); + optionsSource); if (replayErrorSampleRate == null) { return false; @@ -247,9 +241,7 @@ private boolean sampleReplay( } private void setReplayId( - final @NotNull SentryEvent event, - final boolean canUseCurrentOptions, - final boolean optionsCacheForCurrentApp) { + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { @Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class); @Nullable String cacheDirPath = options.getCacheDirPath(); if (cacheDirPath == null) { @@ -257,7 +249,7 @@ private void setReplayId( } final @NotNull File replayFolder = new File(cacheDirPath, "replay_" + persistedReplayId); if (!replayFolder.exists()) { - if (!sampleReplay(event, canUseCurrentOptions, optionsCacheForCurrentApp)) { + if (!sampleReplay(event, optionsSource)) { return; } // if the replay folder does not exist (e.g. running in buffer mode), we need to find the @@ -417,16 +409,14 @@ private void setRequest(final @NotNull SentryBaseEvent event) { // region options persisted values private void backfillOptions( - final @NotNull SentryEvent event, - final boolean canUseCurrentOptions, - final boolean optionsCacheForCurrentApp) { - setRelease(event, canUseCurrentOptions, optionsCacheForCurrentApp); - setEnvironment(event, canUseCurrentOptions, optionsCacheForCurrentApp); - setDist(event, canUseCurrentOptions, optionsCacheForCurrentApp); - setDebugMeta(event); - setSdk(event); + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { + setRelease(event, optionsSource); + setEnvironment(event, optionsSource); + setDist(event, optionsSource); + setDebugMeta(event, optionsSource); + setSdk(event, optionsSource); setApp(event); - setOptionsTags(event, canUseCurrentOptions, optionsCacheForCurrentApp); + setOptionsTags(event, optionsSource); } private void setApp(final @NotNull SentryBaseEvent event) { @@ -481,36 +471,24 @@ private void setAppVersionAndBuild(final @NotNull SentryBaseEvent event) { } private void setRelease( - final @NotNull SentryBaseEvent event, - final boolean canUseCurrentOptions, - final boolean optionsCacheForCurrentApp) { + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getRelease() == null) { event.setRelease( - getOption( - RELEASE_FILENAME, - String.class, - options.getRelease(), - canUseCurrentOptions, - optionsCacheForCurrentApp)); + getLaunchOption(RELEASE_FILENAME, String.class, options.getRelease(), optionsSource)); } } private void setEnvironment( - final @NotNull SentryBaseEvent event, - final boolean canUseCurrentOptions, - final boolean optionsCacheForCurrentApp) { + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getEnvironment() == null) { event.setEnvironment( - getOption( - ENVIRONMENT_FILENAME, - String.class, - options.getEnvironment(), - canUseCurrentOptions, - optionsCacheForCurrentApp)); + getLaunchOption( + ENVIRONMENT_FILENAME, String.class, options.getEnvironment(), optionsSource)); } } - private void setDebugMeta(final @NotNull SentryBaseEvent event) { + private void setDebugMeta( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { DebugMeta debugMeta = event.getDebugMeta(); if (debugMeta == null) { @@ -522,7 +500,8 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) { List images = debugMeta.getImages(); if (images != null) { final String proguardUuid = - PersistingOptionsObserver.read(options, PROGUARD_UUID_FILENAME, String.class); + getBuildOption( + PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); if (proguardUuid != null) { final DebugImage debugImage = new DebugImage(); @@ -535,17 +514,9 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) { } private void setDist( - final @NotNull SentryBaseEvent event, - final boolean canUseCurrentOptions, - final boolean optionsCacheForCurrentApp) { + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getDist() == null) { - event.setDist( - getOption( - DIST_FILENAME, - String.class, - options.getDist(), - canUseCurrentOptions, - optionsCacheForCurrentApp)); + event.setDist(getLaunchOption(DIST_FILENAME, String.class, options.getDist(), optionsSource)); } // if there's no user-set dist, fall back to versionCode from the release string if (event.getDist() == null) { @@ -563,24 +534,38 @@ private void setDist( } } - private @Nullable T getOption( + private @Nullable T getLaunchOption( final @NotNull String fileName, final @NotNull Class clazz, final @Nullable T currentValue, - final boolean canUseCurrentOptions, - final boolean optionsCacheForCurrentApp) { - if (canUseCurrentOptions && !optionsCacheForCurrentApp) { + final @NotNull OptionsSource optionsSource) { + if (optionsSource == OptionsSource.CURRENT) { return currentValue; - } - if (!canUseCurrentOptions && optionsCacheForCurrentApp) { + } else if (optionsSource == OptionsSource.NONE) { return null; } final T persistedValue = PersistingOptionsObserver.read(options, fileName, clazz); - return persistedValue != null ? persistedValue : canUseCurrentOptions ? currentValue : null; + return persistedValue != null || optionsSource == OptionsSource.PERSISTED + ? persistedValue + : currentValue; } - private boolean isAppNotUpdated(final @NotNull Backfillable hint) { + private @Nullable T getBuildOption( + final @NotNull String fileName, + final @NotNull Class clazz, + final @Nullable T currentValue, + final @NotNull OptionsSource optionsSource) { + if (optionsSource == OptionsSource.CURRENT + || optionsSource == OptionsSource.PERSISTED_WITH_CURRENT_FALLBACK) { + return currentValue; + } else if (optionsSource == OptionsSource.NONE) { + return null; + } + return PersistingOptionsObserver.read(options, fileName, clazz); + } + + private @NotNull OptionsSource getOptionsSource(final @NotNull Backfillable hint) { final @Nullable Long timestamp; if (hint instanceof AbnormalExit) { timestamp = ((AbnormalExit) hint).timestamp(); @@ -589,46 +574,45 @@ private boolean isAppNotUpdated(final @NotNull Backfillable hint) { } else { timestamp = null; } - if (timestamp == null) { - return false; - } - - final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); - return packageInfo != null - && packageInfo.lastUpdateTime > 0 - && packageInfo.lastUpdateTime <= timestamp; - } - - private boolean isOptionsCacheForCurrentApp() { final Long cachedLastUpdateTime = PersistingOptionsCacheGenerationObserver.read(options); final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); - return cachedLastUpdateTime != null - && packageInfo != null - && packageInfo.lastUpdateTime > 0 - && cachedLastUpdateTime == packageInfo.lastUpdateTime; + final long currentLastUpdateTime = packageInfo == null ? 0 : packageInfo.lastUpdateTime; + + if (timestamp != null && currentLastUpdateTime > 0 && currentLastUpdateTime <= timestamp) { + return cachedLastUpdateTime != null && cachedLastUpdateTime == currentLastUpdateTime + ? OptionsSource.PERSISTED_WITH_CURRENT_FALLBACK + : OptionsSource.CURRENT; + } + if (cachedLastUpdateTime == null) { + return OptionsSource.PERSISTED; + } + // A cache generation created after the exit cannot describe that exit. + if (timestamp != null && cachedLastUpdateTime > 0 && cachedLastUpdateTime <= timestamp) { + return OptionsSource.PERSISTED; + } + return OptionsSource.NONE; } - private void setSdk(final @NotNull SentryBaseEvent event) { + private void setSdk( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getSdk() == null) { final SdkVersion sdkVersion = - PersistingOptionsObserver.read(options, SDK_VERSION_FILENAME, SdkVersion.class); + getBuildOption( + SDK_VERSION_FILENAME, SdkVersion.class, options.getSdkVersion(), optionsSource); event.setSdk(sdkVersion); } } @SuppressWarnings("unchecked") private void setOptionsTags( - final @NotNull SentryBaseEvent event, - final boolean canUseCurrentOptions, - final boolean optionsCacheForCurrentApp) { + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { final Map tags = (Map) - getOption( + getLaunchOption( PersistingOptionsObserver.TAGS_FILENAME, Map.class, options.getTags(), - canUseCurrentOptions, - optionsCacheForCurrentApp); + optionsSource); if (tags == null) { return; } @@ -645,6 +629,13 @@ private void setOptionsTags( // endregion + private enum OptionsSource { + CURRENT, + PERSISTED, + PERSISTED_WITH_CURRENT_FALLBACK, + NONE + } + @Override public @Nullable Long getOrder() { return 12000L; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index ac1a6cffc92..d5b916d3b44 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -503,10 +503,14 @@ class ApplicationExitInfoEventProcessorTest { fixture.options.release = "io.sentry.samples@2.0.0+300" fixture.options.environment = "current-user" fixture.options.dist = "current-dist" + fixture.options.proguardUuid = "current-uuid" + fixture.options.sdkVersion = SdkVersion("current-sdk", "2.0.0") fixture.options.setTag("account", "current-tag") fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") fixture.persistOptions(ENVIRONMENT_FILENAME, "previous-user") fixture.persistOptions(DIST_FILENAME, "previous-dist") + fixture.persistOptions(PROGUARD_UUID_FILENAME, "previous-uuid") + fixture.persistOptions(SDK_VERSION_FILENAME, SdkVersion("previous-sdk", "1.0.0")) fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "previous-tag")) PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) setLastUpdateTime(2_000) @@ -516,6 +520,8 @@ class ApplicationExitInfoEventProcessorTest { assertEquals("io.sentry.samples@2.0.0+300", processed.release) assertEquals("current-user", processed.environment) assertEquals("current-dist", processed.dist) + assertEquals("current-uuid", processed.debugMeta!!.images!![0].uuid) + assertEquals("current-sdk", processed.sdk!!.name) assertEquals("current-tag", processed.tags!!["account"]) } @@ -542,6 +548,29 @@ class ApplicationExitInfoEventProcessorTest { assertEquals("crashed-tag", processed.tags!!["account"]) } + @Test + fun `if options cache was written after the exit, ignores persisted options`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@2.0.0+200") + fixture.persistOptions(ENVIRONMENT_FILENAME, "newer-user") + fixture.persistOptions(DIST_FILENAME, "newer-dist") + fixture.persistOptions(PROGUARD_UUID_FILENAME, "newer-uuid") + fixture.persistOptions(SDK_VERSION_FILENAME, SdkVersion("newer-sdk", "2.0.0")) + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "newer-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 2_500L).setRelease(null) + setLastUpdateTime(3_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + assertNull(processed.environment) + assertNull(processed.dist) + assertTrue(processed.debugMeta!!.images!!.isEmpty()) + assertNull(processed.sdk) + assertNull(processed.tags?.get("account")) + } + @Test fun `historical event leaves release empty when app was updated`() { val hint = From a0a9ed0d1acb476315c4d6e225678a7371e794c7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 16:47:31 +0200 Subject: [PATCH 10/15] style(android): Annotate nullable app context Co-Authored-By: OpenAI Codex --- .../sentry/android/core/ApplicationExitInfoEventProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index fe69d52ecf0..d4c59c812a1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -452,7 +452,7 @@ private void setAppVersionAndBuild(final @NotNull SentryBaseEvent event) { final String release = event.getRelease(); if (release != null) { try { - App app = event.getContexts().getApp(); + @Nullable App app = event.getContexts().getApp(); if (app == null) { app = new App(); } From 07c919f2f5a946721e68b8dd331c51e646fa4f73 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 19:07:06 +0200 Subject: [PATCH 11/15] docs(android): Explain options cache generation observer Clarify why the observer is ordered after option persistence and update the changelog to describe the generation-aware behavior. Co-Authored-By: OpenAI Codex --- CHANGELOG.md | 2 +- .../PersistingOptionsCacheGenerationObserver.java | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d87607adc..ae98cfeedd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ ### Fixes -- Backfill release, environment, distribution, and app version/build for ANR and native crash events that occurred before SDK initialization, provided the app has not since been updated ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) +- Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - Session Replay: Fix first recording segment missing for replays in `buffer` mode ([#5753](https://github.com/getsentry/sentry-java/pull/5753)) - Session Replay: Fix error-to-replay linkage in `buffer` mode ([#5754](https://github.com/getsentry/sentry-java/pull/5754)) - Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java index 4193b2f8539..1b2eaa3651e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -4,6 +4,7 @@ import io.sentry.IOptionsObserver; import io.sentry.SentryOptions; +import io.sentry.cache.PersistingOptionsObserver; import io.sentry.protocol.SdkVersion; import io.sentry.util.FileUtils; import java.io.File; @@ -14,6 +15,18 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +/** + * Persists the app generation that produced the options cache. + * + *

{@link ApplicationExitInfoEventProcessor} compares the cached {@link + * android.content.pm.PackageInfo#lastUpdateTime} with an exit timestamp before reusing + * launch-specific options. This prevents options written by a later app update from being attached + * to an older ANR or native crash. + * + *

This observer must be registered after {@link PersistingOptionsObserver}. Options observers + * are notified one at a time, so the first callback to this observer writes the generation marker + * only after the preceding observer has persisted the complete options snapshot. + */ final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver { static final String APP_LAST_UPDATE_TIME_FILENAME = "app-last-update-time.json"; From 35d7272f2f80be87be76e5fb3ccf7dfa3496af3c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 19:08:42 +0200 Subject: [PATCH 12/15] docs(android): Add cache generation example Document the same-build, account-specific options scenario that requires preferring a matching persisted snapshot. Co-Authored-By: OpenAI Codex --- .../PersistingOptionsCacheGenerationObserver.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java index 1b2eaa3651e..dd8ca0fb09b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -23,6 +23,18 @@ * launch-specific options. This prevents options written by a later app update from being attached * to an older ANR or native crash. * + *

For example: + * + *

    + *
  1. The installed build launches for account A and persists account A's tags and replay + * sampling options. + *
  2. A later launch of the same build exits before SDK initialization, so it cannot persist a + * new options snapshot. + *
  3. The next launch initializes the SDK for account B and reports the previous exit. + *
  4. The matching generation marker lets the processor use account A's persisted options instead + * of account B's current options. + *
+ * *

This observer must be registered after {@link PersistingOptionsObserver}. Options observers * are notified one at a time, so the first callback to this observer writes the generation marker * only after the preceding observer has persisted the complete options snapshot. From 0030e2f0cbbc5c6edb646ba20965e0e22da1e797 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 19:18:09 +0200 Subject: [PATCH 13/15] ref(android): Reuse cache utilities for generation marker Expose cache serialization helpers as internal API and use them for the Android options cache generation marker. Co-Authored-By: OpenAI Codex --- ...sistingOptionsCacheGenerationObserver.java | 41 +++++-------------- sentry/api/sentry.api | 5 +++ .../main/java/io/sentry/cache/CacheUtils.java | 10 +++-- 3 files changed, 23 insertions(+), 33 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java index dd8ca0fb09b..4d439f4be19 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -3,14 +3,11 @@ import static io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE; import io.sentry.IOptionsObserver; +import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.cache.CacheUtils; import io.sentry.cache.PersistingOptionsObserver; import io.sentry.protocol.SdkVersion; -import io.sentry.util.FileUtils; -import java.io.File; -import java.io.FileOutputStream; -import java.io.OutputStream; -import java.nio.charset.Charset; import java.util.Map; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -42,8 +39,6 @@ final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver { static final String APP_LAST_UPDATE_TIME_FILENAME = "app-last-update-time.json"; - private static final Charset UTF_8 = Charset.forName("UTF-8"); - private final @NotNull SentryOptions options; private final long lastUpdateTime; @@ -55,35 +50,21 @@ final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver @Override public void setRelease(final @Nullable String release) { - // This observer is registered after PersistingOptionsObserver, so its first callback runs only - // after all option cache files have been persisted. - final File cacheDir = new File(options.getCacheDirPath(), OPTIONS_CACHE); - cacheDir.mkdirs(); - try (final OutputStream stream = - new FileOutputStream(new File(cacheDir, APP_LAST_UPDATE_TIME_FILENAME))) { - stream.write(Long.toString(lastUpdateTime).getBytes(UTF_8)); - } catch (Throwable e) { - options - .getLogger() - .log(io.sentry.SentryLevel.ERROR, e, "Failed to persist options cache generation."); - } + CacheUtils.store( + options, Long.toString(lastUpdateTime), OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME); } static @Nullable Long read(final @NotNull SentryOptions options) { - if (options.getCacheDirPath() == null) { + final String value = + CacheUtils.read( + options, OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME, String.class, null); + if (value == null) { return null; } try { - final String value = - FileUtils.readText( - new File( - new File(options.getCacheDirPath(), OPTIONS_CACHE), - APP_LAST_UPDATE_TIME_FILENAME)); - return value == null ? null : Long.valueOf(value); - } catch (Throwable e) { - options - .getLogger() - .log(io.sentry.SentryLevel.ERROR, e, "Failed to read options cache generation."); + return Long.valueOf(value); + } catch (NumberFormatException e) { + options.getLogger().log(SentryLevel.ERROR, e, "Failed to read options cache generation."); return null; } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0e5aad4826b..d1aecf5ccfd 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4821,6 +4821,11 @@ public final class io/sentry/backpressure/NoOpBackpressureMonitor : io/sentry/ba public fun start ()V } +public final class io/sentry/cache/CacheUtils { + public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Class;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public static fun store (Lio/sentry/SentryOptions;Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;)V +} + public class io/sentry/cache/EnvelopeCache : io/sentry/cache/IEnvelopeCache { public static final field CRASH_MARKER_FILE Ljava/lang/String; public static final field NATIVE_CRASH_MARKER_FILE Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/cache/CacheUtils.java b/sentry/src/main/java/io/sentry/cache/CacheUtils.java index 5f578191bd5..142b7c3fdab 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheUtils.java +++ b/sentry/src/main/java/io/sentry/cache/CacheUtils.java @@ -18,15 +18,19 @@ import java.io.Reader; import java.io.Writer; import java.nio.charset.Charset; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -final class CacheUtils { +@ApiStatus.Internal +public final class CacheUtils { @SuppressWarnings("CharsetObjectCanBeUsed") private static final Charset UTF_8 = Charset.forName("UTF-8"); - static void store( + private CacheUtils() {} + + public static void store( final @NotNull SentryOptions options, final @NotNull T entity, final @NotNull String dirName, @@ -63,7 +67,7 @@ static void delete( } } - static @Nullable T read( + public static @Nullable T read( final @NotNull SentryOptions options, final @NotNull String dirName, final @NotNull String fileName, From cb2d0807c65045637ca24011481a36a4d9233c55 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 17 Jul 2026 10:18:11 +0200 Subject: [PATCH 14/15] docs(android): Explain exit option selection Document how launch options, build metadata, and cache generations are selected for application exit events. Co-Authored-By: OpenAI Codex --- .../core/ApplicationExitInfoEventProcessor.java | 16 ++++++++++++++++ ...PersistingOptionsCacheGenerationObserver.java | 3 +-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index d4c59c812a1..f175db90488 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -534,6 +534,11 @@ private void setDist( } } + /** + * Resolves an option that may change between launches of the same build, such as environment or + * tags. A matching persisted value is preferred; the current value is used only when the source + * identifies the current app generation or permits a fallback for a missing persisted value. + */ private @Nullable T getLaunchOption( final @NotNull String fileName, final @NotNull Class clazz, @@ -551,6 +556,11 @@ private void setDist( : currentValue; } + /** + * Resolves metadata that cannot change between launches of the same build, such as the ProGuard + * UUID or SDK version. Current metadata is used for exits from the current app generation, while + * persisted metadata is reserved for historical exits. + */ private @Nullable T getBuildOption( final @NotNull String fileName, final @NotNull Class clazz, @@ -565,6 +575,12 @@ private void setDist( return PersistingOptionsObserver.read(options, fileName, clazz); } + /** + * Chooses the options snapshot that can safely describe an exit by comparing its timestamp with + * the current app update time and the persisted cache generation. A markerless legacy cache is + * accepted for compatibility; {@link OptionsSource#NONE} is returned when neither current nor + * persisted options can be matched to the exit. + */ private @NotNull OptionsSource getOptionsSource(final @NotNull Backfillable hint) { final @Nullable Long timestamp; if (hint instanceof AbnormalExit) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java index 4d439f4be19..9b4433e255e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -56,8 +56,7 @@ public void setRelease(final @Nullable String release) { static @Nullable Long read(final @NotNull SentryOptions options) { final String value = - CacheUtils.read( - options, OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME, String.class, null); + CacheUtils.read(options, OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME, String.class, null); if (value == null) { return null; } From 184111e1d80e9337fc001c9e8e0c42aaeda48fe5 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 17 Jul 2026 12:29:54 +0200 Subject: [PATCH 15/15] docs: Move changelog entry to Unreleased Keep PR #5762 out of the already released 8.49.0 section. Co-Authored-By: Codex --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae98cfeedd3..44231ff4eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) + ## 8.49.0 ### Features @@ -32,7 +38,6 @@ ### Fixes -- Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - Session Replay: Fix first recording segment missing for replays in `buffer` mode ([#5753](https://github.com/getsentry/sentry-java/pull/5753)) - Session Replay: Fix error-to-replay linkage in `buffer` mode ([#5754](https://github.com/getsentry/sentry-java/pull/5754)) - Prevent logs and metrics from remaining queued after a flush scheduling race ([#5756](https://github.com/getsentry/sentry-java/pull/5756))