diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd46b6a4d..ec8bf833fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ ### Features - Add debouncing mechanism and before-capture callbacks for screenshots and view hierarchies ([#2773](https://github.com/getsentry/sentry-java/pull/2773)) +- Improve ANRv2 implementation ([#2792](https://github.com/getsentry/sentry-java/pull/2792)) + - Add a proguard rule to keep `ApplicationNotResponding` class from obfuscation + - Add a new option `setReportHistoricalAnrs`; when enabled, it will report all of the ANRs from the [getHistoricalExitReasons](https://developer.android.com/reference/android/app/ActivityManager?hl=en#getHistoricalProcessExitReasons(java.lang.String,%20int,%20int)) list. + By default, the SDK only reports and enriches the latest ANR and only this one counts towards ANR rate. + Worth noting that this option is mainly useful when updating the SDK to the version where ANRv2 has been introduced, to report all ANRs happened prior to the SDK update. After that, the SDK will always pick up the latest ANR from the historical exit reasons list on next app restart, so there should be no historical ANRs to report. + These ANRs are reported with the `HistoricalAppExitInfo` mechanism. + - Add a new option `setAttachAnrThreadDump` to send ANR thread dump from the system as an attachment. + This is only useful as additional information, because the SDK attempts to parse the thread dump into proper threads with stacktraces by default. + - If [ApplicationExitInfo#getTraceInputStream](https://developer.android.com/reference/android/app/ApplicationExitInfo#getTraceInputStream()) returns null, the SDK no longer reports an ANR event, as these events are not very useful without it. + - Enhance regex patterns for native stackframes ## 6.23.0 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 7fd64ed8c5..ccfe195001 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -216,6 +216,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getStartupCrashDurationThresholdMillis ()J public fun isAnrEnabled ()Z public fun isAnrReportInDebug ()Z + public fun isAttachAnrThreadDump ()Z public fun isAttachScreenshot ()Z public fun isAttachViewHierarchy ()Z public fun isCollectAdditionalContext ()Z @@ -228,9 +229,11 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableNetworkEventBreadcrumbs ()Z public fun isEnableRootCheck ()Z public fun isEnableSystemEventBreadcrumbs ()Z + public fun isReportHistoricalAnrs ()Z public fun setAnrEnabled (Z)V public fun setAnrReportInDebug (Z)V public fun setAnrTimeoutIntervalMillis (J)V + public fun setAttachAnrThreadDump (Z)V public fun setAttachScreenshot (Z)V public fun setAttachViewHierarchy (Z)V public fun setBeforeScreenshotCaptureCallback (Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;)V @@ -249,6 +252,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setNativeSdkName (Ljava/lang/String;)V public fun setProfilingTracesHz (I)V public fun setProfilingTracesIntervalMillis (I)V + public fun setReportHistoricalAnrs (Z)V } public abstract interface class io/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback { diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 6e3c84c3bb..79daaace03 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -37,6 +37,8 @@ # Also keep method names, as they're e.g. used by native via JNI calls -keep class * extends io.sentry.SentryOptions { *; } +-keepnames class io.sentry.android.core.ApplicationNotResponding + ##---------------End: proguard configuration for android-core ---------- ##---------------Begin: proguard configuration for sentry-apollo-3 ---------- diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 7f338af54c..cdbbd12c6b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -152,8 +152,12 @@ private void backfillScope(final @NotNull SentryEvent event) { private void setTrace(final @NotNull SentryEvent event) { final SpanContext spanContext = PersistingScopeObserver.read(options, TRACE_FILENAME, SpanContext.class); - if (event.getContexts().getTrace() == null && spanContext != null) { - event.getContexts().setTrace(spanContext); + if (event.getContexts().getTrace() == null) { + if (spanContext != null + && spanContext.getSpanId() != null + && spanContext.getTraceId() != null) { + event.getContexts().setTrace(spanContext); + } } } @@ -190,8 +194,13 @@ private void setContexts(final @NotNull SentryBaseEvent event) { } final Contexts eventContexts = event.getContexts(); for (Map.Entry entry : new Contexts(persistedContexts).entrySet()) { + final Object value = entry.getValue(); + if (SpanContext.TYPE.equals(entry.getKey()) && value instanceof SpanContext) { + // we fill it in setTrace later on + continue; + } if (!eventContexts.containsKey(entry.getKey())) { - eventContexts.put(entry.getKey(), entry.getValue()); + eventContexts.put(entry.getKey(), value); } } } @@ -446,7 +455,12 @@ private void setExceptions(final @NotNull SentryEvent event, final @NotNull Obje // AnrV2 threads contain a thread dump from the OS, so we just search for the main thread dump // and make an exception out of its stacktrace final Mechanism mechanism = new Mechanism(); - mechanism.setType("AppExitInfo"); + if (!((Backfillable) hint).shouldEnrich()) { + // we only enrich the latest ANR in the list, so this is historical + mechanism.setType("HistoricalAppExitInfo"); + } else { + mechanism.setType("AppExitInfo"); + } final boolean isBackgroundAnr = isBackgroundAnr(hint); String message = "ANR"; 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 17dbff486e..f3d257d225 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 @@ -4,6 +4,7 @@ import android.app.ActivityManager; import android.app.ApplicationExitInfo; import android.content.Context; +import io.sentry.Attachment; import io.sentry.DateUtils; import io.sentry.Hint; import io.sentry.IHub; @@ -20,6 +21,7 @@ import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; +import io.sentry.protocol.Message; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryThread; import io.sentry.transport.CurrentDateProvider; @@ -27,8 +29,11 @@ import io.sentry.util.HintUtils; 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; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Collections; @@ -186,8 +191,10 @@ public void run() { return; } - // report the remainder without enriching - reportNonEnrichedHistoricalAnrs(exitInfos, lastReportedAnrTimestamp); + if (options.isReportHistoricalAnrs()) { + // report the remainder without enriching + reportNonEnrichedHistoricalAnrs(exitInfos, lastReportedAnrTimestamp); + } // report the latest ANR with enriching, if contexts are available, otherwise report it // non-enriched @@ -228,7 +235,16 @@ private void reportAsSentryEvent( final boolean isBackground = exitInfo.getImportance() != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; - final List threads = parseThreadDump(exitInfo, isBackground); + final ParseResult result = parseThreadDump(exitInfo, isBackground); + if (result.type == ParseResult.Type.NO_DUMP) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Not reporting ANR event as there was no thread dump for the ANR %s", + exitInfo.toString()); + return; + } final AnrV2Hint anrHint = new AnrV2Hint( options.getFlushTimeoutMillis(), @@ -240,9 +256,24 @@ private void reportAsSentryEvent( final Hint hint = HintUtils.createWithTypeCheckHint(anrHint); final SentryEvent event = new SentryEvent(); - event.setThreads(threads); - event.setTimestamp(DateUtils.getDateTime(anrTimestamp)); + if (result.type == ParseResult.Type.ERROR) { + final Message sentryMessage = new Message(); + sentryMessage.setFormatted( + "Sentry Android SDK failed to parse system thread dump for " + + "this ANR. We recommend enabling [SentryOptions.isAttachAnrThreadDump] option " + + "to attach the thread dump as plain text and report this issue on GitHub."); + event.setMessage(sentryMessage); + } else if (result.type == ParseResult.Type.DUMP) { + event.setThreads(result.threads); + } event.setLevel(SentryLevel.FATAL); + event.setTimestamp(DateUtils.getDateTime(anrTimestamp)); + + if (options.isAttachAnrThreadDump()) { + if (result.dump != null) { + hint.setThreadDump(Attachment.fromThreadDump(result.dump)); + } + } final @NotNull SentryId sentryId = hub.captureEvent(event, hint); final boolean isEventDropped = sentryId.equals(SentryId.EMPTY_ID); @@ -259,20 +290,56 @@ private void reportAsSentryEvent( } } - private @Nullable List parseThreadDump( + private @NotNull ParseResult parseThreadDump( final @NotNull ApplicationExitInfo exitInfo, final boolean isBackground) { - List threads = null; + InputStream trace; + try { + trace = exitInfo.getTraceInputStream(); + if (trace == null) { + return new ParseResult(ParseResult.Type.NO_DUMP); + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.WARNING, "Failed to read ANR thread dump", e); + return new ParseResult(ParseResult.Type.NO_DUMP); + } + + byte[] dump = null; + try { + dump = getDumpBytes(trace); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to convert ANR thread dump to byte array", e); + } + try (final BufferedReader reader = - new BufferedReader(new InputStreamReader(exitInfo.getTraceInputStream()))) { + new BufferedReader(new InputStreamReader(new ByteArrayInputStream(dump)))) { final Lines lines = Lines.readLines(reader); final ThreadDumpParser threadDumpParser = new ThreadDumpParser(options, isBackground); - threads = threadDumpParser.parse(lines); + final List threads = threadDumpParser.parse(lines); + if (threads.isEmpty()) { + // if the list is empty this means our regex matching is garbage and this is still error + return new ParseResult(ParseResult.Type.ERROR, dump); + } + return new ParseResult(ParseResult.Type.DUMP, dump, threads); } catch (Throwable e) { options.getLogger().log(SentryLevel.WARNING, "Failed to parse ANR thread dump", e); + return new ParseResult(ParseResult.Type.ERROR, dump); + } + } + + private byte[] getDumpBytes(final @NotNull InputStream trace) throws IOException { + 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 threads; + return buffer.toByteArray(); } } @@ -313,4 +380,36 @@ public String mechanism() { return isBackgroundAnr ? "anr_background" : "anr_foreground"; } } + + static final class ParseResult { + + enum Type { + DUMP, + NO_DUMP, + ERROR + } + + final Type type; + final byte[] dump; + final @Nullable List threads; + + ParseResult(final @NotNull Type type) { + this.type = type; + this.dump = null; + this.threads = null; + } + + ParseResult(final @NotNull Type type, final byte[] dump) { + this.type = type; + this.dump = dump; + this.threads = null; + } + + ParseResult( + final @NotNull Type type, final byte[] dump, final @Nullable List threads) { + this.type = type; + this.dump = dump; + this.threads = threads; + } + } } 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 f28112ca11..62437a0425 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 @@ -1,5 +1,7 @@ package io.sentry.android.core; +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; import io.sentry.Hint; import io.sentry.ISpan; import io.sentry.Scope; @@ -8,6 +10,7 @@ import io.sentry.SentryOptions; import io.sentry.SpanStatus; import io.sentry.android.core.internal.util.RootChecker; +import io.sentry.protocol.Mechanism; import io.sentry.protocol.SdkVersion; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -182,6 +185,24 @@ public interface BeforeCaptureCallback { boolean execute(@NotNull SentryEvent event, @NotNull Hint hint, boolean debounce); } + /** + * Controls whether to report historical ANRs (v2) from the {@link ApplicationExitInfo} system + * API. When enabled, reports all of the ANRs available in the {@link + * ActivityManager#getHistoricalProcessExitReasons(String, int, int)} list, as opposed to + * reporting only the latest one. + * + *

