diff --git a/.changeset/native-crash-report-text.md b/.changeset/native-crash-report-text.md new file mode 100644 index 000000000..8e52ef167 --- /dev/null +++ b/.changeset/native-crash-report-text.md @@ -0,0 +1,6 @@ +--- +"posthog-android": minor +"posthog": patch +--- + +Add `$exception_crash_report` to native crash `$exception` events with the tombstone text as debuggerd prints it: the process header, signal, the crashing thread's registers, and every thread's backtrace. The report is left out when it would push the event past the capture size limit diff --git a/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt b/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt index 6dacc8fb4..64a4785a6 100644 --- a/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt @@ -36,8 +36,9 @@ import java.util.concurrent.atomic.AtomicBoolean * integration reads the crash records the OS kept via * [android.app.ActivityManager.getHistoricalProcessExitReasons], parses the * attached tombstone, and captures an `$exception` event with raw native stack - * frames and the `$debug_images` needed for server-side symbolication against - * uploaded `.so` debug symbols. + * frames, the `$debug_images` needed for server-side symbolication against + * uploaded `.so` debug symbols, and the tombstone text, as debuggerd prints + * it, in `$exception_crash_report`. * * Requires Android 12 (API 31), where native crash records carry a tombstone. * Enable with `errorTrackingConfig.captureNativeCrashes`; error tracking diff --git a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercer.kt b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercer.kt index fe262aa54..5998e0368 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercer.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercer.kt @@ -87,6 +87,8 @@ internal class NativeCrashEventCoercer( if (debugImages.isNotEmpty()) { properties["\$debug_images"] = debugImages.values.toList() } + // The report is optional, so a formatting failure must not cost the crash event. + runCatching { NativeCrashReportFormatter.format(tombstone) }.getOrNull()?.let { properties[CRASH_REPORT_PROPERTY] = it } return properties } @@ -107,6 +109,8 @@ internal class NativeCrashEventCoercer( private fun hex(value: Long): String = "0x${java.lang.Long.toUnsignedString(value, 16)}" internal companion object { + internal const val CRASH_REPORT_PROPERTY = "\$exception_crash_report" + /** * Derives the debug id matching an uploaded symbol set from a GNU * build id hex string: the first 16 bytes (zero-padded) read as a diff --git a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashReportFormatter.kt b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashReportFormatter.kt new file mode 100644 index 000000000..3bd66b90c --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashReportFormatter.kt @@ -0,0 +1,130 @@ +package com.posthog.android.internal.errortracking + +/** + * Renders a parsed tombstone as the text debuggerd writes to logcat and + * /data/tombstones, following AOSP's tombstone_proto_to_text.cpp (main, October 2026): the process + * header, signal, abort message, the crashing thread's registers, and every + * thread's backtrace. + * + * Memory dumps, memory maps, open files, and log buffers are left out: they + * can hold user data, and the maps alone run to thousands of lines. Other + * threads' registers are left out too, as in debuggerd's logcat output: they + * add about a sixth to the text and are rarely needed. + */ +internal object NativeCrashReportFormatter { + /** Returns null when the tombstone has no entry for the crashing thread, like debuggerd. */ + fun format(tombstone: NativeCrashTombstone): String? { + val crashedThread = tombstone.tid?.let { tombstone.threads[it] } ?: return null + val wordSize = pointerWidth(tombstone.arch) + val lines = mutableListOf() + + lines += "*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***" + lines += "Build fingerprint: '${tombstone.buildFingerprint.orEmpty()}'" + lines += "Revision: '${tombstone.revision.orEmpty()}'" + lines += "ABI: '${tombstone.arch ?: ""}'" + lines += "Timestamp: ${tombstone.timestamp.orEmpty()}" + lines += "Process uptime: ${tombstone.processUptime ?: 0}s" + tombstone.pageSize?.takeIf { it != 4096L }?.let { lines += "Page size: $it bytes" } + + lines += threadHeader(tombstone, crashedThread) + if (tombstone.signalNumber == null && tombstone.signalName == null) { + lines += "signal information missing" + } else { + val sender = tombstone.sender?.let { (pid, uid) -> " from pid $pid, uid $uid" }.orEmpty() + val faultAddress = + if (tombstone.hasFaultAddress) "0x${hex(tombstone.faultAddress ?: 0, 2 * wordSize)}" else "--------" + lines += "signal ${tombstone.signalNumber ?: 0} (${tombstone.signalName.orEmpty()})," + + " code ${tombstone.signalCode ?: 0} (${tombstone.signalCodeName.orEmpty()}$sender), fault addr $faultAddress" + } + if (tombstone.causes.size == 1) { + lines += "Cause: ${tombstone.causes[0]}" + } + tombstone.abortMessage?.let { lines += "Abort message: '$it'" } + lines += registers(tombstone.arch, crashedThread, wordSize) + lines += backtrace(crashedThread, wordSize) + if (tombstone.causes.size > 1) { + lines += "" + lines += "Note: multiple potential causes for this crash were detected, listing them in decreasing order of likelihood." + tombstone.causes.forEach { lines += listOf("", "Cause: $it") } + } + + // A map has no order, so list the other threads by id, as debuggerd does. + tombstone.threads.keys.filter { it != tombstone.tid }.sorted().forEach { tid -> + val thread = tombstone.threads.getValue(tid) + lines += "--- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---" + lines += threadHeader(tombstone, thread) + lines += backtrace(thread, wordSize) + } + + return lines.joinToString("\n", postfix = "\n") + } + + private fun threadHeader( + tombstone: NativeCrashTombstone, + thread: NativeCrashThread, + ): List { + val processName = tombstone.commandLine.firstOrNull() ?: "" + val commandLine = if (tombstone.commandLine.isEmpty()) "" else tombstone.commandLine.joinToString(" ") + return listOf( + "Cmdline: $commandLine", + "pid: ${tombstone.pid ?: 0}, tid: ${thread.id}, name: ${thread.name.orEmpty()} >>> $processName <<<", + "uid: ${tombstone.uid ?: 0}", + ) + } + + // Four general registers per row, then the special ones (pc, sp, ...) on a final row. + private fun registers( + arch: String?, + thread: NativeCrashThread, + wordSize: Int, + ): List { + val special = specialRegisters(arch) ?: return emptyList() + val (specialRegisters, generalRegisters) = thread.registers.partition { it.first in special } + return (generalRegisters.chunked(4) + listOf(specialRegisters)).map { row -> + " " + row.joinToString("") { (name, value) -> " ${name.padEnd(3)} ${hex(value, 2 * wordSize)}" } + } + } + + private fun backtrace( + thread: NativeCrashThread, + wordSize: Int, + ): List { + val lines = mutableListOf("", "${thread.frames.size} total frames", "backtrace:") + if (thread.backtraceNotes.isNotEmpty()) { + lines += " NOTE: " + thread.backtraceNotes.joinToString("\n NOTE: ") + } + thread.frames.forEachIndexed { index, frame -> + val line = + StringBuilder( + " #${index.toString().padStart(2, '0')} pc ${hex(frame.relPc, 2 * wordSize)} ${frame.fileName.orEmpty()}", + ) + if (frame.fileMapOffset != 0L) { + line.append(" (offset 0x${hex(frame.fileMapOffset, 0)})") + } + frame.functionName?.let { line.append(" ($it+${frame.functionOffset})") } + frame.buildId?.let { line.append(" (BuildId: $it)") } + lines += line.toString() + } + return lines + } + + private fun pointerWidth(arch: String?): Int = + when (arch) { + "arm", "x86" -> 4 + else -> 8 + } + + private fun specialRegisters(arch: String?): Set? = + when (arch) { + "arm", "arm64" -> setOf("ip", "lr", "sp", "pc", "pst") + "riscv64" -> setOf("ra", "sp", "pc") + "x86" -> setOf("ebp", "esp", "eip") + "x86_64" -> setOf("rbp", "rsp", "rip") + else -> null + } + + private fun hex( + value: Long, + width: Int, + ): String = java.lang.Long.toHexString(value).padStart(width, '0') +} diff --git a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/TombstoneParser.kt b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/TombstoneParser.kt index 100d22b60..7b96ef831 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/TombstoneParser.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/TombstoneParser.kt @@ -16,6 +16,35 @@ internal data class NativeCrashTombstone( val faultAddress: Long?, val abortMessage: String?, val frames: List, + val buildFingerprint: String? = null, + val revision: String? = null, + val timestamp: String? = null, + val pid: Long? = null, + val uid: Long? = null, + val processUptime: Long? = null, + val pageSize: Long? = null, + val commandLine: List = emptyList(), + val signalNumber: Int? = null, + val signalCode: Int? = null, + /** + * Whether the signal carries a fault address. [faultAddress] is null for + * address 0, because proto3 leaves zero values off the wire. + */ + val hasFaultAddress: Boolean = false, + /** pid and uid of the process that sent the signal, when another process sent it. */ + val sender: Pair? = null, + val causes: List = emptyList(), + /** Every thread in the tombstone, keyed by thread id. */ + val threads: Map = emptyMap(), +) + +internal data class NativeCrashThread( + val id: Long, + val name: String?, + /** Register names and values, in tombstone order. */ + val registers: List>, + val backtraceNotes: List, + val frames: List, ) internal data class NativeCrashFrame( @@ -29,13 +58,16 @@ internal data class NativeCrashFrame( val fileName: String?, /** GNU build id of the mapped ELF as a hex string, when known. */ val buildId: String?, + /** Offset of the mapping within the file, non-zero for libraries loaded straight from an APK. */ + val fileMapOffset: Long = 0, ) /** * Minimal protobuf wire-format reader for debuggerd's tombstone proto * (AOSP system/core/debuggerd/proto/tombstone.proto), extracting only what - * error tracking needs: the crashing thread's backtrace, signal info, and the - * abort message. Unknown fields are skipped, so schema additions are ignored. + * error tracking needs: the process header, signal info, the abort message, + * and every thread's registers and backtrace. Unknown fields are skipped, so + * schema additions are ignored. * * Hand-rolled to avoid shipping a protobuf runtime in the SDK; the field * numbers used here are frozen in AOSP. @@ -54,25 +86,72 @@ internal class TombstoneParser { var faultAddress: Long? = null var hasFaultAddress = false var abortMessage: String? = null - // tid can serialize after the threads map, so collect every thread's - // backtrace and pick the crashing one at the end. - val backtraces = mutableMapOf>() + var buildFingerprint: String? = null + var revision: String? = null + var timestamp: String? = null + var pid: Long? = null + var uid: Long? = null + var processUptime: Long? = null + var pageSize: Long? = null + val commandLine = mutableListOf() + var signalNumber: Int? = null + var signalCode: Int? = null + var hasSender = false + var senderUid = 0 + var senderPid = 0 + val causes = mutableListOf() + // tid can serialize after the threads map, so collect every thread and + // pick the crashing one at the end. + val threads = mutableMapOf() while (reader.hasMore()) { when (reader.readTag()) { // Architecture arch = 1 fieldVarint(1) -> arch = archName(reader.readVarint()) + // string build_fingerprint = 2 + fieldBytes(2) -> buildFingerprint = reader.readString() + // string revision = 3 + fieldBytes(3) -> revision = reader.readString() + // string timestamp = 4 + fieldBytes(4) -> timestamp = reader.readString() + // uint32 pid = 5 + fieldVarint(5) -> pid = reader.readVarint() // uint32 tid = 6 fieldVarint(6) -> tid = reader.readVarint() + // uint32 uid = 7 + fieldVarint(7) -> uid = reader.readVarint() + // repeated string command_line = 9 + fieldBytes(9) -> commandLine.add(reader.readString()) + // uint32 process_uptime = 20 + fieldVarint(20) -> processUptime = reader.readVarint() + // uint32 page_size = 22 + fieldVarint(22) -> pageSize = reader.readVarint() + // repeated Cause causes = 15 + fieldBytes(15) -> { + val bytes = reader.readBytes() + // Only the report uses causes, so a malformed one is dropped + // rather than failing the whole crash. + runCatching { parseCause(bytes) }.getOrNull()?.let(causes::add) + } // Signal signal_info = 10 fieldBytes(10) -> { val signal = ProtoReader(reader.readBytes()) while (signal.hasMore()) { when (signal.readTag()) { + // int32 number = 1 + fieldVarint(1) -> signalNumber = signal.readVarint().toInt() // string name = 2 fieldBytes(2) -> signalName = signal.readString() + // int32 code = 3; negative codes (e.g. SI_TKILL) arrive sign-extended + fieldVarint(3) -> signalCode = signal.readVarint().toInt() // string code_name = 4 fieldBytes(4) -> signalCodeName = signal.readString() + // bool has_sender = 5 + fieldVarint(5) -> hasSender = signal.readVarint() != 0L + // int32 sender_uid = 6 + fieldVarint(6) -> senderUid = signal.readVarint().toInt() + // int32 sender_pid = 7 + fieldVarint(7) -> senderPid = signal.readVarint().toInt() // bool has_fault_address = 8 fieldVarint(8) -> hasFaultAddress = signal.readVarint() != 0L // uint64 fault_address = 9 @@ -87,15 +166,15 @@ internal class TombstoneParser { fieldBytes(16) -> { val entry = ProtoReader(reader.readBytes()) var key: Long? = null - var frames: List? = null + var thread: NativeCrashThread? = null while (entry.hasMore()) { when (entry.readTag()) { fieldVarint(1) -> key = entry.readVarint() - fieldBytes(2) -> frames = parseThreadBacktrace(entry.readBytes()) + fieldBytes(2) -> thread = parseThread(entry.readBytes()) else -> entry.skipLast() } } - key?.let { backtraces[it] = frames ?: emptyList() } + key?.let { threads[it] = thread ?: NativeCrashThread(it, null, emptyList(), emptyList(), emptyList()) } } else -> reader.skipLast() } @@ -108,15 +187,46 @@ internal class TombstoneParser { signalCodeName = signalCodeName, faultAddress = if (hasFaultAddress) faultAddress else null, abortMessage = abortMessage, - frames = tid?.let { backtraces[it] } ?: emptyList(), + frames = tid?.let { threads[it]?.frames } ?: emptyList(), + buildFingerprint = buildFingerprint, + revision = revision, + timestamp = timestamp, + pid = pid, + uid = uid, + processUptime = processUptime, + pageSize = pageSize, + commandLine = commandLine, + signalNumber = signalNumber, + signalCode = signalCode, + hasFaultAddress = hasFaultAddress, + sender = if (hasSender) senderPid to senderUid else null, + causes = causes, + threads = threads, ) } - private fun parseThreadBacktrace(bytes: ByteArray): List { + private fun parseThread(bytes: ByteArray): NativeCrashThread { val thread = ProtoReader(bytes) + var id = 0L + var name: String? = null + val registers = mutableListOf>() + val backtraceNotes = mutableListOf() val frames = mutableListOf() while (thread.hasMore()) { when (thread.readTag()) { + // int32 id = 1 + fieldVarint(1) -> id = thread.readVarint() + // string name = 2 + fieldBytes(2) -> name = thread.readString().ifEmpty { null } + // repeated Register registers = 3 + fieldBytes(3) -> { + val bytes = thread.readBytes() + // Only the report uses registers, so a malformed one is dropped + // rather than failing the whole crash. + runCatching { parseRegister(bytes) }.getOrNull()?.let(registers::add) + } + // repeated string backtrace_note = 7 + fieldBytes(7) -> backtraceNotes.add(thread.readString()) // repeated BacktraceFrame current_backtrace = 4 fieldBytes(4) -> { val frame = ProtoReader(thread.readBytes()) @@ -126,6 +236,7 @@ internal class TombstoneParser { var functionOffset = 0L var fileName: String? = null var buildId: String? = null + var fileMapOffset = 0L while (frame.hasMore()) { when (frame.readTag()) { fieldVarint(1) -> relPc = frame.readVarint() @@ -133,16 +244,46 @@ internal class TombstoneParser { fieldBytes(4) -> functionName = frame.readString().ifEmpty { null } fieldVarint(5) -> functionOffset = frame.readVarint() fieldBytes(6) -> fileName = frame.readString().ifEmpty { null } + fieldVarint(7) -> fileMapOffset = frame.readVarint() fieldBytes(8) -> buildId = frame.readString().ifEmpty { null } else -> frame.skipLast() } } - frames.add(NativeCrashFrame(relPc, pc, functionName, functionOffset, fileName, buildId)) + frames.add(NativeCrashFrame(relPc, pc, functionName, functionOffset, fileName, buildId, fileMapOffset)) } else -> thread.skipLast() } } - return frames + return NativeCrashThread(id, name, registers, backtraceNotes, frames) + } + + private fun parseCause(bytes: ByteArray): String? { + val cause = ProtoReader(bytes) + var humanReadable: String? = null + while (cause.hasMore()) { + when (cause.readTag()) { + // string human_readable = 1 + fieldBytes(1) -> humanReadable = cause.readString() + else -> cause.skipLast() + } + } + return humanReadable + } + + private fun parseRegister(bytes: ByteArray): Pair { + val register = ProtoReader(bytes) + var name = "" + var value = 0L + while (register.hasMore()) { + when (register.readTag()) { + // string name = 1 + fieldBytes(1) -> name = register.readString() + // uint64 u64 = 2 + fieldVarint(2) -> value = register.readVarint() + else -> register.skipLast() + } + } + return name to value } private fun archName(value: Long): String? = diff --git a/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercerTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercerTest.kt index aaf373ec3..b32bed110 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercerTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercerTest.kt @@ -3,6 +3,7 @@ package com.posthog.android.internal.errortracking import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue internal class NativeCrashEventCoercerTest { private val coercer = NativeCrashEventCoercer(inAppPathPrefixes = listOf("/data/app/~~abc")) @@ -177,4 +178,20 @@ internal class NativeCrashEventCoercerTest { assertNull(NativeCrashEventCoercer.debugIdFromBuildId("zz")) assertNull(NativeCrashEventCoercer.debugIdFromBuildId("abc")) } + + @Test + fun `attaches the tombstone text as the crash report`() { + val thread = NativeCrashThread(4343, "RenderThread", emptyList(), emptyList(), emptyList()) + val properties = coercer.toPostHogProperties(tombstone(frames = emptyList()).copy(threads = mapOf(4343L to thread))) + + val report = properties["\$exception_crash_report"] as String + assertTrue(report.contains("pid: 0, tid: 4343, name: RenderThread")) + } + + @Test + fun `omits the crash report when the crashing thread is missing`() { + val properties = coercer.toPostHogProperties(tombstone(frames = emptyList())) + + assertNull(properties["\$exception_crash_report"]) + } } diff --git a/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashReportFormatterTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashReportFormatterTest.kt new file mode 100644 index 000000000..471829655 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashReportFormatterTest.kt @@ -0,0 +1,139 @@ +package com.posthog.android.internal.errortracking + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class NativeCrashReportFormatterTest { + private val crashedThread = + NativeCrashThread( + id = 4343, + name = "RenderThread", + registers = + listOf( + "x0" to 0x0L, + "x1" to 0x1L, + "x2" to 0x2L, + "x3" to 0x3L, + "x4" to 0x4L, + "sp" to 0x7ffc0L, + "pc" to 0x7a12345103d8L, + ), + backtraceNotes = emptyList(), + frames = + listOf( + NativeCrashFrame( + 0x103d8, + 0x7a12345103d8, + "process_frame", + 12, + "/data/app/~~abc/lib/arm64/libengine.so", + "524acc06c9af", + ), + NativeCrashFrame(0x2040, 0x7a1234502040, null, 0, "/data/app/~~abc/base.apk", null, fileMapOffset = 0x1000), + ), + ) + private val otherThread = + NativeCrashThread( + id = 1, + name = "main", + registers = listOf("x0" to 0x10L, "pc" to 0x7b0000001000L), + backtraceNotes = listOf("frames may be missing"), + frames = + listOf( + NativeCrashFrame(0x4a6c0, 0x7b000004a6c0, "__epoll_pwait", 8, "/apex/com.android.runtime/lib64/bionic/libc.so", "ffee"), + ), + ) + + private fun tombstone(threads: Map) = + NativeCrashTombstone( + arch = "arm64", + tid = 4343, + signalName = "SIGSEGV", + signalCodeName = "SEGV_MAPERR", + // address 0 is left off the wire, so only the flag says a fault address exists + faultAddress = null, + hasFaultAddress = true, + abortMessage = null, + frames = crashedThread.frames, + buildFingerprint = "google/sdk_gphone64_arm64/emu64a:15/AE3A/1:userdebug/dev-keys", + revision = "0", + timestamp = "2026-10-20 23:10:00.000000000+0000", + pid = 4242, + uid = 10123, + processUptime = 12, + pageSize = 16384, + commandLine = listOf("com.example.app"), + signalNumber = 11, + signalCode = 1, + causes = listOf("null pointer dereference"), + threads = threads, + ) + + @Test + fun `renders the crashing thread with its registers first, then the others by id, in debuggerd's format`() { + val text = NativeCrashReportFormatter.format(tombstone(mapOf(4343L to crashedThread, 1L to otherThread))) + + val expected = + """ + *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** + Build fingerprint: 'google/sdk_gphone64_arm64/emu64a:15/AE3A/1:userdebug/dev-keys' + Revision: '0' + ABI: 'arm64' + Timestamp: 2026-10-20 23:10:00.000000000+0000 + Process uptime: 12s + Page size: 16384 bytes + Cmdline: com.example.app + pid: 4242, tid: 4343, name: RenderThread >>> com.example.app <<< + uid: 10123 + signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000000 + Cause: null pointer dereference + x0 0000000000000000 x1 0000000000000001 x2 0000000000000002 x3 0000000000000003 + x4 0000000000000004 + sp 000000000007ffc0 pc 00007a12345103d8 + + 2 total frames + backtrace: + #00 pc 00000000000103d8 /data/app/~~abc/lib/arm64/libengine.so (process_frame+12) (BuildId: 524acc06c9af) + #01 pc 0000000000002040 /data/app/~~abc/base.apk (offset 0x1000) + --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- + Cmdline: com.example.app + pid: 4242, tid: 1, name: main >>> com.example.app <<< + uid: 10123 + + 1 total frames + backtrace: + NOTE: frames may be missing + #00 pc 000000000004a6c0 /apex/com.android.runtime/lib64/bionic/libc.so (__epoll_pwait+8) (BuildId: ffee) + + """.trimIndent() + assertEquals(expected, text) + } + + @Test + fun `returns null without an entry for the crashing thread`() { + assertNull(NativeCrashReportFormatter.format(tombstone(mapOf(1L to otherThread)))) + } + + @Test + fun `lists every cause after the backtrace when there are several`() { + val text = + NativeCrashReportFormatter.format( + tombstone(mapOf(4343L to crashedThread)).copy(causes = listOf("use-after-free", "buffer overflow")), + )!! + + assertFalse(text.contains("Cause: use-after-free\n x0")) + val expectedTail = + """ + Note: multiple potential causes for this crash were detected, listing them in decreasing order of likelihood. + + Cause: use-after-free + + Cause: buffer overflow + + """.trimIndent() + assertTrue(text.endsWith(expectedTail), text) + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/internal/errortracking/TombstoneParserTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/TombstoneParserTest.kt index f1f68ffdb..e067561f3 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/errortracking/TombstoneParserTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/TombstoneParserTest.kt @@ -3,6 +3,7 @@ package com.posthog.android.internal.errortracking import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue internal class TombstoneParserTest { private fun frame( @@ -123,4 +124,110 @@ internal class TombstoneParserTest { assertNull(parsed.frames[0].fileName) assertNull(parsed.frames[0].buildId) } + + @Test + fun `parses the process header and every thread for the crash report`() { + val tombstone = + TestProtoWriter() + .varint(1, 1) // arch = ARM64 + .string(2, "google/sdk_gphone64_arm64/emu64a:15/AE3A/1:userdebug/dev-keys") + .string(3, "0") + .string(4, "2026-10-20 23:10:00.000000000+0000") + .varint(5, 4242) // pid + .varint(6, 4343) // tid + .varint(7, 10123) // uid + .string(9, "com.example.app") + .varint(20, 12) // process_uptime + .message(10) { + varint(1, 6) + string(2, "SIGABRT") + varint(3, -6L) // SI_TKILL, sign-extended on the wire + string(4, "SI_TKILL") + varint(5, 1) // has_sender + varint(6, 10123) + varint(7, 4242) + varint(8, 1) // has_fault_address; address 0 itself is omitted + } + .varint(22, 16384) // page_size + .message(15) { string(1, "null pointer dereference") } + .message(16) { + varint(1, 4343) + message(2) { + varint(1, 4343) + string(2, "RenderThread") + message(3) { + string(1, "x0") + varint(2, 0x1L) + } + message(3) { + string(1, "pc") + varint(2, 0x7a12345103d8L) + } + string(7, "some frames may be missing") + message(4) { + varint(1, 0x103d8) + string(6, "/data/app/~~abc/base.apk") + varint(7, 0x2000) // file_map_offset + } + } + } + .message(16) { + varint(1, 1) + message(2) { + varint(1, 1) + string(2, "main") + } + } + .toByteArray() + + val parsed = TombstoneParser().parse(tombstone) + + assertEquals("google/sdk_gphone64_arm64/emu64a:15/AE3A/1:userdebug/dev-keys", parsed.buildFingerprint) + assertEquals("0", parsed.revision) + assertEquals("2026-10-20 23:10:00.000000000+0000", parsed.timestamp) + assertEquals(4242L, parsed.pid) + assertEquals(10123L, parsed.uid) + assertEquals(12L, parsed.processUptime) + assertEquals(listOf("com.example.app"), parsed.commandLine) + assertEquals(6, parsed.signalNumber) + assertEquals(-6, parsed.signalCode) + assertEquals(4242 to 10123, parsed.sender) + assertTrue(parsed.hasFaultAddress) + assertNull(parsed.faultAddress) + assertEquals(16384L, parsed.pageSize) + assertEquals(listOf("null pointer dereference"), parsed.causes) + + assertEquals(setOf(4343L, 1L), parsed.threads.keys) + val crashed = parsed.threads.getValue(4343L) + assertEquals("RenderThread", crashed.name) + assertEquals(listOf("x0" to 0x1L, "pc" to 0x7a12345103d8L), crashed.registers) + assertEquals(listOf("some frames may be missing"), crashed.backtraceNotes) + assertEquals(0x2000L, crashed.frames.single().fileMapOffset) + assertEquals("main", parsed.threads.getValue(1L).name) + } + + @Test + fun `a malformed cause or register is dropped without failing the crash`() { + // human_readable / name claim 5 bytes but only 1 follows + val malformed = byteArrayOf(0x0A, 0x05, 'x'.code.toByte()) + val tombstone = + TestProtoWriter() + .varint(6, 1) // tid + .bytes(15, malformed) // causes + .message(16) { + varint(1, 1) + message(2) { + varint(1, 1) + bytes(3, malformed) // registers + message(4, frame(0x10, 0x10, null, 0, "/system/lib64/libc.so", null)) + } + } + .toByteArray() + + val parsed = TombstoneParser().parse(tombstone) + + assertEquals(1, parsed.frames.size) + assertTrue(parsed.causes.isEmpty()) + assertTrue(parsed.threads.getValue(1L).registers.isEmpty()) + } } diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index bc54145ad..bf5441125 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -14,6 +14,7 @@ import com.posthog.internal.PostHogQueueInterface import com.posthog.internal.PostHogThreadFactory import com.posthog.internal.errortracking.PostHogThrowable import com.posthog.internal.errortracking.ThrowableCoercer +import com.posthog.internal.errortracking.removeOversizedCrashReport import java.util.Date import java.util.UUID import java.util.concurrent.ExecutorService @@ -326,6 +327,12 @@ public open class PostHogStateless protected constructor( } } + config?.let { config -> + if (removeOversizedCrashReport(eventChecked, config.serializer::serializeObject)) { + config.logger.log("Dropped \$exception_crash_report to keep the $event event under the size limit.") + } + } + return eventChecked } diff --git a/posthog/src/main/java/com/posthog/internal/errortracking/CrashReportSizeLimit.kt b/posthog/src/main/java/com/posthog/internal/errortracking/CrashReportSizeLimit.kt new file mode 100644 index 000000000..7c685742b --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/errortracking/CrashReportSizeLimit.kt @@ -0,0 +1,32 @@ +package com.posthog.internal.errortracking + +import com.posthog.PostHogEvent + +internal const val CRASH_REPORT_PROPERTY = "\$exception_crash_report" + +// Capture rejects events over its default 1 MB limit, and a single rejected +// event is dropped, so leave headroom for the request envelope. +private const val MAX_CRASH_EVENT_BYTES = 900_000 + +/** + * Removes [CRASH_REPORT_PROPERTY] when the serialized event would exceed + * [maxBytes], so an optional crash report cannot cost the crash event itself. + * Runs on the fully built event, after context enrichment and `beforeSend`. + */ +internal fun removeOversizedCrashReport( + event: PostHogEvent, + serialize: (Any) -> String?, + maxBytes: Int = MAX_CRASH_EVENT_BYTES, +): Boolean { + val properties = event.properties ?: return false + val report = properties[CRASH_REPORT_PROPERTY] as? String ?: return false + // The serialized event is never smaller than the report itself, so skip serializing one that cannot fit. + val tooLarge = + report.length > maxBytes || + runCatching { serialize(event)?.toByteArray(Charsets.UTF_8)?.size }.getOrNull()?.let { it > maxBytes } == true + if (!tooLarge) { + return false + } + properties.remove(CRASH_REPORT_PROPERTY) + return true +} diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 01286caba..16c74c8b8 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -1762,6 +1762,35 @@ internal class PostHogTest { sut.close() } + @Test + fun `drops the crash report when registered properties push a crash event over the limit`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + // each fits on its own, but not together once capture merges the registered property in + sut.register("bigSuperProperty", "x".repeat(500_000)) + + sut.capture( + "\$exception", + DISTINCT_ID, + mapOf( + "\$exception_list" to listOf(mapOf("type" to "SIGSEGV")), + "\$exception_crash_report" to "y".repeat(500_000), + ), + ) + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val batch = serializer.deserialize(request.body.unGzip().reader()) + val theEvent = batch.batch.first() + assertEquals("\$exception", theEvent.event) + assertNull(theEvent.properties!!["\$exception_crash_report"]) + assertNotNull(theEvent.properties!!["\$exception_list"]) + assertNotNull(theEvent.properties!!["bigSuperProperty"]) + } + @Test fun `captures an event`() { val http = mockHttp() diff --git a/posthog/src/test/java/com/posthog/internal/errortracking/CrashReportSizeLimitTest.kt b/posthog/src/test/java/com/posthog/internal/errortracking/CrashReportSizeLimitTest.kt new file mode 100644 index 000000000..c2c55bbc4 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/errortracking/CrashReportSizeLimitTest.kt @@ -0,0 +1,46 @@ +package com.posthog.internal.errortracking + +import com.posthog.PostHogConfig +import com.posthog.PostHogEvent +import com.posthog.internal.PostHogSerializer +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class CrashReportSizeLimitTest { + private val serializer = PostHogSerializer(PostHogConfig("phc_test")) + + // Quotes double in size once JSON-escaped. + private val quotes = "\"".repeat(300) + + private fun event() = + PostHogEvent( + "\$exception", + "distinct-id", + properties = + mutableMapOf( + "\$exception_list" to listOf(mapOf("value" to quotes)), + CRASH_REPORT_PROPERTY to "Abort message: '$quotes'", + ), + ) + + @Test + fun `drops the crash report when JSON escaping pushes the event over the limit`() { + val kept = event() + val dropped = event() + + assertFalse(removeOversizedCrashReport(kept, serializer::serializeObject, maxBytes = 2000)) + assertTrue(removeOversizedCrashReport(dropped, serializer::serializeObject, maxBytes = 1200)) + assertTrue(kept.properties!!.containsKey(CRASH_REPORT_PROPERTY)) + assertFalse(dropped.properties!!.containsKey(CRASH_REPORT_PROPERTY)) + assertTrue(dropped.properties!!.containsKey("\$exception_list")) + } + + @Test + fun `drops a report longer than the limit without serializing`() { + val event = event() + + assertTrue(removeOversizedCrashReport(event, { error("must not serialize") }, maxBytes = 100)) + assertFalse(event.properties!!.containsKey(CRASH_REPORT_PROPERTY)) + } +}