Skip to content

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

Open
cat-ph wants to merge 16 commits into
mainfrom
cat/ndk-tombstone-capture
Open

feat(errortracking): capture native ndk crashes from tombstones#659
cat-ph wants to merge 16 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 dedicated, synchronous timestamp watermark prevents duplicate capture across launches and survives identity resets, advancing per record so dying mid-scan cannot re-capture.
  • Frames matching the app's native library, base/split APK, or data paths are in-app; OS and unknown mappings are not. The package-name process scans the package-wide exit history, preventing duplicate capture in typical multi-process apps. Apps that override the application-level android:process still need handling before merge.

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. On the latest head, 17 focused parser/coercer/scanner tests cover tied timestamps, I/O retry versus deterministic parse skip, accurate captured/skipped counts, fresh-executor re-enable, the process guard and main-process gate, omitted ARM32 architecture, one debug image per mapped base, and app-path in_app classification. The tied-timestamp regression test fails when transplanted onto the previous head and passes on this head. Pre-existing PostHogAndroidTest conscrypt failures on that box reproduce on main (arm64 environment issue, no conscrypt aarch64 linux artifact) and are unrelated.
  • Latest-head device/devbox E2E (c93f163 with feat(gradle-plugin): upload native debug symbols via symbol-sets upload #660 ecc097a): installed a debug build on an API 35 ARM64 emulator, triggered a real SIGSEGV (ApplicationExitInfo reason 5/status 11), then cold-relaunched. The SDK queued exactly one $exception; another relaunch and an identity reset produced no duplicate.
  • The event persisted once in ClickHouse with the exact uploaded ELF debug reference. Cymbal resolved the JNI, C++, and inline frames at lines 19, 12, and 8 with bundled latest-v5 source context. Only an actual Play Console upload/distribution remains untested.

📝 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-07 21:01:38 UTC
Duration: 118525ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 387ms
Format Validation.Event Has Uuid 45ms
Format Validation.Event Has Lib Properties 34ms
Format Validation.Distinct Id Is String 35ms
Format Validation.Token Is Present 36ms
Format Validation.Custom Properties Preserved 29ms
Format Validation.Event Has Timestamp 40ms
Retry Behavior.Retries On 503 7031ms
Retry Behavior.Does Not Retry On 400 4027ms
Retry Behavior.Does Not Retry On 401 4027ms
Retry Behavior.Respects Retry After Header 7027ms
Retry Behavior.Implements Backoff 17026ms
Retry Behavior.Retries On 500 7018ms
Retry Behavior.Retries On 502 7019ms
Retry Behavior.Retries On 504 7016ms
Retry Behavior.Max Retries Respected 17039ms
Deduplication.Generates Unique Uuids 43ms
Deduplication.Preserves Uuid On Retry 7017ms
Deduplication.Preserves Uuid And Timestamp On Retry 12033ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7019ms
Deduplication.No Duplicate Events In Batch 43ms
Deduplication.Different Events Have Different Uuids 32ms
Compression.Sends Gzip When Enabled 23ms
Batch Format.Uses Proper Batch Structure 23ms
Batch Format.Flush With No Events Sends Nothing 17ms
Batch Format.Multiple Events Batched Together 45ms
Error Handling.Does Not Retry On 403 4025ms
Error Handling.Does Not Retry On 413 4022ms
Error Handling.Retries On 408 5028ms

Feature_Flags Tests

17/17 tests passed

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

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
}

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have a draft docs PR and will mention it there: PostHog/posthog.com#19035

I definitely agree and we can follow-up separately IMO, it probably needs its own design 🤔 (I can open an issue and take a look after)

Comment thread posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt Outdated
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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

combined the reply in this one #659 (comment) 😆 but updated with a NativeCrashWatermarkStore SharedPreferences file, I think that should work


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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah, great catch; I added the separate own posthog-native-crash SharedPreferences file (NativeCrashWatermarkStore), written with commit()

however I didn't do the durable capture() because I think right now it's fire-and-forget and we don't really ack that 🤔 I would've rathered duplicated than losing silently, I think we need to do some more work to have a ack way

@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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think this was copied from PostHogErrorTrackingAutoCaptureIntegration and might happen there too, but fixed it here and we can follow-up

@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

cat-ph added 3 commits August 5, 2026 17:31
Atomic per-process ownership of the scanner guard, injectable single-thread executor, uninstall cancels the scan, all retained exit records scanned before reason filtering, and the watermark advances per record through the synchronous store.
@cat-ph
cat-ph marked this pull request as ready for review August 6, 2026 14:43
@cat-ph
cat-ph requested a review from a team as a code owner August 6, 2026 14:43
@cat-ph
cat-ph requested review from a team and hpouillot August 6, 2026 14:43
@cat-ph
cat-ph requested a review from ablaszkiewicz August 6, 2026 14:43
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt:143
**Timestamp watermark drops tied records**

If two retained native-crash records have the same exit timestamp and scanning stops after the first is acknowledged, the strict `timestamp > watermark` filter permanently excludes the unprocessed record on the next launch, causing its exception event to be lost.

