From 90b0b050690393c9e6fe8d4ad6c6681ad721b579 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 14 Jul 2026 22:10:09 +0200 Subject: [PATCH 01/14] 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 4555c8881890ab7cff340b02df16f6947479a2c1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 14 Jul 2026 22:17:50 +0200 Subject: [PATCH 02/14] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af29b22ccb5..15de1bf4bf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,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 777c833e4d39971c96118f5d4fdc1d74a19c4d09 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 15 Jul 2026 11:22:34 +0200 Subject: [PATCH 03/14] 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 9e84ef4d775a0c07968c9c0a6fe7123c6c901923 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 15 Jul 2026 11:30:47 +0200 Subject: [PATCH 04/14] 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 e017d22dfdadfd4a3f0b8173bdec21d5ac5a9732 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 10:21:03 +0200 Subject: [PATCH 05/14] 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 17c203e16470fedfbd651c97ad43d2d26993e005 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 11:11:11 +0200 Subject: [PATCH 06/14] 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 29f928e247eb3dbf9578c6d74b1b0cda65d7a8e0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 11:54:34 +0200 Subject: [PATCH 07/14] 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 a7436f4a7c505c38ae4f4c6a44b3401e48cdddde Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 12:14:11 +0200 Subject: [PATCH 08/14] 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 2103801451ffda135eba43806d5254e25b92c1e2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 13:53:46 +0200 Subject: [PATCH 09/14] 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 770911c63da95db5eddcb279f50bacd10f53154b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 16:47:31 +0200 Subject: [PATCH 10/14] 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 8c988df6244954ccddf79f11d10a8d6166255324 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 19:07:06 +0200 Subject: [PATCH 11/14] 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 15de1bf4bf6..a5fe195e44e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,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 b3907d6205878998ac2499d3575042c8409f1e09 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 19:08:42 +0200 Subject: [PATCH 12/14] 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 ed1b21451e95341d616890b00020b89b461f3de0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jul 2026 19:18:09 +0200 Subject: [PATCH 13/14] 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 00183bc9b30..9747918727b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4814,6 +4814,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 31d61c79c43c081c7a1a7df6c1ed84212d70a5b6 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Thu, 16 Jul 2026 17:20:14 +0000 Subject: [PATCH 14/14] Format code --- .../android/core/PersistingOptionsCacheGenerationObserver.java | 3 +-- 1 file changed, 1 insertion(+), 2 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 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; }