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);
+
+ // Reset remaining to 0 to handle multiple close() calls (e.g., from
+ // try-with-resources when wrapped by InputStreamReader).
+ remaining = 0;
+ }
+ }
+}
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java
index 12917ed4b7..d106f63e75 100644
--- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java
@@ -321,7 +321,6 @@ public void setAnrReportInDebug(boolean anrReportInDebug) {
*
* @param enableTombstone true for enabled and false for disabled
*/
- @ApiStatus.Internal
public void setTombstoneEnabled(boolean enableTombstone) {
this.enableTombstone = enableTombstone;
}
@@ -332,7 +331,6 @@ public void setTombstoneEnabled(boolean enableTombstone) {
*
* @return true if enabled or false otherwise
*/
- @ApiStatus.Internal
public boolean isTombstoneEnabled() {
return enableTombstone;
}
@@ -615,12 +613,10 @@ public void setReportHistoricalAnrs(final boolean reportHistoricalAnrs) {
this.reportHistoricalAnrs = reportHistoricalAnrs;
}
- @ApiStatus.Internal
public boolean isReportHistoricalTombstones() {
return reportHistoricalTombstones;
}
- @ApiStatus.Internal
public void setReportHistoricalTombstones(final boolean reportHistoricalTombstones) {
this.reportHistoricalTombstones = reportHistoricalTombstones;
}
diff --git a/sentry-android-core/src/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..3235b56655 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,41 @@ 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;
+ }
+ if (frame.getFileName().startsWith(" 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..ff30dfb2e1 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/~~gu-2hA9_Zg6tfIuDAbLpKA==/io.sentry.samples.android-MFqmKAMnl9AjNlHcO3mejA==/lib/arm64"
fixture.init(context)
}
diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt
new file mode 100644
index 0000000000..243c20a206
--- /dev/null
+++ b/sentry-android-core/src/test/java/io/sentry/android/core/NativeEventCollectorTest.kt
@@ -0,0 +1,191 @@
+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 `collects native event that follows large java event in same envelope`() {
+ // This test verifies that BoundedInputStream.close() correctly handles being
+ // called multiple times (once by InputStreamReader.close() and once by
+ // try-with-resources). With a large payload (>8KB buffer), there will be
+ // remaining bytes after early JSON parsing exit, and double-close would
+ // corrupt the stream position if remaining isn't reset.
+ val sut = fixture.getSut(tmpDir)
+ copyEnvelopeToOutbox("java-then-native-large.txt")
+
+ val timestamp = DateUtils.getDateTime("2023-07-15T10:31:00.000Z").time
+ val match = sut.findAndRemoveMatchingNativeEvent(timestamp)
+ assertNotNull(match)
+ }
+
+ @Test
+ fun `ignores non-native events when collecting multiple envelopes`() {
+ val sut = fixture.getSut(tmpDir)
+ 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..4fe855cd25 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
@@ -70,12 +78,141 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase= 3, "Expected at least 3 in-app frames, got ${inAppFrames.size}")
+ // Should include the native sample library crash function
+ assertTrue(
+ inAppFrames.any { it.`package`?.contains("libnative-sample.so") == true },
+ "Expected in-app frame from libnative-sample.so",
+ )
+
val image =
event.debugMeta?.images?.find { image -> image.codeId == "f60b4b74005f33fb3ef3b98aa4546008" }
assertEquals("744b0bf6-5f00-fb33-3ef3-b98aa4546008", image!!.debugId)
assertNotNull(image)
assertEquals("/system/lib64/libcompiler_rt.so", image.codeFile)
- assertEquals("0x764c32a000", image.imageAddr)
- assertEquals(32768, image.imageSize)
+ assertEquals("0x764c325000", image.imageAddr)
+ assertEquals(57344, image.imageSize)
+ }
+
+ @Test
+ fun `when matching native event has attachments, they are added to the hint`() {
+ val integration =
+ fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options ->
+ // 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..516b919002 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/~~gu-2hA9_Zg6tfIuDAbLpKA==/io.sentry.samples.android-MFqmKAMnl9AjNlHcO3mejA==/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.`package`!!.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,78 @@ 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`() {
+ // 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/java-then-native-large.txt b/sentry-android-core/src/test/resources/envelopes/java-then-native-large.txt
new file mode 100644
index 0000000000..97a0329ca1
--- /dev/null
+++ b/sentry-android-core/src/test/resources/envelopes/java-then-native-large.txt
@@ -0,0 +1,5 @@
+{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"}
+{"type":"event","length":10136,"content_type":"application/json"}
+{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:30:00.000Z","platform":"java","level":"error","extra_data":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}
+{"type":"event","length":122,"content_type":"application/json"}
+{"event_id":"aac79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:31:00.000Z","platform":"native","level":"fatal"}
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..5809f42403
--- /dev/null
+++ b/sentry-android-core/src/test/resources/envelopes/native-event.txt
@@ -0,0 +1,3 @@
+{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"}
+{"type":"event","length":122,"content_type":"application/json"}
+{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","timestamp":"2023-07-15T10:30:00.000Z","platform":"native","level":"fatal"}
diff --git a/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt b/sentry-android-core/src/test/resources/envelopes/native-with-attachment.txt
new file mode 100644
index 0000000000..f7ea1e0a70
--- /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":122,"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 206d1b1315..a5537f1bde 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -3978,6 +3978,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.