Skip to content

fix: remove the RCTFatalException default from ignoredExceptionTypes - #894

Merged
turnipdabeets merged 2 commits into
mainfrom
fix/ignored-exception-types-default
Oct 3, 2026
Merged

turnipdabeets merged 2 commits into
mainfrom
fix/ignored-exception-types-default

Conversation

@turnipdabeets

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

PostHogErrorTrackingConfig.ignoredExceptionTypes has defaulted to ["RCTFatalException"] since 3.64.0 (#666). The docs say this deduplicates React Native crashes. It doesn't. It never matches a real React Native crash, so it filters nothing.

  • React Native's RCTFatal doesn't name the NSException RCTFatalException. It names it "RCTFatalException: <error description>" (React/Base/RCTAssert.m:133: [NSString stringWithFormat:@"%@: %@", RCTFatalExceptionName, error.localizedDescription]). React Native itself matches this name by prefix.
  • ignoredExceptionTypes matches exactly. exceptionListMatchesIgnoredTypes does a Set.contains on each $exception_list[*].type, and captureInternal applies the same list. A crash report's type is the raw exception name.
  • On the new architecture (the default since RN 0.76), a fatal JS error doesn't raise an NSException at all. The app aborts on a C++ facebook::jsi::JSError, and the crash report's type is SIGABRT.
  • The tests in feat(errortracking): add ignoredExceptionTypes config to skip RN-duplicate native crashes (closes #653) #666 used synthetic events typed exactly "RCTFatalException", so they passed without testing a real crash.
  • The React Native plugin removes duplicates itself. Its beforeSend drops $exception events that mention Unhandled JS Exception, ExceptionsManager.reportException or facebook::jsi::JSError. It never sets ignoredExceptionTypes on iOS.
  • The doc also said the option "pairs with posthog-android". That's wrong: Android's ignoredExceptionTypes defaults to empty, and the RN plugin adds JavascriptException there itself. No sdk-specs spec defines this option.

This PR:

  • changes the default to []
  • rewrites the doc comment to say only what the option does: it drops $exception events whose $exception_list[*].type (an NSException's name) exactly matches an entry, case-sensitively
  • removes the React Native example from the internal comments

Effect: none expected. The only apps affected are ones that raise or report an exception named exactly RCTFatalException, and we don't know of any. They can add it back: config.errorTrackingConfig.ignoredExceptionTypes = ["RCTFatalException"].

💚 How did you test it?

Unit tests and builds

  • The test that asserted the old default now asserts [].
  • genericCaptureDropsIgnoredType relied on the default. It now sets the list explicitly.
  • The matcher and capture-path filtering tests are unchanged.
  • Results: make test passed (919 tests in 148 suites), make buildSdk passed, make apiCheck passed (snapshot up to date; only the default value changed), make lint passed (only existing warnings).

React Native app on the iOS simulator

First on an iPhone 17 simulator (iOS 26.4), then on a physical iPhone (next section).

Setup:

  • the example-rn-native-plugin app from posthog-js main (RN 0.79.6, Hermes, Release build)
  • posthog-react-native 4.77.0 with errorTracking.autocapture: { uncaughtExceptions: true, nativeCrashes: true }
  • the plugin's podspec pin relaxed locally so it could link posthog-ios 3.87.0
  • posthog-ios linked by path, built once at origin/main (default ["RCTFatalException"]) and once at this branch (default [])
  • a local mock server recording every request

Each run throws an error from a setTimeout, waits for the crash, then relaunches so the crash report gets sent.

What I observed:

Architecture posthog-ios Native crash name Native $exception_list[*].type JS $exception sent Native duplicate
New (default) main facebook::jsi::JSError (libc++abi abort) SIGABRT yes (Error) sent
New (default) this branch same SIGABRT yes (Error) sent
Old (RCT_NEW_ARCH_ENABLED=0) main NSException RCTFatalException: Unhandled JS Exception: Error: …, js engine: hermes RCTFatalException: Unhandled JS Exception: Error: …, js engine: hermes yes (Error) dropped by the plugin's beforeSend
Old (RCT_NEW_ARCH_ENABLED=0) this branch same same yes (Error) dropped by the plugin's beforeSend
  • On the old architecture, the debug log on main shows the crash report reaching beforeSend with ignoredTypes=["RCTFatalException"]. It says "Crash report processed and captured", never "Crash report skipped: exception type is in errorTrackingConfig.ignoredExceptionTypes". So the old default didn't fire on a real React Native fatal; the plugin's marker match dropped it.
  • To see the type, I added a temporary log line to the plugin's beforeSend in a local copy. That change isn't in any repo.
  • In every row, main and this branch sent the same requests.
  • On the new architecture, the native duplicate is sent under both versions. The plugin's own comments already describe this as a known gap, and this PR doesn't change it.

React Native app on a physical iPhone

I reran the same app on an iPhone 14 Pro Max (iOS 26.5.2, connected over USB), signed with a development team. All four builds were Release, built once at origin/main and once at this branch, for each architecture.

Architecture posthog-ios Native $exception_list[*].type JS $exception Native duplicate
New (default) main SIGABRT queued queued (not dropped)
New (default) this branch SIGABRT sent to the mock server sent to the mock server
Old main RCTFatalException: Unhandled JS Exception: Error: …, js engine: hermes queued dropped by the plugin's beforeSend
Old this branch same queued dropped by the plugin's beforeSend
  • On the old architecture with main, the crash report reached beforeSend with ignoredTypes=["RCTFatalException"]. It was "processed and captured", not "skipped", and the plugin's marker match then dropped it. Same as on the simulator.
  • Network limitation: the app reaches the mock server over the LAN, which needs iOS's Local Network permission. That permission resets on every reinstall and needs a tap on the phone. It was granted only during the branch/new-architecture run, so that row's events actually reached the mock server.
    • The other runs failed to send with NSURLErrorDomain -1009, unsatisfied (Local network prohibited).
    • For those rows, "queued" means I read the event payloads from the SDK's on-disk queue in the app container (xcrun devicectl device copy from), together with the SDK's debug log ("Queued event" / "was dropped").
  • I compared the new-architecture $exception payloads from main (on-disk queue) and this branch (received by the mock server). They have the same property keys and the same types, values, mechanism and stack-frame modules. They differ only in run-specific details: addresses, build path, the crashing thread's id, debug images and session timing.

What I didn't test:

  • full network delivery for three of the four device runs (see above)
  • React Native versions other than 0.79.6
  • the CocoaPods trunk/SPM resolution of the plugin (posthog-ios was linked by local path)

Other wrappers (from reading origin/main source)

  • posthog-flutter: never reads or sets ignoredExceptionTypes. iOS native crash capture is config.errorTrackingConfig.autoCapture = captureNativeExceptions.
  • posthog-kmp: on iOS it appends the user's own ignoredExceptionTypes (Kotlin class simpleName/qualifiedName) to the native default. So a KMP iOS app's list loses the RCTFatalException entry. That only matters for a Kotlin class named exactly RCTFatalException. KMP's own default is emptyList(), and no KMP test checks the merged native list.
  • posthog-js (@posthog/react-native-plugin and posthog-react-native): on iOS, RCTFatalException appears only in a plugin comment. Neither package sets ignoredExceptionTypes. The Android plugin adds JavascriptException. Native crash capture on React Native is opt-in (errorTracking.autocapture.nativeCrashes).

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

I used Claude Code to read the source of posthog-ios, react-native, the posthog-js React Native packages, posthog-android, posthog-flutter and posthog-kmp, and to make the change. It also ran the before/after React Native check on the simulator and a physical iPhone.

I chose to make the default empty rather than switch to prefix matching. Prefix matching would still miss the new architecture's SIGABRT, and the React Native plugin already handles deduplication where it can.

🤖 Generated with Claude Code

React Native names the NSException it raises for a fatal JS error
"RCTFatalException: <message>", and ignoredExceptionTypes matches
exception types exactly, so the default never matched a real React
Native crash and filtered nothing. Default the option to [] and
describe only what it does. The React Native plugin deduplicates
native re-reports of fatal JS errors itself in beforeSend.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 1, 2026 20:29
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-02 14:20:47 UTC
Duration: 220644ms

⚠️ Some Tests Failed

44/45 tests passed, 1 failed


Capture Tests

⚠️ 28/29 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 2732ms
Format Validation.Event Has Uuid ✅ 2725ms
Format Validation.Event Has Lib Properties ✅ 2760ms
Format Validation.Distinct Id Is String ✅ 347ms
Format Validation.Token Is Present ✅ 2705ms
Format Validation.Custom Properties Preserved ✅ 2750ms
Format Validation.Event Has Timestamp ✅ 2750ms
Retry Behavior.Retries On 503 ✅ 11459ms
Retry Behavior.Does Not Retry On 400 ✅ 4756ms
Retry Behavior.Does Not Retry On 401 ✅ 2348ms
Retry Behavior.Respects Retry After Header ✅ 7671ms
Retry Behavior.Implements Backoff ✅ 21333ms
Retry Behavior.Retries On 500 ✅ 5469ms
Retry Behavior.Retries On 502 ✅ 9347ms
Retry Behavior.Retries On 504 ✅ 8768ms
Retry Behavior.Max Retries Respected ❌ 21301ms
Deduplication.Generates Unique Uuids ✅ 2725ms
Deduplication.Preserves Uuid On Retry ✅ 9217ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 16685ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7670ms
Deduplication.No Duplicate Events In Batch ✅ 2932ms
Deduplication.Different Events Have Different Uuids ✅ 2674ms
Compression.Sends Gzip When Enabled ✅ 2750ms
Batch Format.Uses Proper Batch Structure ✅ 375ms
Batch Format.Flush With No Events Sends Nothing ✅ 290ms
Batch Format.Multiple Events Batched Together ✅ 2781ms
Error Handling.Does Not Retry On 403 ✅ 4767ms
Error Handling.Does Not Retry On 413 ✅ 4737ms
Error Handling.Retries On 408 ✅ 9200ms

Failures

retry_behavior.max_retries_respected

Expected 4 requests, got 6

Feature_Flags Tests

✅ 16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 2809ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2782ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2766ms
Request Payload.Flags Request Omits Authorization Header ✅ 2738ms
Request Payload.Token In Flags Body Matches Init ✅ 2763ms
Request Payload.Groups Round Trip ✅ 2737ms
Request Payload.Groups Default To Empty Object ✅ 2699ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It ✅ 2696ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2780ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2765ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2758ms
Request Lifecycle.No Flags Request On Init Alone ✅ 60ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 2643ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5371ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 2657ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 2994ms

@marandaneto

Copy link
Copy Markdown
Member

i remember this being needed in the past
@ioannisj reviewed (and confirmed the problem?) #666 so i think @ioannisj should review and approve if needed

@ioannisj ioannisj 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.

Yeah I think this makes sense. I ran a device repro to check and try to validate the claim

  • New arch on RN 0.76.9, 0.79.6 and 0.81.6: the native report is a bare SIGABRT, so the duplicates do ship
  • RN 0.87.1: the NSException survives (react/react-native#56265) as RCTFatalException: Unhandled JS Exception: …. That still isn't an exact match, and the plugin's beforeSend drops it
  • With the plugin's dedup turned off (to simulate older plugin but latest posthog-ios), the old default still didn't catch it

We'd probably need to address the duplicate handling plugin side though on older new arch versions. Maybe hooking RCTSetFatalHandler there could close it?

'posthog-ios': patch
---

Remove the `RCTFatalException` default from `errorTrackingConfig.ignoredExceptionTypes` and correct its documentation. The default never matched the exception React Native raises for a fatal JS error, which is named `"RCTFatalException: <message>"`, so it filtered nothing. The option now defaults to `[]`. Deduplicating React Native crashes is done by the PostHog React Native plugin, not by this option. If your app relied on filtering an exception named exactly `RCTFatalException`, add it back with `config.errorTrackingConfig.ignoredExceptionTypes = ["RCTFatalException"]`.

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.

[suggestion] Could we qualify the plugin line here? It only dedups on the old arch and on new arch RN 0.83.5+ / 0.85+.

On new arch 0.76 to 0.83.4 and 0.84.x, RN converts the NSException into a jsi::JSError (fixed upstream in react/react-native#56265), so the native report is a bare SIGABRT and the plugin has nothing to match. I reproduced the native duplicate on device on 0.76.9, 0.79.6 and 0.81.6; on 0.87.1 the plugin dropped it.

Maybe something like: "The PostHog React Native plugin removes these duplicates where it can (old architecture, and new architecture on RN 0.83.5+ / 0.85+). On earlier new-architecture versions a native SIGABRT duplicate can still appear."

Same nuance for the PR body's "doesn't raise an NSException at all": RCTFatal does raise one, older RN just converts it before it reaches the crash reporter.

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.

Updated with your wording in ad5e018.

/// than only the outermost entry so a wrapped exception whose underlying
/// cause is, e.g., `RCTFatalException` is still suppressed. Match is
/// cause has an ignored type is still suppressed. Match is
/// case-sensitive and exact (the field is a class name, not free text).

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.

[nit] This still says "(the field is a class name, not free text)", and the test label at PostHogTests/PostHogErrorTrackingIgnoredTypesTest.swift:73 says "NSException class names are stable identifiers". That's the assumption that made the old default dead: an NSException's type is its name, and RCTFatal puts free text in it. Worth rewording to "for an NSException this is its name, which can embed free text" and relabelling the test to "match is case-sensitive"?

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.

Done in ad5e018. The doc comment now says an NSException's type is its name, which can embed free text, and the test label is just "match is case-sensitive".

@marandaneto

Copy link
Copy Markdown
Member

Yeah I think this makes sense. I ran a device repro to check and try to validate the claim

  • New arch on RN 0.76.9, 0.79.6 and 0.81.6: the native report is a bare SIGABRT, so the duplicates do ship
  • RN 0.87.1: the NSException survives (react/react-native#56265) as RCTFatalException: Unhandled JS Exception: …. That still isn't an exact match, and the plugin's beforeSend drops it
  • With the plugin's dedup turned off (to simulate older plugin but latest posthog-ios), the old default still didn't catch it

We'd probably need to address the duplicate handling plugin side though on older new arch versions. Maybe hooking RCTSetFatalHandler there could close it?

you sure? why did we open the issues and merged the PRs then? i recall asking you to check dupe reports
eg https://github.com/getsentry/sentry-react-native/blob/934a328b37a98553c436d5697df2fe3fc5b4f219/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java#L389-L391
https://github.com/getsentry/sentry-react-native/blob/934a328b37a98553c436d5697df2fe3fc5b4f219/packages/core/ios/RNSentryStart.m#L237-L243
are we still deduping those types of issues? i've solved this in the past and i cant tell the specific error types but i do remember capturing twice, sentry has similar checks, so before removing, lets make sure we dont have this issue anymore

@turnipdabeets

Copy link
Copy Markdown
Contributor Author

@marandaneto You're right that the duplicates were real. They were fixed by the plugin, not by this default. Timeline:

The real exception is named "RCTFatalException: Unhandled JS Exception: …", and ignoredExceptionTypes matches exactly, so the default never fired. Matching by type works on Android, where the type is a class (PostHog/posthog-js#4126 registers JavascriptException there). On iOS the name includes the error message, so the plugin matches on text instead. On the Sentry links: their iOS side also matches on text ("Unhandled JS Exception" in the type), not an exact type, which is what our plugin does.

We checked this on devices three times: my simulator and iPhone runs, and @ioannisj's run with the plugin's dedup turned off. In all of them, main logs "processed and captured", never "skipped". Removing the default doesn't change what gets sent. The option itself stays, and nothing in the PostHog org sets it on iOS.

What's still open: on the new architecture, RN 0.76 to 0.83.4, the native report is a bare SIGABRT, so that duplicate still ships. That's true on main too, and the old default couldn't catch it either. We'll follow up on the plugin side.

The changeset now says which React Native versions the plugin dedups on.
The matcher doc and test label no longer claim an NSException type is a
stable class name; its name can embed free text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@turnipdabeets

Copy link
Copy Markdown
Contributor Author

@ioannisj I’ll wait to merge until you get a change to see the comments.

@ioannisj

ioannisj commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

@ioannisj I’ll wait to merge until you get a change to see the comments.

Yeah I think this looks okay to merge. From my runs looks like ignoredExceptionTypes never cathced any duplicates since it was dropped plugin side, so a real miss during the previous code review on this. We still need to check if it's possible to handle SIGABRT from RN 0.76 to 0.83.4 though cause those still go through today

Btw the reason I check with dedup off plugin-side is to check what happens if an old plugin version is installed (without the beforeSend hook) witht he latest pothog-ios. Even then, the duplicate goes through so ignoredExceptionTypes never really matched anything

@turnipdabeets
turnipdabeets merged commit 92f1abe into main Oct 3, 2026
47 checks passed
@turnipdabeets
turnipdabeets deleted the fix/ignored-exception-types-default branch October 3, 2026 14:46
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.

3 participants