diff --git a/CHANGELOG.md b/CHANGELOG.md index 5632b6926b9..7faa69032a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- Add option to attach raw tombstone protobuf on native crash events ([#5446](https://github.com/getsentry/sentry-java/pull/5446)) + - Enable via `options.isAttachRawTombstone = true` or manifest: `` - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) ### Dependencies diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 3d4512fc2b4..249549f8366 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -374,6 +374,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isAnrProfilingEnabled ()Z public fun isAnrReportInDebug ()Z public fun isAttachAnrThreadDump ()Z + public fun isAttachRawTombstone ()Z public fun isAttachScreenshot ()Z public fun isAttachViewHierarchy ()Z public fun isCollectAdditionalContext ()Z @@ -401,6 +402,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setAnrReportInDebug (Z)V public fun setAnrTimeoutIntervalMillis (J)V public fun setAttachAnrThreadDump (Z)V + public fun setAttachRawTombstone (Z)V public fun setAttachScreenshot (Z)V public fun setAttachViewHierarchy (Z)V public fun setBeforeScreenshotCaptureCallback (Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index af3a942c8cc..8d88285a356 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -18,6 +18,7 @@ import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.threaddump.Lines; import io.sentry.android.core.internal.threaddump.ThreadDumpParser; +import io.sentry.android.core.internal.util.NativeEventUtils; import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; @@ -32,7 +33,6 @@ import io.sentry.util.Objects; import java.io.BufferedReader; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; @@ -194,7 +194,7 @@ public boolean shouldReportHistorical() { if (trace == null) { return new ParseResult(ParseResult.Type.NO_DUMP); } - dump = getDumpBytes(trace); + dump = NativeEventUtils.readBytes(trace); } catch (Throwable e) { options.getLogger().log(SentryLevel.WARNING, "Failed to read ANR thread dump", e); return new ParseResult(ParseResult.Type.NO_DUMP); @@ -223,20 +223,6 @@ public boolean shouldReportHistorical() { return new ParseResult(ParseResult.Type.ERROR, dump); } } - - private byte[] getDumpBytes(final @NotNull InputStream trace) throws IOException { - try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { - - int nRead; - final byte[] data = new byte[1024]; - - while ((nRead = trace.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, nRead); - } - - return buffer.toByteArray(); - } - } } @ApiStatus.Internal 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 b52634774d6..e16d4b312fc 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 @@ -36,6 +36,7 @@ final class ManifestMetadataReader { static final String ANR_REPORT_HISTORICAL = "io.sentry.anr.report-historical"; static final String TOMBSTONE_ENABLE = "io.sentry.tombstone.enable"; + static final String TOMBSTONE_ATTACH_RAW = "io.sentry.tombstone.attach-raw"; static final String AUTO_INIT = "io.sentry.auto-init"; static final String NDK_ENABLE = "io.sentry.ndk.enable"; @@ -226,6 +227,8 @@ static void applyMetadata( options.setAnrEnabled(readBool(metadata, logger, ANR_ENABLE, options.isAnrEnabled())); options.setTombstoneEnabled( readBool(metadata, logger, TOMBSTONE_ENABLE, options.isTombstoneEnabled())); + options.setAttachRawTombstone( + readBool(metadata, logger, TOMBSTONE_ATTACH_RAW, options.isAttachRawTombstone())); // use enableAutoSessionTracking as fallback options.setEnableAutoSessionTracking( 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 8fe702aad50..bb9ec17aabd 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 @@ -238,6 +238,12 @@ public interface BeforeCaptureCallback { */ private boolean attachAnrThreadDump = false; + /** + * Controls whether to attach the raw tombstone protobuf as an attachment. The tombstone is being + * attached from {@link ApplicationExitInfo#getTraceInputStream()}, if available. + */ + private boolean attachRawTombstone = false; + private boolean enablePerformanceV2 = true; private @Nullable SentryFrameMetricsCollector frameMetricsCollector; @@ -643,6 +649,14 @@ public void setAttachAnrThreadDump(final boolean attachAnrThreadDump) { this.attachAnrThreadDump = attachAnrThreadDump; } + public boolean isAttachRawTombstone() { + return attachRawTombstone; + } + + public void setAttachRawTombstone(final boolean attachRawTombstone) { + this.attachRawTombstone = attachRawTombstone; + } + /** * @return true if performance-v2 is enabled. See {@link #setEnablePerformanceV2(boolean)} for * more details. 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 f2b87742544..2663051f7e4 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 @@ -24,6 +24,7 @@ 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.android.core.internal.util.NativeEventUtils; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; import io.sentry.hints.NativeCrashExit; @@ -36,6 +37,7 @@ import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.HintUtils; import io.sentry.util.Objects; +import java.io.ByteArrayInputStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; @@ -150,26 +152,35 @@ public boolean shouldReportHistorical() { public @Nullable ApplicationExitInfoHistoryDispatcher.Report buildReport( final @NotNull ApplicationExitInfo exitInfo, final boolean enrich) { SentryEvent event; + @Nullable byte[] rawTombstone = null; try { - final InputStream tombstoneInputStream = exitInfo.getTraceInputStream(); - if (tombstoneInputStream == null) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "No tombstone InputStream available for ApplicationExitInfo from %s", - DateTimeFormatter.ISO_INSTANT.format( - Instant.ofEpochMilli(exitInfo.getTimestamp()))); - return null; - } + final boolean attachRaw = options.isAttachRawTombstone(); + try (final InputStream tombstoneInputStream = exitInfo.getTraceInputStream()) { + if (tombstoneInputStream == null) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "No tombstone InputStream available for ApplicationExitInfo from %s", + DateTimeFormatter.ISO_INSTANT.format( + Instant.ofEpochMilli(exitInfo.getTimestamp()))); + return null; + } - try (final TombstoneParser parser = - new TombstoneParser( - tombstoneInputStream, - this.options.getInAppIncludes(), - this.options.getInAppExcludes(), - this.context.getApplicationInfo().nativeLibraryDir)) { - event = parser.parse(); + if (attachRaw) { + rawTombstone = NativeEventUtils.readBytes(tombstoneInputStream); + } + + final InputStream parserInput = + attachRaw ? new ByteArrayInputStream(rawTombstone) : tombstoneInputStream; + try (final TombstoneParser parser = + new TombstoneParser( + parserInput, + this.options.getInAppIncludes(), + this.options.getInAppExcludes(), + this.context.getApplicationInfo().nativeLibraryDir)) { + event = parser.parse(); + } } } catch (Throwable e) { options @@ -190,6 +201,10 @@ public boolean shouldReportHistorical() { options.getFlushTimeoutMillis(), options.getLogger(), tombstoneTimestamp, enrich); final Hint hint = HintUtils.createWithTypeCheckHint(tombstoneHint); + if (rawTombstone != null) { + hint.setTombstone(Attachment.fromTombstone(rawTombstone)); + } + try { final @Nullable SentryEvent mergedEvent = mergeWithMatchingNativeEvents(tombstoneTimestamp, event, hint); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java index f8bd70cb6c3..c5e766b3c44 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/NativeEventUtils.java @@ -1,5 +1,8 @@ package io.sentry.android.core.internal.util; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.math.BigInteger; import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; @@ -8,6 +11,18 @@ import org.jetbrains.annotations.Nullable; public class NativeEventUtils { + + public static byte[] readBytes(final @NotNull InputStream stream) throws IOException { + try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + int nRead; + final byte[] data = new byte[1024]; + while ((nRead = stream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + return buffer.toByteArray(); + } + } + @Nullable public static String buildIdToDebugId(final @NotNull String buildId) { try { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index cedf5ca18bb..d8ac959601a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -288,6 +288,31 @@ class ManifestMetadataReaderTest { assertEquals(false, fixture.options.isAttachAnrThreadDump) } + @Test + fun `applyMetadata reads tombstone attach raw to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.TOMBSTONE_ATTACH_RAW to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isAttachRawTombstone) + } + + @Test + fun `applyMetadata reads tombstone attach raw to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isAttachRawTombstone) + } + @Test fun `applyMetadata reads anr report historical to options`() { // Arrange 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 3b27d69d087..9890d553dbc 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 @@ -96,6 +96,45 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase + options.isAttachRawTombstone = true + } + + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + any(), + argThat { + val tombstone = this.tombstone + tombstone != null && + tombstone.filename == "tombstone.pb" && + tombstone.contentType == "application/x-protobuf" && + tombstone.bytes != null && + tombstone.bytes!!.isNotEmpty() + }, + ) + } + + @Test + fun `when attachRawTombstone is disabled, no tombstone is attached to hint`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) { options -> + options.isAttachRawTombstone = false + } + + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), argThat { this.tombstone == null }) + } + @Test fun `when matching native event has attachments, they are added to the hint`() { val integration = diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index a433abbb37c..6bef3fe3c11 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -19,6 +19,7 @@ public final class io/sentry/Attachment { public static fun fromByteProvider (Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Z)Lio/sentry/Attachment; public static fun fromScreenshot ([B)Lio/sentry/Attachment; public static fun fromThreadDump ([B)Lio/sentry/Attachment; + public static fun fromTombstone ([B)Lio/sentry/Attachment; public static fun fromViewHierarchy (Lio/sentry/protocol/ViewHierarchy;)Lio/sentry/Attachment; public fun getAttachmentType ()Ljava/lang/String; public fun getByteProvider ()Ljava/util/concurrent/Callable; @@ -613,6 +614,7 @@ public final class io/sentry/Hint { public fun getReplayRecording ()Lio/sentry/ReplayRecording; public fun getScreenshot ()Lio/sentry/Attachment; public fun getThreadDump ()Lio/sentry/Attachment; + public fun getTombstone ()Lio/sentry/Attachment; public fun getViewHierarchy ()Lio/sentry/Attachment; public fun remove (Ljava/lang/String;)V public fun replaceAttachments (Ljava/util/List;)V @@ -620,6 +622,7 @@ public final class io/sentry/Hint { public fun setReplayRecording (Lio/sentry/ReplayRecording;)V public fun setScreenshot (Lio/sentry/Attachment;)V public fun setThreadDump (Lio/sentry/Attachment;)V + public fun setTombstone (Lio/sentry/Attachment;)V public fun setViewHierarchy (Lio/sentry/Attachment;)V public static fun withAttachment (Lio/sentry/Attachment;)Lio/sentry/Hint; public static fun withAttachments (Ljava/util/List;)Lio/sentry/Hint; diff --git a/sentry/src/main/java/io/sentry/Attachment.java b/sentry/src/main/java/io/sentry/Attachment.java index 439ad812b0c..3e4cb859e5e 100644 --- a/sentry/src/main/java/io/sentry/Attachment.java +++ b/sentry/src/main/java/io/sentry/Attachment.java @@ -396,4 +396,14 @@ boolean isAddToTransactions() { public static @NotNull Attachment fromThreadDump(final byte[] bytes) { return new Attachment(bytes, "thread-dump.txt", "text/plain", false); } + + /** + * Creates a new Tombstone Attachment + * + * @param bytes the array bytes + * @return the Attachment + */ + public static @NotNull Attachment fromTombstone(final byte[] bytes) { + return new Attachment(bytes, "tombstone.pb", "application/x-protobuf", false); + } } diff --git a/sentry/src/main/java/io/sentry/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index d7949b3133b..1e09dca5541 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -32,6 +32,7 @@ public final class Hint { private @Nullable Attachment screenshot = null; private @Nullable Attachment viewHierarchy = null; private @Nullable Attachment threadDump = null; + private @Nullable Attachment tombstone = null; private @Nullable ReplayRecording replayRecording = null; public static @NotNull Hint withAttachment(@Nullable Attachment attachment) { @@ -147,6 +148,14 @@ public void setThreadDump(final @Nullable Attachment threadDump) { return threadDump; } + public void setTombstone(final @Nullable Attachment tombstone) { + this.tombstone = tombstone; + } + + public @Nullable Attachment getTombstone() { + return tombstone; + } + @Nullable public ReplayRecording getReplayRecording() { return replayRecording; diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index c99fcaeaa2f..6f328d0fd58 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -399,6 +399,11 @@ private boolean shouldSendSessionUpdateForDroppedEvent( attachments.add(threadDump); } + @Nullable final Attachment tombstone = hint.getTombstone(); + if (tombstone != null) { + attachments.add(tombstone); + } + return attachments; } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 11ff80fd573..663b1f9bdee 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -2124,6 +2124,37 @@ class SentryClientTest { .send(check { envelope -> assertEquals(1, envelope.items.count()) }, anyOrNull()) } + @Test + fun `tombstone is added to the envelope from the hint`() { + val sut = fixture.getSut() + val attachment = Attachment.fromTombstone(byteArrayOf()) + val hint = Hint().also { it.tombstone = attachment } + + sut.captureEvent(SentryEvent(), hint) + + verify(fixture.transport) + .send( + check { envelope -> + val tombstone = envelope.items.last() + assertNotNull(tombstone) { assertEquals(attachment.filename, tombstone.header.fileName) } + }, + anyOrNull(), + ) + } + + @Test + fun `tombstone is dropped from hint via before send`() { + fixture.sentryOptions.beforeSend = CustomBeforeSendCallback() + val sut = fixture.getSut() + val attachment = Attachment.fromTombstone(byteArrayOf()) + val hint = Hint().also { it.tombstone = attachment } + + sut.captureEvent(SentryEvent(), hint) + + verify(fixture.transport) + .send(check { envelope -> assertEquals(1, envelope.items.count()) }, anyOrNull()) + } + @Test fun `capturing an error updates session and sends event + session`() { val sut = fixture.getSut() @@ -3647,6 +3678,7 @@ class SentryClientTest { hint.screenshot = null hint.viewHierarchy = null hint.threadDump = null + hint.tombstone = null return event } } diff --git a/sentry/src/test/java/io/sentry/hints/HintTest.kt b/sentry/src/test/java/io/sentry/hints/HintTest.kt index 7be03e7dd67..7b0c695bfd4 100644 --- a/sentry/src/test/java/io/sentry/hints/HintTest.kt +++ b/sentry/src/test/java/io/sentry/hints/HintTest.kt @@ -210,6 +210,7 @@ class HintTest { hint.screenshot = newAttachment("2") hint.viewHierarchy = newAttachment("3") hint.threadDump = newAttachment("4") + hint.tombstone = newAttachment("5") hint.clear() @@ -219,6 +220,7 @@ class HintTest { assertNotNull(hint.screenshot) assertNotNull(hint.viewHierarchy) assertNotNull(hint.threadDump) + assertNotNull(hint.tombstone) } @Test @@ -248,6 +250,15 @@ class HintTest { assertNotNull(hint.threadDump) } + @Test + fun `can create hint with a tombstone`() { + val hint = Hint() + val attachment = newAttachment("tombstone") + hint.tombstone = attachment + + assertNotNull(hint.tombstone) + } + companion object { fun newAttachment(content: String) = Attachment(content.toByteArray(), "$content.txt") }