From 028f17baffac10d03d40ef53e4ee3bd8b7bd6f0a Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Mon, 19 Jan 2026 15:36:09 +0100 Subject: [PATCH 01/20] 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 02/20] 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 From d7d54476eadd7ce65d4e88aa273b64995b4ccda0 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Wed, 21 Jan 2026 16:35:41 +0100 Subject: [PATCH 03/20] add preliminary change log --- .../java/io/sentry/android/core/TombstoneIntegration.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ddd5e844756..35357660475 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 @@ -192,7 +192,7 @@ public boolean shouldReportHistorical() { boolean deletionSuccess = nativeEventCollector.deleteNativeEventFile(matchingNativeEvent); if (deletionSuccess) { - event = mergeNaiveCrashes(matchingNativeEvent.getEvent(), event); + event = mergeNativeCrashes(matchingNativeEvent.getEvent(), event); } } else { options.getLogger().log(SentryLevel.DEBUG, "No matching native event found for tombstone."); @@ -206,7 +206,7 @@ public boolean shouldReportHistorical() { return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, tombstoneHint); } - private SentryEvent mergeNaiveCrashes( + private SentryEvent mergeNativeCrashes( final @NotNull SentryEvent nativeEvent, final @NotNull SentryEvent tombstoneEvent) { nativeEvent.setExceptions(tombstoneEvent.getExceptions()); nativeEvent.setDebugMeta(tombstoneEvent.getDebugMeta()); From ba1bfc78c90893c39d286ffeb7ed1d3a40ac0448 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Fri, 23 Jan 2026 23:41:13 +0100 Subject: [PATCH 04/20] apply review+sync feedback --- .gitignore | 1 + .../api/sentry-android-core.api | 2 +- .../android/core/TombstoneIntegration.java | 49 ++++++++++++++++--- .../internal/tombstone/TombstoneParser.java | 40 ++++++++++++++- .../ApplicationExitIntegrationTestBase.kt | 3 ++ .../internal/tombstone/TombstoneParserTest.kt | 24 ++++++++- 6 files changed, 107 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 5f772be1b27..b8c2d7e9da2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.java-version .idea/ .gradle/ .run/ diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 08a0fd0376f..aa583d76b38 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -517,7 +517,7 @@ public final class io/sentry/android/core/TombstoneIntegration$TombstoneHint : i } public class io/sentry/android/core/TombstoneIntegration$TombstonePolicy : io/sentry/android/core/ApplicationExitInfoHistoryDispatcher$ApplicationExitInfoPolicy { - public fun (Lio/sentry/android/core/SentryAndroidOptions;)V + public fun (Lio/sentry/android/core/SentryAndroidOptions;Landroid/content/Context;)V public fun buildReport (Landroid/app/ApplicationExitInfo;Z)Lio/sentry/android/core/ApplicationExitInfoHistoryDispatcher$Report; public fun getLabel ()Ljava/lang/String; public fun getLastReportedTimestamp ()Ljava/lang/Long; 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 35357660475..d6a64fa0cb3 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 @@ -17,11 +17,16 @@ 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.NativeExceptionMechanism; import io.sentry.android.core.internal.tombstone.TombstoneParser; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; import io.sentry.hints.NativeCrashExit; +import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.Mechanism; +import io.sentry.protocol.SentryException; import io.sentry.protocol.SentryId; +import io.sentry.protocol.SentryThread; import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.HintUtils; @@ -32,6 +37,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.format.DateTimeFormatter; +import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -85,7 +91,7 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { scopes, this.options, dateProvider, - new TombstonePolicy(this.options))); + new TombstonePolicy(this.options, this.context))); } catch (Throwable e) { options.getLogger().log(SentryLevel.DEBUG, "Failed to start tombstone processor.", e); } @@ -106,10 +112,12 @@ public static class TombstonePolicy implements ApplicationExitInfoPolicy { private final @NotNull SentryAndroidOptions options; private final @NotNull NativeEventCollector nativeEventCollector; + @NotNull private final Context context; - public TombstonePolicy(final @NotNull SentryAndroidOptions options) { + public TombstonePolicy(final @NotNull SentryAndroidOptions options, @NotNull Context context) { this.options = options; this.nativeEventCollector = new NativeEventCollector(options); + this.context = context; } @Override @@ -151,7 +159,12 @@ public boolean shouldReportHistorical() { return null; } - try (final TombstoneParser parser = new TombstoneParser(tombstoneInputStream)) { + try (final TombstoneParser parser = + new TombstoneParser( + tombstoneInputStream, + this.options.getInAppIncludes(), + this.options.getInAppExcludes(), + this.context.getApplicationInfo().nativeLibraryDir)) { event = parser.parse(); } } catch (Throwable e) { @@ -206,11 +219,33 @@ public boolean shouldReportHistorical() { return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, tombstoneHint); } - private SentryEvent mergeNativeCrashes( + private SentryEvent mergeNativeCrashes( final @NotNull SentryEvent nativeEvent, final @NotNull SentryEvent tombstoneEvent) { - nativeEvent.setExceptions(tombstoneEvent.getExceptions()); - nativeEvent.setDebugMeta(tombstoneEvent.getDebugMeta()); - nativeEvent.setThreads(tombstoneEvent.getThreads()); + // we take the event data verbatim from the Native SDK and only apply tombstone data where we + // are sure that it will improve the outcome: + // * context from the Native SDK will be closer to what users want than any backfilling + // * the Native SDK only tracks the crashing thread (vs. tombstone dumps all) + // * even for the crashing we expect a much better stack-trace (+ symbolication) + // * tombstone adds additional exception meta-data to signal handler content + // * we add debug-meta for consistency since the Native SDK caches memory maps early + @Nullable List tombstoneExceptions = tombstoneEvent.getExceptions(); + @Nullable DebugMeta tombstoneDebugMeta = tombstoneEvent.getDebugMeta(); + @Nullable List tombstoneThreads = tombstoneEvent.getThreads(); + if (tombstoneExceptions != null + && !tombstoneExceptions.isEmpty() + && tombstoneDebugMeta != null + && tombstoneThreads != null) { + // native crashes don't nest, we always expect one level. + SentryException exception = tombstoneExceptions.get(0); + @Nullable Mechanism mechanism = exception.getMechanism(); + if (mechanism != null) { + mechanism.setType(NativeExceptionMechanism.TOMBSTONE_MERGED.getValue()); + } + nativeEvent.setExceptions(tombstoneExceptions); + nativeEvent.setDebugMeta(tombstoneDebugMeta); + nativeEvent.setThreads(tombstoneThreads); + } + return nativeEvent; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java index 18c10fac445..ae6123bef05 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java @@ -21,18 +21,30 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import org.jetbrains.annotations.NotNull; public class TombstoneParser implements Closeable { private final InputStream tombstoneStream; + @NotNull private final List inAppIncludes; + @NotNull private final List inAppExcludes; + // TODO: in theory can be null, but practically not for native crashes + private final String nativeLibraryDir; private final Map excTypeValueMap = new HashMap<>(); private static String formatHex(long value) { return String.format("0x%x", value); } - public TombstoneParser(@NonNull final InputStream tombstoneStream) { + public TombstoneParser( + @NonNull final InputStream tombstoneStream, + @NotNull List inAppIncludes, + @NotNull List inAppExcludes, + String nativeLibraryDir) { this.tombstoneStream = tombstoneStream; + this.inAppIncludes = inAppIncludes; + this.inAppExcludes = inAppExcludes; + this.nativeLibraryDir = nativeLibraryDir; // keep the current signal type -> value mapping for compatibility excTypeValueMap.put("SIGILL", "IllegalInstruction"); @@ -91,14 +103,38 @@ private List createThreads( } @NonNull - private static SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread thread) { + private SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread thread) { final List frames = new ArrayList<>(); for (TombstoneProtos.BacktraceFrame frame : thread.getCurrentBacktraceList()) { + if (frame.getFileName().endsWith("libart.so")) { + // We ignore all ART frames for time being because they aren't actionable for app developers + continue; + } final SentryStackFrame stackFrame = new SentryStackFrame(); stackFrame.setPackage(frame.getFileName()); stackFrame.setFunction(frame.getFunctionName()); stackFrame.setInstructionAddr(formatHex(frame.getPc())); + + // TODO: is this the right order? + boolean inApp = false; + for (String inclusion : this.inAppIncludes) { + if (frame.getFunctionName().startsWith(inclusion)) { + inApp = true; + break; + } + } + + for (String exclusion : this.inAppExcludes) { + if (frame.getFunctionName().startsWith(exclusion)) { + inApp = false; + break; + } + } + + inApp = inApp || frame.getFileName().startsWith(this.nativeLibraryDir); + + stackFrame.setInApp(inApp); frames.add(0, stackFrame); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt index 4f2533d4268..13f9fca1782 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -54,6 +54,9 @@ abstract class ApplicationExitIntegrationTestBase { @BeforeTest fun `set up`() { val context = ApplicationProvider.getApplicationContext() + // the integration test app has no native library and as such we have to inject one here + context.applicationInfo.nativeLibraryDir = + "/data/app/~~YtXYvdWm5vDHUWYCmVLG_Q==/io.sentry.samples.android-Q2_nG8SyOi4X_6hGGDGE2Q==/lib/arm64" fixture.init(context) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt index 954ad0eccc6..e346c23e3ee 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt @@ -46,11 +46,16 @@ class TombstoneParserTest { "x28", ) + val inAppIncludes = arrayListOf("io.sentry.samples.android") + val inAppExcludes = arrayListOf() + val nativeLibraryDir = + "/data/app/~~YtXYvdWm5vDHUWYCmVLG_Q==/io.sentry.samples.android-Q2_nG8SyOi4X_6hGGDGE2Q==/lib/arm64" + @Test fun `parses a snapshot tombstone into Event`() { val tombstoneStream = GZIPInputStream(TombstoneParserTest::class.java.getResourceAsStream("/tombstone.pb.gz")) - val parser = TombstoneParser(tombstoneStream) + val parser = TombstoneParser(tombstoneStream, inAppIncludes, inAppExcludes, nativeLibraryDir) val event = parser.parse() // top-level data @@ -93,6 +98,15 @@ class TombstoneParserTest { assertNotNull(frame.function) assertNotNull(frame.`package`) assertNotNull(frame.instructionAddr) + + if (thread.id == crashedThreadId) { + if (frame.isInApp!!) { + assert( + frame.function!!.startsWith(inAppIncludes[0]) || + frame.filename!!.startsWith(nativeLibraryDir) + ) + } + } } assert(thread.stacktrace!!.registers!!.keys.containsAll(expectedRegisters)) @@ -160,7 +174,13 @@ class TombstoneParserTest { ) .build() - val parser = TombstoneParser(ByteArrayInputStream(tombstone.toByteArray())) + val parser = + TombstoneParser( + ByteArrayInputStream(tombstone.toByteArray()), + inAppIncludes, + inAppExcludes, + nativeLibraryDir, + ) val event = parser.parse() val images = event.debugMeta!!.images!! From cd970cb3f63ee17530520af6fc0e55c1cdfd3c04 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Wed, 28 Jan 2026 08:20:27 +0100 Subject: [PATCH 05/20] add tombstone manifest flag --- .../java/io/sentry/android/core/ManifestMetadataReader.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 7fed68da6d9..10d9e30abe7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -34,6 +34,8 @@ final class ManifestMetadataReader { static final String ANR_TIMEOUT_INTERVAL_MILLIS = "io.sentry.anr.timeout-interval-millis"; static final String ANR_ATTACH_THREAD_DUMPS = "io.sentry.anr.attach-thread-dumps"; + static final String TOMBSTONE_ENABLE = "io.sentry.tombstone.enable"; + static final String AUTO_INIT = "io.sentry.auto-init"; static final String NDK_ENABLE = "io.sentry.ndk.enable"; static final String NDK_SCOPE_SYNC_ENABLE = "io.sentry.ndk.scope-sync.enable"; @@ -201,6 +203,8 @@ static void applyMetadata( } options.setAnrEnabled(readBool(metadata, logger, ANR_ENABLE, options.isAnrEnabled())); + options.setTombstoneEnabled( + readBool(metadata, logger, TOMBSTONE_ENABLE, options.isTombstoneEnabled())); // use enableAutoSessionTracking as fallback options.setEnableAutoSessionTracking( From b832e7c89f7b1e310475cfc7cc5682b2b6cf015d Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Wed, 28 Jan 2026 11:21:56 +0100 Subject: [PATCH 06/20] remove tombstone-native correlation via processStateSummary --- .../api/sentry-android-core.api | 5 +-- .../core/AndroidOptionsInitializer.java | 37 ------------------- .../android/core/NativeEventCollector.java | 35 ++---------------- .../android/core/SentryAndroidOptions.java | 28 -------------- .../android/core/TombstoneIntegration.java | 29 +-------------- .../java/io/sentry/android/ndk/SentryNdk.java | 6 --- 6 files changed, 5 insertions(+), 135 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index aa583d76b38..4b417ed5b45 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -295,11 +295,10 @@ 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 fun findAndRemoveMatchingNativeEvent (J)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; @@ -354,7 +353,6 @@ 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 @@ -408,7 +406,6 @@ 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 784546f230a..de42e13ee23 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,7 +2,6 @@ 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; @@ -58,10 +57,8 @@ 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; @@ -248,11 +245,6 @@ static void initializeIntegrationsAndProcessors( 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())); @@ -506,33 +498,4 @@ 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 index 17379299ea0..44f493600f9 100644 --- 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 @@ -78,19 +78,6 @@ public static final class NativeEventData { 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; - } } /** @@ -151,36 +138,20 @@ public void collect() { } /** - * 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. + * Finds a native event that matches the given tombstone timestamp. 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) { + final long tombstoneTimestampMs) { // 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) { 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 3baba6a3235..12917ed4b7c 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,13 +174,6 @@ 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. @@ -614,27 +607,6 @@ 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 d6a64fa0cb3..b882b99541c 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 @@ -34,7 +34,6 @@ 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 java.util.List; @@ -181,17 +180,9 @@ 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); + nativeEventCollector.findAndRemoveMatchingNativeEvent(tombstoneTimestamp); if (matchingNativeEvent != null) { options @@ -248,24 +239,6 @@ private SentryEvent mergeNativeCrashes( 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 ff0fe421f8e..9d6d64a1236 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,12 +73,6 @@ 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 df83d578684d40d47a0c21660e283a118b6b97ba Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Thu, 29 Jan 2026 12:43:38 +0100 Subject: [PATCH 07/20] 2-phase streaming NativeEventCollector (#5065) --- .../api/sentry-android-core.api | 1 - .../android/core/NativeEventCollector.java | 343 ++++++++++++++++-- .../android/core/NativeEventCollectorTest.kt | 176 +++++++++ .../test/resources/envelopes/attachment.txt | 3 + .../resources/envelopes/event-attachment.txt | 10 + .../src/test/resources/envelopes/feedback.txt | 3 + .../test/resources/envelopes/java-event.txt | 3 + .../test/resources/envelopes/native-event.txt | 3 + .../envelopes/native-with-attachment.txt | 5 + .../test/resources/envelopes/session-only.txt | 3 + .../src/test/resources/envelopes/session.txt | 3 + .../test/resources/envelopes/transaction.txt | 3 + 12 files changed, 524 insertions(+), 32 deletions(-) create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt create mode 100644 sentry-android-core/src/test/resources/envelopes/attachment.txt create mode 100644 sentry-android-core/src/test/resources/envelopes/event-attachment.txt create mode 100644 sentry-android-core/src/test/resources/envelopes/feedback.txt create mode 100644 sentry-android-core/src/test/resources/envelopes/java-event.txt create mode 100644 sentry-android-core/src/test/resources/envelopes/native-event.txt create mode 100644 sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt create mode 100644 sentry-android-core/src/test/resources/envelopes/session-only.txt create mode 100644 sentry-android-core/src/test/resources/envelopes/session.txt create mode 100644 sentry-android-core/src/test/resources/envelopes/transaction.txt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 4b417ed5b45..600fe404fb0 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -302,7 +302,6 @@ public final class io/sentry/android/core/NativeEventCollector$NativeEventData { 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 { 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 index 44f493600f9..de048f40b7a 100644 --- 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 @@ -3,21 +3,25 @@ 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 static java.nio.charset.StandardCharsets.UTF_8; +import io.sentry.JsonObjectReader; import io.sentry.SentryEnvelope; import io.sentry.SentryEnvelopeItem; import io.sentry.SentryEvent; import io.sentry.SentryItemType; import io.sentry.SentryLevel; +import io.sentry.vendor.gson.stream.JsonToken; import java.io.BufferedInputStream; import java.io.BufferedReader; import java.io.ByteArrayInputStream; +import java.io.EOFException; import java.io.File; import java.io.FileInputStream; +import java.io.IOException; 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; @@ -34,33 +38,52 @@ 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<>(); + + /** Lightweight metadata collected during scan phase. */ + private final @NotNull List nativeEnvelopes = new ArrayList<>(); + private boolean collected = false; public NativeEventCollector(final @NotNull SentryAndroidOptions options) { this.options = options; } + /** Lightweight metadata for matching phase - only file reference and timestamp. */ + static final class NativeEnvelopeMetadata { + private final @NotNull File file; + private final long timestampMs; + + NativeEnvelopeMetadata(final @NotNull File file, final long timestampMs) { + this.file = file; + this.timestampMs = timestampMs; + } + + @NotNull + File getFile() { + return file; + } + + long getTimestampMs() { + return timestampMs; + } + } + /** 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) { + final @NotNull SentryEnvelope envelope) { this.event = event; this.file = file; this.envelope = envelope; - this.timestampMs = timestampMs; } public @NotNull SentryEvent getEvent() { @@ -74,10 +97,6 @@ public static final class NativeEventData { public @NotNull SentryEnvelope getEnvelope() { return envelope; } - - public long getTimestampMs() { - return timestampMs; - } } /** @@ -119,22 +138,22 @@ public void collect() { continue; } - final @Nullable NativeEventData nativeEventData = extractNativeEventFromFile(file); - if (nativeEventData != null) { - nativeEvents.add(nativeEventData); + final @Nullable NativeEnvelopeMetadata metadata = extractNativeEnvelopeMetadata(file); + if (metadata != null) { + nativeEnvelopes.add(metadata); options .getLogger() .log( SentryLevel.DEBUG, "Found native event in outbox: %s (timestamp: %d)", file.getName(), - nativeEventData.getTimestampMs()); + metadata.getTimestampMs()); } } options .getLogger() - .log(SentryLevel.DEBUG, "Collected %d native events from outbox.", nativeEvents.size()); + .log(SentryLevel.DEBUG, "Collected %d native events from outbox.", nativeEnvelopes.size()); } /** @@ -152,14 +171,15 @@ public void collect() { // Lazily collect on first use (runs on executor thread, not main thread) collect(); - for (final NativeEventData nativeEvent : nativeEvents) { - final long timeDiff = Math.abs(tombstoneTimestampMs - nativeEvent.getTimestampMs()); + for (final NativeEnvelopeMetadata metadata : nativeEnvelopes) { + final long timeDiff = Math.abs(tombstoneTimestampMs - metadata.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; + nativeEnvelopes.remove(metadata); + // Only load full event data when we have a match + return loadFullNativeEventData(metadata.getFile()); } } @@ -198,7 +218,121 @@ public boolean deleteNativeEventFile(final @NotNull NativeEventData nativeEventD } } - private @Nullable NativeEventData extractNativeEventFromFile(final @NotNull File file) { + /** + * Extracts only lightweight metadata (timestamp) from an envelope file using streaming parsing. + * This avoids loading the entire envelope and deserializing the full event. + */ + private @Nullable NativeEnvelopeMetadata extractNativeEnvelopeMetadata(final @NotNull File file) { + // we use the backend envelope size limit as a bound for the read loop + final long maxEnvelopeSize = 200 * 1024 * 1024; + long bytesProcessed = 0; + + try (final InputStream stream = new BufferedInputStream(new FileInputStream(file))) { + // Skip envelope header line + final int headerBytes = skipLine(stream); + if (headerBytes < 0) { + return null; + } + bytesProcessed += headerBytes; + + while (bytesProcessed < maxEnvelopeSize) { + final @Nullable String itemHeaderLine = readLine(stream); + if (itemHeaderLine == null || itemHeaderLine.isEmpty()) { + // We reached the end of the envelope + break; + } + bytesProcessed += itemHeaderLine.length() + 1; // +1 for newline + + final @Nullable ItemHeaderInfo headerInfo = parseItemHeader(itemHeaderLine); + if (headerInfo == null) { + break; + } + + if ("event".equals(headerInfo.type)) { + final @Nullable NativeEnvelopeMetadata metadata = + extractMetadataFromEventPayload(stream, headerInfo.length, file); + if (metadata != null) { + return metadata; + } + } else { + skipBytes(stream, headerInfo.length); + } + bytesProcessed += headerInfo.length; + + // Skip the newline after payload (if present) + final int next = stream.read(); + if (next == -1) { + break; + } + bytesProcessed++; + if (next != '\n') { + // Not a newline, we're at the next item header. Can't unread easily, + // but this shouldn't happen with well-formed envelopes + break; + } + } + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + e, + "Error extracting metadata from envelope file: %s", + file.getAbsolutePath()); + } + return null; + } + + /** + * Extracts platform and timestamp from an event payload using streaming JSON parsing. Only reads + * the fields we need and exits early once found. Uses a bounded stream to track position within + * the payload and skip any unread bytes on close, avoiding allocation of the full payload. + */ + private @Nullable NativeEnvelopeMetadata extractMetadataFromEventPayload( + final @NotNull InputStream stream, final int payloadLength, final @NotNull File file) { + + NativeEnvelopeMetadata result = null; + + try (final BoundedInputStream boundedStream = new BoundedInputStream(stream, payloadLength); + final Reader reader = new InputStreamReader(boundedStream, UTF_8)) { + final JsonObjectReader jsonReader = new JsonObjectReader(reader); + + String platform = null; + Date timestamp = null; + + jsonReader.beginObject(); + while (jsonReader.peek() == JsonToken.NAME) { + final String name = jsonReader.nextName(); + switch (name) { + case "platform": + platform = jsonReader.nextStringOrNull(); + break; + case "timestamp": + timestamp = jsonReader.nextDateOrNull(options.getLogger()); + break; + default: + jsonReader.skipValue(); + break; + } + if (platform != null && timestamp != null) { + break; + } + } + + if (NATIVE_PLATFORM.equals(platform) && timestamp != null) { + result = new NativeEnvelopeMetadata(file, timestamp.getTime()); + } + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.DEBUG, e, "Error parsing event JSON from: %s", file.getName()); + } + + return result; + } + + /** Loads the full envelope and event data from a file. Used only when a match is found. */ + private @Nullable NativeEventData loadFullNativeEventData(final @NotNull File file) { try (final InputStream stream = new BufferedInputStream(new FileInputStream(file))) { final SentryEnvelope envelope = options.getEnvelopeReader().read(stream); if (envelope == null) { @@ -212,30 +346,115 @@ public boolean deleteNativeEventFile(final @NotNull NativeEventData nativeEventD try (final Reader eventReader = new BufferedReader( - new InputStreamReader( - new ByteArrayInputStream(item.getData()), StandardCharsets.UTF_8))) { + new InputStreamReader(new ByteArrayInputStream(item.getData()), 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); + return new NativeEventData(event, file, envelope); } } } } catch (Throwable e) { options .getLogger() - .log(SentryLevel.DEBUG, e, "Error reading envelope file: %s", file.getAbsolutePath()); + .log(SentryLevel.DEBUG, e, "Error loading envelope file: %s", file.getAbsolutePath()); + } + return null; + } + + /** Minimal item header info needed for streaming. */ + private static final class ItemHeaderInfo { + final @Nullable String type; + final int length; + + ItemHeaderInfo(final @Nullable String type, final int length) { + this.type = type; + this.length = length; + } + } + + /** Parses item header JSON to extract only type and length fields. */ + private @Nullable ItemHeaderInfo parseItemHeader(final @NotNull String headerLine) { + try (final Reader reader = + new InputStreamReader(new ByteArrayInputStream(headerLine.getBytes(UTF_8)), UTF_8)) { + final JsonObjectReader jsonReader = new JsonObjectReader(reader); + + String type = null; + int length = -1; + + jsonReader.beginObject(); + while (jsonReader.peek() == JsonToken.NAME) { + final String name = jsonReader.nextName(); + switch (name) { + case "type": + type = jsonReader.nextStringOrNull(); + break; + case "length": + length = jsonReader.nextInt(); + break; + default: + jsonReader.skipValue(); + break; + } + // Early exit if we have both + if (type != null && length >= 0) { + break; + } + } + + if (length >= 0) { + return new ItemHeaderInfo(type, length); + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Error parsing item header"); } return null; } - private long extractTimestampMs(final @NotNull SentryEvent event) { - final @Nullable Date timestamp = event.getTimestamp(); - if (timestamp != null) { - return timestamp.getTime(); + /** Reads a line from the stream (up to and including newline). Returns null on EOF. */ + private @Nullable String readLine(final @NotNull InputStream stream) throws IOException { + final StringBuilder sb = new StringBuilder(); + int b; + while ((b = stream.read()) != -1) { + if (b == '\n') { + return sb.toString(); + } + sb.append((char) b); + } + return sb.length() > 0 ? sb.toString() : null; + } + + /** + * Skips a line in the stream (up to and including newline). Returns bytes skipped, or -1 on EOF. + */ + private int skipLine(final @NotNull InputStream stream) throws IOException { + int count = 0; + int b; + while ((b = stream.read()) != -1) { + count++; + if (b == '\n') { + return count; + } + } + return count > 0 ? count : -1; + } + + /** Skips exactly n bytes from the stream. */ + private static void skipBytes(final @NotNull InputStream stream, final long count) + throws IOException { + long remaining = count; + while (remaining > 0) { + final long skipped = stream.skip(remaining); + if (skipped == 0) { + // skip() returned 0, try reading instead + if (stream.read() == -1) { + throw new EOFException("Unexpected end of stream while skipping bytes"); + } + remaining--; + } else { + remaining -= skipped; + } } - return 0; } private boolean isRelevantFileName(final @Nullable String fileName) { @@ -244,4 +463,66 @@ private boolean isRelevantFileName(final @Nullable String fileName) { && !fileName.startsWith(PREFIX_PREVIOUS_SESSION_FILE) && !fileName.startsWith(STARTUP_CRASH_MARKER_FILE); } + + /** + * An InputStream wrapper that tracks reads within a bounded section of the stream. This allows + * callers to read/parse only what they need (e.g., extract a few JSON fields), then skip the + * remainder of the section on close to position the stream at the next envelope item. Does not + * close the underlying stream. + */ + private static final class BoundedInputStream extends InputStream { + private final @NotNull InputStream inner; + private long remaining; + + BoundedInputStream(final @NotNull InputStream inner, final int limit) { + this.inner = inner; + this.remaining = limit; + } + + @Override + public int read() throws IOException { + if (remaining <= 0) { + return -1; + } + final int result = inner.read(); + if (result != -1) { + remaining--; + } + return result; + } + + @Override + public int read(final byte[] b, final int off, final int len) throws IOException { + if (remaining <= 0) { + return -1; + } + final int toRead = Math.min(len, (int) remaining); + final int result = inner.read(b, off, toRead); + if (result > 0) { + remaining -= result; + } + return result; + } + + @Override + public long skip(final long n) throws IOException { + final long toSkip = Math.min(n, remaining); + final long skipped = inner.skip(toSkip); + remaining -= skipped; + return skipped; + } + + @Override + public int available() throws IOException { + return Math.min(inner.available(), (int) remaining); + } + + @Override + public void close() throws IOException { + // Skip any remaining bytes to advance the underlying stream position, + // but don't close the underlying stream, because we might have other + // envelope items to read. + skipBytes(inner, remaining); + } + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt new file mode 100644 index 00000000000..a6521280604 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt @@ -0,0 +1,176 @@ +package io.sentry.android.core + +import io.sentry.DateUtils +import java.io.File +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.mock + +class NativeEventCollectorTest { + + @get:Rule val tmpDir = TemporaryFolder() + + class Fixture { + lateinit var outboxDir: File + + val options = + SentryAndroidOptions().apply { + setLogger(mock()) + isDebug = true + } + + fun getSut(tmpDir: TemporaryFolder): NativeEventCollector { + outboxDir = File(tmpDir.root, "outbox") + outboxDir.mkdirs() + options.cacheDirPath = tmpDir.root.absolutePath + return NativeEventCollector(options) + } + } + + private val fixture = Fixture() + + @Test + fun `collects native event from outbox`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("native-event.txt") + + val timestamp = DateUtils.getDateTime("2023-07-15T10:30:00.000Z").time + val match = sut.findAndRemoveMatchingNativeEvent(timestamp) + assertNotNull(match) + } + + @Test + fun `does not collect java platform event`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("java-event.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `does not collect session-only envelope`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("session-only.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `collects native event after skipping attachment`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("native-with-attachment.txt") + + val timestamp = DateUtils.getDateTime("2023-07-15T11:45:30.500Z").time + val match = sut.findAndRemoveMatchingNativeEvent(timestamp) + assertNotNull(match) + } + + @Test + fun `handles empty file without throwing`() { + val sut = fixture.getSut(tmpDir) + File(fixture.outboxDir, "empty.envelope").writeText("") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles malformed envelope without throwing`() { + val sut = fixture.getSut(tmpDir) + File(fixture.outboxDir, "malformed.envelope").writeText("this is not a valid envelope") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles envelope with event and attachments without throwing`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("event-attachment.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles transaction envelope without throwing`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("transaction.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles session envelope without throwing`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("session.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles feedback envelope without throwing`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("feedback.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `handles attachment-only envelope without throwing`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("attachment.txt") + + val match = sut.findAndRemoveMatchingNativeEvent(0L) + assertNull(match) + } + + @Test + fun `collects multiple native events`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("native-event.txt") + copyEnvelopeToOutbox("native-with-attachment.txt") + + val timestamp1 = DateUtils.getDateTime("2023-07-15T10:30:00.000Z").time + val timestamp2 = DateUtils.getDateTime("2023-07-15T11:45:30.500Z").time + val match1 = sut.findAndRemoveMatchingNativeEvent(timestamp1) + val match2 = sut.findAndRemoveMatchingNativeEvent(timestamp2) + assertNotNull(match1) + assertNotNull(match2) + } + + @Test + fun `ignores non-native events when collecting multiple envelopes`() { + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("native-event.txt") + copyEnvelopeToOutbox("java-event.txt") + copyEnvelopeToOutbox("transaction.txt") + copyEnvelopeToOutbox("session.txt") + + val timestamp = DateUtils.getDateTime("2023-07-15T10:30:00.000Z").time + val nativeMatch = sut.findAndRemoveMatchingNativeEvent(timestamp) + assertNotNull(nativeMatch) + + // No other matches (already removed) + val noMatch = sut.findAndRemoveMatchingNativeEvent(timestamp) + assertNull(noMatch) + } + + private fun copyEnvelopeToOutbox(name: String): File { + val resourcePath = "envelopes/$name" + val inputStream = + javaClass.classLoader?.getResourceAsStream(resourcePath) + ?: throw IllegalArgumentException("Resource not found: $resourcePath") + val outFile = File(fixture.outboxDir, name) + inputStream.use { input -> outFile.outputStream().use { output -> input.copyTo(output) } } + return outFile + } +} diff --git a/sentry-android-core/src/test/resources/envelopes/attachment.txt b/sentry-android-core/src/test/resources/envelopes/attachment.txt new file mode 100644 index 00000000000..04a6e32325e --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/attachment.txt @@ -0,0 +1,3 @@ +{} +{"type":"attachment","length":61,"filename":"attachment.txt","content_type":"text/plain"} +some plain text attachment file which include two line breaks diff --git a/sentry-android-core/src/test/resources/envelopes/event-attachment.txt b/sentry-android-core/src/test/resources/envelopes/event-attachment.txt new file mode 100644 index 00000000000..4abe1bc18f1 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/event-attachment.txt @@ -0,0 +1,10 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"event","length":107,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc", "sdk": {"name":"sentry-android","version":"2.0.0-SNAPSHOT"} +{"type":"attachment","length":61,"filename":"attachment.txt","content_type":"text/plain","attachment_type":"event.minidump"} +some plain text attachment file which include two line breaks +{"type":"attachment","length":29,"filename":"log.txt","content_type":"text/plain"} +attachment +with +line breaks + diff --git a/sentry-android-core/src/test/resources/envelopes/feedback.txt b/sentry-android-core/src/test/resources/envelopes/feedback.txt new file mode 100644 index 00000000000..21202669864 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/feedback.txt @@ -0,0 +1,3 @@ +{"event_id":"bdd63725a2b84c1eabd761106e17d390","sdk":{"name":"sentry.dart.flutter","version":"6.0.0-beta.3","packages":[{"name":"pub:sentry","version":"6.0.0-beta.3"},{"name":"pub:sentry_flutter","version":"6.0.0-beta.3"}],"integrations":["isolateErrorIntegration","runZonedGuardedIntegration","widgetsFlutterBindingIntegration","flutterErrorIntegration","widgetsBindingIntegration","nativeSdkIntegration","loadAndroidImageListIntegration","loadReleaseIntegration"]}} +{"content_type":"application/json","type":"user_report","length":103} +{"event_id":"bdd63725a2b84c1eabd761106e17d390","name":"jonas","email":"a@b.com","comments":"bad stuff"} diff --git a/sentry-android-core/src/test/resources/envelopes/java-event.txt b/sentry-android-core/src/test/resources/envelopes/java-event.txt new file mode 100644 index 00000000000..9d16e5bf4e0 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/java-event.txt @@ -0,0 +1,3 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"event","length":121,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:30:00.000Z","platform":"java","level":"error"} diff --git a/sentry-android-core/src/test/resources/envelopes/native-event.txt b/sentry-android-core/src/test/resources/envelopes/native-event.txt new file mode 100644 index 00000000000..b826347a7fd --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/native-event.txt @@ -0,0 +1,3 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"event","length":123,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:30:00.000Z","platform":"native","level":"fatal"} diff --git a/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt b/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt new file mode 100644 index 00000000000..1b4ff75a112 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt @@ -0,0 +1,5 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"attachment","length":20,"filename":"log.txt","content_type":"text/plain"} +some attachment data +{"type":"event","length":123,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T11:45:30.500Z","platform":"native","level":"fatal"} diff --git a/sentry-android-core/src/test/resources/envelopes/session-only.txt b/sentry-android-core/src/test/resources/envelopes/session-only.txt new file mode 100644 index 00000000000..2b616d77e23 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/session-only.txt @@ -0,0 +1,3 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"session","length":85,"content_type":"application/json"} +{"sid":"12345678-1234-1234-1234-123456789012","status":"ok","timestamp":"2023-07-15T10:30:00.000Z"} diff --git a/sentry-android-core/src/test/resources/envelopes/session.txt b/sentry-android-core/src/test/resources/envelopes/session.txt new file mode 100644 index 00000000000..fe34ebf32e2 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/session.txt @@ -0,0 +1,3 @@ +{} +{"content_type":"application/json","type":"session","length":306} +{"sid":"c81d4e2e-bcf2-11e6-869b-7df92533d2db","did":"123","init":true,"started":"2020-02-07T14:16:00Z","status":"ok","seq":123456,"errors":2,"duration":6000.0,"timestamp":"2020-02-07T14:16:00Z","attrs":{"release":"io.sentry@1.0+123","environment":"debug","ip_address":"127.0.0.1","user_agent":"jamesBond"}} diff --git a/sentry-android-core/src/test/resources/envelopes/transaction.txt b/sentry-android-core/src/test/resources/envelopes/transaction.txt new file mode 100644 index 00000000000..a685facab65 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/transaction.txt @@ -0,0 +1,3 @@ +{"event_id":"3367f5196c494acaae85bbbd535379ac","trace":{"trace_id":"b156a475de54423d9c1571df97ec7eb6","public_key":"key"}} +{"type":"transaction","length":640,"content_type":"application/json"} +{"transaction":"a-transaction","type":"transaction","start_timestamp":"2020-10-23T10:24:01.791Z","timestamp":"2020-10-23T10:24:02.791Z","event_id":"3367f5196c494acaae85bbbd535379ac","contexts":{"trace":{"trace_id":"b156a475de54423d9c1571df97ec7eb6","span_id":"0a53026963414893","op":"http","status":"ok"},"custom":{"some-key":"some-value"}},"spans":[{"start_timestamp":"2021-03-05T08:51:12.838Z","timestamp":"2021-03-05T08:51:12.949Z","trace_id":"2b099185293344a5bfdd7ad89ebf9416","span_id":"5b95c29a5ded4281","parent_span_id":"a3b2d1d58b344b07","op":"PersonService.create","description":"desc","status":"aborted","tags":{"name":"value"}}]} From 72d56443fedc5942629d9ec19bac8c0c4966b21f Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Tue, 3 Feb 2026 10:54:45 +0100 Subject: [PATCH 08/20] extract inner inApp check into a reusable static method --- .../internal/tombstone/TombstoneParser.java | 22 +++++----------- sentry/api/sentry.api | 1 + .../io/sentry/SentryStackTraceFactory.java | 26 ++++++++++++++----- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java index ae6123bef05..7d1cb765359 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java @@ -3,6 +3,7 @@ import androidx.annotation.NonNull; import io.sentry.SentryEvent; import io.sentry.SentryLevel; +import io.sentry.SentryStackTraceFactory; import io.sentry.android.core.internal.util.NativeEventUtils; import io.sentry.protocol.DebugImage; import io.sentry.protocol.DebugMeta; @@ -22,6 +23,7 @@ import java.util.Map; import java.util.Objects; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public class TombstoneParser implements Closeable { @@ -116,23 +118,11 @@ private SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread stackFrame.setFunction(frame.getFunctionName()); stackFrame.setInstructionAddr(formatHex(frame.getPc())); - // TODO: is this the right order? - boolean inApp = false; - for (String inclusion : this.inAppIncludes) { - if (frame.getFunctionName().startsWith(inclusion)) { - inApp = true; - break; - } - } - - for (String exclusion : this.inAppExcludes) { - if (frame.getFunctionName().startsWith(exclusion)) { - inApp = false; - break; - } - } + @Nullable + Boolean inApp = + SentryStackTraceFactory.isInApp(frame.getFunctionName(), inAppIncludes, inAppExcludes); - inApp = inApp || frame.getFileName().startsWith(this.nativeLibraryDir); + inApp = (inApp != null && inApp) || frame.getFileName().startsWith(this.nativeLibraryDir); stackFrame.setInApp(inApp); frames.add(0, stackFrame); diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 7020c8e216d..c4cedd818a1 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3974,6 +3974,7 @@ public final class io/sentry/SentryStackTraceFactory { public fun getInAppCallStack ()Ljava/util/List; public fun getStackFrames ([Ljava/lang/StackTraceElement;Z)Ljava/util/List; public fun isInApp (Ljava/lang/String;)Ljava/lang/Boolean; + public static fun isInApp (Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Ljava/lang/Boolean; } public final class io/sentry/SentryThreadFactory { diff --git a/sentry/src/main/java/io/sentry/SentryStackTraceFactory.java b/sentry/src/main/java/io/sentry/SentryStackTraceFactory.java index 41934b0b513..3a36228784d 100644 --- a/sentry/src/main/java/io/sentry/SentryStackTraceFactory.java +++ b/sentry/src/main/java/io/sentry/SentryStackTraceFactory.java @@ -72,26 +72,29 @@ public List getStackFrames( } /** - * Returns if the className is InApp or not. + * Provides the logic to decide whether a className is part of the includes or excludes list of + * strings. The bias is towards includes, meaning once a className starts with a prefix in the + * includes list, it immediately returns, ignoring any counter entry in excludes. * * @param className the className * @return true if it is or false otherwise */ @Nullable - public Boolean isInApp(final @Nullable String className) { + public static Boolean isInApp( + final @Nullable String className, + final @NotNull List includes, + final @NotNull List excludes) { if (className == null || className.isEmpty()) { return true; } - final List inAppIncludes = options.getInAppIncludes(); - for (String include : inAppIncludes) { + for (String include : includes) { if (className.startsWith(include)) { return true; } } - final List inAppExcludes = options.getInAppExcludes(); - for (String exclude : inAppExcludes) { + for (String exclude : excludes) { if (className.startsWith(exclude)) { return false; } @@ -100,6 +103,17 @@ public Boolean isInApp(final @Nullable String className) { return null; } + /** + * Returns if the className is InApp or not. + * + * @param className the className + * @return true if it is or false otherwise + */ + @Nullable + public Boolean isInApp(final @Nullable String className) { + return isInApp(className, options.getInAppIncludes(), options.getInAppExcludes()); + } + /** * Returns the call stack leading to the exception, including in-app frames and excluding sentry * and system frames. From 2fd6b287d2e835570b313d71d5475d28da3c43d1 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Tue, 3 Feb 2026 11:16:47 +0100 Subject: [PATCH 09/20] reduce I/O in the collect() method of the NativeEventCollector. --- .../android/core/NativeEventCollector.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 index de048f40b7a..6e7c1736562 100644 --- 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 @@ -118,13 +118,17 @@ public void collect() { } final File outboxDir = new File(outboxPath); - if (!outboxDir.isDirectory()) { - options.getLogger().log(SentryLevel.DEBUG, "Outbox path is not a directory: %s", outboxPath); + final File[] files = outboxDir.listFiles(); + if (files == null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Outbox path is not a directory or an I/O error occurred: %s", + outboxPath); return; } - - final File[] files = outboxDir.listFiles((d, name) -> isRelevantFileName(name)); - if (files == null || files.length == 0) { + if (files.length == 0) { options.getLogger().log(SentryLevel.DEBUG, "No envelope files found in outbox."); return; } @@ -134,7 +138,7 @@ public void collect() { .log(SentryLevel.DEBUG, "Scanning %d files in outbox for native events.", files.length); for (final File file : files) { - if (!file.isFile()) { + if (!file.isFile() || !isRelevantFileName(file.getName())) { continue; } From c3be3896d17f8df421c6d7e0933c41865df26196 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Tue, 3 Feb 2026 16:59:06 +0100 Subject: [PATCH 10/20] add native attachments to TombstoneHint. --- .../android/core/TombstoneIntegration.java | 86 ++++++++++++++----- .../android/core/TombstoneIntegrationTest.kt | 69 +++++++++++++++ 2 files changed, 134 insertions(+), 21 deletions(-) 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 b882b99541c..e38d931bcbd 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 @@ -5,13 +5,18 @@ import android.app.ApplicationExitInfo; import android.content.Context; import android.os.Build; +import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; +import io.sentry.Attachment; import io.sentry.DateUtils; import io.sentry.Hint; import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.Integration; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; import io.sentry.SentryEvent; +import io.sentry.SentryItemType; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.ApplicationExitInfoHistoryDispatcher.ApplicationExitInfoPolicy; @@ -180,37 +185,78 @@ public boolean shouldReportHistorical() { final long tombstoneTimestamp = exitInfo.getTimestamp(); event.setTimestamp(DateUtils.getDateTime(tombstoneTimestamp)); - // Try to find and remove matching native event from outbox - final @Nullable NativeEventData matchingNativeEvent = - nativeEventCollector.findAndRemoveMatchingNativeEvent(tombstoneTimestamp); + final TombstoneHint tombstoneHint = + new TombstoneHint( + options.getFlushTimeoutMillis(), options.getLogger(), tombstoneTimestamp, enrich); + final Hint hint = HintUtils.createWithTypeCheckHint(tombstoneHint); - if (matchingNativeEvent != null) { + try { + mergeWithMatchingNativeEvents(tombstoneTimestamp, event, hint); + } catch (Throwable e) { options .getLogger() .log( - SentryLevel.DEBUG, - "Found matching native event for tombstone, removing from outbox: %s", - matchingNativeEvent.getFile().getName()); + SentryLevel.WARNING, + "Failed to merge native event with tombstone, continuing without merge: %s", + e.getMessage()); + } - // Delete from outbox so OutboxSender doesn't send it - boolean deletionSuccess = nativeEventCollector.deleteNativeEventFile(matchingNativeEvent); + return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, tombstoneHint); + } - if (deletionSuccess) { - event = mergeNativeCrashes(matchingNativeEvent.getEvent(), event); - } - } else { + private void mergeWithMatchingNativeEvents( + long tombstoneTimestamp, SentryEvent event, Hint hint) { + // Try to find and remove matching native event from outbox + final @Nullable NativeEventData matchingNativeEvent = + nativeEventCollector.findAndRemoveMatchingNativeEvent(tombstoneTimestamp); + + if (matchingNativeEvent == null) { options.getLogger().log(SentryLevel.DEBUG, "No matching native event found for tombstone."); + return; } - final TombstoneHint tombstoneHint = - new TombstoneHint( - options.getFlushTimeoutMillis(), options.getLogger(), tombstoneTimestamp, enrich); - final Hint hint = HintUtils.createWithTypeCheckHint(tombstoneHint); + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Found matching native event for tombstone, removing from outbox: %s", + matchingNativeEvent.getFile().getName()); - return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, tombstoneHint); + // Delete from outbox so OutboxSender doesn't send it + boolean deletionSuccess = nativeEventCollector.deleteNativeEventFile(matchingNativeEvent); + + if (deletionSuccess) { + mergeNativeCrashes(matchingNativeEvent.getEvent(), event); + addNativeAttachmentsToTombstoneHint(matchingNativeEvent, hint); + } + } + + private void addNativeAttachmentsToTombstoneHint( + @NonNull NativeEventData matchingNativeEvent, Hint hint) { + @NotNull SentryEnvelope nativeEnvelope = matchingNativeEvent.getEnvelope(); + for (SentryEnvelopeItem item : nativeEnvelope.getItems()) { + try { + @Nullable String attachmentFileName = item.getHeader().getFileName(); + if (item.getHeader().getType() != SentryItemType.Attachment + || attachmentFileName == null) { + continue; + } + hint.addAttachment( + new Attachment( + item.getData(), + attachmentFileName, + item.getHeader().getContentType(), + item.getHeader().getAttachmentType(), + false)); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Failed to process envelope item: %s", e.getMessage()); + } + } } - private SentryEvent mergeNativeCrashes( + private void mergeNativeCrashes( final @NotNull SentryEvent nativeEvent, final @NotNull SentryEvent tombstoneEvent) { // we take the event data verbatim from the Native SDK and only apply tombstone data where we // are sure that it will improve the outcome: @@ -236,8 +282,6 @@ private SentryEvent mergeNativeCrashes( nativeEvent.setDebugMeta(tombstoneDebugMeta); nativeEvent.setThreads(tombstoneThreads); } - - return nativeEvent; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt index 56aeebf7d28..e1e11d9991d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt @@ -2,16 +2,23 @@ package io.sentry.android.core import android.app.ApplicationExitInfo import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.DateUtils +import io.sentry.Hint import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.android.core.TombstoneIntegration.TombstoneHint import io.sentry.android.core.cache.AndroidEnvelopeCache +import java.io.File import java.util.zip.GZIPInputStream +import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat import org.mockito.kotlin.spy +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder @@ -78,4 +85,66 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase + // Set up the outbox directory with the native envelope containing an attachment + // Use newTimestamp to match the tombstone timestamp + val outboxDir = File(options.outboxPath!!) + outboxDir.mkdirs() + createNativeEnvelopeWithAttachment(outboxDir, newTimestamp) + } + + // Add tombstone with timestamp matching the native event + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + any(), + argThat { + val attachments = this.attachments + attachments.size == 2 && + attachments[0].filename == "test-attachment.txt" && + attachments[0].contentType == "text/plain" && + String(attachments[0].bytes!!) == "some attachment content" && + attachments[1].filename == "test-another-attachment.txt" && + attachments[1].contentType == "text/plain" && + String(attachments[1].bytes!!) == "another attachment content" + }, + ) + } + + private fun createNativeEnvelopeWithAttachment(outboxDir: File, timestamp: Long): File { + val isoTimestamp = DateUtils.getTimestamp(DateUtils.getDateTime(timestamp)) + + val eventJson = + """{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"$isoTimestamp","platform":"native","level":"fatal"}""" + val eventJsonSize = eventJson.toByteArray(Charsets.UTF_8).size + + val attachment1Content = "some attachment content" + val attachment1ContentSize = attachment1Content.toByteArray(Charsets.UTF_8).size + + val attachment2Content = "another attachment content" + val attachment2ContentSize = attachment2Content.toByteArray(Charsets.UTF_8).size + + val envelopeContent = + """ + {"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} + {"type":"attachment","length":$attachment1ContentSize,"filename":"test-attachment.txt","content_type":"text/plain"} + $attachment1Content + {"type":"attachment","length":$attachment2ContentSize,"filename":"test-another-attachment.txt","content_type":"text/plain"} + $attachment2Content + {"type":"event","length":$eventJsonSize,"content_type":"application/json"} + $eventJson + """ + .trimIndent() + + return File(outboxDir, "native-envelope-with-attachment.envelope").apply { + writeText(envelopeContent) + } + } } From c8dafdd01474e9c60501dddb3fe30de7bd9f752f Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Tue, 3 Feb 2026 19:39:10 +0100 Subject: [PATCH 11/20] introduce VMA -> module coalescing via ModuleAccumulator --- .../internal/tombstone/TombstoneParser.java | 106 ++++++-- .../android/core/TombstoneIntegrationTest.kt | 4 +- .../internal/tombstone/TombstoneParserTest.kt | 237 +++++++++++++++++- .../resources/tombstone_debug_meta.json.gz | Bin 0 -> 19912 bytes 4 files changed, 327 insertions(+), 20 deletions(-) create mode 100644 sentry-android-core/src/test/resources/tombstone_debug_meta.json.gz diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java index 7d1cb765359..68ce50d84a1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java @@ -210,29 +210,105 @@ private Message constructMessage(@NonNull final TombstoneProtos.Tombstone tombst return message; } + /** + * Helper class to accumulate memory mappings into a single module. Modules in the Sentry sense + * are the entire readable memory map for a file, not just the executable segment. This is + * important to maintain the file-offset contract of map entries, which is necessary to resolve + * runtime instruction addresses in the files uploaded for symbolication. + */ + private static class ModuleAccumulator { + String mappingName; + String buildId; + long beginAddress; + long endAddress; + + ModuleAccumulator(TombstoneProtos.MemoryMapping mapping) { + this.mappingName = mapping.getMappingName(); + this.buildId = mapping.getBuildId(); + this.beginAddress = mapping.getBeginAddress(); + this.endAddress = mapping.getEndAddress(); + } + + void extendTo(long newEndAddress) { + this.endAddress = newEndAddress; + } + + DebugImage toDebugImage() { + if (buildId.isEmpty()) { + return null; + } + final DebugImage image = new DebugImage(); + image.setCodeId(buildId); + image.setCodeFile(mappingName); + + final String debugId = NativeEventUtils.buildIdToDebugId(buildId); + image.setDebugId(debugId != null ? debugId : buildId); + + image.setImageAddr(formatHex(beginAddress)); + image.setImageSize(endAddress - beginAddress); + image.setType("elf"); + + return image; + } + } + private DebugMeta createDebugMeta(@NonNull final TombstoneProtos.Tombstone tombstone) { final List images = new ArrayList<>(); - for (TombstoneProtos.MemoryMapping module : tombstone.getMemoryMappingsList()) { - // exclude anonymous and non-executable maps - if (module.getBuildId().isEmpty() - || module.getMappingName().isEmpty() - || !module.getExecute()) { + // Coalesce memory mappings into modules similar to how sentry-native does it. + // A module consists of all readable mappings for the same file, starting from + // the first mapping that has a valid ELF header (indicated by offset 0 with build_id). + // In sentry-native, is_valid_elf_header() reads the ELF magic bytes from memory, + // which is only present at the start of the file (offset 0). We use offset == 0 + // combined with non-empty build_id as a proxy for this check. + ModuleAccumulator currentModule = null; + + for (TombstoneProtos.MemoryMapping mapping : tombstone.getMemoryMappingsList()) { + // Skip mappings that are not readable + if (!mapping.getRead()) { continue; } - final DebugImage image = new DebugImage(); - final String codeId = module.getBuildId(); - image.setCodeId(codeId); - image.setCodeFile(module.getMappingName()); - final String debugId = NativeEventUtils.buildIdToDebugId(codeId); - image.setDebugId(debugId != null ? debugId : codeId); + // Skip mappings with empty name or in /dev/ + final String mappingName = mapping.getMappingName(); + if (mappingName.isEmpty() || mappingName.startsWith("/dev/")) { + continue; + } - image.setImageAddr(formatHex(module.getBeginAddress())); - image.setImageSize(module.getEndAddress() - module.getBeginAddress()); - image.setType("elf"); + final boolean hasBuildId = !mapping.getBuildId().isEmpty(); + final boolean isFileStart = mapping.getOffset() == 0; + + if (hasBuildId && isFileStart) { + // Check for duplicated mappings: On Android, the same ELF can have multiple + // mappings at offset 0 with different permissions (r--p, r-xp, r--p). + // If it's the same file as the current module, just extend it. + if (currentModule != null && mappingName.equals(currentModule.mappingName)) { + currentModule.extendTo(mapping.getEndAddress()); + continue; + } + + // Flush the previous module (different file) + if (currentModule != null) { + final DebugImage image = currentModule.toDebugImage(); + if (image != null) { + images.add(image); + } + } + + // Start a new module + currentModule = new ModuleAccumulator(mapping); + } else if (currentModule != null && mappingName.equals(currentModule.mappingName)) { + // Extend the current module with this mapping (same file, continuation) + currentModule.extendTo(mapping.getEndAddress()); + } + } - images.add(image); + // Flush the last module + if (currentModule != null) { + final DebugImage image = currentModule.toDebugImage(); + if (image != null) { + images.add(image); + } } final DebugMeta debugMeta = new DebugMeta(); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt index e1e11d9991d..2238f8357bf 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt @@ -82,8 +82,8 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase debugId should fall back to codeId val invalidImage = images.find { it.codeFile == "/system/lib64/libc.so" }!! assertEquals(invalidBuildId, invalidImage.codeId) assertEquals(invalidBuildId, invalidImage.debugId) - // Second image has valid buildId - debugId should be converted + // Second image has valid buildId -> debugId should be converted val validImage = images.find { it.codeFile == "/system/lib64/libm.so" }!! assertEquals(validBuildId, validImage.codeId) assertEquals("c0bcc3f1-9827-fe65-3058-404b2831d9e6", validImage.debugId) } + + @Test + fun `debug meta images snapshot test`() { + // also test against a full snapshot so that we can track regressions in the VMA -> module + // reduction + val tombstoneStream = + GZIPInputStream(TombstoneParserTest::class.java.getResourceAsStream("/tombstone.pb.gz")) + val parser = TombstoneParser(tombstoneStream, inAppIncludes, inAppExcludes, nativeLibraryDir) + val event = parser.parse() + + val actualJson = serializeDebugMeta(event.debugMeta!!) + val expectedJson = readGzippedResourceFile("/tombstone_debug_meta.json.gz") + + assertEquals(expectedJson, actualJson) + } + + private fun serializeDebugMeta(debugMeta: DebugMeta): String { + val logger = mock() + val writer = StringWriter() + val jsonWriter = JsonObjectWriter(writer, 100) + debugMeta.serialize(jsonWriter, logger) + return writer.toString() + } + + private fun readGzippedResourceFile(path: String): String { + return TombstoneParserTest::class + .java + .getResourceAsStream(path) + ?.let { GZIPInputStream(it) } + ?.bufferedReader() + ?.use { it.readText().replace(Regex("[\\n\\r\\s]"), "") } + ?: throw RuntimeException("Cannot read resource file: $path") + } } diff --git a/sentry-android-core/src/test/resources/tombstone_debug_meta.json.gz b/sentry-android-core/src/test/resources/tombstone_debug_meta.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..7bb9bdbadb25518087c10fdb7ce5f3c16bfc5a4a GIT binary patch literal 19912 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 tL`^`~ Date: Tue, 3 Feb 2026 20:02:21 +0100 Subject: [PATCH 12/20] ensure native crash survives the merge --- .../android/core/TombstoneIntegration.java | 23 ++++++-- .../android/core/TombstoneIntegrationTest.kt | 59 +++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) 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 e38d931bcbd..b4f2678dc9e 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 @@ -191,7 +191,11 @@ public boolean shouldReportHistorical() { final Hint hint = HintUtils.createWithTypeCheckHint(tombstoneHint); try { - mergeWithMatchingNativeEvents(tombstoneTimestamp, event, hint); + final @Nullable SentryEvent mergedEvent = + mergeWithMatchingNativeEvents(tombstoneTimestamp, event, hint); + if (mergedEvent != null) { + event = mergedEvent; + } } catch (Throwable e) { options .getLogger() @@ -204,15 +208,21 @@ public boolean shouldReportHistorical() { return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, tombstoneHint); } - private void mergeWithMatchingNativeEvents( - long tombstoneTimestamp, SentryEvent event, Hint hint) { + /** + * Attempts to find a matching native SDK event for the tombstone and merge them. + * + * @return The merged native event (with tombstone data applied) if a match was found and + * merged, or null if no matching event was found or merge failed. + */ + private @Nullable SentryEvent mergeWithMatchingNativeEvents( + long tombstoneTimestamp, SentryEvent tombstoneEvent, Hint hint) { // Try to find and remove matching native event from outbox final @Nullable NativeEventData matchingNativeEvent = nativeEventCollector.findAndRemoveMatchingNativeEvent(tombstoneTimestamp); if (matchingNativeEvent == null) { options.getLogger().log(SentryLevel.DEBUG, "No matching native event found for tombstone."); - return; + return null; } options @@ -226,9 +236,12 @@ private void mergeWithMatchingNativeEvents( boolean deletionSuccess = nativeEventCollector.deleteNativeEventFile(matchingNativeEvent); if (deletionSuccess) { - mergeNativeCrashes(matchingNativeEvent.getEvent(), event); + final SentryEvent nativeEvent = matchingNativeEvent.getEvent(); + mergeNativeCrashes(nativeEvent, tombstoneEvent); addNativeAttachmentsToTombstoneHint(matchingNativeEvent, hint); + return nativeEvent; } + return null; } private void addNativeAttachmentsToTombstoneHint( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt index 2238f8357bf..dccad763718 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt @@ -17,6 +17,7 @@ import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.argThat +import org.mockito.kotlin.check import org.mockito.kotlin.spy import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -118,6 +119,64 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase + val outboxDir = File(options.outboxPath!!) + outboxDir.mkdirs() + createNativeEnvelopeWithContext(outboxDir, newTimestamp) + } + + // Add tombstone with timestamp matching the native event + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + // Verify native SDK context is preserved + assertEquals("native-sdk-user-id", event.user?.id) + assertEquals("native-sdk-tag-value", event.getTag("native-sdk-tag")) + + // Verify tombstone stack trace data is applied + assertNotNull(event.exceptions) + assertTrue(event.exceptions!!.isNotEmpty()) + assertEquals("TombstoneMerged", event.exceptions!![0].mechanism?.type) + + // Verify tombstone debug meta is applied + assertNotNull(event.debugMeta) + assertTrue(event.debugMeta!!.images!!.isNotEmpty()) + + // Verify tombstone threads are applied (tombstone has 62 threads) + assertEquals(62, event.threads?.size) + }, + any(), + ) + } + + private fun createNativeEnvelopeWithContext(outboxDir: File, timestamp: Long): File { + val isoTimestamp = DateUtils.getTimestamp(DateUtils.getDateTime(timestamp)) + + // Native SDK event with user context and tags that should be preserved after merge + val eventJson = + """{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"$isoTimestamp","platform":"native","level":"fatal","user":{"id":"native-sdk-user-id"},"tags":{"native-sdk-tag":"native-sdk-tag-value"}}""" + val eventJsonSize = eventJson.toByteArray(Charsets.UTF_8).size + + val envelopeContent = + """ + {"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} + {"type":"event","length":$eventJsonSize,"content_type":"application/json"} + $eventJson + """ + .trimIndent() + + return File(outboxDir, "native-envelope-with-context.envelope").apply { + writeText(envelopeContent) + } + } + private fun createNativeEnvelopeWithAttachment(outboxDir: File, timestamp: Long): File { val isoTimestamp = DateUtils.getTimestamp(DateUtils.getDateTime(timestamp)) From 011008d3d1c46351788938ee6bf02803b03d3227 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Tue, 3 Feb 2026 20:06:28 +0100 Subject: [PATCH 13/20] handle null nativeLibraryDir in TombstoneParser --- .../internal/tombstone/TombstoneParser.java | 9 +++++---- .../internal/tombstone/TombstoneParserTest.kt | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java index 68ce50d84a1..cc3a28cef75 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java @@ -30,8 +30,7 @@ public class TombstoneParser implements Closeable { private final InputStream tombstoneStream; @NotNull private final List inAppIncludes; @NotNull private final List inAppExcludes; - // TODO: in theory can be null, but practically not for native crashes - private final String nativeLibraryDir; + @Nullable private final String nativeLibraryDir; private final Map excTypeValueMap = new HashMap<>(); private static String formatHex(long value) { @@ -42,7 +41,7 @@ public TombstoneParser( @NonNull final InputStream tombstoneStream, @NotNull List inAppIncludes, @NotNull List inAppExcludes, - String nativeLibraryDir) { + @Nullable String nativeLibraryDir) { this.tombstoneStream = tombstoneStream; this.inAppIncludes = inAppIncludes; this.inAppExcludes = inAppExcludes; @@ -122,7 +121,9 @@ private SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread Boolean inApp = SentryStackTraceFactory.isInApp(frame.getFunctionName(), inAppIncludes, inAppExcludes); - inApp = (inApp != null && inApp) || frame.getFileName().startsWith(this.nativeLibraryDir); + final boolean isInNativeLibraryDir = + nativeLibraryDir != null && frame.getFileName().startsWith(nativeLibraryDir); + inApp = (inApp != null && inApp) || isInNativeLibraryDir; stackFrame.setInApp(inApp); frames.add(0, stackFrame); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt index d5518abe675..14c044a73d4 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt @@ -410,6 +410,26 @@ class TombstoneParserTest { assertEquals(expectedJson, actualJson) } + @Test + fun `parses tombstone when nativeLibraryDir is null`() { + val tombstoneStream = + GZIPInputStream(TombstoneParserTest::class.java.getResourceAsStream("/tombstone.pb.gz")) + val parser = TombstoneParser(tombstoneStream, inAppIncludes, inAppExcludes, null) + val event = parser.parse() + + // Parsing should succeed without NPE + assertNotNull(event) + assertEquals(62, event.threads!!.size) + + // Without nativeLibraryDir, frames can only be marked inApp via inAppIncludes + // All frames should still have inApp set (either true or false) + for (thread in event.threads!!) { + for (frame in thread.stacktrace!!.frames!!) { + assertNotNull(frame.isInApp) + } + } + } + private fun serializeDebugMeta(debugMeta: DebugMeta): String { val logger = mock() val writer = StringWriter() From 423145d0537b361e8950a5b9566f0600b32573f9 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Tue, 3 Feb 2026 20:24:17 +0100 Subject: [PATCH 14/20] clarify inApp vs nativeLibraryDir usage in code comment --- .../android/core/internal/tombstone/TombstoneParser.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java index cc3a28cef75..b4aaca1138b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java @@ -117,6 +117,9 @@ private SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread stackFrame.setFunction(frame.getFunctionName()); stackFrame.setInstructionAddr(formatHex(frame.getPc())); + // inAppIncludes/inAppExcludes filter by Java/Kotlin package names, which don't overlap + // with native C/C++ function names (e.g., "crash", "__libc_init"). For native frames, + // isInApp() returns null, making nativeLibraryDir the effective in-app check. @Nullable Boolean inApp = SentryStackTraceFactory.isInApp(frame.getFunctionName(), inAppIncludes, inAppExcludes); From f0283199b91db4347b7b24da72e76b2b0355a574 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Thu, 5 Feb 2026 18:23:53 +0100 Subject: [PATCH 15/20] ignore stack frames from anonymous VMAs that don't resolve to a function name --- .../android/core/internal/tombstone/TombstoneParser.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java index b4aaca1138b..e1a0f754627 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java @@ -112,6 +112,11 @@ private SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread // We ignore all ART frames for time being because they aren't actionable for app developers continue; } + if (frame.getFileName().startsWith(" Date: Thu, 5 Feb 2026 21:16:17 +0100 Subject: [PATCH 16/20] use the right nativeLibraryDir for the tombstone test fixture --- .../android/core/ApplicationExitIntegrationTestBase.kt | 2 +- .../io/sentry/android/core/TombstoneIntegrationTest.kt | 9 +++++++++ .../core/internal/tombstone/TombstoneParserTest.kt | 7 +++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt index 13f9fca1782..ff30dfb2e10 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -56,7 +56,7 @@ abstract class ApplicationExitIntegrationTestBase { val context = ApplicationProvider.getApplicationContext() // the integration test app has no native library and as such we have to inject one here context.applicationInfo.nativeLibraryDir = - "/data/app/~~YtXYvdWm5vDHUWYCmVLG_Q==/io.sentry.samples.android-Q2_nG8SyOi4X_6hGGDGE2Q==/lib/arm64" + "/data/app/~~gu-2hA9_Zg6tfIuDAbLpKA==/io.sentry.samples.android-MFqmKAMnl9AjNlHcO3mejA==/lib/arm64" fixture.init(context) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt index dccad763718..4fe855cd255 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt @@ -78,6 +78,15 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase= 3, "Expected at least 3 in-app frames, got ${inAppFrames.size}") + // Should include the native sample library crash function + assertTrue( + inAppFrames.any { it.`package`?.contains("libnative-sample.so") == true }, + "Expected in-app frame from libnative-sample.so", + ) + val image = event.debugMeta?.images?.find { image -> image.codeId == "f60b4b74005f33fb3ef3b98aa4546008" } assertEquals("744b0bf6-5f00-fb33-3ef3-b98aa4546008", image!!.debugId) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt index 14c044a73d4..516b9190022 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/tombstone/TombstoneParserTest.kt @@ -54,7 +54,7 @@ class TombstoneParserTest { val inAppIncludes = arrayListOf("io.sentry.samples.android") val inAppExcludes = arrayListOf() val nativeLibraryDir = - "/data/app/~~YtXYvdWm5vDHUWYCmVLG_Q==/io.sentry.samples.android-Q2_nG8SyOi4X_6hGGDGE2Q==/lib/arm64" + "/data/app/~~gu-2hA9_Zg6tfIuDAbLpKA==/io.sentry.samples.android-MFqmKAMnl9AjNlHcO3mejA==/lib/arm64" @Test fun `parses a snapshot tombstone into Event`() { @@ -108,7 +108,7 @@ class TombstoneParserTest { if (frame.isInApp!!) { assert( frame.function!!.startsWith(inAppIncludes[0]) || - frame.filename!!.startsWith(nativeLibraryDir) + frame.`package`!!.startsWith(nativeLibraryDir) ) } } @@ -397,8 +397,7 @@ class TombstoneParserTest { @Test fun `debug meta images snapshot test`() { - // also test against a full snapshot so that we can track regressions in the VMA -> module - // reduction + // test against a full snapshot so that we can track regressions in the VMA -> module reduction val tombstoneStream = GZIPInputStream(TombstoneParserTest::class.java.getResourceAsStream("/tombstone.pb.gz")) val parser = TombstoneParser(tombstoneStream, inAppIncludes, inAppExcludes, nativeLibraryDir) From aeeacd2e4109c9bf4a73f4e976371f67ee88b2ad Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Fri, 6 Feb 2026 11:14:22 +0100 Subject: [PATCH 17/20] add proguard rule for protobuf-lite --- sentry-android-core/proguard-rules.pro | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index d5815518e15..25086b4d2b6 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -54,6 +54,10 @@ -keepnames class io.sentry.android.core.ApplicationNotResponding +# protobuf-java lite +# https://github.com/protocolbuffers/protobuf/blob/5d876c9fec1a6f2feb0750694f803f89312bffff/java/lite.md#r8-rule-to-make-production-app-builds-work +-keep class * extends com.google.protobuf.GeneratedMessageLite { *; } + ##---------------End: proguard configuration for android-core ---------- ##---------------Begin: proguard configuration for sentry-apollo-3 ---------- From 017ac5faabb927dc022c29ed6c633b4b50dcea52 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Fri, 6 Feb 2026 13:25:12 +0100 Subject: [PATCH 18/20] make BoundedInputStream safer wrt double closes --- .../sentry/android/core/NativeEventCollector.java | 4 ++++ .../android/core/NativeEventCollectorTest.kt | 15 +++++++++++++++ .../envelopes/java-then-native-large.txt | 5 +++++ 3 files changed, 24 insertions(+) create mode 100644 sentry-android-core/src/test/resources/envelopes/java-then-native-large.txt 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 index 6e7c1736562..2cf5acd05fa 100644 --- 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 @@ -527,6 +527,10 @@ public void close() throws IOException { // but don't close the underlying stream, because we might have other // envelope items to read. skipBytes(inner, remaining); + + // Reset remaining to 0 to handle multiple close() calls (e.g., from + // try-with-resources when wrapped by InputStreamReader). + remaining = 0; } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt index a6521280604..243c20a2069 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt @@ -147,6 +147,21 @@ class NativeEventCollectorTest { assertNotNull(match2) } + @Test + fun `collects native event that follows large java event in same envelope`() { + // This test verifies that BoundedInputStream.close() correctly handles being + // called multiple times (once by InputStreamReader.close() and once by + // try-with-resources). With a large payload (>8KB buffer), there will be + // remaining bytes after early JSON parsing exit, and double-close would + // corrupt the stream position if remaining isn't reset. + val sut = fixture.getSut(tmpDir) + copyEnvelopeToOutbox("java-then-native-large.txt") + + val timestamp = DateUtils.getDateTime("2023-07-15T10:31:00.000Z").time + val match = sut.findAndRemoveMatchingNativeEvent(timestamp) + assertNotNull(match) + } + @Test fun `ignores non-native events when collecting multiple envelopes`() { val sut = fixture.getSut(tmpDir) diff --git a/sentry-android-core/src/test/resources/envelopes/java-then-native-large.txt b/sentry-android-core/src/test/resources/envelopes/java-then-native-large.txt new file mode 100644 index 00000000000..97a0329ca15 --- /dev/null +++ b/sentry-android-core/src/test/resources/envelopes/java-then-native-large.txt @@ -0,0 +1,5 @@ +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +{"type":"event","length":10136,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:30:00.000Z","platform":"java","level":"error","extra_data":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} +{"type":"event","length":122,"content_type":"application/json"} +{"event_id":"aac79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:31:00.000Z","platform":"native","level":"fatal"} From 67aee7b92555c916aa2693e3e573c774344ba62c Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Fri, 6 Feb 2026 13:40:27 +0100 Subject: [PATCH 19/20] override empty function name behavior from SentryStackTraceFactory.isInApp() because it applies to class-names generically whereas we use it only for function name prefixes. --- .../android/core/internal/tombstone/TombstoneParser.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java index e1a0f754627..3235b566556 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/tombstone/TombstoneParser.java @@ -125,9 +125,14 @@ private SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread // inAppIncludes/inAppExcludes filter by Java/Kotlin package names, which don't overlap // with native C/C++ function names (e.g., "crash", "__libc_init"). For native frames, // isInApp() returns null, making nativeLibraryDir the effective in-app check. + // Protobuf returns "" for unset function names, which would incorrectly return true + // from isInApp(), so we treat empty as false to let nativeLibraryDir decide. + final String functionName = frame.getFunctionName(); @Nullable Boolean inApp = - SentryStackTraceFactory.isInApp(frame.getFunctionName(), inAppIncludes, inAppExcludes); + functionName.isEmpty() + ? Boolean.FALSE + : SentryStackTraceFactory.isInApp(functionName, inAppIncludes, inAppExcludes); final boolean isInNativeLibraryDir = nativeLibraryDir != null && frame.getFileName().startsWith(nativeLibraryDir); From 3e3fc7318069aa9bbb19b1fc6b1442e2feb2fd9d Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Fri, 6 Feb 2026 20:30:58 +0100 Subject: [PATCH 20/20] pre merge preps. --- CHANGELOG.md | 15 ++++++++++++++- .../sentry/android/core/SentryAndroidOptions.java | 4 ---- .../src/test/resources/envelopes/native-event.txt | 2 +- .../envelopes/native-with-attachment.txt | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6dc7597dbb..e8396a1e1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ - Add `installGroupsOverride` parameter and `installGroups` property to Build Distribution SDK ([#5062](https://github.com/getsentry/sentry-java/pull/5062)) - Update Android targetSdk to API 36 (Android 16) ([#5016](https://github.com/getsentry/sentry-java/pull/5016)) -- Merge Tombstone and Native SDK events into single crash event. ([#5037](https://github.com/getsentry/sentry-java/pull/5037)) - Add AndroidManifest support for Spotlight configuration via `io.sentry.spotlight.enable` and `io.sentry.spotlight.url` ([#5064](https://github.com/getsentry/sentry-java/pull/5064)) - Collect database transaction spans (`BEGIN`, `COMMIT`, `ROLLBACK`) ([#5072](https://github.com/getsentry/sentry-java/pull/5072)) - To enable creation of these spans, set `options.enableDatabaseTransactionTracing` to `true` @@ -16,6 +15,20 @@ sentry: enable-database-transaction-tracing: true ``` +- Add support for collecting native crashes using Tombstones ([#4933](https://github.com/getsentry/sentry-java/pull/4933), [#5037](https://github.com/getsentry/sentry-java/pull/5037)) + - Added Tombstone integration that detects native crashes using `ApplicationExitInfo.REASON_CRASH_NATIVE` on Android 12+ + - Crashes enriched with Tombstones contain more crash details and detailed thread info + - Tombstone and NDK integrations are now automatically merged into a single crash event, eliminating duplicate reports + - To enable it, add the integration in your Sentry initialization: + ```kotlin + SentryAndroid.init(context, options -> { + options.isTombstoneEnabled = true + }) + ``` + or in the `AndroidManifest.xml` using: + ```xml + + ``` ### Fixes 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..d106f63e75b 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 @@ -321,7 +321,6 @@ public void setAnrReportInDebug(boolean anrReportInDebug) { * * @param enableTombstone true for enabled and false for disabled */ - @ApiStatus.Internal public void setTombstoneEnabled(boolean enableTombstone) { this.enableTombstone = enableTombstone; } @@ -332,7 +331,6 @@ public void setTombstoneEnabled(boolean enableTombstone) { * * @return true if enabled or false otherwise */ - @ApiStatus.Internal public boolean isTombstoneEnabled() { return enableTombstone; } @@ -615,12 +613,10 @@ public void setReportHistoricalAnrs(final boolean reportHistoricalAnrs) { this.reportHistoricalAnrs = reportHistoricalAnrs; } - @ApiStatus.Internal public boolean isReportHistoricalTombstones() { return reportHistoricalTombstones; } - @ApiStatus.Internal public void setReportHistoricalTombstones(final boolean reportHistoricalTombstones) { this.reportHistoricalTombstones = reportHistoricalTombstones; } diff --git a/sentry-android-core/src/test/resources/envelopes/native-event.txt b/sentry-android-core/src/test/resources/envelopes/native-event.txt index b826347a7fd..5809f424035 100644 --- a/sentry-android-core/src/test/resources/envelopes/native-event.txt +++ b/sentry-android-core/src/test/resources/envelopes/native-event.txt @@ -1,3 +1,3 @@ {"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} -{"type":"event","length":123,"content_type":"application/json"} +{"type":"event","length":122,"content_type":"application/json"} {"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:30:00.000Z","platform":"native","level":"fatal"} diff --git a/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt b/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt index 1b4ff75a112..f7ea1e0a705 100644 --- a/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt +++ b/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt @@ -1,5 +1,5 @@ {"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} {"type":"attachment","length":20,"filename":"log.txt","content_type":"text/plain"} some attachment data -{"type":"event","length":123,"content_type":"application/json"} +{"type":"event","length":122,"content_type":"application/json"} {"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T11:45:30.500Z","platform":"native","level":"fatal"}