From 69306945c9179e5ba4a89edfd762ab6cbd1f93a3 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Wed, 25 Feb 2026 10:51:59 +0100 Subject: [PATCH 1/3] fix(android): identify and correctly structure Java/Kotlin frames in mixed Tombstone stack traces --- .../internal/tombstone/TombstoneParser.java | 49 +++++++++++++++--- .../internal/tombstone/TombstoneParserTest.kt | 43 +++++++++++++-- .../resources/tombstone_java_frames.json.gz | Bin 0 -> 1437 bytes 3 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 sentry-android-core/src/test/resources/tombstone_java_frames.json.gz 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 3235b56655..d4c1245942 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 @@ -33,6 +33,20 @@ public class TombstoneParser implements Closeable { @Nullable private final String nativeLibraryDir; private final Map excTypeValueMap = new HashMap<>(); + private static boolean isJavaFrame(@NonNull final TombstoneProtos.BacktraceFrame frame) { + final String fileName = frame.getFileName(); + return !fileName.endsWith(".so") + && !fileName.endsWith("app_process64") + && (fileName.endsWith(".jar") + || fileName.endsWith(".odex") + || fileName.endsWith(".vdex") + || fileName.endsWith(".oat") + || fileName.startsWith("[anon:dalvik-") + || fileName.startsWith(" frames = new ArrayList<>(); for (TombstoneProtos.BacktraceFrame frame : thread.getCurrentBacktraceList()) { - if (frame.getFileName().endsWith("libart.so")) { + if (frame.getFileName().endsWith("libart.so") + || Objects.equals(frame.getFunctionName(), "art_jni_trampoline")) { // We ignore all ART frames for time being because they aren't actionable for app developers continue; } @@ -118,9 +133,15 @@ private SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread continue; } final SentryStackFrame stackFrame = new SentryStackFrame(); - stackFrame.setPackage(frame.getFileName()); - stackFrame.setFunction(frame.getFunctionName()); - stackFrame.setInstructionAddr(formatHex(frame.getPc())); + if (isJavaFrame(frame)) { + stackFrame.setPlatform("java"); + stackFrame.setFunction(extractJavaFunctionName(frame.getFunctionName())); + stackFrame.setModule(extractJavaModuleName(frame.getFunctionName())); + } else { + stackFrame.setPackage(frame.getFileName()); + stackFrame.setFunction(frame.getFunctionName()); + stackFrame.setInstructionAddr(formatHex(frame.getPc())); + } // inAppIncludes/inAppExcludes filter by Java/Kotlin package names, which don't overlap // with native C/C++ function names (e.g., "crash", "__libc_init"). For native frames, @@ -159,6 +180,22 @@ private SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread return stacktrace; } + private static @Nullable String extractJavaModuleName(String fqFunctionName) { + if (fqFunctionName.contains(".")) { + return fqFunctionName.substring(0, fqFunctionName.lastIndexOf(".")); + } else { + return ""; + } + } + + private static @Nullable String extractJavaFunctionName(String fqFunctionName) { + if (fqFunctionName.contains(".")) { + return fqFunctionName.substring(fqFunctionName.lastIndexOf(".") + 1); + } else { + return fqFunctionName; + } + } + @NonNull private List createException(@NonNull TombstoneProtos.Tombstone tombstone) { final SentryException exception = new SentryException(); @@ -296,7 +333,7 @@ private DebugMeta createDebugMeta(@NonNull final TombstoneProtos.Tombstone tombs // 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)) { + if (currentModule != null && Objects.equals(mappingName, currentModule.mappingName)) { currentModule.extendTo(mapping.getEndAddress()); continue; } @@ -311,7 +348,7 @@ private DebugMeta createDebugMeta(@NonNull final TombstoneProtos.Tombstone tombs // Start a new module currentModule = new ModuleAccumulator(mapping); - } else if (currentModule != null && mappingName.equals(currentModule.mappingName)) { + } else if (currentModule != null && Objects.equals(mappingName, currentModule.mappingName)) { // Extend the current module with this mapping (same file, continuation) currentModule.extendTo(mapping.getEndAddress()); } 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 516b919002..41ae5a5450 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 @@ -101,14 +101,20 @@ class TombstoneParserTest { for (frame in thread.stacktrace!!.frames!!) { assertNotNull(frame.function) - assertNotNull(frame.`package`) - assertNotNull(frame.instructionAddr) + if (frame.platform == "java") { + // Java frames have module instead of package/instructionAddr + assertNotNull(frame.module) + } else { + assertNotNull(frame.`package`) + assertNotNull(frame.instructionAddr) + } if (thread.id == crashedThreadId) { if (frame.isInApp!!) { assert( - frame.function!!.startsWith(inAppIncludes[0]) || - frame.`package`!!.startsWith(nativeLibraryDir) + frame.module?.startsWith(inAppIncludes[0]) == true || + frame.function!!.startsWith(inAppIncludes[0]) || + frame.`package`?.startsWith(nativeLibraryDir) == true ) } } @@ -429,6 +435,35 @@ class TombstoneParserTest { } } + @Test + fun `java frames snapshot test for all threads`() { + val tombstoneStream = + GZIPInputStream(TombstoneParserTest::class.java.getResourceAsStream("/tombstone.pb.gz")) + val parser = TombstoneParser(tombstoneStream, inAppIncludes, inAppExcludes, nativeLibraryDir) + val event = parser.parse() + + val logger = mock() + val writer = StringWriter() + val jsonWriter = JsonObjectWriter(writer, 100) + jsonWriter.beginObject() + for (thread in event.threads!!) { + val javaFrames = thread.stacktrace!!.frames!!.filter { it.platform == "java" } + if (javaFrames.isEmpty()) continue + jsonWriter.name(thread.id.toString()) + jsonWriter.beginArray() + for (frame in javaFrames) { + frame.serialize(jsonWriter, logger) + } + jsonWriter.endArray() + } + jsonWriter.endObject() + + val actualJson = writer.toString() + val expectedJson = readGzippedResourceFile("/tombstone_java_frames.json.gz") + + assertEquals(expectedJson, actualJson) + } + private fun serializeDebugMeta(debugMeta: DebugMeta): String { val logger = mock() val writer = StringWriter() diff --git a/sentry-android-core/src/test/resources/tombstone_java_frames.json.gz b/sentry-android-core/src/test/resources/tombstone_java_frames.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..5ef692db65e1d9a68bbad78f60276c7acf7d3073 GIT binary patch literal 1437 zcmV;O1!DRiiwFoWz@BLW|8#F{VsmtFZe?F;VRm6(W^!R|WpgfSb8l_{?OR(@>N*tu zFH9e$Q}Bkqu|YWM!?a z^mnOR*{kl9_W%7|awW9|X0)_l;*yp&N*;400rl@48X}V*+B2{F9FU`a!}J#UerexD zB!G>QPmpjK_u_|3e>UdgNr8!JGipoDfr=RVf-!RN2(}cA8y&_6z_YMTTBhY(v}-o= zOhg^duw$}8UkKl9FiIhOToW0U1Qh>mXuE80&#}vdC@>Gz-voF@`w+(b8Nes%rwlgG zi09OXd{HSpiW!Kf5Co_Pml7mo!aac=vPb!#NA{~)N!C?3C$F)W*?1np^yG~J^Fe+< zSWNc==iNIsr4x^YWqpQ+3Gtc;JVpZ}`p$xTLdRa;EoJe47!D(?)J&4Dj;ou9BKhE+F(HCZ)_h6N+ z!lUGsS4^d8+kMc#J5%~N`K={|joJICzXAK(+Kl9|X2fkk@sPm0E(qYX%A18szq=zuWQcr|q-4 zb$sJ?F3!uRm7lxSqo0{nrq&Y3u7^b%xj;r^QPygxlUm{`{L=Avk0af7Ii@(m>=9q? zRN>emzm5)_yN2I^M)R)Y9%Yl6T8tYf4;LJUn8_eQ9P6uCE0SR=FCw4~Jx>uN;=s;RwVUbxrM(YX}96hEZvzTTzBv>rGZnWy@5EHYtUx>=mX zt@Byjc5%l2b+X%!;@&1GzXFaPoNV*&^A!|{caeB6l6cql-W{Uq^UhgiDjc70Yv1~7 z)MEmUC>D7Z!d^0{EKXoAq%clF^qEtqPM!CHhROyrIKsC*8*?fMv#uT*Vln#SJF$ZU z9|^l3Z;YP-9)~=s(feULZTc>Hy)1J8pAn%AF+7+z>chAIuA4Uqw$q&GG&^V2hELXO6iZbVVhvJ2AEqrTH<}Avb-{O98@qS!U30$NS*yG6WV_V~L=K*oJ z0_p$bWkR+J8B|<>O+Ky zzFYnJ2JrF}FxukCgHsS(;ZEphmL6X|y-d>g7Zq*B;rRkX=)BtD6ECqEj1aZ=u-Ox4 zE3=W{!KfOTl_Oo{SXWu=Dvx!QwXX69y7KH}mbfYeYWT7#Cs&hKsV6SYT19ZY4#Ba{ r61*3Z+Z%4*C%doh?S1)!;@9snFK|k{U Date: Wed, 25 Feb 2026 11:05:06 +0100 Subject: [PATCH 2/3] update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b74bc0886e..95a5271eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Identify and correctly structure Java/Kotlin frames in mixed Tombstone stack traces. ([#5116](https://github.com/getsentry/sentry-java/pull/5116)) + ## 8.33.0 ### Features From 35e101078e4d265cda02608da1c18fe597de0c25 Mon Sep 17 00:00:00 2001 From: Mischan Toosarani-Hausberger Date: Wed, 25 Feb 2026 13:39:52 +0100 Subject: [PATCH 3/3] support PrettyMethod with_signature formatted java frames --- .../internal/tombstone/TombstoneParser.java | 75 ++++++++---- .../internal/tombstone/TombstoneParserTest.kt | 113 ++++++++++++++++++ 2 files changed, 164 insertions(+), 24 deletions(-) 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 d4c1245942..920ba52c08 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 @@ -135,31 +135,27 @@ private SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread final SentryStackFrame stackFrame = new SentryStackFrame(); if (isJavaFrame(frame)) { stackFrame.setPlatform("java"); + final String module = extractJavaModuleName(frame.getFunctionName()); stackFrame.setFunction(extractJavaFunctionName(frame.getFunctionName())); - stackFrame.setModule(extractJavaModuleName(frame.getFunctionName())); + stackFrame.setModule(module); + + // For Java frames, check in-app against the module (package name), which is what + // inAppIncludes/inAppExcludes are designed to match against. + @Nullable + Boolean inApp = + (module == null || module.isEmpty()) + ? Boolean.FALSE + : SentryStackTraceFactory.isInApp(module, inAppIncludes, inAppExcludes); + stackFrame.setInApp(inApp != null && inApp); } else { stackFrame.setPackage(frame.getFileName()); stackFrame.setFunction(frame.getFunctionName()); stackFrame.setInstructionAddr(formatHex(frame.getPc())); - } - // inAppIncludes/inAppExcludes filter by Java/Kotlin package names, which don't overlap - // with native C/C++ function names (e.g., "crash", "__libc_init"). For native frames, - // isInApp() returns null, making nativeLibraryDir the effective in-app check. - // Protobuf returns "" for unset function names, which would incorrectly return true - // from isInApp(), so we treat empty as false to let nativeLibraryDir decide. - final String functionName = frame.getFunctionName(); - @Nullable - Boolean inApp = - functionName.isEmpty() - ? Boolean.FALSE - : SentryStackTraceFactory.isInApp(functionName, inAppIncludes, inAppExcludes); - - final boolean isInNativeLibraryDir = - nativeLibraryDir != null && frame.getFileName().startsWith(nativeLibraryDir); - inApp = (inApp != null && inApp) || isInNativeLibraryDir; - - stackFrame.setInApp(inApp); + final boolean isInNativeLibraryDir = + nativeLibraryDir != null && frame.getFileName().startsWith(nativeLibraryDir); + stackFrame.setInApp(isInNativeLibraryDir); + } frames.add(0, stackFrame); } @@ -180,19 +176,50 @@ private SentryStackTrace createStackTrace(@NonNull final TombstoneProtos.Thread return stacktrace; } + /** + * Normalizes a PrettyMethod-formatted function name by stripping the return type prefix and + * parameter list suffix that dex2oat may include when compiling AOT frames into the symtab. + * + *

e.g. "void com.example.MyClass.myMethod(int, java.lang.String)" -> + * "com.example.MyClass.myMethod" + */ + private static String normalizeFunctionName(String fqFunctionName) { + String normalized = fqFunctionName.trim(); + + // When dex2oat compiles AOT frames, PrettyMethod with_signature format may be used: + // "void com.example.MyClass.myMethod(int, java.lang.String)" + // A space is never part of a normal fully-qualified method name, so its presence + // reliably indicates the with_signature format. + final int spaceIndex = normalized.indexOf(' '); + if (spaceIndex >= 0) { + // Strip return type prefix + normalized = normalized.substring(spaceIndex + 1).trim(); + + // Strip parameter list suffix + final int parenIndex = normalized.indexOf('('); + if (parenIndex >= 0) { + normalized = normalized.substring(0, parenIndex); + } + } + + return normalized; + } + private static @Nullable String extractJavaModuleName(String fqFunctionName) { - if (fqFunctionName.contains(".")) { - return fqFunctionName.substring(0, fqFunctionName.lastIndexOf(".")); + final String normalized = normalizeFunctionName(fqFunctionName); + if (normalized.contains(".")) { + return normalized.substring(0, normalized.lastIndexOf(".")); } else { return ""; } } private static @Nullable String extractJavaFunctionName(String fqFunctionName) { - if (fqFunctionName.contains(".")) { - return fqFunctionName.substring(fqFunctionName.lastIndexOf(".") + 1); + final String normalized = normalizeFunctionName(fqFunctionName); + if (normalized.contains(".")) { + return normalized.substring(normalized.lastIndexOf(".") + 1); } else { - return fqFunctionName; + return normalized; } } 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 41ae5a5450..b3cd17e2c3 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 @@ -464,6 +464,119 @@ class TombstoneParserTest { assertEquals(expectedJson, actualJson) } + @Test + fun `extracts java function and module from plain PrettyMethod format`() { + val event = parseTombstoneWithJavaFunctionName("com.example.MyClass.myMethod") + val frame = event.threads!![0].stacktrace!!.frames!![0] + assertEquals("java", frame.platform) + assertEquals("myMethod", frame.function) + assertEquals("com.example.MyClass", frame.module) + } + + @Test + fun `extracts java function and module from PrettyMethod with_signature format`() { + val event = + parseTombstoneWithJavaFunctionName("void com.example.MyClass.myMethod(int, java.lang.String)") + val frame = event.threads!![0].stacktrace!!.frames!![0] + assertEquals("java", frame.platform) + assertEquals("myMethod", frame.function) + assertEquals("com.example.MyClass", frame.module) + } + + @Test + fun `extracts java function and module from PrettyMethod with_signature with object return type`() { + val event = + parseTombstoneWithJavaFunctionName("java.lang.String com.example.MyClass.myMethod(int)") + val frame = event.threads!![0].stacktrace!!.frames!![0] + assertEquals("java", frame.platform) + assertEquals("myMethod", frame.function) + assertEquals("com.example.MyClass", frame.module) + } + + @Test + fun `extracts java function and module from PrettyMethod with_signature with no params`() { + val event = parseTombstoneWithJavaFunctionName("void com.example.MyClass.myMethod()") + val frame = event.threads!![0].stacktrace!!.frames!![0] + assertEquals("java", frame.platform) + assertEquals("myMethod", frame.function) + assertEquals("com.example.MyClass", frame.module) + } + + @Test + fun `handles bare function name without package`() { + val event = parseTombstoneWithJavaFunctionName("myMethod") + val frame = event.threads!![0].stacktrace!!.frames!![0] + assertEquals("java", frame.platform) + assertEquals("myMethod", frame.function) + assertEquals("", frame.module) + } + + @Test + fun `handles PrettyMethod with_signature bare function name`() { + val event = parseTombstoneWithJavaFunctionName("void myMethod()") + val frame = event.threads!![0].stacktrace!!.frames!![0] + assertEquals("java", frame.platform) + assertEquals("myMethod", frame.function) + assertEquals("", frame.module) + } + + @Test + fun `java frame with_signature format is correctly detected as inApp`() { + val event = + parseTombstoneWithJavaFunctionName("void io.sentry.samples.android.MyClass.myMethod(int)") + val frame = event.threads!![0].stacktrace!!.frames!![0] + assertEquals("java", frame.platform) + assertEquals(true, frame.isInApp) + } + + @Test + fun `java frame with_signature format is correctly detected as not inApp`() { + val event = + parseTombstoneWithJavaFunctionName( + "void android.os.Handler.handleCallback(android.os.Message)" + ) + val frame = event.threads!![0].stacktrace!!.frames!![0] + assertEquals("java", frame.platform) + assertEquals(false, frame.isInApp) + } + + private fun parseTombstoneWithJavaFunctionName(functionName: String): io.sentry.SentryEvent { + val tombstone = + TombstoneProtos.Tombstone.newBuilder() + .setPid(1234) + .setTid(1234) + .setSignalInfo( + TombstoneProtos.Signal.newBuilder() + .setNumber(11) + .setName("SIGSEGV") + .setCode(1) + .setCodeName("SEGV_MAPERR") + ) + .putThreads( + 1234, + TombstoneProtos.Thread.newBuilder() + .setId(1234) + .setName("main") + .addCurrentBacktrace( + TombstoneProtos.BacktraceFrame.newBuilder() + .setPc(0x1000) + .setFunctionName(functionName) + .setFileName("/data/app/base.apk!classes.oat") + ) + .build(), + ) + .build() + + val parser = + TombstoneParser( + ByteArrayInputStream(tombstone.toByteArray()), + inAppIncludes, + inAppExcludes, + nativeLibraryDir, + ) + return parser.parse() + } + private fun serializeDebugMeta(debugMeta: DebugMeta): String { val logger = mock() val writer = StringWriter()