diff --git a/.gitignore b/.gitignore index 5f772be1b2..b8c2d7e9da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.java-version .idea/ .gradle/ .run/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 72431094bc..fa826520ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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)) ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ff9a0c7597..600fe404fb 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -291,6 +291,19 @@ 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 (J)Lio/sentry/android/core/NativeEventCollector$NativeEventData; +} + +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 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; @@ -500,7 +513,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/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index b7bb5bf21a..de42e13ee2 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 @@ -244,6 +244,7 @@ static void initializeIntegrationsAndProcessors( if (options.getSocketTagger() instanceof NoOpSocketTagger) { options.setSocketTagger(AndroidSocketTagger.getInstance()); } + if (options.getPerformanceCollectors().isEmpty()) { options.addPerformanceCollector(new AndroidMemoryCollector()); options.addPerformanceCollector(new AndroidCpuCollector(options.getLogger())); 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 f816c74f74..17d75b39c7 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"; @@ -205,6 +207,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( 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 0000000000..6e7c173656 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NativeEventCollector.java @@ -0,0 +1,532 @@ +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 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.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"; + + private static final long TIMESTAMP_TOLERANCE_MS = 5000; + + private final @NotNull SentryAndroidOptions options; + + /** 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; + + NativeEventData( + final @NotNull SentryEvent event, + final @NotNull File file, + final @NotNull SentryEnvelope envelope) { + this.event = event; + this.file = file; + this.envelope = envelope; + } + + public @NotNull SentryEvent getEvent() { + return event; + } + + public @NotNull File getFile() { + return file; + } + + public @NotNull SentryEnvelope getEnvelope() { + return envelope; + } + } + + /** + * 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); + 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; + } + if (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() || !isRelevantFileName(file.getName())) { + continue; + } + + 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(), + metadata.getTimestampMs()); + } + } + + options + .getLogger() + .log(SentryLevel.DEBUG, "Collected %d native events from outbox.", nativeEnvelopes.size()); + } + + /** + * 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 + * @return the matching native event data, or null if no match found + */ + public @Nullable NativeEventData findAndRemoveMatchingNativeEvent( + final long tombstoneTimestampMs) { + + // Lazily collect on first use (runs on executor thread, not main thread) + collect(); + + 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); + nativeEnvelopes.remove(metadata); + // Only load full event data when we have a match + return loadFullNativeEventData(metadata.getFile()); + } + } + + 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; + } + } + + /** + * 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) { + 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()), UTF_8))) { + final SentryEvent event = + options.getSerializer().deserialize(eventReader, SentryEvent.class); + if (event != null && NATIVE_PLATFORM.equals(event.getPlatform())) { + return new NativeEventData(event, file, envelope); + } + } + } + } catch (Throwable e) { + options + .getLogger() + .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; + } + + /** 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; + } + } + } + + 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); + } + + /** + * 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/main/java/io/sentry/android/core/TombstoneIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java index 6d1c56db5e..b4f2678dc9 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,22 +5,33 @@ 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; +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; @@ -30,6 +41,7 @@ import java.io.InputStream; 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; @@ -83,7 +95,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); } @@ -103,9 +115,13 @@ public void close() throws IOException { 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 @@ -133,7 +149,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) { @@ -147,7 +163,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) { @@ -169,8 +190,112 @@ public boolean shouldReportHistorical() { options.getFlushTimeoutMillis(), options.getLogger(), tombstoneTimestamp, enrich); final Hint hint = HintUtils.createWithTypeCheckHint(tombstoneHint); + try { + final @Nullable SentryEvent mergedEvent = + mergeWithMatchingNativeEvents(tombstoneTimestamp, event, hint); + if (mergedEvent != null) { + event = mergedEvent; + } + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to merge native event with tombstone, continuing without merge: %s", + e.getMessage()); + } + return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, tombstoneHint); } + + /** + * 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 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) { + final SentryEvent nativeEvent = matchingNativeEvent.getEvent(); + mergeNativeCrashes(nativeEvent, tombstoneEvent); + addNativeAttachmentsToTombstoneHint(matchingNativeEvent, hint); + return nativeEvent; + } + return null; + } + + 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 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: + // * 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); + } + } } @ApiStatus.Internal 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 18c10fac44..b4aaca1138 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; @@ -21,18 +22,30 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public class TombstoneParser implements Closeable { private final InputStream tombstoneStream; + @NotNull private final List inAppIncludes; + @NotNull private final List inAppExcludes; + @Nullable 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, + @Nullable 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 +104,31 @@ 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())); + + // 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); + + final boolean isInNativeLibraryDir = + nativeLibraryDir != null && frame.getFileName().startsWith(nativeLibraryDir); + inApp = (inApp != null && inApp) || isInNativeLibraryDir; + + stackFrame.setInApp(inApp); frames.add(0, stackFrame); } @@ -184,29 +214,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/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt index 4f2533d426..13f9fca178 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/NativeEventCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt new file mode 100644 index 0000000000..a652128060 --- /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/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt index 56aeebf7d2..dccad76371 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,24 @@ 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.check 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 @@ -75,7 +83,127 @@ 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" + }, + ) + } + + @Test + fun `when merging with native event, uses native event as base with tombstone stack traces`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + 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)) + + 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) + } } } 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 954ad0eccc..14c044a73d 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 @@ -1,10 +1,15 @@ package io.sentry.android.core.internal.tombstone +import io.sentry.ILogger +import io.sentry.JsonObjectWriter +import io.sentry.protocol.DebugMeta import java.io.ByteArrayInputStream +import java.io.StringWriter import java.util.zip.GZIPInputStream import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import org.mockito.kotlin.mock class TombstoneParserTest { val expectedRegisters = @@ -46,11 +51,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,13 +103,22 @@ 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)) } // debug-meta - assertEquals(357, event.debugMeta!!.images!!.size) + assertEquals(352, event.debugMeta!!.images!!.size) for (image in event.debugMeta!!.images!!) { assertEquals("elf", image.type) assertNotNull(image.debugId) @@ -111,6 +130,195 @@ class TombstoneParserTest { } } + @Test + fun `coalesces multiple memory mappings into single module`() { + // Simulate typical Android memory mappings where a single ELF file has multiple + // mappings with different permissions (r--p, r-xp, r--p, rw-p) + val buildId = "f1c3bcc0279865fe3058404b2831d9e64135386c" + + val tombstone = + TombstoneProtos.Tombstone.newBuilder() + .setPid(1234) + .setTid(1234) + .setSignalInfo( + TombstoneProtos.Signal.newBuilder() + .setNumber(11) + .setName("SIGSEGV") + .setCode(1) + .setCodeName("SEGV_MAPERR") + ) + // First mapping: r--p at offset 0 (ELF header, has build_id) + .addMemoryMappings( + TombstoneProtos.MemoryMapping.newBuilder() + .setBuildId(buildId) + .setMappingName("/system/lib64/libc.so") + .setBeginAddress(0x7000000000) + .setEndAddress(0x7000001000) + .setOffset(0) + .setRead(true) + .setWrite(false) + .setExecute(false) + ) + // Second mapping: r-xp at offset 0x1000 (executable segment) + .addMemoryMappings( + TombstoneProtos.MemoryMapping.newBuilder() + .setBuildId(buildId) + .setMappingName("/system/lib64/libc.so") + .setBeginAddress(0x7000001000) + .setEndAddress(0x7000010000) + .setOffset(0x1000) + .setRead(true) + .setWrite(false) + .setExecute(true) + ) + // Third mapping: r--p at offset 0x10000 (read-only data) + .addMemoryMappings( + TombstoneProtos.MemoryMapping.newBuilder() + .setBuildId(buildId) + .setMappingName("/system/lib64/libc.so") + .setBeginAddress(0x7000010000) + .setEndAddress(0x7000011000) + .setOffset(0x10000) + .setRead(true) + .setWrite(false) + .setExecute(false) + ) + // Fourth mapping: rw-p at offset 0x11000 (writable data) + .addMemoryMappings( + TombstoneProtos.MemoryMapping.newBuilder() + .setBuildId(buildId) + .setMappingName("/system/lib64/libc.so") + .setBeginAddress(0x7000011000) + .setEndAddress(0x7000012000) + .setOffset(0x11000) + .setRead(true) + .setWrite(true) + .setExecute(false) + ) + .putThreads( + 1234, + TombstoneProtos.Thread.newBuilder() + .setId(1234) + .setName("main") + .addCurrentBacktrace( + TombstoneProtos.BacktraceFrame.newBuilder() + .setPc(0x7000001100) + .setFunctionName("crash") + .setFileName("/system/lib64/libc.so") + ) + .build(), + ) + .build() + + val parser = + TombstoneParser( + ByteArrayInputStream(tombstone.toByteArray()), + inAppIncludes, + inAppExcludes, + nativeLibraryDir, + ) + val event = parser.parse() + + // All 4 mappings should be coalesced into a single module + val images = event.debugMeta!!.images!! + assertEquals(1, images.size) + + val image = images[0] + assertEquals("/system/lib64/libc.so", image.codeFile) + assertEquals(buildId, image.codeId) + // Module should span from first mapping start to last mapping end + assertEquals("0x7000000000", image.imageAddr) + assertEquals(0x7000012000 - 0x7000000000, image.imageSize) + } + + @Test + fun `handles duplicate mappings at offset 0 on Android`() { + // On some Android versions, the same ELF can have multiple mappings at offset 0 + // with different permissions (r--p and r-xp both at offset 0) + val buildId = "f1c3bcc0279865fe3058404b2831d9e64135386c" + + val tombstone = + TombstoneProtos.Tombstone.newBuilder() + .setPid(1234) + .setTid(1234) + .setSignalInfo( + TombstoneProtos.Signal.newBuilder() + .setNumber(11) + .setName("SIGSEGV") + .setCode(1) + .setCodeName("SEGV_MAPERR") + ) + // First mapping: r--p at offset 0 + .addMemoryMappings( + TombstoneProtos.MemoryMapping.newBuilder() + .setBuildId(buildId) + .setMappingName("/system/lib64/libdl.so") + .setBeginAddress(0x7000000000) + .setEndAddress(0x7000001000) + .setOffset(0) + .setRead(true) + .setWrite(false) + .setExecute(false) + ) + // Second mapping: r-xp at offset 0 (duplicate!) + .addMemoryMappings( + TombstoneProtos.MemoryMapping.newBuilder() + .setBuildId(buildId) + .setMappingName("/system/lib64/libdl.so") + .setBeginAddress(0x7000001000) + .setEndAddress(0x7000002000) + .setOffset(0) + .setRead(true) + .setWrite(false) + .setExecute(true) + ) + // Third mapping: r--p at offset 0 (another duplicate!) + .addMemoryMappings( + TombstoneProtos.MemoryMapping.newBuilder() + .setBuildId(buildId) + .setMappingName("/system/lib64/libdl.so") + .setBeginAddress(0x7000002000) + .setEndAddress(0x7000003000) + .setOffset(0) + .setRead(true) + .setWrite(false) + .setExecute(false) + ) + .putThreads( + 1234, + TombstoneProtos.Thread.newBuilder() + .setId(1234) + .setName("main") + .addCurrentBacktrace( + TombstoneProtos.BacktraceFrame.newBuilder() + .setPc(0x7000001100) + .setFunctionName("crash") + .setFileName("/system/lib64/libdl.so") + ) + .build(), + ) + .build() + + val parser = + TombstoneParser( + ByteArrayInputStream(tombstone.toByteArray()), + inAppIncludes, + inAppExcludes, + nativeLibraryDir, + ) + val event = parser.parse() + + // All duplicate mappings should be coalesced into a single module + val images = event.debugMeta!!.images!! + assertEquals(1, images.size) + + val image = images[0] + assertEquals("/system/lib64/libdl.so", image.codeFile) + // Module should span from first to last mapping + assertEquals("0x7000000000", image.imageAddr) + assertEquals(0x7000003000 - 0x7000000000, image.imageSize) + } + @Test fun `debugId falls back to codeId when OleGuidFormatter conversion fails`() { // Create a tombstone with a memory mapping that has an invalid buildId @@ -135,6 +343,8 @@ class TombstoneParserTest { .setMappingName("/system/lib64/libc.so") .setBeginAddress(0x7000000000) .setEndAddress(0x7000001000) + .setOffset(0) + .setRead(true) .setExecute(true) ) .addMemoryMappings( @@ -143,6 +353,8 @@ class TombstoneParserTest { .setMappingName("/system/lib64/libm.so") .setBeginAddress(0x7000002000) .setEndAddress(0x7000003000) + .setOffset(0) + .setRead(true) .setExecute(true) ) .putThreads( @@ -160,20 +372,79 @@ 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!! assertEquals(2, images.size) - // First image has invalid buildId - debugId should fall back to codeId + // First image has invalid buildId -> 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) + } + + @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() + 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/envelopes/attachment.txt b/sentry-android-core/src/test/resources/envelopes/attachment.txt new file mode 100644 index 0000000000..04a6e32325 --- /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 0000000000..4abe1bc18f --- /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 0000000000..2120266986 --- /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 0000000000..9d16e5bf4e --- /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 0000000000..b826347a7f --- /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 0000000000..1b4ff75a11 --- /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 0000000000..2b616d77e2 --- /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 0000000000..fe34ebf32e --- /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 0000000000..a685facab6 --- /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"}}]} 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 0000000000..7bb9bdbadb Binary files /dev/null and b/sentry-android-core/src/test/resources/tombstone_debug_meta.json.gz differ diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 7020c8e216..c4cedd818a 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 41934b0b51..3a36228784 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.