From 028f17baffac10d03d40ef53e4ee3bd8b7bd6f0a Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Mon, 19 Jan 2026 15:36:09 +0100 Subject: [PATCH 1/2] feat: merge tombstone and native sdk events --- .../api/sentry-android-core.api | 17 ++ .../core/AndroidOptionsInitializer.java | 38 +++ .../android/core/NativeEventCollector.java | 276 ++++++++++++++++++ .../android/core/SentryAndroidOptions.java | 28 ++ .../android/core/TombstoneIntegration.java | 62 +++- .../java/io/sentry/android/ndk/SentryNdk.java | 6 + 6 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/NativeEventCollector.java diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ff9a0c7597d..08a0fd0376f 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -291,6 +291,21 @@ public final class io/sentry/android/core/LoadClass : io/sentry/util/LoadClass { public fun loadClass (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/lang/Class; } +public final class io/sentry/android/core/NativeEventCollector { + public fun (Lio/sentry/android/core/SentryAndroidOptions;)V + public fun collect ()V + public fun deleteNativeEventFile (Lio/sentry/android/core/NativeEventCollector$NativeEventData;)Z + public fun findAndRemoveMatchingNativeEvent (JLjava/lang/String;)Lio/sentry/android/core/NativeEventCollector$NativeEventData; +} + +public final class io/sentry/android/core/NativeEventCollector$NativeEventData { + public fun getCorrelationId ()Ljava/lang/String; + public fun getEnvelope ()Lio/sentry/SentryEnvelope; + public fun getEvent ()Lio/sentry/SentryEvent; + public fun getFile ()Ljava/io/File; + public fun getTimestampMs ()J +} + public final class io/sentry/android/core/NdkHandlerStrategy : java/lang/Enum { public static final field SENTRY_HANDLER_STRATEGY_CHAIN_AT_START Lio/sentry/android/core/NdkHandlerStrategy; public static final field SENTRY_HANDLER_STRATEGY_DEFAULT Lio/sentry/android/core/NdkHandlerStrategy; @@ -339,6 +354,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getBeforeViewHierarchyCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback; public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader; public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector; + public fun getNativeCrashCorrelationId ()Ljava/lang/String; public fun getNativeSdkName ()Ljava/lang/String; public fun getNdkHandlerStrategy ()I public fun getStartupCrashDurationThresholdMillis ()J @@ -392,6 +408,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableSystemEventBreadcrumbs (Z)V public fun setEnableSystemEventBreadcrumbsExtras (Z)V public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V + public fun setNativeCrashCorrelationId (Ljava/lang/String;)V public fun setNativeHandlerStrategy (Lio/sentry/android/core/NdkHandlerStrategy;)V public fun setNativeSdkName (Ljava/lang/String;)V public fun setReportHistoricalAnrs (Z)V 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 b7bb5bf21ac..784546f230a 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 @@ -2,6 +2,7 @@ import static io.sentry.android.core.NdkIntegration.SENTRY_NDK_CLASS_NAME; +import android.app.ActivityManager; import android.app.Application; import android.content.Context; import android.content.pm.PackageInfo; @@ -57,8 +58,10 @@ import io.sentry.util.Objects; import io.sentry.util.thread.NoOpThreadChecker; import java.io.File; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -244,6 +247,12 @@ static void initializeIntegrationsAndProcessors( if (options.getSocketTagger() instanceof NoOpSocketTagger) { options.setSocketTagger(AndroidSocketTagger.getInstance()); } + + // Set native crash correlation ID before NDK integration is registered + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.R) { + setNativeCrashCorrelationId(context, options); + } + if (options.getPerformanceCollectors().isEmpty()) { options.addPerformanceCollector(new AndroidMemoryCollector()); options.addPerformanceCollector(new AndroidCpuCollector(options.getLogger())); @@ -497,4 +506,33 @@ private static void readDefaultOptionValues( static @NotNull File getCacheDir(final @NotNull Context context) { return new File(context.getCacheDir(), "sentry"); } + + /** + * Sets a native crash correlation ID that can be used to associate native crash events (from + * sentry-native) with tombstone events (from ApplicationExitInfo). The ID is stored via + * ActivityManager.setProcessStateSummary() and passed to the native SDK. + * + * @param context the Application context + * @param options the SentryAndroidOptions + */ + private static void setNativeCrashCorrelationId( + final @NotNull Context context, final @NotNull SentryAndroidOptions options) { + final String correlationId = UUID.randomUUID().toString(); + options.setNativeCrashCorrelationId(correlationId); + + try { + final ActivityManager am = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (am != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + am.setProcessStateSummary(correlationId.getBytes(StandardCharsets.UTF_8)); + options + .getLogger() + .log(SentryLevel.DEBUG, "Native crash correlation ID set: %s", correlationId); + } + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to set process state summary for correlation ID", e); + } + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NativeEventCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/NativeEventCollector.java new file mode 100644 index 00000000000..17379299ea0 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NativeEventCollector.java @@ -0,0 +1,276 @@ +package io.sentry.android.core; + +import static io.sentry.cache.EnvelopeCache.PREFIX_CURRENT_SESSION_FILE; +import static io.sentry.cache.EnvelopeCache.PREFIX_PREVIOUS_SESSION_FILE; +import static io.sentry.cache.EnvelopeCache.STARTUP_CRASH_MARKER_FILE; + +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; +import io.sentry.SentryEvent; +import io.sentry.SentryItemType; +import io.sentry.SentryLevel; +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Collects native crash events from the outbox directory. These events can be correlated with + * tombstone events from ApplicationExitInfo to avoid sending duplicate crash reports. + */ +@ApiStatus.Internal +public final class NativeEventCollector { + + private static final String NATIVE_PLATFORM = "native"; + + // TODO: will be replaced with the correlationId once the Native SDK supports it + private static final long TIMESTAMP_TOLERANCE_MS = 5000; + + private final @NotNull SentryAndroidOptions options; + private final @NotNull List nativeEvents = new ArrayList<>(); + private boolean collected = false; + + public NativeEventCollector(final @NotNull SentryAndroidOptions options) { + this.options = options; + } + + /** Holds a native event along with its source file for later deletion. */ + public static final class NativeEventData { + private final @NotNull SentryEvent event; + private final @NotNull File file; + private final @NotNull SentryEnvelope envelope; + private final long timestampMs; + + NativeEventData( + final @NotNull SentryEvent event, + final @NotNull File file, + final @NotNull SentryEnvelope envelope, + final long timestampMs) { + this.event = event; + this.file = file; + this.envelope = envelope; + this.timestampMs = timestampMs; + } + + public @NotNull SentryEvent getEvent() { + return event; + } + + public @NotNull File getFile() { + return file; + } + + public @NotNull SentryEnvelope getEnvelope() { + return envelope; + } + + public long getTimestampMs() { + return timestampMs; + } + + /** + * Extracts the correlation ID from the event's extra data. + * + * @return the correlation ID, or null if not present + */ + public @Nullable String getCorrelationId() { + final @Nullable Object correlationId = event.getExtra("sentry.native.correlation_id"); + if (correlationId instanceof String) { + return (String) correlationId; + } + return null; + } + } + + /** + * Scans the outbox directory and collects all native crash events. This method should be called + * once before processing tombstones. Subsequent calls are no-ops. + */ + public void collect() { + if (collected) { + return; + } + collected = true; + + final @Nullable String outboxPath = options.getOutboxPath(); + if (outboxPath == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Outbox path is null, skipping native event collection."); + return; + } + + final File outboxDir = new File(outboxPath); + if (!outboxDir.isDirectory()) { + options.getLogger().log(SentryLevel.DEBUG, "Outbox path is not a directory: %s", outboxPath); + return; + } + + final File[] files = outboxDir.listFiles((d, name) -> isRelevantFileName(name)); + if (files == null || files.length == 0) { + options.getLogger().log(SentryLevel.DEBUG, "No envelope files found in outbox."); + return; + } + + options + .getLogger() + .log(SentryLevel.DEBUG, "Scanning %d files in outbox for native events.", files.length); + + for (final File file : files) { + if (!file.isFile()) { + continue; + } + + final @Nullable NativeEventData nativeEventData = extractNativeEventFromFile(file); + if (nativeEventData != null) { + nativeEvents.add(nativeEventData); + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Found native event in outbox: %s (timestamp: %d)", + file.getName(), + nativeEventData.getTimestampMs()); + } + } + + options + .getLogger() + .log(SentryLevel.DEBUG, "Collected %d native events from outbox.", nativeEvents.size()); + } + + /** + * Finds a native event that matches the given tombstone timestamp or correlation ID. If a match + * is found, it is removed from the internal list so it won't be matched again. + * + *

This method will lazily collect native events from the outbox on first call. + * + * @param tombstoneTimestampMs the timestamp from ApplicationExitInfo + * @param correlationId the correlation ID from processStateSummary, or null + * @return the matching native event data, or null if no match found + */ + public @Nullable NativeEventData findAndRemoveMatchingNativeEvent( + final long tombstoneTimestampMs, final @Nullable String correlationId) { + + // Lazily collect on first use (runs on executor thread, not main thread) + collect(); + + // First, try to match by correlation ID (when sentry-native supports it) + if (correlationId != null) { + for (final NativeEventData nativeEvent : nativeEvents) { + final @Nullable String nativeCorrelationId = nativeEvent.getCorrelationId(); + if (correlationId.equals(nativeCorrelationId)) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Matched native event by correlation ID: %s", correlationId); + nativeEvents.remove(nativeEvent); + return nativeEvent; + } + } + } + + // Fall back to timestamp-based matching + for (final NativeEventData nativeEvent : nativeEvents) { + final long timeDiff = Math.abs(tombstoneTimestampMs - nativeEvent.getTimestampMs()); + if (timeDiff <= TIMESTAMP_TOLERANCE_MS) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Matched native event by timestamp (diff: %d ms)", timeDiff); + nativeEvents.remove(nativeEvent); + return nativeEvent; + } + } + + return null; + } + + /** + * Deletes a native event file from the outbox. + * + * @param nativeEventData the native event data containing the file reference + * @return true if the file was deleted successfully + */ + public boolean deleteNativeEventFile(final @NotNull NativeEventData nativeEventData) { + final File file = nativeEventData.getFile(); + try { + if (file.delete()) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Deleted native event file from outbox: %s", file.getName()); + return true; + } else { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to delete native event file: %s", + file.getAbsolutePath()); + return false; + } + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, e, "Error deleting native event file: %s", file.getAbsolutePath()); + return false; + } + } + + private @Nullable NativeEventData extractNativeEventFromFile(final @NotNull File file) { + try (final InputStream stream = new BufferedInputStream(new FileInputStream(file))) { + final SentryEnvelope envelope = options.getEnvelopeReader().read(stream); + if (envelope == null) { + return null; + } + + for (final SentryEnvelopeItem item : envelope.getItems()) { + if (!SentryItemType.Event.equals(item.getHeader().getType())) { + continue; + } + + try (final Reader eventReader = + new BufferedReader( + new InputStreamReader( + new ByteArrayInputStream(item.getData()), StandardCharsets.UTF_8))) { + final SentryEvent event = + options.getSerializer().deserialize(eventReader, SentryEvent.class); + if (event != null && NATIVE_PLATFORM.equals(event.getPlatform())) { + final long timestampMs = extractTimestampMs(event); + return new NativeEventData(event, file, envelope, timestampMs); + } + } + } + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.DEBUG, e, "Error reading envelope file: %s", file.getAbsolutePath()); + } + return null; + } + + private long extractTimestampMs(final @NotNull SentryEvent event) { + final @Nullable Date timestamp = event.getTimestamp(); + if (timestamp != null) { + return timestamp.getTime(); + } + return 0; + } + + private boolean isRelevantFileName(final @Nullable String fileName) { + return fileName != null + && !fileName.startsWith(PREFIX_CURRENT_SESSION_FILE) + && !fileName.startsWith(PREFIX_PREVIOUS_SESSION_FILE) + && !fileName.startsWith(STARTUP_CRASH_MARKER_FILE); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 12917ed4b7c..3baba6a3235 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -174,6 +174,13 @@ public final class SentryAndroidOptions extends SentryOptions { */ private boolean enableScopeSync = true; + /** + * A correlation ID used to associate native crash events (from sentry-native) with tombstone + * events (from ApplicationExitInfo). This is set via ActivityManager.setProcessStateSummary() and + * passed to the native SDK during initialization. + */ + private @Nullable String nativeCrashCorrelationId; + /** * Whether to enable automatic trace ID generation. This is mainly used by the Hybrid SDKs to * control the trace ID generation from the outside. @@ -607,6 +614,27 @@ public void setEnableScopeSync(boolean enableScopeSync) { this.enableScopeSync = enableScopeSync; } + /** + * Returns the correlation ID used to associate native crash events with tombstone events. + * + * @return the correlation ID, or null if not set + */ + @ApiStatus.Internal + public @Nullable String getNativeCrashCorrelationId() { + return nativeCrashCorrelationId; + } + + /** + * Sets the correlation ID used to associate native crash events with tombstone events. This is + * typically set automatically during SDK initialization. + * + * @param nativeCrashCorrelationId the correlation ID + */ + @ApiStatus.Internal + public void setNativeCrashCorrelationId(final @Nullable String nativeCrashCorrelationId) { + this.nativeCrashCorrelationId = nativeCrashCorrelationId; + } + public boolean isReportHistoricalAnrs() { return reportHistoricalAnrs; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java index 6d1c56db5ee..ddd5e844756 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java @@ -15,6 +15,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.ApplicationExitInfoHistoryDispatcher.ApplicationExitInfoPolicy; +import io.sentry.android.core.NativeEventCollector.NativeEventData; import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.tombstone.TombstoneParser; import io.sentry.hints.Backfillable; @@ -28,6 +29,7 @@ import java.io.Closeable; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.format.DateTimeFormatter; import org.jetbrains.annotations.ApiStatus; @@ -103,9 +105,11 @@ public void close() throws IOException { public static class TombstonePolicy implements ApplicationExitInfoPolicy { private final @NotNull SentryAndroidOptions options; + private final @NotNull NativeEventCollector nativeEventCollector; public TombstonePolicy(final @NotNull SentryAndroidOptions options) { this.options = options; + this.nativeEventCollector = new NativeEventCollector(options); } @Override @@ -133,7 +137,7 @@ public boolean shouldReportHistorical() { @Override public @Nullable ApplicationExitInfoHistoryDispatcher.Report buildReport( final @NotNull ApplicationExitInfo exitInfo, final boolean enrich) { - final SentryEvent event; + SentryEvent event; try { final InputStream tombstoneInputStream = exitInfo.getTraceInputStream(); if (tombstoneInputStream == null) { @@ -164,6 +168,36 @@ public boolean shouldReportHistorical() { final long tombstoneTimestamp = exitInfo.getTimestamp(); event.setTimestamp(DateUtils.getDateTime(tombstoneTimestamp)); + // Extract correlation ID from process state summary (if set during previous session) + final @Nullable String correlationId = extractCorrelationId(exitInfo); + if (correlationId != null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Tombstone correlation ID found: %s", correlationId); + } + + // Try to find and remove matching native event from outbox + final @Nullable NativeEventData matchingNativeEvent = + nativeEventCollector.findAndRemoveMatchingNativeEvent(tombstoneTimestamp, correlationId); + + if (matchingNativeEvent != null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Found matching native event for tombstone, removing from outbox: %s", + matchingNativeEvent.getFile().getName()); + + // Delete from outbox so OutboxSender doesn't send it + boolean deletionSuccess = nativeEventCollector.deleteNativeEventFile(matchingNativeEvent); + + if (deletionSuccess) { + event = mergeNaiveCrashes(matchingNativeEvent.getEvent(), event); + } + } else { + options.getLogger().log(SentryLevel.DEBUG, "No matching native event found for tombstone."); + } + final TombstoneHint tombstoneHint = new TombstoneHint( options.getFlushTimeoutMillis(), options.getLogger(), tombstoneTimestamp, enrich); @@ -171,6 +205,32 @@ public boolean shouldReportHistorical() { return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, tombstoneHint); } + + private SentryEvent mergeNaiveCrashes( + final @NotNull SentryEvent nativeEvent, final @NotNull SentryEvent tombstoneEvent) { + nativeEvent.setExceptions(tombstoneEvent.getExceptions()); + nativeEvent.setDebugMeta(tombstoneEvent.getDebugMeta()); + nativeEvent.setThreads(tombstoneEvent.getThreads()); + return nativeEvent; + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private @Nullable String extractCorrelationId(final @NotNull ApplicationExitInfo exitInfo) { + try { + final byte[] summary = exitInfo.getProcessStateSummary(); + if (summary != null && summary.length > 0) { + return new String(summary, StandardCharsets.UTF_8); + } + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Failed to extract correlation ID from process state summary", + e); + } + return null; + } } @ApiStatus.Internal diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java index 9d6d64a1236..ff0fe421f8e 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -73,6 +73,12 @@ public static void init(@NotNull final SentryAndroidOptions options) { ndkOptions.setTracesSampleRate(tracesSampleRate.floatValue()); } + // TODO: Pass correlation ID to native SDK when sentry-native supports it + // final @Nullable String correlationId = options.getNativeCrashCorrelationId(); + // if (correlationId != null) { + // ndkOptions.setCorrelationId(correlationId); + // } + //noinspection UnstableApiUsage io.sentry.ndk.SentryNdk.init(ndkOptions); From 6befa438f38af2394183aa9ded55edc802379d9b Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Mon, 19 Jan 2026 16:35:07 +0100 Subject: [PATCH 2/2] add preliminary change log --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aca5108ab7..6d1f144c9c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - Added `io.sentry.ndk.sdk-name` Android manifest option to configure the native SDK's name ([#5027](https://github.com/getsentry/sentry-java/pull/5027)) +- Merge Tombstone and Native SDK events into single crash event. ([#5037](https://github.com/getsentry/sentry-java/pull/5037)) ### Dependencies