Skip to content

feat(errortracking): capture native ndk crashes from tombstones - #659

Draft
cat-ph wants to merge 5 commits into
mainfrom
cat/ndk-tombstone-capture
Draft

feat(errortracking): capture native ndk crashes from tombstones#659
cat-ph wants to merge 5 commits into
mainfrom
cat/ndk-tombstone-capture

Conversation

@cat-ph

@cat-ph cat-ph commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Native (NDK) crashes kill the process before any JVM handler runs, so today they are invisible to error tracking. This adds capture for them without shipping any native code in the SDK: on startup, the SDK reads the crash records the OS kept via ApplicationExitInfo (REASON_CRASH_NATIVE, Android 12+), parses the attached tombstone protobuf, and captures one $exception event per crash using the native stack frame contract PostHog already resolves for the Rust and Go SDKs.

Per crash, the event carries:

  • raw native frames (platform: "native", instruction_addr, image_addr, optional client-resolved function/symbol_addr) in canonical bottom-up order
  • $debug_images entries derived from the tombstone's per-frame GNU build ids, so the server matches frames to .so symbols uploaded with posthog-cli symbol-sets upload
  • signal metadata (SIGSEGV / SEGV_MAPERR at 0x..., abort message when present), $exception_level: fatal, and the original crash timestamp

Design notes:

  • Tombstone parsing is a minimal hand-rolled protobuf wire reader (field numbers are frozen in AOSP), avoiding a protobuf runtime dependency. Unknown fields are skipped.
  • instruction_addr is biased by +1: tombstone pcs are already the correct lookup address (the leaf is the faulting instruction and libunwindstack rewinds caller pcs to the call instruction), so the bias cancels the server's uniform -1 return-address adjustment. This is pinned by a cymbal fixture test on the server side.
  • Opt-in via errorTrackingConfig.captureNativeCrashes, additionally gated on the project's exception autocapture remote toggle. A persisted timestamp watermark prevents duplicate capture across launches, advancing per record so dying mid-scan cannot re-capture.
  • Frames from /data/ are in-app; /system, /apex, /vendor and unknown mappings are not.

Known limitations (draft): events are associated with the identity at next launch, not at crash time; $exception_steps recorded in the new process may attach to the previous run's crash; API 31+ only (tombstone protos attach from Android 12).

