From 85d6c741ef954c07d47fb99fdd5ae9f21ad3b8ee Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Tue, 28 Jul 2026 19:11:56 +0300 Subject: [PATCH 1/4] feat(errortracking): capture native ndk crashes from applicationexitinfo tombstones --- .changeset/native-crash-capture.md | 6 + .../com/posthog/android/PostHogAndroid.kt | 4 + .../PostHogNativeCrashIntegration.kt | 133 +++++++++++ .../errortracking/NativeCrashEventCoercer.kt | 126 ++++++++++ .../internal/errortracking/TombstoneParser.kt | 219 ++++++++++++++++++ .../NativeCrashEventCoercerTest.kt | 141 +++++++++++ .../errortracking/TombstoneParserTest.kt | 175 ++++++++++++++ .../PostHogErrorTrackingConfig.kt | 14 ++ .../posthog/internal/PostHogRemoteConfig.kt | 7 + 9 files changed, 825 insertions(+) create mode 100644 .changeset/native-crash-capture.md create mode 100644 posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt create mode 100644 posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercer.kt create mode 100644 posthog-android/src/main/java/com/posthog/android/internal/errortracking/TombstoneParser.kt create mode 100644 posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercerTest.kt create mode 100644 posthog-android/src/test/java/com/posthog/android/internal/errortracking/TombstoneParserTest.kt diff --git a/.changeset/native-crash-capture.md b/.changeset/native-crash-capture.md new file mode 100644 index 000000000..c2c75e016 --- /dev/null +++ b/.changeset/native-crash-capture.md @@ -0,0 +1,6 @@ +--- +"posthog": minor +"posthog-android": minor +--- + +Add native (NDK) crash capture on Android 12+, opt-in via `errorTrackingConfig.captureNativeCrashes`. On startup the SDK reads the native crash records the OS kept (`ApplicationExitInfo` tombstones) and captures an `$exception` event per crash with raw native stack frames and `$debug_images`, so PostHog symbolicates them against `.so` debug symbols uploaded with `posthog-cli symbol-sets upload`. diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt index 2a8d61e84..7c2331c8d 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -21,6 +21,7 @@ import com.posthog.android.internal.getPackageInfo import com.posthog.android.internal.versionCodeCompat import com.posthog.android.replay.PostHogReplayIntegration import com.posthog.android.replay.internal.PostHogLogCatIntegration +import com.posthog.android.errortracking.PostHogNativeCrashIntegration import com.posthog.android.surveys.PostHogSurveysIntegration import com.posthog.internal.PostHogDeviceDateProvider import com.posthog.internal.PostHogNoOpLogger @@ -160,6 +161,9 @@ public class PostHogAndroid private constructor() { if (config.surveys) { config.addIntegration(PostHogSurveysIntegration(context, config)) } + if (config.errorTrackingConfig.captureNativeCrashes) { + config.addIntegration(PostHogNativeCrashIntegration(context, config)) + } } } } 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 new file mode 100644 index 000000000..cb57e1dae --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt @@ -0,0 +1,133 @@ +package com.posthog.android.errortracking + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context +import android.os.Build +import com.posthog.PostHogEventName +import com.posthog.PostHogIntegration +import com.posthog.PostHogInterface +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.internal.errortracking.NativeCrashEventCoercer +import com.posthog.android.internal.errortracking.TombstoneParser +import java.util.Date + +/** + * Captures native (NDK) crashes from previous runs of the app. + * + * A native signal (e.g. SIGSEGV) kills the process before any JVM handler + * runs, so nothing can be captured in-process. Instead, on startup this + * integration reads the crash records the OS kept via + * [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. + * + * Requires Android 12 (API 31), where native crash records carry a tombstone. + * Enable with `errorTrackingConfig.captureNativeCrashes`; error tracking + * autocapture must also be enabled in the project settings. + */ +public class PostHogNativeCrashIntegration( + private val context: Context, + private val config: PostHogAndroidConfig, +) : PostHogIntegration { + private var postHog: PostHogInterface? = null + + private companion object { + private const val LAST_CAPTURED_TIMESTAMP_KEY = "nativeCrashLastCapturedTimestamp" + private const val MAX_EXIT_RECORDS = 20 + + @Volatile + private var integrationInstalled = false + } + + override fun install(postHog: PostHogInterface) { + this.postHog = postHog + + if (integrationInstalled || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return + } + if (config.remoteConfigHolder?.isNativeCrashCaptureEnabled() != true) { + return + } + // While the store is unreadable (Direct Boot) the watermark reads as + // absent, which would re-capture already-reported crashes. + if (config.cachePreferences?.isAvailable() == false) { + return + } + integrationInstalled = true + + Thread({ scanSafely(postHog) }, "PostHogNativeCrashScanner") + .apply { isDaemon = true } + .start() + } + + override fun uninstall() { + integrationInstalled = false + } + + override fun onRemoteConfig(loaded: Boolean) { + // Only react to a live config; a failed attempt applies no fresh values. + if (!loaded) { + return + } + if (config.remoteConfigHolder?.isNativeCrashCaptureEnabled() == true) { + postHog?.let { install(it) } + } + } + + private fun scanSafely(postHog: PostHogInterface) { + try { + scan(postHog) + } catch (e: Throwable) { + config.logger.log("Native crash scan failed: $e.") + } + } + + private fun scan(postHog: PostHogInterface) { + val activityManager = + context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return + val preferences = config.cachePreferences ?: return + + val watermark = preferences.getValue(LAST_CAPTURED_TIMESTAMP_KEY) as? Long ?: 0L + val crashes = + activityManager + .getHistoricalProcessExitReasons(context.packageName, 0, MAX_EXIT_RECORDS) + .filter { it.reason == ApplicationExitInfo.REASON_CRASH_NATIVE && it.timestamp > watermark } + // oldest first, so events arrive in crash order + .sortedBy { it.timestamp } + + if (crashes.isEmpty()) { + return + } + + val parser = TombstoneParser() + val coercer = NativeCrashEventCoercer() + + crashes.forEach { exitInfo -> + val properties = + try { + exitInfo.traceInputStream?.use { stream -> + coercer.toPostHogProperties(parser.parse(stream)) + } + } catch (e: Throwable) { + config.logger.log("Tombstone parsing failed: $e.") + null + } + + properties?.let { + postHog.capture( + PostHogEventName.EXCEPTION.event, + properties = it, + timestamp = Date(exitInfo.timestamp), + ) + } + + // Advance per record — unparsable ones too, retrying can't succeed — + // so dying mid-scan never re-captures already-reported crashes. + preferences.setValue(LAST_CAPTURED_TIMESTAMP_KEY, exitInfo.timestamp) + } + + config.logger.log("Captured ${crashes.size} native crash record(s) from previous runs.") + } +} 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 new file mode 100644 index 000000000..2e3d81dd9 --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercer.kt @@ -0,0 +1,126 @@ +package com.posthog.android.internal.errortracking + +/** + * Converts a parsed tombstone into `$exception` event properties using the + * native stack frame contract: frames carry raw instruction addresses and the + * event carries `$debug_images` entries keyed by debug id, so the server can + * symbolicate against uploaded `.so` debug symbols. + */ +internal class NativeCrashEventCoercer { + fun toPostHogProperties(tombstone: NativeCrashTombstone): MutableMap { + val frames = mutableListOf>() + // debug id -> image entry; one image per mapped ELF, shared by its frames + val debugImages = LinkedHashMap>() + + // Tombstones list the crash site first; the wire order is canonical + // bottom-up (outermost first, crash site last). + tombstone.frames.reversed().forEach { frame -> + val imageAddr = frame.pc - frame.relPc + + val myFrame = mutableMapOf() + myFrame["platform"] = "native" + myFrame["instruction_addr"] = hex(frame.pc) + myFrame["image_addr"] = hex(imageAddr) + myFrame["in_app"] = isInApp(frame.fileName) + myFrame["synthetic"] = false + + frame.functionName?.let { name -> + myFrame["function"] = name + myFrame["client_resolved"] = true + // The tombstone's function_offset is pc-relative, so the + // enclosing symbol starts at pc - offset. + myFrame["symbol_addr"] = hex(frame.pc - frame.functionOffset) + } + frame.fileName?.let { path -> + myFrame["module"] = path.substringAfterLast('/') + } + + frames.add(myFrame) + + val buildId = frame.buildId ?: return@forEach + val debugId = debugIdFromBuildId(buildId) ?: return@forEach + debugImages.getOrPut(debugId) { + val image = mutableMapOf() + image["debug_id"] = debugId + image["code_id"] = buildId + image["image_addr"] = hex(imageAddr) + image["type"] = "elf" + frame.fileName?.let { image["code_file"] = it } + tombstone.arch?.let { image["arch"] = it } + image + } + } + + val exception = mutableMapOf() + exception["type"] = tombstone.signalName ?: "NativeCrash" + exception["value"] = exceptionValue(tombstone) + exception["mechanism"] = + mapOf( + "handled" to false, + "synthetic" to false, + "type" to "signal", + ) + tombstone.tid?.let { exception["thread_id"] = it } + if (frames.isNotEmpty()) { + exception["stacktrace"] = + mapOf( + "type" to "raw", + "frames" to frames, + ) + } + + val properties = mutableMapOf() + properties["\$exception_list"] = listOf(exception) + properties["\$exception_level"] = "fatal" + if (debugImages.isNotEmpty()) { + properties["\$debug_images"] = debugImages.values.toList() + } + return properties + } + + private fun exceptionValue(tombstone: NativeCrashTombstone): String { + tombstone.abortMessage?.let { return it } + val code = tombstone.signalCodeName + val fault = tombstone.faultAddress + return when { + code != null && fault != null -> "$code at ${hex(fault)}" + code != null -> code + else -> "Native crash" + } + } + + // App code lives under /data (installed APKs and extracted libs); anything + // else (/system, /apex, /vendor) is OS-owned. Unknown mappings (JIT, + // anonymous) stay out-of-app. + private fun isInApp(fileName: String?): Boolean = fileName?.startsWith("/data/") == true + + private fun hex(value: Long): String = "0x${java.lang.Long.toUnsignedString(value, 16)}" + + internal companion object { + /** + * 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 + * little-endian GUID, i.e. the first three fields byte-swapped. This + * mirrors how the upload side derives the chunk id from the ELF. + */ + internal fun debugIdFromBuildId(buildId: String): String? { + if (buildId.length < 2 || buildId.length % 2 != 0) { + return null + } + val bytes = ByteArray(16) + val available = minOf(buildId.length / 2, 16) + for (i in 0 until available) { + val byte = buildId.substring(i * 2, i * 2 + 2).toIntOrNull(16) ?: return null + bytes[i] = byte.toByte() + } + + fun byte(i: Int): String = String.format("%02x", bytes[i]) + val d1 = byte(3) + byte(2) + byte(1) + byte(0) + val d2 = byte(5) + byte(4) + val d3 = byte(7) + byte(6) + val d4 = byte(8) + byte(9) + val d5 = (10..15).joinToString("") { byte(it) } + return "$d1-$d2-$d3-$d4-$d5" + } + } +} 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 new file mode 100644 index 000000000..596b23bce --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/TombstoneParser.kt @@ -0,0 +1,219 @@ +package com.posthog.android.internal.errortracking + +import java.io.IOException +import java.io.InputStream + +/** + * A native crash extracted from a debuggerd tombstone. + * + * Frames are in tombstone order: frame 0 is the crash site, the last frame is + * the outermost caller. + */ +internal data class NativeCrashTombstone( + val arch: String?, + val tid: Long?, + val signalName: String?, + val signalCodeName: String?, + val faultAddress: Long?, + val abortMessage: String?, + val frames: List, +) + +internal data class NativeCrashFrame( + /** Program counter relative to the ELF the frame is in. */ + val relPc: Long, + /** Absolute program counter at crash time. */ + val pc: Long, + val functionName: String?, + val functionOffset: Long, + /** Path of the mapped ELF (e.g. /data/app/.../libfoo.so). */ + val fileName: String?, + /** GNU build id of the mapped ELF as a hex string, when known. */ + val buildId: String?, +) + +/** + * 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. + * + * Hand-rolled to avoid shipping a protobuf runtime in the SDK; the field + * numbers used here are frozen in AOSP. + */ +internal class TombstoneParser { + @Throws(IOException::class) + fun parse(stream: InputStream): NativeCrashTombstone { + val reader = ProtoReader(stream.readBytes()) + + var arch: String? = null + var tid: Long? = null + var signalName: String? = null + var signalCodeName: String? = null + 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>() + + while (reader.hasMore()) { + when (reader.readTag()) { + // Architecture arch = 1 + fieldVarint(1) -> arch = archName(reader.readVarint()) + // uint32 tid = 6 + fieldVarint(6) -> tid = reader.readVarint() + // Signal signal_info = 10 + fieldBytes(10) -> { + val signal = ProtoReader(reader.readBytes()) + while (signal.hasMore()) { + when (signal.readTag()) { + // string name = 2 + fieldBytes(2) -> signalName = signal.readString() + // string code_name = 4 + fieldBytes(4) -> signalCodeName = signal.readString() + // bool has_fault_address = 8 + fieldVarint(8) -> hasFaultAddress = signal.readVarint() != 0L + // uint64 fault_address = 9 + fieldVarint(9) -> faultAddress = signal.readVarint() + else -> signal.skipLast() + } + } + } + // string abort_message = 14 + fieldBytes(14) -> abortMessage = reader.readString().ifEmpty { null } + // map threads = 16 + fieldBytes(16) -> { + val entry = ProtoReader(reader.readBytes()) + var key: Long? = null + var frames: List? = null + while (entry.hasMore()) { + when (entry.readTag()) { + fieldVarint(1) -> key = entry.readVarint() + fieldBytes(2) -> frames = parseThreadBacktrace(entry.readBytes()) + else -> entry.skipLast() + } + } + key?.let { backtraces[it] = frames ?: emptyList() } + } + else -> reader.skipLast() + } + } + + return NativeCrashTombstone( + arch = arch, + tid = tid, + signalName = signalName, + signalCodeName = signalCodeName, + faultAddress = if (hasFaultAddress) faultAddress else null, + abortMessage = abortMessage, + frames = tid?.let { backtraces[it] } ?: emptyList(), + ) + } + + private fun parseThreadBacktrace(bytes: ByteArray): List { + val thread = ProtoReader(bytes) + val frames = mutableListOf() + while (thread.hasMore()) { + when (thread.readTag()) { + // repeated BacktraceFrame current_backtrace = 4 + fieldBytes(4) -> { + val frame = ProtoReader(thread.readBytes()) + var relPc = 0L + var pc = 0L + var functionName: String? = null + var functionOffset = 0L + var fileName: String? = null + var buildId: String? = null + while (frame.hasMore()) { + when (frame.readTag()) { + fieldVarint(1) -> relPc = frame.readVarint() + fieldVarint(2) -> pc = frame.readVarint() + fieldBytes(4) -> functionName = frame.readString().ifEmpty { null } + fieldVarint(5) -> functionOffset = frame.readVarint() + fieldBytes(6) -> fileName = frame.readString().ifEmpty { null } + fieldBytes(8) -> buildId = frame.readString().ifEmpty { null } + else -> frame.skipLast() + } + } + frames.add(NativeCrashFrame(relPc, pc, functionName, functionOffset, fileName, buildId)) + } + else -> thread.skipLast() + } + } + return frames + } + + private fun archName(value: Long): String? = + when (value) { + 0L -> "arm" + 1L -> "arm64" + 2L -> "x86" + 3L -> "x86_64" + 4L -> "riscv64" + else -> null + } + + private fun fieldVarint(field: Int): Int = field shl 3 + + private fun fieldBytes(field: Int): Int = (field shl 3) or 2 +} + +/** Cursor over protobuf wire data: tags, varints, and length-delimited chunks. */ +private class ProtoReader(private val data: ByteArray) { + private var pos = 0 + private var lastTag = 0 + + fun hasMore(): Boolean = pos < data.size + + fun readTag(): Int { + lastTag = readVarint().toInt() + return lastTag + } + + fun readVarint(): Long { + var result = 0L + var shift = 0 + while (true) { + if (pos >= data.size || shift > 63) { + throw IOException("malformed varint at $pos") + } + val b = data[pos++].toInt() + result = result or ((b.toLong() and 0x7f) shl shift) + if (b and 0x80 == 0) { + return result + } + shift += 7 + } + } + + fun readBytes(): ByteArray { + val length = readVarint().toInt() + if (length < 0 || pos + length > data.size) { + throw IOException("malformed length $length at $pos") + } + val bytes = data.copyOfRange(pos, pos + length) + pos += length + return bytes + } + + fun readString(): String = String(readBytes(), Charsets.UTF_8) + + /** Skips the value of the tag returned by the last [readTag] call. */ + fun skipLast() { + when (lastTag and 0x7) { + 0 -> readVarint() + 1 -> advance(8) + 2 -> readBytes() + 5 -> advance(4) + else -> throw IOException("unsupported wire type ${lastTag and 0x7} at $pos") + } + } + + private fun advance(count: Int) { + if (pos + count > data.size) { + throw IOException("truncated fixed field at $pos") + } + pos += count + } +} 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 new file mode 100644 index 000000000..5d20eb303 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercerTest.kt @@ -0,0 +1,141 @@ +package com.posthog.android.internal.errortracking + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +internal class NativeCrashEventCoercerTest { + private val coercer = NativeCrashEventCoercer() + + private fun tombstone( + frames: List, + abortMessage: String? = null, + ): NativeCrashTombstone = + NativeCrashTombstone( + arch = "arm64", + tid = 4343L, + signalName = "SIGSEGV", + signalCodeName = "SEGV_MAPERR", + faultAddress = 0xdeadL, + abortMessage = abortMessage, + frames = frames, + ) + + @Suppress("UNCHECKED_CAST") + private fun exception(properties: Map): Map = + (properties["\$exception_list"] as List>).single() + + @Suppress("UNCHECKED_CAST") + private fun frames(properties: Map): List> = + (exception(properties)["stacktrace"] as Map)["frames"] as List> + + @Test + fun `emits native frames bottom-up with tombstone-derived addresses`() { + val properties = + coercer.toPostHogProperties( + tombstone( + frames = + listOf( + // crash site first, as tombstones report it + NativeCrashFrame(0x103d8, 0x7a12345103d8, "process_frame", 0xc, "/data/app/~~abc/libengine.so", "5c6893c3dc6e76d2cbd637e4c8b4e2aaf90088b3"), + NativeCrashFrame(0x8501c, 0x7a123468501c, "__start_thread", 0x40, "/apex/com.android.runtime/lib64/bionic/libc.so", "aabbccdd"), + ), + ), + ) + + val frames = frames(properties) + assertEquals(2, frames.size) + + // Wire order is canonical bottom-up: outermost first, crash site last + val outer = frames[0] + assertEquals("native", outer["platform"]) + assertEquals("0x7a123468501c", outer["instruction_addr"]) + assertEquals("0x7a1234600000", outer["image_addr"]) + assertEquals("0x7a1234684fdc", outer["symbol_addr"]) + assertEquals("__start_thread", outer["function"]) + assertEquals(true, outer["client_resolved"]) + assertEquals("libc.so", outer["module"]) + assertEquals(false, outer["in_app"]) + + val crash = frames[1] + assertEquals("0x7a12345103d8", crash["instruction_addr"]) + assertEquals("0x7a1234500000", crash["image_addr"]) + assertEquals("libengine.so", crash["module"]) + assertEquals(true, crash["in_app"]) + + val exception = exception(properties) + assertEquals("SIGSEGV", exception["type"]) + assertEquals("SEGV_MAPERR at 0xdead", exception["value"]) + assertEquals(4343L, exception["thread_id"]) + assertEquals( + mapOf("handled" to false, "synthetic" to false, "type" to "signal"), + exception["mechanism"], + ) + assertEquals("fatal", properties["\$exception_level"]) + } + + @Suppress("UNCHECKED_CAST") + @Test + fun `debug images are deduplicated per module and carry the derived debug id`() { + val buildId = "5c6893c3dc6e76d2cbd637e4c8b4e2aaf90088b3" + val properties = + coercer.toPostHogProperties( + tombstone( + frames = + listOf( + NativeCrashFrame(0x103d8, 0x7a12345103d8, null, 0, "/data/app/~~abc/libengine.so", buildId), + NativeCrashFrame(0x10458, 0x7a1234510458, null, 0, "/data/app/~~abc/libengine.so", buildId), + NativeCrashFrame(0x100, 0x100, null, 0, null, null), + ), + ), + ) + + val images = properties["\$debug_images"] as List> + assertEquals(1, images.size) + val image = images.single() + // The same derivation the upload side applies to the ELF, pinned by + // cymbal's android fixture (libtest_android.so) + assertEquals("c393685c-6edc-d276-cbd6-37e4c8b4e2aa", image["debug_id"]) + assertEquals(buildId, image["code_id"]) + assertEquals("0x7a1234500000", image["image_addr"]) + assertEquals("elf", image["type"]) + assertEquals("/data/app/~~abc/libengine.so", image["code_file"]) + assertEquals("arm64", image["arch"]) + + // Frames without a function are not client-resolved and carry no symbol + val frame = frames(properties)[0] + assertNull(frame["function"]) + assertNull(frame["client_resolved"]) + assertNull(frame["symbol_addr"]) + } + + @Test + fun `abort message wins over the signal description`() { + val properties = + coercer.toPostHogProperties( + tombstone(frames = emptyList(), abortMessage = "FORTIFY: fdsan double-close"), + ) + + val exception = exception(properties) + assertEquals("FORTIFY: fdsan double-close", exception["value"]) + assertNull(exception["stacktrace"]) + assertNull(properties["\$debug_images"]) + } + + @Test + fun `debug id derivation matches the symbolic vocabulary`() { + // 20-byte GNU build id: first 16 bytes read as a little-endian GUID + assertEquals( + "c393685c-6edc-d276-cbd6-37e4c8b4e2aa", + NativeCrashEventCoercer.debugIdFromBuildId("5c6893c3dc6e76d2cbd637e4c8b4e2aaf90088b3"), + ) + // 8-byte fast build id: zero-padded to 16 bytes + assertEquals( + "c393685c-6edc-d276-0000-000000000000", + NativeCrashEventCoercer.debugIdFromBuildId("5c6893c3dc6e76d2"), + ) + assertNull(NativeCrashEventCoercer.debugIdFromBuildId("")) + assertNull(NativeCrashEventCoercer.debugIdFromBuildId("zz")) + assertNull(NativeCrashEventCoercer.debugIdFromBuildId("abc")) + } +} 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 new file mode 100644 index 000000000..d4a399729 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/TombstoneParserTest.kt @@ -0,0 +1,175 @@ +package com.posthog.android.internal.errortracking + +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kotlin.test.assertEquals +import kotlin.test.assertNull + +internal class TombstoneParserTest { + // Minimal protobuf wire encoder, so the parser is exercised against real + // wire bytes rather than its own inverse. + private class ProtoWriter { + private val out = ByteArrayOutputStream() + + fun varint( + field: Int, + value: Long, + ): ProtoWriter { + writeVarint((field shl 3).toLong()) + writeVarint(value) + return this + } + + fun bytes( + field: Int, + value: ByteArray, + ): ProtoWriter { + writeVarint(((field shl 3) or 2).toLong()) + writeVarint(value.size.toLong()) + out.write(value) + return this + } + + fun string( + field: Int, + value: String, + ): ProtoWriter = bytes(field, value.toByteArray(Charsets.UTF_8)) + + fun message( + field: Int, + block: ProtoWriter.() -> Unit, + ): ProtoWriter = bytes(field, ProtoWriter().apply(block).toByteArray()) + + fun toByteArray(): ByteArray = out.toByteArray() + + private fun writeVarint(value: Long) { + var v = value + while (true) { + if (v and 0x7fL.inv() == 0L) { + out.write(v.toInt()) + return + } + out.write(((v and 0x7f) or 0x80).toInt()) + v = v ushr 7 + } + } + } + + private fun frame( + relPc: Long, + pc: Long, + function: String?, + functionOffset: Long, + file: String?, + buildId: String?, + ): ProtoWriter.() -> Unit = + { + varint(1, relPc) + varint(2, pc) + function?.let { string(4, it) } + varint(5, functionOffset) + file?.let { string(6, it) } + buildId?.let { string(8, it) } + } + + @Test + fun `parses signal info, abort message, and the crashing thread's backtrace`() { + val tombstone = + ProtoWriter() + .varint(1, 1) // arch = ARM64 + .varint(5, 4242) // pid + .varint(6, 4343) // tid + .message(10) { + // signal_info + varint(1, 11) + string(2, "SIGSEGV") + varint(3, 1) + string(4, "SEGV_MAPERR") + varint(8, 1) // has_fault_address + varint(9, 0xdeadL) // fault_address + } + .message(16) { + // threads[4343] — the crashing one + varint(1, 4343) + message(2) { + varint(1, 4343) + string(2, "RenderThread") + message(4, frame(0x103d8, 0x7a12345103d8, "process_frame", 0xc, "/data/app/~~abc/libengine.so", "524acc06c9af")) + message(4, frame(0x10458, 0x7a1234510458, null, 0, "/data/app/~~abc/libengine.so", "524acc06c9af")) + } + } + .message(16) { + // threads[1] — another thread that must be ignored + varint(1, 1) + message(2) { + varint(1, 1) + message(4, frame(0x1, 0x1, "other_thread_frame", 0, "/system/lib64/libc.so", "ffff")) + } + } + .toByteArray() + + val parsed = TombstoneParser().parse(ByteArrayInputStream(tombstone)) + + assertEquals("arm64", parsed.arch) + assertEquals(4343L, parsed.tid) + assertEquals("SIGSEGV", parsed.signalName) + assertEquals("SEGV_MAPERR", parsed.signalCodeName) + assertEquals(0xdeadL, parsed.faultAddress) + assertNull(parsed.abortMessage) + + assertEquals(2, parsed.frames.size) + val crashFrame = parsed.frames[0] + assertEquals(0x103d8L, crashFrame.relPc) + assertEquals(0x7a12345103d8L, crashFrame.pc) + assertEquals("process_frame", crashFrame.functionName) + assertEquals(0xcL, crashFrame.functionOffset) + assertEquals("/data/app/~~abc/libengine.so", crashFrame.fileName) + assertEquals("524acc06c9af", crashFrame.buildId) + assertNull(parsed.frames[1].functionName) + } + + @Test + fun `fault address is null when the signal has none`() { + val tombstone = + ProtoWriter() + .varint(6, 1) + .message(10) { + string(2, "SIGABRT") + varint(8, 0) // has_fault_address = false + varint(9, 0x1234) + } + .string(14, "assertion failed: x != null") + .toByteArray() + + val parsed = TombstoneParser().parse(ByteArrayInputStream(tombstone)) + + assertEquals("SIGABRT", parsed.signalName) + assertNull(parsed.faultAddress) + assertEquals("assertion failed: x != null", parsed.abortMessage) + } + + @Test + fun `unknown fields and wire types are skipped`() { + val tombstone = + ProtoWriter() + .string(2, "google/panther/panther:13") // build_fingerprint, unused + .varint(6, 7) + .varint(22, 4096) // page_size, unused + .message(16) { + varint(1, 7) + message(2) { + varint(6, -1) // tagged_addr_ctrl (unused varint) + message(4, frame(0x10, 0x2010, "f", 0, null, null)) + } + } + .toByteArray() + + val parsed = TombstoneParser().parse(ByteArrayInputStream(tombstone)) + + assertEquals(1, parsed.frames.size) + assertEquals("f", parsed.frames[0].functionName) + assertNull(parsed.frames[0].fileName) + assertNull(parsed.frames[0].buildId) + } +} diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt index 79a966962..7e756a52f 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt @@ -45,4 +45,18 @@ public class PostHogErrorTrackingConfig * Defaults to empty. */ public val ignoredExceptionTypes: MutableList> = mutableListOf(), + /** + * Capture native (NDK) crashes from previous runs of the app. + * + * Android only, requires Android 12 (API 31). On startup the SDK reads the + * native crash records the OS kept for the app and captures an `$exception` + * event per crash, with raw stack frames for server-side symbolication + * against uploaded `.so` debug symbols. + * + * Exception autocapture must also be enabled in the project's error tracking + * settings (the same remote toggle that gates [autoCapture]). + * + * Disabled by default + */ + public var captureNativeCrashes: Boolean = false, ) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index b9b732d3b..8be7c0a6e 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -634,6 +634,13 @@ public class PostHogRemoteConfig( */ public fun isAutocaptureExceptionsEnabled(): Boolean = autoCaptureExceptions && config.errorTrackingConfig.autoCapture + /** + * Returns whether native (NDK) crash capture is enabled. + * Both remote config (errorTracking.autocaptureExceptions) AND local config + * (PostHogConfig.errorTrackingConfig.captureNativeCrashes) must be enabled. + */ + public fun isNativeCrashCaptureEnabled(): Boolean = autoCaptureExceptions && config.errorTrackingConfig.captureNativeCrashes + /** * Returns whether console log recording is enabled remotely. * Both remote config (sessionRecording.consoleLogRecordingEnabled) AND local config must be enabled. From fa4488e71d5f33eff43fdaaa193e380af53f83d4 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Tue, 28 Jul 2026 19:18:44 +0300 Subject: [PATCH 2/4] style: fix import order --- .../src/main/java/com/posthog/android/PostHogAndroid.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt index 7c2331c8d..dc3c1c9dd 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -5,6 +5,7 @@ import android.content.Context import android.os.Build import com.posthog.PostHog import com.posthog.PostHogInterface +import com.posthog.android.errortracking.PostHogNativeCrashIntegration import com.posthog.android.internal.MainHandler import com.posthog.android.internal.PostHogActivityLifecycleCallbackIntegration import com.posthog.android.internal.PostHogAndroidContext @@ -21,7 +22,6 @@ import com.posthog.android.internal.getPackageInfo import com.posthog.android.internal.versionCodeCompat import com.posthog.android.replay.PostHogReplayIntegration import com.posthog.android.replay.internal.PostHogLogCatIntegration -import com.posthog.android.errortracking.PostHogNativeCrashIntegration import com.posthog.android.surveys.PostHogSurveysIntegration import com.posthog.internal.PostHogDeviceDateProvider import com.posthog.internal.PostHogNoOpLogger From e0f6500be86017132a451633c6fac31d19c7a13e Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Tue, 28 Jul 2026 19:42:01 +0300 Subject: [PATCH 3/4] chore: regenerate api dumps and format test fixtures --- posthog-android/api/posthog-android.api | 7 ++++++ .../errortracking/NativeCrashEventCoercer.kt | 8 ++++++- .../NativeCrashEventCoercerTest.kt | 22 +++++++++++++++---- posthog/api/posthog.api | 6 ++++- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 153a871c2..17cffc7a8 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -33,6 +33,13 @@ public class com/posthog/android/PostHogAndroidConfig : com/posthog/PostHogConfi public final fun setSessionReplayConfig (Lcom/posthog/android/replay/PostHogSessionReplayConfig;)V } +public final class com/posthog/android/errortracking/PostHogNativeCrashIntegration : com/posthog/PostHogIntegration { + public fun (Landroid/content/Context;Lcom/posthog/android/PostHogAndroidConfig;)V + public fun install (Lcom/posthog/PostHogInterface;)V + public fun onRemoteConfig (Z)V + public fun uninstall ()V +} + public final class com/posthog/android/internal/MainHandler { public fun ()V public fun (Landroid/os/Looper;)V 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 2e3d81dd9..e9cfe27ad 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 @@ -19,7 +19,13 @@ internal class NativeCrashEventCoercer { val myFrame = mutableMapOf() myFrame["platform"] = "native" - myFrame["instruction_addr"] = hex(frame.pc) + // Tombstone pcs are already the right lookup address: the leaf is + // the faulting instruction, and libunwindstack rewinds every + // caller pc to the call instruction. The server applies a uniform + // -1 return-address adjustment, so bias by +1 to cancel it — + // otherwise the leaf shifts out of the crash line and callers get + // adjusted twice. + myFrame["instruction_addr"] = hex(frame.pc + 1) myFrame["image_addr"] = hex(imageAddr) myFrame["in_app"] = isInApp(frame.fileName) myFrame["synthetic"] = false 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 5d20eb303..f5713198d 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 @@ -37,8 +37,22 @@ internal class NativeCrashEventCoercerTest { frames = listOf( // crash site first, as tombstones report it - NativeCrashFrame(0x103d8, 0x7a12345103d8, "process_frame", 0xc, "/data/app/~~abc/libengine.so", "5c6893c3dc6e76d2cbd637e4c8b4e2aaf90088b3"), - NativeCrashFrame(0x8501c, 0x7a123468501c, "__start_thread", 0x40, "/apex/com.android.runtime/lib64/bionic/libc.so", "aabbccdd"), + NativeCrashFrame( + relPc = 0x103d8, + pc = 0x7a12345103d8, + functionName = "process_frame", + functionOffset = 0xc, + fileName = "/data/app/~~abc/libengine.so", + buildId = "5c6893c3dc6e76d2cbd637e4c8b4e2aaf90088b3", + ), + NativeCrashFrame( + relPc = 0x8501c, + pc = 0x7a123468501c, + functionName = "__start_thread", + functionOffset = 0x40, + fileName = "/apex/com.android.runtime/lib64/bionic/libc.so", + buildId = "aabbccdd", + ), ), ), ) @@ -49,7 +63,7 @@ internal class NativeCrashEventCoercerTest { // Wire order is canonical bottom-up: outermost first, crash site last val outer = frames[0] assertEquals("native", outer["platform"]) - assertEquals("0x7a123468501c", outer["instruction_addr"]) + assertEquals("0x7a123468501d", outer["instruction_addr"]) assertEquals("0x7a1234600000", outer["image_addr"]) assertEquals("0x7a1234684fdc", outer["symbol_addr"]) assertEquals("__start_thread", outer["function"]) @@ -58,7 +72,7 @@ internal class NativeCrashEventCoercerTest { assertEquals(false, outer["in_app"]) val crash = frames[1] - assertEquals("0x7a12345103d8", crash["instruction_addr"]) + assertEquals("0x7a12345103d9", crash["instruction_addr"]) assertEquals("0x7a1234500000", crash["image_addr"]) assertEquals("libengine.so", crash["module"]) assertEquals(true, crash["in_app"]) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 116af9bc6..8f2296918 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -528,12 +528,15 @@ public final class com/posthog/errortracking/PostHogErrorTrackingConfig { public fun (ZLjava/util/List;)V public fun (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;)V public fun (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;Ljava/util/List;)V - public synthetic fun (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;Ljava/util/List;Z)V + public synthetic fun (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;Ljava/util/List;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getAutoCapture ()Z + public final fun getCaptureNativeCrashes ()Z public final fun getExceptionSteps ()Lcom/posthog/errortracking/PostHogExceptionStepsConfig; public final fun getIgnoredExceptionTypes ()Ljava/util/List; public final fun getInAppIncludes ()Ljava/util/List; public final fun setAutoCapture (Z)V + public final fun setCaptureNativeCrashes (Z)V } public final class com/posthog/errortracking/PostHogExceptionStepsConfig { @@ -959,6 +962,7 @@ public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/intern public final fun isAutocaptureExceptionsEnabled ()Z public final fun isCaptureNetworkTimingEnabled ()Z public final fun isConsoleLogRecordingEnabled ()Z + public final fun isNativeCrashCaptureEnabled ()Z public final fun isSessionReplayFlagActive ()Z public final fun loadFeatureFlags (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;)V public static synthetic fun loadFeatureFlags$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V From 6247b2c12f13c2950d1805e0a5fe7f7208745f61 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Tue, 28 Jul 2026 19:55:44 +0300 Subject: [PATCH 4/4] fix: annotate native crash scan with requiresapi --- .../android/errortracking/PostHogNativeCrashIntegration.kt | 3 +++ 1 file changed, 3 insertions(+) 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 cb57e1dae..fa1ce46b9 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 @@ -4,6 +4,7 @@ import android.app.ActivityManager import android.app.ApplicationExitInfo import android.content.Context import android.os.Build +import androidx.annotation.RequiresApi import com.posthog.PostHogEventName import com.posthog.PostHogIntegration import com.posthog.PostHogInterface @@ -76,6 +77,7 @@ public class PostHogNativeCrashIntegration( } } + @RequiresApi(Build.VERSION_CODES.S) private fun scanSafely(postHog: PostHogInterface) { try { scan(postHog) @@ -84,6 +86,7 @@ public class PostHogNativeCrashIntegration( } } + @RequiresApi(Build.VERSION_CODES.S) private fun scan(postHog: PostHogInterface) { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return