Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/native-crash-capture.md
Original file line number Diff line number Diff line change
@@ -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`.
7 changes: 7 additions & 0 deletions posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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 <init> ()V
public fun <init> (Landroid/os/Looper;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package com.posthog.android.errortracking

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
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) }
}
}

@RequiresApi(Build.VERSION_CODES.S)
private fun scanSafely(postHog: PostHogInterface) {
try {
scan(postHog)
} catch (e: Throwable) {
config.logger.log("Native crash scan failed: $e.")
}
}

@RequiresApi(Build.VERSION_CODES.S)
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.")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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<String, Any> {
val frames = mutableListOf<Map<String, Any>>()
// debug id -> image entry; one image per mapped ELF, shared by its frames
val debugImages = LinkedHashMap<String, Map<String, Any>>()

// 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<String, Any>()
myFrame["platform"] = "native"
// 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

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<String, Any>()
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<String, Any>()
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<String, Any>()
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"
}
}
}
Loading
Loading