💚 How did you test it?

  • Unit tests for the tombstone parser (against independently hand-encoded proto wire bytes) and the event coercer (frame shape, address math, debug-id derivation pinned to the same vocabulary as the server fixture and CLI).
  • Full gradle test suite on a remote linux box; the new tests pass. Pre-existing PostHogAndroidTest conscrypt failures on that box reproduce on main (arm64 environment issue, no conscrypt aarch64 linux artifact) and are unrelated.
  • Devbox E2E: replayed an Android 12-shaped ApplicationExitInfo tombstone through the scanner, verified one real SDK batch delivery plus watermark deduplication on a second scan, then confirmed issue creation and full Cymbal/UI symbolication against the Android ELF uploaded by feat(gradle-plugin): upload native debug symbols via symbol-sets upload #660, including inline frames and source lines.
  • Remaining draft coverage: a device/emulator-generated crash and a Play Store build. The devbox had no KVM/ADB device, so only the OS crash/process-death boundary was replayed.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed. (docs PR is staged separately, gated on release)
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-08-04 20:00:55 UTC
Duration: 118382ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 389ms
Format Validation.Event Has Uuid 29ms
Format Validation.Event Has Lib Properties 31ms
Format Validation.Distinct Id Is String 22ms
Format Validation.Token Is Present 26ms
Format Validation.Custom Properties Preserved 29ms
Format Validation.Event Has Timestamp 25ms
Retry Behavior.Retries On 503 7021ms
Retry Behavior.Does Not Retry On 400 4024ms
Retry Behavior.Does Not Retry On 401 4025ms
Retry Behavior.Respects Retry After Header 7027ms
Retry Behavior.Implements Backoff 17034ms
Retry Behavior.Retries On 500 7019ms
Retry Behavior.Retries On 502 7018ms
Retry Behavior.Retries On 504 7019ms
Retry Behavior.Max Retries Respected 17033ms
Deduplication.Generates Unique Uuids 40ms
Deduplication.Preserves Uuid On Retry 7012ms
Deduplication.Preserves Uuid And Timestamp On Retry 12029ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7017ms
Deduplication.No Duplicate Events In Batch 35ms
Deduplication.Different Events Have Different Uuids 22ms
Compression.Sends Gzip When Enabled 19ms
Batch Format.Uses Proper Batch Structure 18ms
Batch Format.Flush With No Events Sends Nothing 11ms
Batch Format.Multiple Events Batched Together 32ms
Error Handling.Does Not Retry On 403 4021ms
Error Handling.Does Not Retry On 413 4021ms
Error Handling.Retries On 408 5025ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 33ms
Request Payload.Flags Request Uses V2 Query Param 24ms
Request Payload.Flags Request Hits Flags Path Not Decide 25ms
Request Payload.Flags Request Omits Authorization Header 28ms
Request Payload.Token In Flags Body Matches Init 23ms
Request Payload.Groups Round Trip 35ms
Request Payload.Groups Default To Empty Object 26ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 19ms
Request Payload.Disable Geoip Omitted Defaults To False 18ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 19ms
Request Lifecycle.No Flags Request On Init Alone 9ms
Request Lifecycle.No Flags Request On Normal Capture 20ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 38ms
Request Lifecycle.Mock Response Value Is Returned To Caller 20ms
Retry Behavior.Retries Flags On 502 322ms
Retry Behavior.Retries Flags On 504 324ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 20ms

cat-ph added 2 commits July 28, 2026 19:55
…ture

# Conflicts:
#	posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt
#	posthog/api/posthog.api
#	posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt
Comment thread posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt
Comment on lines +91 to +93
val activityManager =
context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return
val preferences = config.cachePreferences ?: return

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can move this to PostHogAndroidUtils.kt i think

}
integrationInstalled = true

Thread({ scanSafely(postHog) }, "PostHogNativeCrashScanner")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can probably use a single thread executor instead, and we'd like to be able to pass the executor as ctor param so we can unit test

}

