Skip to content

feat(errortracking): add ignoredExceptionTypes config to skip RN-duplicate native crashes (closes #653)#666

Merged
ioannisj merged 6 commits into
PostHog:mainfrom
tsushanth:fix/error-tracking-ignored-exception-types
Jul 2, 2026
Merged

feat(errortracking): add ignoredExceptionTypes config to skip RN-duplicate native crashes (closes #653)#666
ioannisj merged 6 commits into
PostHog:mainfrom
tsushanth:fix/error-tracking-ignored-exception-types

Conversation

@tsushanth

Copy link
Copy Markdown
Contributor

Why

Closes #653. Pairs with the Android side at PostHog/posthog-android#567 (fixed by PostHog/posthog-android#569).

React Native rethrows fatal JS errors via `RCTFatal(...)` as an uncaught `NSException` named `RCTFatalException`. When the `@posthog/react-native-plugin` enables native crash autocapture (`errorTracking.autocapture.nativeCrashes`), the iOS crash reporter captures that as a separate `$exception` event with a native stack trace — duplicating the event the JS layer already captured with its own (richer) JS stack trace. Result: two `$exception` events for one logical error, the duplicate carrying less useful context.

sentry-react-native solves the equivalent on its iOS side with `addIgnoredExceptionForType(...)`. posthog-android shipped a parallel `errorTrackingConfig.ignoredExceptionTypes` filter via PostHog/posthog-android#569. This PR brings the same lever to posthog-ios so callers can keep native crash autocapture on but dedup the RCTFatalException churn.

Fix

New config field

```swift
// PostHogErrorTrackingConfig.swift
@objc public var ignoredExceptionTypes: [String] = []
```

Caller-facing usage from the React Native plugin (or any consumer hitting this duplicate):

```swift
config.errorTrackingConfig.ignoredExceptionTypes = ["RCTFatalException"]
```

Filter site

In `PostHogErrorTrackingAutoCaptureIntegration.processPendingCrashReport()`, after `PostHogCrashReportProcessor.processReport(...)` produces the merged properties and before `captureInternal("$exception", ...)` runs:

```swift
let ignored = postHog.config.errorTrackingConfig.ignoredExceptionTypes
if !ignored.isEmpty, PostHogErrorTrackingAutoCaptureIntegration.exceptionListMatchesIgnoredTypes(finalProperties, ignoredTypes: ignored) {
hedgeLog("Crash report skipped: exception type is in errorTrackingConfig.ignoredExceptionTypes")
return
}
```

The helper walks the full `$exception_list` rather than only the outermost entry — a wrapped `RCTFatalException` underneath an outer `NSException` wrapper is still suppressed. Match is exact and case-sensitive (NSException class names are stable identifiers, not free text).

Tests

New `PostHogTests/PostHogErrorTrackingIgnoredTypesTest.swift` using the Swift Testing framework. Seven cases:

  1. Empty list is a no-op — `ignoredExceptionTypes: []` passes all exceptions through (the default behavior, fully backwards-compatible).
  2. Outer-type match suppresses — the typical `["RCTFatalException"]` case.
  3. Inner-chain match suppresses — a wrapped RCTFatalException underneath an outer NSException still triggers the filter.
  4. Mismatched type passes through — `NSGenericException` with a `RCTFatalException` ignore list isn't filtered.
  5. Missing `$exception_list` key is safe — properties without exceptions return `false` cleanly.
  6. Empty `$exception_list` is safe — same.
  7. Match is case-sensitive — `"rctfatalexception"` does NOT match `"RCTFatalException"` (the field is a class name, not user input).

Plus a sanity check that the config field defaults to `[]`.

Backwards compatibility

Strict additive change:

  • `ignoredExceptionTypes` is a new property with a default of `[]`, so existing apps with no config changes see identical behavior.
  • The filter only runs when the list is non-empty (`if !ignored.isEmpty`), so the happy path adds zero cost for callers who don't opt in.
  • No existing public API changes; no behavior change on Mach exceptions, POSIX signals, or NSExceptions whose type isn't in the ignore list.

Local verification

  • `swift build` clean.
  • I couldn't run `swift test` to completion locally because the main branch's `PostHogSessionManagerTest` has pre-existing unrelated compile errors (`value of type 'PostHogSDK' has no member 'getSessionManager'`). CI should handle the test target correctly. The new tests are pure data-flow against a static helper, so I'm confident in the logic.

@tsushanth tsushanth requested a review from a team as a code owner June 17, 2026 01:04
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
PostHogTests/PostHogErrorTrackingIgnoredTypesTest.swift:21-129
**Prefer parameterised tests**

The seven `@Test` functions that exercise `exceptionListMatchesIgnoredTypes` share an identical structure (properties, ignoredTypes, expected bool) and differ only in their input/output data. Swift Testing's `@Test(arguments:)` is made for exactly this pattern and is explicitly preferred by the team's review standards. `defaultIsEmpty` can stay as its own test since it covers a different subject.

### Issue 2 of 2
PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift:187-198
**Verbose closure body can be simplified**

The `if-let / return true / return false` pattern is equivalent to a `guard` + direct return, which removes the superfluous parts per the team's simplicity rules and avoids shadowing the built-in `type(of:)` via the local `type` binding.

```suggestion
        static func exceptionListMatchesIgnoredTypes(_ properties: [String: Any], ignoredTypes: [String]) -> Bool {
            guard let exceptionList = properties["$exception_list"] as? [[String: Any]] else {
                return false
            }
            let ignored = Set(ignoredTypes)
            return exceptionList.contains { entry in
                guard let exType = entry["type"] as? String else { return false }
                return ignored.contains(exType)
            }
        }
```

Reviews (1): Last reviewed commit: "feat(errortracking): add ignoredExceptio..." | Re-trigger Greptile

Comment on lines +21 to +129
@Test("returns false when ignoredExceptionTypes is empty")
func emptyIgnoredListPassesAllExceptions() {
let properties: [String: Any] = [
"$exception_list": [["type": "RCTFatalException", "value": "boom"]],
]
#expect(
PostHogErrorTrackingAutoCaptureIntegration
.exceptionListMatchesIgnoredTypes(properties, ignoredTypes: []) == false
)
}

@Test("matches when outer exception type is in ignored list")
func outerTypeMatchSuppresses() {
let properties: [String: Any] = [
"$exception_list": [["type": "RCTFatalException", "value": "boom"]],
]
#expect(
PostHogErrorTrackingAutoCaptureIntegration
.exceptionListMatchesIgnoredTypes(
properties,
ignoredTypes: ["RCTFatalException"]
) == true
)
}

