diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e81bd10e0..098a728ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Features + +- Add native stack frame address information and debug image metadata to ANR events ([#4061](https://github.com/getsentry/sentry-java/pull/4061)) + - This enables symbolication for stripped native code in ANRs + ### Fixes - Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) 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 6b66106d3f..c6d47cadcb 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 @@ -23,6 +23,8 @@ import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; +import io.sentry.protocol.DebugImage; +import io.sentry.protocol.DebugMeta; import io.sentry.protocol.Message; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryThread; @@ -267,6 +269,11 @@ private void reportAsSentryEvent( event.setMessage(sentryMessage); } else if (result.type == ParseResult.Type.DUMP) { event.setThreads(result.threads); + if (result.debugImages != null) { + final DebugMeta debugMeta = new DebugMeta(); + debugMeta.setImages(result.debugImages); + event.setDebugMeta(debugMeta); + } } event.setLevel(SentryLevel.FATAL); event.setTimestamp(DateUtils.getDateTime(anrTimestamp)); @@ -311,7 +318,11 @@ private void reportAsSentryEvent( final Lines lines = Lines.readLines(reader); final ThreadDumpParser threadDumpParser = new ThreadDumpParser(options, isBackground); - final List threads = threadDumpParser.parse(lines); + threadDumpParser.parse(lines); + + final @NotNull List threads = threadDumpParser.getThreads(); + final @NotNull List debugImages = threadDumpParser.getDebugImages(); + if (threads.isEmpty()) { // if the list is empty this means the system failed to capture a proper thread dump of // the android threads, and only contains kernel-level threads and statuses, those ANRs @@ -319,7 +330,7 @@ private void reportAsSentryEvent( // fall back to not reporting them return new ParseResult(ParseResult.Type.NO_DUMP); } - return new ParseResult(ParseResult.Type.DUMP, dump, threads); + return new ParseResult(ParseResult.Type.DUMP, dump, threads, debugImages); } catch (Throwable e) { options.getLogger().log(SentryLevel.WARNING, "Failed to parse ANR thread dump", e); return new ParseResult(ParseResult.Type.ERROR, dump); @@ -403,24 +414,31 @@ enum Type { final Type type; final byte[] dump; final @Nullable List threads; + final @Nullable List debugImages; ParseResult(final @NotNull Type type) { this.type = type; this.dump = null; this.threads = null; + this.debugImages = null; } ParseResult(final @NotNull Type type, final byte[] dump) { this.type = type; this.dump = dump; this.threads = null; + this.debugImages = null; } ParseResult( - final @NotNull Type type, final byte[] dump, final @Nullable List threads) { + final @NotNull Type type, + final byte[] dump, + final @Nullable List threads, + final @Nullable List debugImages) { this.type = type; this.dump = dump; this.threads = threads; + this.debugImages = debugImages; } } } 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 43d729b78b..922828c207 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 @@ -22,9 +22,14 @@ import io.sentry.SentryLockReason; import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; +import io.sentry.protocol.DebugImage; import io.sentry.protocol.SentryStackFrame; import io.sentry.protocol.SentryStackTrace; import io.sentry.protocol.SentryThread; +import java.math.BigInteger; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -42,12 +47,40 @@ public class ThreadDumpParser { private static final Pattern BEGIN_UNMANAGED_NATIVE_THREAD_RE = Pattern.compile("\"(.*)\" (.*) ?sysTid=(\\d+)"); + // For reference, see native_stack_dump.cc and tombstone_proto_to_text.cpp in Android sources + // Groups + // 0:entire regex + // 1:index + // 2:pc + // 3:mapinfo + // 4:filename + // 5:mapoffset + // 6:function + // 7:fnoffset + // 8:buildid private static final Pattern NATIVE_RE = 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*\\(?(.*)\\)?(?: \\(.*\\))?"); + // " native: #12 pc 0xabcd1234" + " *(?:native: )?#(\\d+) \\S+ ([0-9a-fA-F]+)" + // The map info includes a filename and an optional offset into the file + + ("\\s+(" + // "/path/to/file.ext", + + "(.*?)" + // optional " (deleted)" suffix (deleted files) needed here to bias regex + // correctly + + "(?:\\s+\\(deleted\\))?" + // " (offset 0xabcd1234)", if the mapping is not into the beginning of the file + + "(?:\\s+\\(offset (.*?)\\))?" + + ")") + // Optional function + + ("(?:\\s+\\((?:" + + "\\?\\?\\?" // " (???) marks a missing function, so don't capture it in a group + + "|(.*?)(?:\\+(\\d+))?" // " (func+1234)", offset is + // optional + + ")\\))?") + // Optional " (BuildId: abcd1234abcd1234abcd1234abcd1234abcd1234)" + + "(?:\\s+\\(BuildId: (.*?)\\))?"); + private static final Pattern JAVA_RE = Pattern.compile(" *at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\((.*):([\\d-]+)\\)"); private static final Pattern JNI_RE = @@ -75,15 +108,48 @@ public class ThreadDumpParser { private final @NotNull SentryStackTraceFactory stackTraceFactory; + private final @NotNull Map debugImages; + + private final @NotNull List threads; + public ThreadDumpParser(final @NotNull SentryOptions options, final boolean isBackground) { this.options = options; this.isBackground = isBackground; this.stackTraceFactory = new SentryStackTraceFactory(options); + this.debugImages = new HashMap<>(); + this.threads = new ArrayList<>(); + } + + @NotNull + public List getDebugImages() { + return new ArrayList<>(debugImages.values()); } @NotNull - public List parse(final @NotNull Lines lines) { - final List sentryThreads = new ArrayList<>(); + public List getThreads() { + return threads; + } + + @Nullable + private static String buildIdToDebugId(final @NotNull String buildId) { + try { + // Abuse BigInteger as a hex string parser. Extra byte needed to handle leading zeros. + final ByteBuffer buf = ByteBuffer.wrap(new BigInteger("10" + buildId, 16).toByteArray()); + buf.get(); + return String.format( + "%08x-%04x-%04x-%04x-%04x%08x", + buf.order(ByteOrder.LITTLE_ENDIAN).getInt(), + buf.getShort(), + buf.getShort(), + buf.order(ByteOrder.BIG_ENDIAN).getShort(), + buf.getShort(), + buf.getInt()); + } catch (NumberFormatException | BufferUnderflowException e) { + return null; + } + } + + public void parse(final @NotNull Lines lines) { final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); final Matcher beginUnmanagedNativeThreadRe = BEGIN_UNMANAGED_NATIVE_THREAD_RE.matcher(""); @@ -92,7 +158,7 @@ public List parse(final @NotNull Lines lines) { final Line line = lines.next(); if (line == null) { options.getLogger().log(SentryLevel.WARNING, "Internal error while parsing thread dump."); - return sentryThreads; + return; } final String text = line.text; // we only handle managed threads, as unmanaged/not attached do not have the thread id and @@ -102,11 +168,10 @@ public List parse(final @NotNull Lines lines) { final SentryThread thread = parseThread(lines); if (thread != null) { - sentryThreads.add(thread); + threads.add(thread); } } } - return sentryThreads; } private SentryThread parseThread(final @NotNull Lines lines) { @@ -176,7 +241,6 @@ private SentryStackTrace parseStacktrace( SentryStackFrame lastJavaFrame = null; final Matcher nativeRe = NATIVE_RE.matcher(""); - final Matcher nativeNoLocRe = NATIVE_NO_LOC_RE.matcher(""); final Matcher javaRe = JAVA_RE.matcher(""); final Matcher jniRe = JNI_RE.matcher(""); final Matcher lockedRe = LOCKED_RE.matcher(""); @@ -194,20 +258,7 @@ private SentryStackTrace parseStacktrace( break; } final String text = line.text; - if (matches(nativeRe, text)) { - final SentryStackFrame frame = new SentryStackFrame(); - frame.setPackage(nativeRe.group(1)); - 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.setFunction(nativeNoLocRe.group(2)); - frames.add(frame); - lastJavaFrame = null; - } else if (matches(javaRe, text)) { + if (matches(javaRe, text)) { final SentryStackFrame frame = new SentryStackFrame(); final String packageName = javaRe.group(1); final String className = javaRe.group(2); @@ -219,6 +270,31 @@ private SentryStackTrace parseStacktrace( frame.setInApp(stackTraceFactory.isInApp(module)); frames.add(frame); lastJavaFrame = frame; + } else if (matches(nativeRe, text)) { + final SentryStackFrame frame = new SentryStackFrame(); + frame.setPackage(nativeRe.group(3)); + frame.setFunction(nativeRe.group(6)); + frame.setLineno(getInteger(nativeRe, 7, null)); + frame.setInstructionAddr("0x" + nativeRe.group(2)); + frame.setPlatform("native"); + + final String buildId = nativeRe.group(8); + final String debugId = buildId == null ? null : buildIdToDebugId(buildId); + if (debugId != null) { + if (!debugImages.containsKey(debugId)) { + final DebugImage debugImage = new DebugImage(); + debugImage.setDebugId(debugId); + debugImage.setType("elf"); + debugImage.setCodeFile(nativeRe.group(4)); + debugImage.setCodeId(buildId); + debugImages.put(debugId, debugImage); + } + // The addresses in the thread dump are relative to the image + frame.setAddrMode("rel:" + debugId); + } + + frames.add(frame); + lastJavaFrame = null; } else if (matches(jniRe, text)) { final SentryStackFrame frame = new SentryStackFrame(); final String packageName = jniRe.group(1); @@ -227,6 +303,7 @@ private SentryStackTrace parseStacktrace( frame.setModule(module); frame.setFunction(jniRe.group(3)); frame.setInApp(stackTraceFactory.isInApp(module)); + frame.setNative(true); frames.add(frame); lastJavaFrame = frame; } else if (matches(lockedRe, text)) { @@ -334,8 +411,8 @@ private Long getLong( @Nullable private Integer getInteger( - final @NotNull Matcher matcher, final int group, final @Nullable Integer defaultValue) { - final String str = matcher.group(group); + final @NotNull Matcher matcher, final int groupIndex, final @Nullable Integer defaultValue) { + final String str = matcher.group(groupIndex); if (str == null || str.length() == 0) { return defaultValue; } else { 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 68339a4b79..ddc1554253 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 @@ -305,6 +305,15 @@ class AnrV2IntegrationTest { ) assertEquals("__start_thread", firstFrame.function) assertEquals(64, firstFrame.lineno) + assertEquals("0x00000000000530b8", firstFrame.instructionAddr) + assertEquals("native", firstFrame.platform) + assertEquals("rel:741f3301-bbb0-b92c-58bd-c15282b8ec7b", firstFrame.addrMode) + + val image = it.debugMeta?.images?.find { + it.debugId == "741f3301-bbb0-b92c-58bd-c15282b8ec7b" + } + assertNotNull(image) + assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) }, argThat { val hint = HintUtils.getSentrySdkHint(this) 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 19de2e4935..b5e7adb896 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 @@ -18,7 +18,8 @@ class ThreadDumpParserTest { SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false ) - val threads = parser.parse(lines) + parser.parse(lines) + val threads = parser.threads // just verifying a few important threads, as there are many val main = threads.find { it.name == "main" } assertEquals(1, main!!.id) @@ -73,6 +74,25 @@ class ThreadDumpParserTest { assertEquals("HandlerThread.java", firstFrame.filename) assertEquals(67, firstFrame.lineno) assertEquals(null, firstFrame.isInApp) + assertNull(firstFrame.isNative) + assertNull(firstFrame.platform) + + val jniFrame = randomThread.stacktrace!!.frames!!.get(4) + assertEquals("android.os.MessageQueue", jniFrame.module) + assertEquals("nativePollOnce", jniFrame.function) + assertNull(jniFrame.lineno) + assertEquals(true, jniFrame.isNative) + assertNull(firstFrame.platform) + + val nativeFrame = randomThread.stacktrace!!.frames!!.get(5) + assertEquals("/system/lib64/libandroid_runtime.so", nativeFrame.`package`) + assertEquals( + "android::android_os_MessageQueue_nativePollOnce(_JNIEnv*, _jobject*, long, int)", + nativeFrame.function + ) + assertEquals(44, nativeFrame.lineno) + assertNull(nativeFrame.isNative) // Confusing, but "isNative" means JVM frame for a JNI method + assertEquals("native", nativeFrame.platform) } @Test @@ -82,7 +102,8 @@ class ThreadDumpParserTest { SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false ) - val threads = parser.parse(lines) + parser.parse(lines) + val threads = parser.threads // just verifying a few important threads, as there are many val thread = threads.find { it.name == "samples.android" } assertEquals(9955, thread!!.id) @@ -90,11 +111,57 @@ class ThreadDumpParserTest { assertEquals(false, thread.isCrashed) assertEquals(false, thread.isMain) assertEquals(false, thread.isCurrent) - val lastFrame = thread.stacktrace!!.frames!!.last() + + // Reverse frames so we can index them with the active frame at index 0 + val frames = thread.stacktrace!!.frames!!.reversed() + + val lastFrame = frames.get(0) assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", lastFrame.`package`) assertEquals("syscall", lastFrame.function) assertEquals(28, lastFrame.lineno) assertNull(lastFrame.isInApp) + assertEquals("0x000000000004c35c", lastFrame.instructionAddr) + assertEquals("rel:499d48ba-c085-17cf-3209-da67405662f9", lastFrame.addrMode) + assertEquals("native", lastFrame.platform) + + val nosymFrame = frames.get(21) + assertEquals("/apex/com.android.art/javalib/core-oj.jar", nosymFrame.`package`) + assertNull(nosymFrame.function) + assertNull(nosymFrame.lineno) + assertEquals("0x00000000000ec474", nosymFrame.instructionAddr) + assertNull(nosymFrame.addrMode) + + val spaceFrame = frames.get(14) + assertEquals( + "[anon:dalvik-classes16.dex extracted in memory from /data/app/~~izn1xSZpFlzfVmWi_I0xlQ==" + + "/io.sentry.samples.android-tQSGMNiGA-qdjZm6lPOcNw==/base.apk!classes16.dex]", + spaceFrame.`package` + ) + assertNull(spaceFrame.function) + assertNull(spaceFrame.lineno) + assertEquals("0x00000000000306f0", spaceFrame.instructionAddr) + assertNull(spaceFrame.addrMode) + + val offsetFrame = frames.get(145) + assertEquals("/system/framework/framework.jar (offset 0x12c2000)", offsetFrame.`package`) + assertNull(offsetFrame.function) + assertNull(offsetFrame.lineno) + assertEquals("0x00000000002c8e18", offsetFrame.instructionAddr) + assertNull(offsetFrame.addrMode) + + val deletedFrame = frames.get(117) + assertEquals("/memfd:jit-cache (deleted) (offset 0x2000000)", deletedFrame.`package`) + assertEquals("kotlinx.coroutines.DispatchedTask.run", deletedFrame.function) + assertEquals(1816, deletedFrame.lineno) + assertEquals("0x00000000020b89d8", deletedFrame.instructionAddr) + assertNull(deletedFrame.addrMode) + + val debugImages = parser.debugImages + val image = debugImages.first { image -> image.debugId == "499d48ba-c085-17cf-3209-da67405662f9" } + assertNotNull(image) + assertEquals("499d48ba-c085-17cf-3209-da67405662f9", image.debugId) + assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) + assertEquals("ba489d4985c0cf173209da67405662f9", image.codeId) } @Test @@ -104,7 +171,7 @@ class ThreadDumpParserTest { SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false ) - val threads = parser.parse(lines) - assertTrue(threads.isEmpty()) + parser.parse(lines) + assertTrue(parser.threads.isEmpty()) } } diff --git a/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp b/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp index 2a31e6c6e5..de1f0f0d3e 100644 --- a/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp +++ b/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp @@ -22,4 +22,29 @@ JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_message(JNIEn sentry_capture_event(event); } +[[gnu::noinline]] +static void idle_pointlessly() { + static const volatile int x = 42; + (void)x; +} + +[[gnu::noinline]] +static void loop_eternally() { + while (true) { + idle_pointlessly(); + } +} + +[[gnu::noinline]] +static void keep_object_locked(JNIEnv* env, jobject obj) { + env->MonitorEnter(obj); + loop_eternally(); + env->MonitorExit(obj); +} + +JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_freezeMysteriously(JNIEnv *env, jclass cls, jobject obj) { + __android_log_print(ANDROID_LOG_WARN, TAG, "About to lock object eternally."); + keep_object_locked(env, obj); +} + } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index da52c72a68..e881612bd8 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -210,6 +210,31 @@ public void run() { 1000); }); + binding.nativeAnr.setOnClickListener( + view -> { + new Thread( + new Runnable() { + @Override + public void run() { + NativeSample.freezeMysteriously(mutex); + } + }) + .start(); + + new Handler() + .postDelayed( + new Runnable() { + @Override + public void run() { + synchronized (mutex) { + // Shouldn't happen + throw new IllegalStateException(); + } + } + }, + 1000); + }); + binding.openSecondActivity.setOnClickListener( view -> { // finishing so its completely destroyed diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/NativeSample.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/NativeSample.java index bde645c6e3..064818ad4e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/NativeSample.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/NativeSample.java @@ -5,6 +5,9 @@ public class NativeSample { public static native void message(); + // Named to demonstrate the value of native stack frames during ANR + public static native void freezeMysteriously(Object obj); + static { System.loadLibrary("native-sample"); } diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index 6fb8d02863..b8a47c6bd5 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -64,6 +64,12 @@ android:layout_height="wrap_content" android:text="@string/anr" /> +