properties?.let {
postHog.capture(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should attach the cached properties as we do for error tracking hard crashes (iOS)
not a blocker for this PR, but an improvement we'd need to make at some point to avoid issues with error investigation
eg sdk version 1.0.0, app version 2.0.0, but the app got upgraded after sending this error, customers would be investigating the wrong version eg 2.0.1 instead
this should be written down in the docs/config since its an important caveat that can waste lots of hours of investigation

*
* Disabled by default
*/
public var captureNativeCrashes: Boolean = false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Adding this defaulted parameter to the public Kotlin primary constructor changes/removes the synthetic default-argument constructor. @JvmOverloads preserves the ordinary overloads, but not that synthetic descriptor, so existing Kotlin callers that omit any constructor argument can fail at runtime with NoSuchMethodError after upgrading. I reproduced this by compiling PostHogErrorTrackingConfig(inAppIncludes = mutableListOf("com.example")) against main and running it against this branch. Could we keep the primary constructor unchanged and declare captureNativeCrashes as a property in the class body instead?

private var postHog: PostHogInterface? = null

private companion object {
private const val LAST_CAPTURED_TIMESTAMP_KEY = "nativeCrashLastCapturedTimestamp"

@marandaneto marandaneto Aug 5, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: This watermark needs to be treated as SDK-internal, project-level state. Because the key is not included in PostHogPreferences.ALL_INTERNAL_KEYS, PostHogSharedPreferences.getAll() returns it as a registered property and buildProperties() attaches nativeCrashLastCapturedTimestamp to subsequent customer events. It is also absent from PostHog.reset()'s preserved keys, so reset/logout clears the deduplication cursor and the same retained tombstones are captured again after restart.

There is also a durability issue: setValue() ultimately uses SharedPreferences.Editor.apply(), which updates memory synchronously but writes to disk asynchronously without reporting failures. An abrupt process death before that write completes can lose the cursor and recapture an already queued crash on the next launch. Could we add the key to ALL_INTERNAL_KEYS, preserve it across reset(), and persist this marker synchronously in project-scoped storage before treating the record as acknowledged?

val watermark = preferences.getValue(LAST_CAPTURED_TIMESTAMP_KEY) as? Long ?: 0L
val crashes =
activityManager
.getHistoricalProcessExitReasons(context.packageName, 0, MAX_EXIT_RECORDS)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Android applies maxNum before this code filters by exit reason. If a native crash is older than 20 newer non-native exits, it is excluded from every scan and will never be reported. Since the OS history is already a bounded ring buffer and maxNum = 0 means all retained matching records, could we request all records here and filter by reason and watermark afterward?


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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: This advances the watermark without knowing whether capture() durably queued the event. capture() returns Unit, and queue submission/storage failures are swallowed, so a rejected executor, serialization/disk failure, or close() disabling the client while this scanner is still running can drop the event while permanently marking the crash as handled. Could we advance the watermark only after acknowledged durable queue persistence, and ensure uninstall/close cancels or waits for the scanner so it cannot acknowledge a dropped capture?

@marandaneto

Copy link
Copy Markdown
Member

A few additional follow-ups from the review:

  • Debug images are currently deduplicated only by build/debug ID. If the same ELF is loaded at multiple base addresses, only one corresponding image entry survives and frames from the other base may not symbolicate. Please key these by (debugId, imageAddr) and cover the multiple-base case.
  • Treating every /data/ mapping as in_app is too broad. Please classify frames using app-specific native library, data, base APK, and split APK paths instead.
  • ARM32 is enum value zero and may be omitted by proto3 as the default value. In that case the parser currently leaves arch unset; initialize it to "arm" and add coverage for an omitted architecture field.
  • The final log reports every matching record as “Captured”, including records whose trace was missing or whose tombstone failed to parse. Please report captured/skipped counts accurately.
  • Please add integration-level coverage around the scanner itself, especially history limits, reset/watermark behavior, internal-property leakage, close races, queue persistence failure, and concurrent installation.

@marandaneto

Copy link
Copy Markdown
Member

Two additional behavioral points to address before a stable release:

  • Recovered tombstones are captured in a new process, so the current process's $exception_steps did not lead to the native crash and would be misleading in the resulting issue. Please suppress current-run exception steps for these events. Current identity/app-version enrichment can remain a documented limitation if avoiding it requires substantially more state.
  • Please define and enforce a historical reporting policy. Reporting all unseen retained crashes is reasonable, but without an age limit, enabling the feature can emit arbitrarily old crashes with current-process metadata. A bounded lookback would make the behavior predictable and avoid misleading historical events.

override fun install(postHog: PostHogInterface) {
this.postHog = postHog

if (integrationInstalled || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: @Volatile only guarantees visibility; the check followed by assignment is a compound operation and is not atomic, so concurrent installs can both observe false and start duplicate scanners. uninstall() also clears the process-wide flag from any integration instance, even one that did not acquire it, which can allow another scanner to start while the original is still active. Could we use atomic acquisition (for example AtomicBoolean.compareAndSet), track ownership per integration instance, and release the guard only from the owner after its scanner has terminated?

@marandaneto

Copy link
Copy Markdown
Member

left a few comments @cat-ph its in the right direction

@marandaneto

Copy link
Copy Markdown
Member

you can also check the https://github.com/abovevacant/epitaph TombstoneDecoder impl for parsing the exit metadata

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants