Skip to content

Commit 031ec33

Browse files
romtsncodex
andauthored
fix(android): Backfill pre-init ANR and native crash metadata (#5762)
* 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. * changelog * 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 <noreply@openai.com> * 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 <noreply@openai.com> * 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 <noreply@openai.com> * 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 <noreply@openai.com> * 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 <noreply@openai.com> * 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 <noreply@openai.com> * 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 <noreply@openai.com> * style(android): Annotate nullable app context Co-Authored-By: OpenAI Codex <noreply@openai.com> * 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 <noreply@openai.com> * 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 <noreply@openai.com> * 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 <noreply@openai.com> * docs(android): Explain exit option selection Document how launch options, build metadata, and cache generations are selected for application exit events. Co-Authored-By: OpenAI Codex <noreply@openai.com> * docs: Move changelog entry to Unreleased Keep PR #5762 out of the already released 8.49.0 section. Co-Authored-By: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
1 parent 455eb6e commit 031ec33

8 files changed

Lines changed: 522 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Fixes
6+
7+
- 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+
39
## 8.49.0
410

511
### Features

sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ static void initializeIntegrationsAndProcessors(
182182
if (options.getCacheDirPath() != null) {
183183
options.addScopeObserver(new PersistingScopeObserver(options));
184184
options.addOptionsObserver(new PersistingOptionsObserver(options));
185+
final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider);
186+
if (packageInfo != null && packageInfo.lastUpdateTime > 0) {
187+
options.addOptionsObserver(
188+
new PersistingOptionsCacheGenerationObserver(options, packageInfo.lastUpdateTime));
189+
}
185190
}
186191

187192
options.addEventProcessor(new DeduplicateMultithreadedEventProcessor(options));

sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java

Lines changed: 166 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import io.sentry.exception.ExceptionMechanismException;
5252
import io.sentry.hints.AbnormalExit;
5353
import io.sentry.hints.Backfillable;
54+
import io.sentry.hints.NativeCrashExit;
5455
import io.sentry.protocol.App;
5556
import io.sentry.protocol.Contexts;
5657
import io.sentry.protocol.DebugImage;
@@ -161,7 +162,13 @@ public ApplicationExitInfoEventProcessor(
161162
mergeOS(event);
162163
setDevice(event);
163164

165+
final OptionsSource optionsSource = getOptionsSource(backfillable);
166+
164167
if (!backfillable.shouldEnrich()) {
168+
setRelease(event, optionsSource);
169+
setEnvironment(event, optionsSource);
170+
setDist(event, optionsSource);
171+
setAppVersionAndBuild(event);
165172
options
166173
.getLogger()
167174
.log(
@@ -170,9 +177,9 @@ public ApplicationExitInfoEventProcessor(
170177
return event;
171178
}
172179

173-
backfillScope(event);
180+
backfillScope(event, optionsSource);
174181

175-
backfillOptions(event);
182+
backfillOptions(event, optionsSource);
176183

177184
setStaticValues(event);
178185

@@ -184,7 +191,8 @@ public ApplicationExitInfoEventProcessor(
184191
}
185192

186193
// region scope persisted values
187-
private void backfillScope(final @NotNull SentryEvent event) {
194+
private void backfillScope(
195+
final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) {
188196
setRequest(event);
189197
setUser(event);
190198
setScopeTags(event);
@@ -195,19 +203,25 @@ private void backfillScope(final @NotNull SentryEvent event) {
195203
setFingerprints(event);
196204
setLevel(event);
197205
setTrace(event);
198-
setReplayId(event);
206+
setReplayId(event, optionsSource);
199207
}
200208

201-
private boolean sampleReplay(final @NotNull SentryEvent event) {
209+
private boolean sampleReplay(
210+
final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) {
211+
final @Nullable Double currentSampleRate = options.getSessionReplay().getOnErrorSampleRate();
202212
final @Nullable String replayErrorSampleRate =
203-
PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class);
213+
getLaunchOption(
214+
REPLAY_ERROR_SAMPLE_RATE_FILENAME,
215+
String.class,
216+
currentSampleRate == null ? null : currentSampleRate.toString(),
217+
optionsSource);
204218

205219
if (replayErrorSampleRate == null) {
206220
return false;
207221
}
208222

209223
try {
210-
// we have to sample here with the old sample rate, because it may change between app launches
224+
// Sample with the rate from the relevant launch because it may change between launches.
211225
final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate);
212226
if (replayErrorSampleRateDouble < SentryRandom.current().nextDouble()) {
213227
options
@@ -226,15 +240,16 @@ private boolean sampleReplay(final @NotNull SentryEvent event) {
226240
return true;
227241
}
228242

229-
private void setReplayId(final @NotNull SentryEvent event) {
243+
private void setReplayId(
244+
final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) {
230245
@Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class);
231246
@Nullable String cacheDirPath = options.getCacheDirPath();
232247
if (cacheDirPath == null) {
233248
return;
234249
}
235250
final @NotNull File replayFolder = new File(cacheDirPath, "replay_" + persistedReplayId);
236251
if (!replayFolder.exists()) {
237-
if (!sampleReplay(event)) {
252+
if (!sampleReplay(event, optionsSource)) {
238253
return;
239254
}
240255
// 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) {
393408
// endregion
394409

395410
// region options persisted values
396-
private void backfillOptions(final @NotNull SentryEvent event) {
397-
setRelease(event);
398-
setEnvironment(event);
399-
setDist(event);
400-
setDebugMeta(event);
401-
setSdk(event);
411+
private void backfillOptions(
412+
final @NotNull SentryEvent event, final @NotNull OptionsSource optionsSource) {
413+
setRelease(event, optionsSource);
414+
setEnvironment(event, optionsSource);
415+
setDist(event, optionsSource);
416+
setDebugMeta(event, optionsSource);
417+
setSdk(event, optionsSource);
402418
setApp(event);
403-
setOptionsTags(event);
419+
setOptionsTags(event, optionsSource);
404420
}
405421

406422
private void setApp(final @NotNull SentryBaseEvent event) {
@@ -415,25 +431,6 @@ private void setApp(final @NotNull SentryBaseEvent event) {
415431
app.setAppIdentifier(packageInfo.packageName);
416432
}
417433

418-
// backfill versionName and versionCode from the persisted release string
419-
final String release =
420-
event.getRelease() != null
421-
? event.getRelease()
422-
: PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class);
423-
if (release != null) {
424-
try {
425-
final String versionName =
426-
release.substring(release.indexOf('@') + 1, release.indexOf('+'));
427-
final String versionCode = release.substring(release.indexOf('+') + 1);
428-
app.setAppVersion(versionName);
429-
app.setAppBuild(versionCode);
430-
} catch (Throwable e) {
431-
options
432-
.getLogger()
433-
.log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release);
434-
}
435-
}
436-
437434
try {
438435
final ContextUtils.SplitApksInfo splitApksInfo =
439436
DeviceInfoUtil.getInstance(context, options).getSplitApksInfo();
@@ -448,25 +445,50 @@ private void setApp(final @NotNull SentryBaseEvent event) {
448445
}
449446

450447
event.getContexts().setApp(app);
448+
setAppVersionAndBuild(event);
449+
}
450+
451+
private void setAppVersionAndBuild(final @NotNull SentryBaseEvent event) {
452+
final String release = event.getRelease();
453+
if (release != null) {
454+
try {
455+
@Nullable App app = event.getContexts().getApp();
456+
if (app == null) {
457+
app = new App();
458+
}
459+
final String versionName =
460+
release.substring(release.indexOf('@') + 1, release.indexOf('+'));
461+
final String versionCode = release.substring(release.indexOf('+') + 1);
462+
app.setAppVersion(versionName);
463+
app.setAppBuild(versionCode);
464+
event.getContexts().setApp(app);
465+
} catch (Throwable e) {
466+
options
467+
.getLogger()
468+
.log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release);
469+
}
470+
}
451471
}
452472

453-
private void setRelease(final @NotNull SentryBaseEvent event) {
473+
private void setRelease(
474+
final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) {
454475
if (event.getRelease() == null) {
455-
final String release =
456-
PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class);
457-
event.setRelease(release);
476+
event.setRelease(
477+
getLaunchOption(RELEASE_FILENAME, String.class, options.getRelease(), optionsSource));
458478
}
459479
}
460480

461-
private void setEnvironment(final @NotNull SentryBaseEvent event) {
481+
private void setEnvironment(
482+
final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) {
462483
if (event.getEnvironment() == null) {
463-
final String environment =
464-
PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String.class);
465-
event.setEnvironment(environment != null ? environment : options.getEnvironment());
484+
event.setEnvironment(
485+
getLaunchOption(
486+
ENVIRONMENT_FILENAME, String.class, options.getEnvironment(), optionsSource));
466487
}
467488
}
468489

469-
private void setDebugMeta(final @NotNull SentryBaseEvent event) {
490+
private void setDebugMeta(
491+
final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) {
470492
DebugMeta debugMeta = event.getDebugMeta();
471493

472494
if (debugMeta == null) {
@@ -478,7 +500,8 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) {
478500
List<DebugImage> images = debugMeta.getImages();
479501
if (images != null) {
480502
final String proguardUuid =
481-
PersistingOptionsObserver.read(options, PROGUARD_UUID_FILENAME, String.class);
503+
getBuildOption(
504+
PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource);
482505

483506
if (proguardUuid != null) {
484507
final DebugImage debugImage = new DebugImage();
@@ -490,15 +513,14 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) {
490513
}
491514
}
492515

493-
private void setDist(final @NotNull SentryBaseEvent event) {
516+
private void setDist(
517+
final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) {
494518
if (event.getDist() == null) {
495-
final String dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String.class);
496-
event.setDist(dist);
519+
event.setDist(getLaunchOption(DIST_FILENAME, String.class, options.getDist(), optionsSource));
497520
}
498-
// if there's no user-set dist, fall back to versionCode from the persisted release string
521+
// if there's no user-set dist, fall back to versionCode from the release string
499522
if (event.getDist() == null) {
500-
final String release =
501-
PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class);
523+
final String release = event.getRelease();
502524
if (release != null) {
503525
try {
504526
final String versionCode = release.substring(release.indexOf('+') + 1);
@@ -512,20 +534,101 @@ private void setDist(final @NotNull SentryBaseEvent event) {
512534
}
513535
}
514536

515-
private void setSdk(final @NotNull SentryBaseEvent event) {
537+
/**
538+
* Resolves an option that may change between launches of the same build, such as environment or
539+
* tags. A matching persisted value is preferred; the current value is used only when the source
540+
* identifies the current app generation or permits a fallback for a missing persisted value.
541+
*/
542+
private <T> @Nullable T getLaunchOption(
543+
final @NotNull String fileName,
544+
final @NotNull Class<T> clazz,
545+
final @Nullable T currentValue,
546+
final @NotNull OptionsSource optionsSource) {
547+
if (optionsSource == OptionsSource.CURRENT) {
548+
return currentValue;
549+
} else if (optionsSource == OptionsSource.NONE) {
550+
return null;
551+
}
552+
553+
final T persistedValue = PersistingOptionsObserver.read(options, fileName, clazz);
554+
return persistedValue != null || optionsSource == OptionsSource.PERSISTED
555+
? persistedValue
556+
: currentValue;
557+
}
558+
559+
/**
560+
* Resolves metadata that cannot change between launches of the same build, such as the ProGuard
561+
* UUID or SDK version. Current metadata is used for exits from the current app generation, while
562+
* persisted metadata is reserved for historical exits.
563+
*/
564+
private <T> @Nullable T getBuildOption(
565+
final @NotNull String fileName,
566+
final @NotNull Class<T> clazz,
567+
final @Nullable T currentValue,
568+
final @NotNull OptionsSource optionsSource) {
569+
if (optionsSource == OptionsSource.CURRENT
570+
|| optionsSource == OptionsSource.PERSISTED_WITH_CURRENT_FALLBACK) {
571+
return currentValue;
572+
} else if (optionsSource == OptionsSource.NONE) {
573+
return null;
574+
}
575+
return PersistingOptionsObserver.read(options, fileName, clazz);
576+
}
577+
578+
/**
579+
* Chooses the options snapshot that can safely describe an exit by comparing its timestamp with
580+
* the current app update time and the persisted cache generation. A markerless legacy cache is
581+
* accepted for compatibility; {@link OptionsSource#NONE} is returned when neither current nor
582+
* persisted options can be matched to the exit.
583+
*/
584+
private @NotNull OptionsSource getOptionsSource(final @NotNull Backfillable hint) {
585+
final @Nullable Long timestamp;
586+
if (hint instanceof AbnormalExit) {
587+
timestamp = ((AbnormalExit) hint).timestamp();
588+
} else if (hint instanceof NativeCrashExit) {
589+
timestamp = ((NativeCrashExit) hint).timestamp();
590+
} else {
591+
timestamp = null;
592+
}
593+
final Long cachedLastUpdateTime = PersistingOptionsCacheGenerationObserver.read(options);
594+
final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider);
595+
final long currentLastUpdateTime = packageInfo == null ? 0 : packageInfo.lastUpdateTime;
596+
597+
if (timestamp != null && currentLastUpdateTime > 0 && currentLastUpdateTime <= timestamp) {
598+
return cachedLastUpdateTime != null && cachedLastUpdateTime == currentLastUpdateTime
599+
? OptionsSource.PERSISTED_WITH_CURRENT_FALLBACK
600+
: OptionsSource.CURRENT;
601+
}
602+
if (cachedLastUpdateTime == null) {
603+
return OptionsSource.PERSISTED;
604+
}
605+
// A cache generation created after the exit cannot describe that exit.
606+
if (timestamp != null && cachedLastUpdateTime > 0 && cachedLastUpdateTime <= timestamp) {
607+
return OptionsSource.PERSISTED;
608+
}
609+
return OptionsSource.NONE;
610+
}
611+
612+
private void setSdk(
613+
final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) {
516614
if (event.getSdk() == null) {
517615
final SdkVersion sdkVersion =
518-
PersistingOptionsObserver.read(options, SDK_VERSION_FILENAME, SdkVersion.class);
616+
getBuildOption(
617+
SDK_VERSION_FILENAME, SdkVersion.class, options.getSdkVersion(), optionsSource);
519618
event.setSdk(sdkVersion);
520619
}
521620
}
522621

523622
@SuppressWarnings("unchecked")
524-
private void setOptionsTags(final @NotNull SentryBaseEvent event) {
623+
private void setOptionsTags(
624+
final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) {
525625
final Map<String, String> tags =
526626
(Map<String, String>)
527-
PersistingOptionsObserver.read(
528-
options, PersistingOptionsObserver.TAGS_FILENAME, Map.class);
627+
getLaunchOption(
628+
PersistingOptionsObserver.TAGS_FILENAME,
629+
Map.class,
630+
options.getTags(),
631+
optionsSource);
529632
if (tags == null) {
530633
return;
531634
}
@@ -542,6 +645,13 @@ private void setOptionsTags(final @NotNull SentryBaseEvent event) {
542645

543646
// endregion
544647

648+
private enum OptionsSource {
649+
CURRENT,
650+
PERSISTED,
651+
PERSISTED_WITH_CURRENT_FALLBACK,
652+
NONE
653+
}
654+
545655
@Override
546656
public @Nullable Long getOrder() {
547657
return 12000L;

0 commit comments

Comments
 (0)