@Test("matches when an underlying exception in the chain is in the ignored list")
func underlyingTypeMatchSuppresses() {
// PHPLCrashReportExceptionInfo doesn't currently expose
// `userInfo`, but for non-crash report paths the SDK builds a
// multi-entry `$exception_list` walking `NSUnderlyingErrorKey`
// (see `PostHogExceptionProcessor.buildExceptionList`). Make
// sure a wrapped RCTFatalException is still suppressed when
// it's not the outermost entry.
let properties: [String: Any] = [
"$exception_list": [
["type": "NSException", "value": "wrapper"],
["type": "RCTFatalException", "value": "boom"],
],
]
#expect(
PostHogErrorTrackingAutoCaptureIntegration
.exceptionListMatchesIgnoredTypes(
properties,
ignoredTypes: ["RCTFatalException"]
) == true
)
}

@Test("does not match exceptions whose type isn't in the ignored list")
func nonMatchPassesThrough() {
let properties: [String: Any] = [
"$exception_list": [["type": "NSGenericException", "value": "boom"]],
]
#expect(
PostHogErrorTrackingAutoCaptureIntegration
.exceptionListMatchesIgnoredTypes(
properties,
ignoredTypes: ["RCTFatalException"]
) == false
)
}

@Test("returns false when properties has no $exception_list key")
func missingExceptionListReturnsFalse() {
let properties: [String: Any] = [
"$exception_level": "fatal",
]
#expect(
PostHogErrorTrackingAutoCaptureIntegration
.exceptionListMatchesIgnoredTypes(
properties,
ignoredTypes: ["RCTFatalException"]
) == false
)
}

