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

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