diff --git a/CHANGELOG.md b/CHANGELOG.md index c57c75fe6a..44231ff4ee 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 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 9cc5cb3df0..434dfc73d3 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 2eca0e68b5..f175db9048 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,13 @@ public ApplicationExitInfoEventProcessor( mergeOS(event); setDevice(event); + final OptionsSource optionsSource = getOptionsSource(backfillable); + if (!backfillable.shouldEnrich()) { + setRelease(event, optionsSource); + setEnvironment(event, optionsSource); + setDist(event, optionsSource); + setAppVersionAndBuild(event); options .getLogger() .log( @@ -170,9 +177,9 @@ public ApplicationExitInfoEventProcessor( return event; } - backfillScope(event); + backfillScope(event, optionsSource); - backfillOptions(event); + backfillOptions(event, optionsSource); setStaticValues(event); @@ -184,7 +191,8 @@ public ApplicationExitInfoEventProcessor( } // region scope persisted values - private void backfillScope(final @NotNull SentryEvent event) { + private void backfillScope( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { setRequest(event); setUser(event); setScopeTags(event); @@ -195,19 +203,25 @@ private void backfillScope(final @NotNull SentryEvent event) { setFingerprints(event); setLevel(event); setTrace(event); - setReplayId(event); + setReplayId(event, optionsSource); } - private boolean sampleReplay(final @NotNull SentryEvent event) { + private boolean sampleReplay( + final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) { + final @Nullable Double currentSampleRate = options.getSessionReplay().getOnErrorSampleRate(); final @Nullable String replayErrorSampleRate = - PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class); + getLaunchOption( + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + String.class, + currentSampleRate == null ? null : currentSampleRate.toString(), + optionsSource); 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 @@ -226,7 +240,8 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { return true; } - private void setReplayId(final @NotNull SentryEvent event) { + private void setReplayId( + 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) { @@ -234,7 +249,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, optionsSource)) { return; } // if the replay folder does not exist (e.g. running in buffer mode), we need to find the @@ -393,14 +408,15 @@ 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); - setDebugMeta(event); - setSdk(event); + private void backfillOptions( + 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); + setOptionsTags(event, optionsSource); } private void setApp(final @NotNull SentryBaseEvent event) { @@ -415,25 +431,6 @@ 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); - if (release != null) { - try { - final String versionName = - release.substring(release.indexOf('@') + 1, release.indexOf('+')); - final String versionCode = release.substring(release.indexOf('+') + 1); - app.setAppVersion(versionName); - app.setAppBuild(versionCode); - } 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(); @@ -448,25 +445,50 @@ private void setApp(final @NotNull SentryBaseEvent event) { } event.getContexts().setApp(app); + setAppVersionAndBuild(event); + } + + private void setAppVersionAndBuild(final @NotNull SentryBaseEvent event) { + final String release = event.getRelease(); + if (release != null) { + try { + @Nullable 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); + } + } } - private void setRelease(final @NotNull SentryBaseEvent event) { + private void setRelease( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getRelease() == null) { - final String release = - PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); - event.setRelease(release); + event.setRelease( + getLaunchOption(RELEASE_FILENAME, String.class, options.getRelease(), optionsSource)); } } - private void setEnvironment(final @NotNull SentryBaseEvent event) { + private void setEnvironment( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getEnvironment() == null) { - final String environment = - PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String.class); - event.setEnvironment(environment != null ? environment : options.getEnvironment()); + event.setEnvironment( + 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) { @@ -478,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(); @@ -490,15 +513,14 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) { } } - private void setDist(final @NotNull SentryBaseEvent event) { + private void setDist( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getDist() == null) { - final String dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String.class); - event.setDist(dist); + event.setDist(getLaunchOption(DIST_FILENAME, String.class, options.getDist(), optionsSource)); } - // 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,20 +534,101 @@ private void setDist(final @NotNull SentryBaseEvent event) { } } - private void setSdk(final @NotNull SentryBaseEvent event) { + /** + * 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, + final @Nullable T currentValue, + final @NotNull OptionsSource optionsSource) { + if (optionsSource == OptionsSource.CURRENT) { + return currentValue; + } else if (optionsSource == OptionsSource.NONE) { + return null; + } + + final T persistedValue = PersistingOptionsObserver.read(options, fileName, clazz); + return persistedValue != null || optionsSource == OptionsSource.PERSISTED + ? persistedValue + : 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, + 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); + } + + /** + * 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) { + timestamp = ((AbnormalExit) hint).timestamp(); + } else if (hint instanceof NativeCrashExit) { + timestamp = ((NativeCrashExit) hint).timestamp(); + } else { + timestamp = null; + } + final Long cachedLastUpdateTime = PersistingOptionsCacheGenerationObserver.read(options); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); + 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, 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) { + private void setOptionsTags( + final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { final Map tags = (Map) - PersistingOptionsObserver.read( - options, PersistingOptionsObserver.TAGS_FILENAME, Map.class); + getLaunchOption( + PersistingOptionsObserver.TAGS_FILENAME, + Map.class, + options.getTags(), + optionsSource); if (tags == null) { return; } @@ -542,6 +645,13 @@ private void setOptionsTags(final @NotNull SentryBaseEvent event) { // 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/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 0000000000..9b4433e255 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PersistingOptionsCacheGenerationObserver.java @@ -0,0 +1,88 @@ +package io.sentry.android.core; + +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 java.util.Map; +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. + * + *

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. + */ +final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver { + static final String APP_LAST_UPDATE_TIME_FILENAME = "app-last-update-time.json"; + + 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) { + CacheUtils.store( + options, Long.toString(lastUpdateTime), OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME); + } + + static @Nullable Long read(final @NotNull SentryOptions options) { + final String value = + CacheUtils.read(options, OPTIONS_CACHE, APP_LAST_UPDATE_TIME_FILENAME, String.class, null); + if (value == null) { + return null; + } + try { + return Long.valueOf(value); + } catch (NumberFormatException e) { + options.getLogger().log(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 f8724d286f..cbe42faa10 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 e758342991..d5b916d3b4 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()) } @@ -198,6 +199,7 @@ class ApplicationExitInfoEventProcessorTest { @BeforeTest fun `set up`() { DeviceInfoUtil.resetInstance() + ContextUtils.resetInstance() fixture.context = ApplicationProvider.getApplicationContext() } @@ -390,14 +392,199 @@ 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) 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 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)) + 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) + val app = processed.contexts.app!! + assertEquals("1.2.0", app.appVersion) + assertEquals("232", app.appBuild) + assertNull(app.appName) + 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.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) + + 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) + assertEquals("current-uuid", processed.debugMeta!!.images!![0].uuid) + assertEquals("current-sdk", processed.sdk!!.name) + assertEquals("current-tag", processed.tags!!["account"]) + } + + @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.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) + + 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) + 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 = + 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) + assertNull(processed.contexts.app) + } + @Test fun `if dist is not persisted, backfills it from release`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -890,6 +1077,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()) @@ -938,15 +1158,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 } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0e5aad4826..d1aecf5ccf 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 5f578191bd..142b7c3fda 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,