### Issue 2
posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt:41
**Internal integration exposed publicly**

This SDK-managed integration is included in the generated public API without `@PostHogInternal`, presenting its constructor and lifecycle methods as supported consumer API and creating unnecessary compatibility obligations.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(errortracking): harden the native cr..." | Re-trigger Greptile

@posthog

posthog Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 1 should fix, 3 consider.

Published 4 findings (view the review).

@posthog

posthog Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ReviewHog Report

Feature

Issues: 4 issues

Files (9)
  • posthog-android/src/main/java/com/posthog/android/errortracking/PostHogNativeCrashIntegration.kt
  • posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashWatermarkStore.kt
  • posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt
  • posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt
  • posthog-android/api/posthog-android.api
  • posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt
  • posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt
  • posthog/api/posthog.api
  • .changeset/native-crash-capture.md
What were the main changes
  • New PostHogNativeCrashIntegration scans ApplicationExitInfo history on startup for REASON_CRASH_NATIVE records (API 31+), parses tombstones, and captures one $exception per crash via a single-thread executor with atomic install/uninstall guarding against concurrent scanners
  • NativeCrashWatermarkStore persists a synchronous (commit()), device-scoped dedup watermark in its own SharedPreferences file, isolated from the shared preferences/registered-properties store and immune to reset()
  • Watermark advanced per-record only after capture() returns, one record at a time, and interruptible via executor shutdown on uninstall
  • Requests all retained exit records (maxNum=0) then filters by reason/timestamp, avoiding starvation by newer non-crash exits under a positive cap
  • Wired into PostHogAndroid.kt behind new errorTrackingConfig.captureNativeCrashes opt-in flag, added as a class-body property (not a constructor param) to avoid breaking the synthetic default-arguments constructor for existing Kotlin callers
  • New PostHogRemoteConfig.isNativeCrashCaptureEnabled() combining the remote exception-autocapture toggle with the local captureNativeCrashes flag
  • New getActivityManager() helper in PostHogAndroidUtils.kt; generated API surface and changeset updates

Comment thread posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt
cat-ph added 2 commits August 7, 2026 01:11
Sibling processes can die in the same millisecond; advancing the watermark on the first record of a tie would orphan the rest if the scan dies mid-group.
Robolectric integration tests for reason filtering, watermark acknowledgement, tied-timestamp retry, the process-wide scanner guard, and the remote toggle, plus PostHogAndroid registration tests for the captureNativeCrashes flag.
@cat-ph
cat-ph requested a review from marandaneto August 6, 2026 22:18
exitInfo.traceInputStream?.use { stream ->
coercer.toPostHogProperties(parser.parse(stream))
}
} catch (e: Throwable) {

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 catch turns any tombstone read/parser failure into null, but the loop still advances the timestamp watermark below. A transient IOException or unsupported tombstone is therefore permanently lost; for tied timestamps, a later successful record acknowledges the failed sibling too. Please distinguish a documented missing trace from a parse failure and stop or propagate on the latter so it is not acknowledged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

soooo I tried to do that in c93f163, just that when a tombstone is fully read and doesn't parse, I ack'd it so it doesn't poison-pill ourselves (since future reads still won't parse)

@marandaneto

Copy link
Copy Markdown
Member

@cat-ph Could you take another look at the four follow-ups in this review comment? They still appear to be present on the current head:

  • debug images are still keyed only by debug ID rather than (debugId, imageAddr)
  • every /data/ mapping is still classified as in_app
  • an omitted proto3 architecture still leaves ARM32 as null
  • the final “Captured” count still includes missing or failed tombstones

Could you either address these or reply with the rationale and intended follow-up for anything being deferred?

@marandaneto

Copy link
Copy Markdown
Member

did another pass after commits, a few small things to be fixed but almost there

@marandaneto
marandaneto requested a review from a team August 7, 2026 07:41
cat-ph added 6 commits August 7, 2026 15:39
… in_app by app paths

The same ELF can be mapped at multiple bases, and frames from a base without its own image entry would not symbolicate. in_app now matches the app's native library dir, APKs, and data dir instead of everything under /data.
A transient tombstone read failure aborts the scan without acknowledging so the next launch retries it, while a tombstone that read fully but does not parse is acknowledged and skipped, because retrying it forever would block every newer crash behind it. Scanning is restricted to the main process, since the exit history spans the whole package while the guard and watermark are process-local. A live remote disable now aborts an in-flight scan, and the executor is created per acquisition so a later re-enable schedules a fresh scanner.
… process

android:process on the application element renames the default process away from the package name, so the package-name comparison disabled scanning in every process of such apps. Covered by renamed-default-process and secondary-process tests.
@cat-ph

cat-ph commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

the four follow-ups in #659 (comment)?

I .. totally missed that reply sorry @marandaneto 🤦 addressed all 4! plus the others and small test fixes

@cat-ph
cat-ph requested a review from marandaneto August 7, 2026 21:50
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