These events do not affect ANR rate nor are they enriched with additional information from + * {@link Scope} like breadcrumbs. The events are reported with 'HistoricalAppExitInfo' {@link + * Mechanism}. + */ + private boolean reportHistoricalAnrs = false; + + /** + * Controls whether to send ANR (v2) thread dump as an attachment with plain text. The thread dump + * is being attached from {@link ApplicationExitInfo#getTraceInputStream()}, if available. + */ + private boolean attachAnrThreadDump = false; + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -510,4 +531,20 @@ public void setBeforeViewHierarchyCaptureCallback( final @NotNull BeforeCaptureCallback beforeViewHierarchyCaptureCallback) { this.beforeViewHierarchyCaptureCallback = beforeViewHierarchyCaptureCallback; } + + public boolean isReportHistoricalAnrs() { + return reportHistoricalAnrs; + } + + public void setReportHistoricalAnrs(final boolean reportHistoricalAnrs) { + this.reportHistoricalAnrs = reportHistoricalAnrs; + } + + public boolean isAttachAnrThreadDump() { + return attachAnrThreadDump; + } + + public void setAttachAnrThreadDump(final boolean attachAnrThreadDump) { + this.attachAnrThreadDump = attachAnrThreadDump; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java index 850d8da211..da90653092 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java @@ -39,9 +39,11 @@ public class ThreadDumpParser { private static final Pattern BEGIN_MANAGED_THREAD_RE = Pattern.compile("\"(.*)\" (.*) ?prio=(\\d+)\\s+tid=(\\d+)\\s*(.*)"); private static final Pattern NATIVE_RE = - Pattern.compile(" (?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*)\\s+\\((.*)\\+(\\d+)\\)"); + Pattern.compile( + " (?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*?)\\s+\\((.*)\\+(\\d+)\\)(?: \\(.*\\))?"); private static final Pattern NATIVE_NO_LOC_RE = - Pattern.compile(" (?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*)\\s*\\(?(.*)\\)?"); + Pattern.compile( + " (?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*)\\s*\\(?(.*)\\)?(?: \\(.*\\))?"); private static final Pattern JAVA_RE = Pattern.compile(" at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\((.*):([\\d-]+)\\)"); private static final Pattern JNI_RE = @@ -179,14 +181,14 @@ private SentryStackTrace parseStacktrace( if (matches(nativeRe, text)) { final SentryStackFrame frame = new SentryStackFrame(); frame.setPackage(nativeRe.group(1)); - frame.setSymbol(nativeRe.group(2)); + frame.setFunction(nativeRe.group(2)); frame.setLineno(getInteger(nativeRe, 3, null)); frames.add(frame); lastJavaFrame = null; } else if (matches(nativeNoLocRe, text)) { final SentryStackFrame frame = new SentryStackFrame(); frame.setPackage(nativeNoLocRe.group(1)); - frame.setSymbol(nativeNoLocRe.group(2)); + frame.setFunction(nativeNoLocRe.group(2)); frames.add(frame); lastJavaFrame = null; } else if (matches(javaRe, text)) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index a52aee527a..4b0e6de7b1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -107,6 +107,7 @@ class AnrV2EventProcessorTest { persistScope( CONTEXTS_FILENAME, Contexts().apply { + trace = SpanContext("test") setResponse(Response().apply { bodySize = 1024 }) setBrowser(Browser().apply { name = "Google Chrome" }) } @@ -169,6 +170,15 @@ class AnrV2EventProcessorTest { assertEquals(emptyMap(), processed.contexts) } + @Test + fun `when backfillable event is not enrichable, sets different mechanism`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) + + val processed = processEvent(hint) + + assertEquals("HistoricalAppExitInfo", processed.exceptions!![0].mechanism!!.type) + } + @Test fun `when backfillable event is not enrichable, sets platform`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index 4db0d425c4..7fce1a126e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -24,6 +24,7 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat +import org.mockito.kotlin.atMost import org.mockito.kotlin.check import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -42,6 +43,7 @@ import kotlin.concurrent.thread import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -67,7 +69,9 @@ class AnrV2IntegrationTest { flushTimeoutMillis: Long = 0L, lastReportedAnrTimestamp: Long? = null, lastEventId: SentryId = SentryId(), - sessionTrackingEnabled: Boolean = true + sessionTrackingEnabled: Boolean = true, + reportHistoricalAnrs: Boolean = true, + attachAnrThreadDump: Boolean = false ): AnrV2Integration { options.run { setLogger(this@Fixture.logger) @@ -78,6 +82,8 @@ class AnrV2IntegrationTest { this.isAnrEnabled = isAnrEnabled this.flushTimeoutMillis = flushTimeoutMillis this.isEnableAutoSessionTracking = sessionTrackingEnabled + this.isReportHistoricalAnrs = reportHistoricalAnrs + this.isAttachAnrThreadDump = attachAnrThreadDump addInAppInclude("io.sentry.samples") setEnvelopeDiskCache(EnvelopeCache.create(this)) } @@ -93,7 +99,8 @@ class AnrV2IntegrationTest { fun addAppExitInfo( reason: Int? = ApplicationExitInfo.REASON_ANR, timestamp: Long? = null, - importance: Int? = null + importance: Int? = null, + addTrace: Boolean = true ) { val builder = ApplicationExitInfoBuilder.newBuilder() if (reason != null) { @@ -106,6 +113,9 @@ class AnrV2IntegrationTest { builder.setImportance(importance) } val exitInfo = spy(builder.build()) { + if (!addTrace) { + return + } whenever(mock.traceInputStream).thenReturn( """ "main" prio=5 tid=1 Blocked @@ -259,9 +269,11 @@ class AnrV2IntegrationTest { assertEquals(false, otherThread.isMain) val firstFrame = otherThread.stacktrace!!.frames!!.first() assertEquals( - "/apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b)", + "/apex/com.android.runtime/lib64/bionic/libc.so", firstFrame.`package` ) + assertEquals("__start_thread", firstFrame.function) + assertEquals(64, firstFrame.lineno) }, argThat { val hint = HintUtils.getSentrySdkHint(this) @@ -358,6 +370,29 @@ class AnrV2IntegrationTest { ) } + @Test + fun `when historical ANRs flag is disabled, does not report`() { + val integration = fixture.getSut( + tmpDir, + lastReportedAnrTimestamp = oldTimestamp, + reportHistoricalAnrs = false + ) + fixture.addAppExitInfo(timestamp = newTimestamp - 2 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.hub, fixture.options) + + // only the latest anr is reported which should be enrichable + verify(fixture.hub, atMost(1)).captureEvent( + any(), + argThat { + val hint = HintUtils.getSentrySdkHint(this) + (hint as AnrV2Hint).shouldEnrich() + } + ) + } + @Test fun `historical ANRs are reported in reverse order to keep track of last reported ANR in a marker file`() { val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) @@ -486,4 +521,33 @@ class AnrV2IntegrationTest { // should return true, because latch is 0 now assertTrue((fixture.options.envelopeDiskCache as EnvelopeCache).waitPreviousSessionFlush()) } + + @Test + fun `attaches plain thread dump, if enabled`() { + val integration = fixture.getSut( + tmpDir, + lastReportedAnrTimestamp = oldTimestamp, + attachAnrThreadDump = true + ) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub).captureEvent( + any(), + check { + assertNotNull(it.threadDump) + } + ) + } + + @Test + fun `when traceInputStream is null, does not report ANR`() { + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp, addTrace = false) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 5ec657c21c..876bb3bd78 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -41,7 +41,9 @@ import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.spy import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager @@ -101,7 +103,41 @@ class SentryAndroidTest { if (importance != null) { builder.setImportance(importance) } - shadowActivityManager.addApplicationExitInfo(builder.build()) + val exitInfo = spy(builder.build()) { + whenever(mock.traceInputStream).thenReturn( + """ +"main" prio=5 tid=1 Blocked + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) + - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 + at android.os.Handler.handleCallback(Handler.java:942) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7872) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) + +"perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) + | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 + | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 + | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 + | stack=0x7b20124000-0x7b20126000 stackSize=991KB + | held mutexes= + native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) + native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + (no managed stack frames) + """.trimIndent().byteInputStream() + ) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt index 3f91a051f4..c2996c5d08 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt @@ -60,9 +60,11 @@ class ThreadDumpParserTest { assertEquals(false, randomThread.isMain) assertEquals(false, randomThread.isCurrent) assertEquals( - "/apex/com.android.runtime/lib64/bionic/libc.so (__epoll_pwait+8) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b)", + "/apex/com.android.runtime/lib64/bionic/libc.so", randomThread.stacktrace!!.frames!!.last().`package` ) + assertEquals("__epoll_pwait", randomThread.stacktrace!!.frames!!.last()!!.function) + assertEquals(8, randomThread.stacktrace!!.frames!!.last()!!.lineno) val firstFrame = randomThread.stacktrace!!.frames!!.first() assertEquals("android.os.HandlerThread", firstFrame.module) assertEquals("run", firstFrame.function) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 187725650e..0e9d4c1a84 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -16,6 +16,7 @@ public final class io/sentry/Attachment { public fun ([BLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun ([BLjava/lang/String;Ljava/lang/String;Z)V public static fun fromScreenshot ([B)Lio/sentry/Attachment; + public static fun fromThreadDump ([B)Lio/sentry/Attachment; public static fun fromViewHierarchy (Lio/sentry/protocol/ViewHierarchy;)Lio/sentry/Attachment; public fun getAttachmentType ()Ljava/lang/String; public fun getBytes ()[B @@ -309,11 +310,13 @@ public final class io/sentry/Hint { public fun getAs (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; public fun getAttachments ()Ljava/util/List; public fun getScreenshot ()Lio/sentry/Attachment; + public fun getThreadDump ()Lio/sentry/Attachment; public fun getViewHierarchy ()Lio/sentry/Attachment; public fun remove (Ljava/lang/String;)V public fun replaceAttachments (Ljava/util/List;)V public fun set (Ljava/lang/String;Ljava/lang/Object;)V public fun setScreenshot (Lio/sentry/Attachment;)V + public fun setThreadDump (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 4f2e12fe40..7a4ec3b99d 100644 --- a/sentry/src/main/java/io/sentry/Attachment.java +++ b/sentry/src/main/java/io/sentry/Attachment.java @@ -334,4 +334,14 @@ boolean isAddToTransactions() { VIEW_HIERARCHY_ATTACHMENT_TYPE, false); } + + /** + * Creates a new Thread Dump Attachment + * + * @param bytes the array bytes + * @return the Attachment + */ + public static @NotNull Attachment fromThreadDump(final byte[] bytes) { + return new Attachment(bytes, "thread-dump.txt", "text/plain", false); + } } diff --git a/sentry/src/main/java/io/sentry/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index ab945c5605..07dde3cb80 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -30,6 +30,8 @@ public final class Hint { private @Nullable Attachment screenshot = null; private @Nullable Attachment viewHierarchy = null; + private @Nullable Attachment threadDump = null; + public static @NotNull Hint withAttachment(@Nullable Attachment attachment) { @NotNull final Hint hint = new Hint(); hint.addAttachment(attachment); @@ -126,6 +128,14 @@ public void setViewHierarchy(final @Nullable Attachment viewHierarchy) { return viewHierarchy; } + public void setThreadDump(final @Nullable Attachment threadDump) { + this.threadDump = threadDump; + } + + public @Nullable Attachment getThreadDump() { + return threadDump; + } + private boolean isCastablePrimitive(@Nullable Object hintValue, @NotNull Class clazz) { Class nonPrimitiveClass = PRIMITIVE_MAPPINGS.get(clazz.getCanonicalName()); return hintValue != null diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 7570611b58..77f3e1462e 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -251,6 +251,11 @@ private boolean shouldSendSessionUpdateForDroppedEvent( attachments.add(viewHierarchy); } + @Nullable final Attachment threadDump = hint.getThreadDump(); + if (threadDump != null) { + attachments.add(threadDump); + } + return attachments; } diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index f5c531628c..24d7c62aa3 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -154,7 +154,7 @@ public void store(final @NotNull SentryEnvelope envelope, final @NotNull Hint hi SentryCrashLastRunState.getInstance().setCrashedLastRun(crashedLastRun); - previousSessionLatch.countDown(); + flushPreviousSession(); } // TODO: probably we need to update the current session file for session updates to because of diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 19dff0a504..b17235f738 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -1537,6 +1537,42 @@ class SentryClientTest { ) } + @Test + fun `thread dump is added to the envelope from the hint`() { + val sut = fixture.getSut() + val attachment = Attachment.fromThreadDump(byteArrayOf()) + val hint = Hint().also { it.threadDump = attachment } + + sut.captureEvent(SentryEvent(), hint) + + verify(fixture.transport).send( + check { envelope -> + val threadDump = envelope.items.last() + assertNotNull(threadDump) { + assertEquals(attachment.filename, threadDump.header.fileName) + } + }, + anyOrNull() + ) + } + + @Test + fun `thread dump is dropped from hint via before send`() { + fixture.sentryOptions.beforeSend = CustomBeforeSendCallback() + val sut = fixture.getSut() + val attachment = Attachment.fromThreadDump(byteArrayOf()) + val hint = Hint().also { it.threadDump = 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() @@ -2127,6 +2163,7 @@ class SentryClientTest { override fun execute(event: SentryEvent, hint: Hint): SentryEvent? { hint.screenshot = null hint.viewHierarchy = null + hint.threadDump = 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 054baedcae..520e0a723b 100644 --- a/sentry/src/test/java/io/sentry/hints/HintTest.kt +++ b/sentry/src/test/java/io/sentry/hints/HintTest.kt @@ -209,6 +209,7 @@ class HintTest { hint.addAttachment(newAttachment("test attachment")) hint.screenshot = newAttachment("2") hint.viewHierarchy = newAttachment("3") + hint.threadDump = newAttachment("4") hint.clear() @@ -217,6 +218,7 @@ class HintTest { assertEquals(1, hint.attachments.size) assertNotNull(hint.screenshot) assertNotNull(hint.viewHierarchy) + assertNotNull(hint.threadDump) } @Test @@ -237,6 +239,15 @@ class HintTest { assertNotNull(hint.viewHierarchy) } + @Test + fun `can create hint with a thread dump`() { + val hint = Hint() + val attachment = newAttachment("thread-dump") + hint.threadDump = attachment + + assertNotNull(hint.threadDump) + } + companion object { fun newAttachment(content: String) = Attachment(content.toByteArray(), "$content.txt") }