@Test("returns false when $exception_list is empty")
func emptyExceptionListReturnsFalse() {
let properties: [String: Any] = [
"$exception_list": [[String: Any]](),
]
#expect(
PostHogErrorTrackingAutoCaptureIntegration
.exceptionListMatchesIgnoredTypes(
properties,
ignoredTypes: ["RCTFatalException"]
) == false
)
}

@Test("match is exact and case-sensitive (NSException class names are stable identifiers)")
func caseSensitiveMatch() {
let properties: [String: Any] = [
"$exception_list": [["type": "RCTFatalException", "value": "boom"]],
]
#expect(
PostHogErrorTrackingAutoCaptureIntegration
.exceptionListMatchesIgnoredTypes(
properties,
ignoredTypes: ["rctfatalexception"]
) == false
)
}

@Test("config field default is empty so callers see no behavior change")
func defaultIsEmpty() {
let config = PostHogErrorTrackingConfig()
#expect(config.ignoredExceptionTypes.isEmpty)
}

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.

P2 Prefer parameterised tests

The seven @Test functions that exercise exceptionListMatchesIgnoredTypes share an identical structure (properties, ignoredTypes, expected bool) and differ only in their input/output data. Swift Testing's @Test(arguments:) is made for exactly this pattern and is explicitly preferred by the team's review standards. defaultIsEmpty can stay as its own test since it covers a different subject.

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: PostHogTests/PostHogErrorTrackingIgnoredTypesTest.swift
Line: 21-129

Comment:
**Prefer parameterised tests**

The seven `@Test` functions that exercise `exceptionListMatchesIgnoredTypes` share an identical structure (properties, ignoredTypes, expected bool) and differ only in their input/output data. Swift Testing's `@Test(arguments:)` is made for exactly this pattern and is explicitly preferred by the team's review standards. `defaultIsEmpty` can stay as its own test since it covers a different subject.

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

Yes let's use parameterized tests where possible

@ioannisj

Copy link
Copy Markdown
Contributor

Thank you @tsushanth, will have a look shortly. In the meantime can you please add changeset entry (see https://github.com/PostHog/posthog-ios/blob/92138fd8b48aa34a08ce54bdd366e4495d29d405/RELEASING.md) and run make apiUpdate since we are expanding the api surface?

@tsushanth

Copy link
Copy Markdown
Contributor Author

Thanks @ioannisj — added .changeset/feat-ignored-exception-types.md as minor since this surfaces a new public knob on PostHogErrorTrackingConfig.

While here I also took greptile's second suggestion (9f961d5) — collapsed the exceptionListMatchesIgnoredTypes closure to a guard + direct return and renamed the local binding off type to avoid shadowing type(of:). Left the test-parameterisation suggestion alone since the seven cases read more clearly as separate @Test methods, but happy to fold them with @Test(arguments:) if you'd prefer.

Ready for another look whenever you have a moment.

@tsushanth tsushanth force-pushed the fix/error-tracking-ignored-exception-types branch from 9f961d5 to 11bfea9 Compare June 18, 2026 15:27
@tsushanth

Copy link
Copy Markdown
Contributor Author

Thanks for the patience @ioannisj. Rebased onto current main, resolved the conflict against the new exceptionSteps block in PostHogErrorTrackingConfig.swift (the two sit side-by-side now), and added the matching api/posthog-ios.public-api.txt entry. Ready for another look whenever you have a moment.

@ioannisj

Copy link
Copy Markdown
Contributor

@tsushanth taking a look now, apologies for the delay

@ioannisj

Copy link
Copy Markdown
Contributor

@tsushanth looks like it needs a fresh rebase since I can see reverting some changes on main (workflow, PR template etc)

/// See https://github.com/PostHog/posthog-ios/issues/653.
///
/// Default: empty (no filtering).
@objc public var ignoredExceptionTypes: [String] = []

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.

Would it make sense providing a sensible default ["RCTFatalException"] here?

@tsushanth tsushanth force-pushed the fix/error-tracking-ignored-exception-types branch from 11bfea9 to d64a912 Compare June 19, 2026 14:13
@tsushanth

Copy link
Copy Markdown
Contributor Author

Thanks @ioannisj — pushed d64a9123c dropping the workaround commit that reverted the workflow / PR-template updates. The diff is now scoped to the five real files (errortracking config + integration, the new test, the changeset, and the api/posthog-ios.public-api.txt snapshot entry). Ready for another pass whenever you have a moment.

tsushanth added a commit to tsushanth/posthog-ios that referenced this pull request Jun 23, 2026
Three asks from the 2026-06-18 review, all addressed:

1. Default `ignoredExceptionTypes` to `["RCTFatalException"]` so React
   Native apps get JS/native dedup out of the box. Native-only iOS apps
   never raise that type, so they're unaffected; the doc shows how to
   add more types or clear the default. (Mirrors what posthog-android's
   counterpart will default to.)

2. Honor `ignoredExceptionTypes` on the manual capture path too, not
   just the crash-report autocapture path. The filter now lives in the
   `captureExceptionEvent` funnel, which both `captureException(_:Error)`
   and `captureException(_:NSException)` flow through.

3. Parameterize the matcher tests via Swift Testing's `@Test(arguments:)`
   so adding a new shape is a single `MatchCase` row instead of another
   `@Test` block. Default-config check now asserts the new
   `["RCTFatalException"]` default.

All 594 tests still pass (`make test`).
@tsushanth

Copy link
Copy Markdown
Contributor Author

Thanks @ioannisj — sorry for the delay, addressed all three in 8b7d0a4:

1. Parameterized tests. Collapsed the seven hand-rolled cases into a single @Test(arguments:) over a MatchCase table — adding a new shape is now one row instead of another @Test block. Swift Testing reports all seven sub-cases individually so failures still point at the right shape.

2. Default to ["RCTFatalException"]. Good call — the only audience this matters to is React Native apps, and native-only iOS apps never raise that exception type so there's no behavioral risk. Docstring shows how to extend or clear the default. Updated the defaultIsEmpty test accordingly (now defaultIsRCTFatalException).

3. Manual capture path. Threaded the same ignoredExceptionTypes check through captureExceptionEvent so both captureException(_:Error) and captureException(_:NSException) honor it — matching what posthog-android's counterpart does. Crash-report autocapture path keeps its own check (the two paths surface different exception sources).

make test clean — 594 tests pass.

@github-actions

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in another week.

@github-actions github-actions Bot added the stale label Jun 30, 2026
@ioannisj

Copy link
Copy Markdown
Contributor

@tsushanth look like builds are failing on non-ios platforms. Mind making sure the CI is green before approving?

@ioannisj ioannisj removed the stale label Jun 30, 2026
@tsushanth

Copy link
Copy Markdown
Contributor Author

Fixed the watchOS/visionOS build failures: exceptionListMatchesIgnoredTypes was defined only inside the #if os(iOS) || os(macOS) || os(tvOS) block, so the watchOS/visionOS stub class was missing it. PostHogSDK.captureExceptionEvent calls it unconditionally, causing the compile error on those platforms. Added a no-op stub (returns false) to the #else branch — crash reporting is unavailable on watchOS/visionOS anyway so the filter is a no-op.

@turnipdabeets turnipdabeets requested a review from ioannisj July 1, 2026 20:08
tsushanth added 6 commits July 2, 2026 14:22
…icate native crashes (closes PostHog#653)

React Native rethrows fatal JS errors via `RCTFatal(...)` as an
uncaught `NSException` named `RCTFatalException`. When the
`@posthog/react-native-plugin` enables native crash autocapture
(`errorTracking.autocapture.nativeCrashes`), the iOS crash reporter
captures that as a separate `$exception` event with a native stack
trace — duplicating the event the JS layer has already captured
with its own JS stack trace. The result is two `$exception` events
for one logical error, with the duplicate carrying less useful
context.

sentry-react-native solves the equivalent on its iOS side with
`addIgnoredExceptionForType(...)`; posthog-android added a parallel
`errorTrackingConfig.ignoredExceptionTypes` filter (PostHog/posthog-android#569).
This commit brings the same lever to posthog-ios so the JS-side
event can be the single source of truth without forcing the
React Native plugin to disable native crash autocapture entirely.

Implementation:

- PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift: new
  `@objc public var ignoredExceptionTypes: [String] = []`
  documenting the RN use case and pointing at the parity rule
  in sentry-react-native + posthog-android.
- PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift:
  after `PostHogCrashReportProcessor.processReport(...)` runs and
  before `captureInternal("$exception", ...)`, walk the produced
  `$exception_list` for any `type` that matches an entry in
  `ignoredExceptionTypes` and short-circuit the capture if it does.
  Walks the full list (not just the outermost entry) so a wrapped
  RCTFatalException underneath an outer NSException wrapper is
  still suppressed.
- PostHogTests/PostHogErrorTrackingIgnoredTypesTest.swift: new
  Swift Testing suite with 7 cases — empty list is a no-op,
  outer-type match suppresses, inner-chain match suppresses, mismatched
  types pass through, missing/empty `$exception_list` are safe,
  match is case-sensitive (NSException class names are stable
  identifiers), and the config field defaults to empty so the change
  is fully backwards-compatible.

`swift build` clean. Could not run `swift test` to completion locally
because the main branch's `PostHogSessionManagerTest` has pre-existing
unrelated compile errors (`value of type 'PostHogSDK' has no member
'getSessionManager'`); CI should handle the test target correctly.

Pairs with: PostHog/posthog-android#567 (the Android side of the
same issue, fixed by PostHog/posthog-android#569).
…osure

Drop the if-let/return true/return false pattern in favour of a guard +
direct return, and rename the local binding from `type` to `exType` to
avoid shadowing Swift's built-in `type(of:)`.
Three asks from the 2026-06-18 review, all addressed:

1. Default `ignoredExceptionTypes` to `["RCTFatalException"]` so React
   Native apps get JS/native dedup out of the box. Native-only iOS apps
   never raise that type, so they're unaffected; the doc shows how to
   add more types or clear the default. (Mirrors what posthog-android's
   counterpart will default to.)

2. Honor `ignoredExceptionTypes` on the manual capture path too, not
   just the crash-report autocapture path. The filter now lives in the
   `captureExceptionEvent` funnel, which both `captureException(_:Error)`
   and `captureException(_:NSException)` flow through.

3. Parameterize the matcher tests via Swift Testing's `@Test(arguments:)`
   so adding a new shape is a single `MatchCase` row instead of another
   `@Test` block. Default-config check now asserts the new
   `["RCTFatalException"]` default.

All 594 tests still pass (`make test`).
The method was defined only inside the #if os(iOS) || os(macOS) || os(tvOS)
block. The watchOS/visionOS stub class was missing it, causing a compile error
when PostHogSDK.captureExceptionEvent calls it unconditionally. Added a
no-op stub that returns false (crash reporting is unavailable on these
platforms anyway).
@ioannisj ioannisj force-pushed the fix/error-tracking-ignored-exception-types branch from 24fc9d9 to 29b0aee Compare July 2, 2026 11:22
@ioannisj ioannisj enabled auto-merge (squash) July 2, 2026 11:23
@ioannisj ioannisj merged commit b9afc8f into PostHog:main Jul 2, 2026
42 of 43 checks passed
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.

Native crash autocapture double-reports fatal React Native JS errors (RCTFatalException)

2 participants