Skip to content

feat(errortracking): ignoredExceptionTypes config to skip RN-duplicate JS errors#569

Open
tsushanth wants to merge 2 commits into
PostHog:mainfrom
tsushanth:fix/issue-567-ignored-exception-types
Open

feat(errortracking): ignoredExceptionTypes config to skip RN-duplicate JS errors#569
tsushanth wants to merge 2 commits into
PostHog:mainfrom
tsushanth:fix/issue-567-ignored-exception-types

Conversation

@tsushanth

Copy link
Copy Markdown

Summary

Closes #567.

In React Native apps that enable native crash autocapture, a fatal JS error is captured twice:

  1. The JS layer captures it via uncaughtExceptions autocapture (with the JS stack trace), and
  2. React Native rethrows the same fatal JS error on the native side as com.facebook.react.common.JavascriptException. posthog-android's uncaught-exception handler then captures it as a second $exception event (with a native stack trace).

The result is two $exception events for one logical error.

API

New optional field on PostHogErrorTrackingConfig:

public val ignoredExceptionTypes: MutableList<String> = mutableListOf()

Setup in an RN app:

PostHogAndroidConfig(apiKey).apply {
    errorTrackingConfig.ignoredExceptionTypes.add(
        "com.facebook.react.common.JavascriptException",
    )
}

Whenever the uncaught-exception handler fires, the throwable and every cause in its chain are checked against the list by class name:

  • If any link matches → the SDK skips emitting \$exception, but still chains to the next handler (so the process termination / RN red-box / etc. behave the same way as before).
  • If nothing matches (or the list is empty, the default) → behavior is unchanged.

Mirrors sentry-android's addIgnoredExceptionForType(...).

Implementation notes

  • Class-name based, never Class<*>::isInstance. This is deliberate: matching by name means apps that don't have React Native (or any other optional dependency) on their classpath are not affected at startup or call time. The SDK never has to load or even know about RN's types — the consumer just passes the FQCN string.
  • Cause-chain walk. Real RN apps sometimes wrap the JS exception in a platform-level wrapper before it reaches the uncaught handler, so the check walks cause until either a match is found or a seen-set short-circuits a self-referential chain.
  • Always chains. The defaultExceptionHandler is always invoked, ignored or not. The contract of Thread.UncaughtExceptionHandler (process termination, RN's red-box, etc.) shouldn't change based on whether we emit a \$exception event.
  • No public API removed. Pure additive change. Apps that don't set ignoredExceptionTypes see zero behavior difference.

Tests

Three new tests in PostHogErrorTrackingAutoCaptureIntegrationTest:

  • uncaughtException skips capture when throwable class is in ignoredExceptionTypes — direct match.
  • uncaughtException skips capture when ignored class is anywhere in the cause chainRuntimeException("outer", cause = JsException). The outermost type is not in the list, but the chain match still suppresses.
  • uncaughtException still captures when throwable class is not in ignoredExceptionTypes — guards against the strip widening to all throwables when the list is non-empty.

The RN exception type is stubbed locally (ReactNativeJavascriptExceptionStub) so the test doesn't drag React Native into the SDK's test classpath — the name-based check exercises the same code path either way.

All 17 tests pass (14 existing + 3 new):

> Task :posthog:test
BUILD SUCCESSFUL in 22s

Test plan

  • ./gradlew :posthog:test --tests "com.posthog.errortracking.*" — 17/17 pass
  • ./gradlew :posthog:compileDebugKotlin (via the test task) — clean
  • Manual verification: with ignoredExceptionTypes = [], all existing tests behave identically (no behavior change to the autocapture path).

Sibling issue

A matching fix is needed on the iOS side per #653 (RN rethrows fatal JS as RCTFatalException). Happy to ship that next as a separate PR if this shape is what you want — the iOS surface would be PostHogErrorTrackingConfig.ignoredExceptionNames: [String] with the same cause-chain walk.

Notes for review

@marandaneto — you suggested beforeSend as a "for now" workaround on the issue thread; this PR proposes the structural fix. Naming and scope are intentionally narrow: just the FQCN list, no Class-object overload, no auto-detection of RN. Happy to refactor if you'd rather keep the surface even smaller (e.g. hardcode the JavascriptException class internally and gate on an RN-detection bool) — let me know which shape you'd prefer.

@tsushanth tsushanth requested a review from a team as a code owner June 12, 2026 14:18
@greptile-apps

greptile-apps Bot commented Jun 12, 2026

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

---

### Issue 1 of 3
posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt:88-92
When the cause chain is what triggered the skip (not the direct throwable), this log reports the outer wrapper's class name — e.g. `java.lang.RuntimeException` — even though that class is not in `ignoredExceptionTypes`. A developer looking at these logs would wonder why `RuntimeException` was suppressed, since they only configured `com.facebook.react.common.JavascriptException`. Logging `throwable.javaClass.name` is only accurate when the outermost exception matched; the message should be phrased to cover the cause-chain case too.

```suggestion
            } else {
                config.logger.log(
                    "Skipping autocapture: ${throwable.javaClass.name} (or a cause in its chain) matches ignoredExceptionTypes",
                )
            }
```

### Issue 2 of 3
posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt:157-226
Prefer parameterised tests — the three new tests share identical setup (`remoteConfigHolder`, `isAutocaptureExceptionsEnabled`, `getDefaultUncaughtExceptionHandler`) and verify the same contract with different inputs. At minimum, the "skips capture" (direct match) and "still captures" (no match) cases are a natural `@ParameterizedTest` pair: the only axes that vary are the throwable type, the ignored-list contents, and whether `captureException` is expected to be called. Collapsing them reduces boilerplate and makes it immediately clear when a new edge case is added that it's intentionally distinct from existing ones. The cause-chain test is structurally different enough to stay standalone.

### Issue 3 of 3
posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt:228-234
The comment says "a local class named the same way", but the stub's actual JVM name is `com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegrationTest$ReactNativeJavascriptExceptionStub` — nothing like `com.facebook.react.common.JavascriptException`. The tests work correctly because they pass `ReactNativeJavascriptExceptionStub::class.java.name` as the filter value, not the real RN FQCN. The comment should reflect the actual design: a stub with an arbitrary local name, registered under that same local name, so the name-based matching code path is exercised correctly without pulling in the RN dependency.

```suggestion
    /**
     * Local stand-in for `com.facebook.react.common.JavascriptException`. The real type
     * lives in React Native, which isn't (and shouldn't be) a test dependency of the
     * SDK. The ignored-exception filter is purely class-name based: tests register this
     * stub's own JVM name as the ignored FQCN, which exercises the matching logic without
     * requiring RN on the classpath. The stub name intentionally differs from the real RN
     * class name; the code path is the same either way.
     */
    private class ReactNativeJavascriptExceptionStub(message: String) : RuntimeException(message)
```

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

Comment on lines +88 to +92
} else {
config.logger.log(
"Skipping autocapture for ignored exception type: ${throwable.javaClass.name}",
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 When the cause chain is what triggered the skip (not the direct throwable), this log reports the outer wrapper's class name — e.g. java.lang.RuntimeException — even though that class is not in ignoredExceptionTypes. A developer looking at these logs would wonder why RuntimeException was suppressed, since they only configured com.facebook.react.common.JavascriptException. Logging throwable.javaClass.name is only accurate when the outermost exception matched; the message should be phrased to cover the cause-chain case too.

Suggested change
} else {
config.logger.log(
"Skipping autocapture for ignored exception type: ${throwable.javaClass.name}",
)
}
} else {
config.logger.log(
"Skipping autocapture: ${throwable.javaClass.name} (or a cause in its chain) matches ignoredExceptionTypes",
)
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt
Line: 88-92

Comment:
When the cause chain is what triggered the skip (not the direct throwable), this log reports the outer wrapper's class name — e.g. `java.lang.RuntimeException` — even though that class is not in `ignoredExceptionTypes`. A developer looking at these logs would wonder why `RuntimeException` was suppressed, since they only configured `com.facebook.react.common.JavascriptException`. Logging `throwable.javaClass.name` is only accurate when the outermost exception matched; the message should be phrased to cover the cause-chain case too.

```suggestion
            } else {
                config.logger.log(
                    "Skipping autocapture: ${throwable.javaClass.name} (or a cause in its chain) matches ignoredExceptionTypes",
                )
            }
```

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

Comment on lines +157 to +226
fun `uncaughtException skips capture when throwable class is in ignoredExceptionTypes`() {
whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig)
whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true)
whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler)

val thread = Thread.currentThread()
// Simulates the React Native scenario: posthog-js has already captured the
// fatal JS error; React Native rethrows it natively as JavascriptException,
// and the SDK should not emit a duplicate $exception event.
val throwable = ReactNativeJavascriptExceptionStub("Unhandled JS Exception: ReferenceError")

val integration =
getSut(ignoredExceptionTypes = listOf(ReactNativeJavascriptExceptionStub::class.java.name))
integration.install(mockPostHog)

integration.uncaughtException(thread, throwable)

verify(mockPostHog, never()).captureException(any<PostHogThrowable>(), anyOrNull())
// The downstream handler still runs so the process terminates / RN's red-box
// appears as it would without PostHog installed.
verify(mockExceptionHandler).uncaughtException(thread, throwable)

integration.uninstall()
}

@Test
fun `uncaughtException skips capture when ignored class is anywhere in the cause chain`() {
whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig)
whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true)
whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler)

val thread = Thread.currentThread()
// The outermost type is RuntimeException, not the ignored type, but the
// cause chain contains the ignored type. Real RN apps wrap the JS exception
// inside platform-level wrappers, so walking the chain matters.
val inner = ReactNativeJavascriptExceptionStub("inner")
val outer = RuntimeException("outer", inner)

val integration =
getSut(ignoredExceptionTypes = listOf(ReactNativeJavascriptExceptionStub::class.java.name))
integration.install(mockPostHog)

integration.uncaughtException(thread, outer)

verify(mockPostHog, never()).captureException(any<PostHogThrowable>(), anyOrNull())
verify(mockExceptionHandler).uncaughtException(thread, outer)

integration.uninstall()
}

@Test
fun `uncaughtException still captures when throwable class is not in ignoredExceptionTypes`() {
whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig)
whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true)
whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler)

val thread = Thread.currentThread()
val throwable = RuntimeException("Genuine native crash")

val integration =
getSut(ignoredExceptionTypes = listOf("com.facebook.react.common.JavascriptException"))
integration.install(mockPostHog)

integration.uncaughtException(thread, throwable)

verify(mockPostHog).captureException(any<PostHogThrowable>(), anyOrNull())
verify(mockExceptionHandler).uncaughtException(thread, throwable)

integration.uninstall()
}

Copy link
Copy Markdown

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 three new tests share identical setup (remoteConfigHolder, isAutocaptureExceptionsEnabled, getDefaultUncaughtExceptionHandler) and verify the same contract with different inputs. At minimum, the "skips capture" (direct match) and "still captures" (no match) cases are a natural @ParameterizedTest pair: the only axes that vary are the throwable type, the ignored-list contents, and whether captureException is expected to be called. Collapsing them reduces boilerplate and makes it immediately clear when a new edge case is added that it's intentionally distinct from existing ones. The cause-chain test is structurally different enough to stay standalone.

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: posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt
Line: 157-226

Comment:
Prefer parameterised tests — the three new tests share identical setup (`remoteConfigHolder`, `isAutocaptureExceptionsEnabled`, `getDefaultUncaughtExceptionHandler`) and verify the same contract with different inputs. At minimum, the "skips capture" (direct match) and "still captures" (no match) cases are a natural `@ParameterizedTest` pair: the only axes that vary are the throwable type, the ignored-list contents, and whether `captureException` is expected to be called. Collapsing them reduces boilerplate and makes it immediately clear when a new edge case is added that it's intentionally distinct from existing ones. The cause-chain test is structurally different enough to stay standalone.

**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!

Comment on lines +228 to +234
/**
* Local stand-in for `com.facebook.react.common.JavascriptException`. The real type
* lives in React Native, which isn't (and shouldn't be) a test dependency of the
* SDK. The ignored-exception filter is purely class-name based, so a local class
* named the same way exercises the same code path without dragging in RN.
*/
private class ReactNativeJavascriptExceptionStub(message: String) : RuntimeException(message)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The comment says "a local class named the same way", but the stub's actual JVM name is com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegrationTest$ReactNativeJavascriptExceptionStub — nothing like com.facebook.react.common.JavascriptException. The tests work correctly because they pass ReactNativeJavascriptExceptionStub::class.java.name as the filter value, not the real RN FQCN. The comment should reflect the actual design: a stub with an arbitrary local name, registered under that same local name, so the name-based matching code path is exercised correctly without pulling in the RN dependency.

Suggested change
/**
* Local stand-in for `com.facebook.react.common.JavascriptException`. The real type
* lives in React Native, which isn't (and shouldn't be) a test dependency of the
* SDK. The ignored-exception filter is purely class-name based, so a local class
* named the same way exercises the same code path without dragging in RN.
*/
private class ReactNativeJavascriptExceptionStub(message: String) : RuntimeException(message)
/**
* Local stand-in for `com.facebook.react.common.JavascriptException`. The real type
* lives in React Native, which isn't (and shouldn't be) a test dependency of the
* SDK. The ignored-exception filter is purely class-name based: tests register this
* stub's own JVM name as the ignored FQCN, which exercises the matching logic without
* requiring RN on the classpath. The stub name intentionally differs from the real RN
* class name; the code path is the same either way.
*/
private class ReactNativeJavascriptExceptionStub(message: String) : RuntimeException(message)
Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt
Line: 228-234

Comment:
The comment says "a local class named the same way", but the stub's actual JVM name is `com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegrationTest$ReactNativeJavascriptExceptionStub` — nothing like `com.facebook.react.common.JavascriptException`. The tests work correctly because they pass `ReactNativeJavascriptExceptionStub::class.java.name` as the filter value, not the real RN FQCN. The comment should reflect the actual design: a stub with an arbitrary local name, registered under that same local name, so the name-based matching code path is exercised correctly without pulling in the RN dependency.

```suggestion
    /**
     * Local stand-in for `com.facebook.react.common.JavascriptException`. The real type
     * lives in React Native, which isn't (and shouldn't be) a test dependency of the
     * SDK. The ignored-exception filter is purely class-name based: tests register this
     * stub's own JVM name as the ignored FQCN, which exercises the matching logic without
     * requiring RN on the classpath. The stub name intentionally differs from the real RN
     * class name; the code path is the same either way.
     */
    private class ReactNativeJavascriptExceptionStub(message: String) : RuntimeException(message)
```

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!

tsushanth added a commit to tsushanth/posthog-android that referenced this pull request Jun 12, 2026
Address two of three Greptile review notes on PR PostHog#569:

1. The "skipping autocapture" log message previously read as if the
   throwable's outermost class was always the matched ignoredType. A
   cause-chain match would report e.g. `java.lang.RuntimeException` as
   suppressed even when only `JavascriptException` was in the ignore
   list. Reworded to surface that "the throwable or a cause in its
   chain" matched.

2. The `ReactNativeJavascriptExceptionStub` doc comment claimed it was
   "named the same way" as the real RN class — it isn't; the stub's
   JVM name lives under the test class. Restated the actual design:
   the stub's own FQCN is what tests register as the ignored value,
   exercising the same code path without needing RN on the classpath.

Greptile's third note (parameterising the new tests) is intentionally
deferred — the individual test names ("skips capture when…", "still
captures when…", etc.) document distinct behaviours and read more
clearly than a `@ParameterizedTest` table would. Happy to revisit if
the maintainer prefers the table form.
@tsushanth

Copy link
Copy Markdown
Author

Thanks @greptile-apps — pushed 7262292 addressing issues 1 and 3.

  • Issue 1 (suppress log accuracy): reworded so the log surfaces "the throwable or a cause in its chain" matched — readers won't see a bare RuntimeException reported as suppressed when the ignore list only mentions JavascriptException.
  • Issue 3 (test-stub doc comment): restated the actual design (the stub's own JVM name is what tests register as the ignored FQCN, exercising the same code path without dragging RN onto the classpath). The "named the same way" framing was wrong.

Issue 2 (parameterising the new tests): intentionally deferred. The individual test names — skips capture when throwable class is in ignoredExceptionTypes, skips capture when ignored class is anywhere in the cause chain, still captures when throwable class is not in ignoredExceptionTypes — read more clearly under a failure than a @ParameterizedTest table would, and the three cases aren't quite the structural pair Greptile reads them as (the cause-chain case threads cause through a wrapper, the negative case asserts the inverse contract). Happy to collapse to a parameterised form if maintainers prefer it.

All 17 tests still pass after the changes.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Both fixes look correct.

Issue 1: The log now reads ${throwable.javaClass.name} (or a cause in its chain) matches ignoredExceptionTypes — accurate without pinning the message to whichever link in the chain actually matched.

Issue 3: The stub doc comment correctly describes the design: tests register the stub's own JVM name as the ignored FQCN, exercising the name-based matching without pulling RN onto the classpath.

Issue 2 (deferred): The rationale holds. These aren't symmetric variants — the cause-chain test threads cause through a wrapper to exercise a different code path, and the negative test asserts the inverse contract (non-empty ignore list, no match → capture still fires). Collapsing them into a @ParameterizedTest table would obscure those distinctions under a tuple of inputs. The current three-test shape is clearer.

LGTM on 7262292.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@marandaneto

Copy link
Copy Markdown
Member

what i'd do differently is to drop after capturing the error, where we do the beforeSend as well
so even if the event is manually captured, its also dropped
right now if you call captureException manually with a suprressed type, its still captured which is a bug

@tsushanth

Copy link
Copy Markdown
Author

Good call — pushed d11c33d restructuring the filter to live at the beforeSend layer per your suggestion.

What moved

  • New PostHogIgnoredExceptionTypesBeforeSend (internal) implementing PostHogBeforeSend. It scopes itself to $exception events only, reads $exception_list[*].type from properties, and drops if any entry's type is in errorTrackingConfig.ignoredExceptionTypes. The cause chain is already serialized in $exception_list outer-to-inner by ThrowableCoercer, so walking the list covers the wrapper case (RuntimeException(cause=JavascriptException)) without re-reaching for the original throwable.
  • PostHogErrorTrackingAutoCaptureIntegration.install now calls config.addBeforeSend(...) for the new hook (and removeBeforeSend(...) on uninstall), so both autocapture and manual captureException(throwable) callers go through the same drop logic. The isIgnored(throwable) helper that was inside uncaughtException is gone; the handler now always hands off to captureException and the drop decision is the beforeSend's.

Tests

  • PostHogIgnoredExceptionTypesBeforeSendTest — 6 cases covering: outer-type match, cause-chain match (wrapped), no-match passthrough, empty ignore list, non-$exception events ignored by the filter, and missing/empty $exception_list passthrough. The wrapped-cause case is the regression equivalent of the old uncaughtException skips capture when ignored class is anywhere in the cause chain test, now at the right layer.
  • PostHogErrorTrackingAutoCaptureIntegrationTest — replaced the three old uncaughtException skips capture … tests with two new ones: one asserts addBeforeSend / removeBeforeSend are called with a PostHogIgnoredExceptionTypesBeforeSend instance on install / uninstall, the other asserts the handler always calls captureException regardless of the throwable's class. The drop semantics now live in the beforeSend's own unit tests.

Full posthog module suite passes locally: ./gradlew :posthog:test --tests "com.posthog.errortracking.*" → 22 tests, 0 failures.

(The greptile "non-parameterised tests" P2 from the earlier review still applies to the BeforeSend tests by the same argument, but they're shaped distinctly enough — different inputs assert different invariants, not the same one with different values — that I'd default to leaving them. Easy to collapse if you'd prefer.)


val list = event.properties?.get("\$exception_list") as? List<*> ?: return event
for (entry in list) {
val type = (entry as? Map<*, *>)?.get("type") as? String ?: continue

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.

that wont work, types might be minified/not available, we have to match the type with the instance type

@marandaneto marandaneto requested a review from a team June 15, 2026 13:10
*
* Defaults to empty.
*/
public val ignoredExceptionTypes: MutableList<String> = mutableListOf(),

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.

I think I agree with @marandaneto on this, string matching will probably not work because of minification. So this should probably take a Class not a String and check via isInstance. This means that we can't use beforeSend to run these checks.

I believe we can drop from

override fun captureException(
which is called for both manual and automatic error captures

we'll want to keep the cause-chain walk too cause we'll need that for PostHogThrowable

tsushanth added a commit to tsushanth/posthog-ios that referenced this pull request Jun 18, 2026
…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).
tsushanth added a commit to tsushanth/posthog-android that referenced this pull request Jun 18, 2026
…rowable>

Per @marandaneto and @ioannisj's reviews on PostHog#569: string matching against
fully-qualified class names is unsafe under R8 / ProGuard minification —
the consumer app's class names get renamed at build time while the
SDK-side ignore list stays as the unminified source names, so the filter
silently stops matching.

Switch the public API to `MutableList<Class<out Throwable>>` and move
the check from a `PostHogBeforeSend` hook into `PostHog.captureException`
itself, where the original `Throwable` is still in scope and
`Class.isInstance` can compare by reference. The check walks the cause
chain (bounded by `MAX_CAUSE_DEPTH = 32` to guard against cyclic
chains), so a wrapped throwable — including the `PostHogThrowable`
created by the uncaught handler — still matches when its inner cause
does. Drop the now-dead `PostHogIgnoredExceptionTypesBeforeSend` class
and its register/unregister wiring on
`PostHogErrorTrackingAutoCaptureIntegration`.

Tests pivot accordingly: `PostHogIgnoredExceptionTypesBeforeSendTest`
goes away, two BeforeSend-shaped tests on
`PostHogErrorTrackingAutoCaptureIntegrationTest` go away, and three new
tests on `PostHogTest` cover the direct-match drop, the cause-chain
drop, and the keep-when-not-matching path. 696 tests pass under
`./gradlew :posthog:test`; spotless format + api dump up to date.
@tsushanth

Copy link
Copy Markdown
Author

Thanks @marandaneto and @ioannisj. Refactored to take MutableList<Class<out Throwable>> instead of List<String> and moved the check into PostHog.captureException so both autocapture and direct callers go through it, exactly as suggested. Matching is by Class.isInstance so R8 / ProGuard renaming can't silently break the filter, and the cause-chain walk is preserved (bounded by MAX_CAUSE_DEPTH = 32 to guard against cyclic chains) so the PostHogThrowable wrapper from the uncaught handler still surfaces the underlying type.

PostHogIgnoredExceptionTypesBeforeSend is gone (and so is its register/unregister wiring on the autocapture integration); the two BeforeSend-shaped tests under PostHogErrorTrackingAutoCaptureIntegrationTest are replaced by three tests on PostHogTest covering the direct-match drop, the cause-chain drop, and the keep-when-not-matching path. 696 tests pass under ./gradlew :posthog:test; spotless and apiDump are up to date.

Ready for another look whenever you have a moment.

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

Thanx @tsushanth, this is almost there. Left some last few comments + some merge conflicts to resolve

Comment on lines +654 to +663
val ignored = config?.errorTrackingConfig?.ignoredExceptionTypes
if (!ignored.isNullOrEmpty()) {
val match = findIgnoredTypeInCauseChain(throwable, ignored)
if (match != null) {
config?.logger?.log(
"Skipping \$exception: ${match.name} (or a cause in its chain) matches ignoredExceptionTypes",
)
return
}
}

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.

Let's move this inside try-catch below?

* Setting
* `ignoredExceptionTypes = mutableListOf(JavascriptException::class.java)`
* suppresses the native duplicate so the JS-captured event is the
* single source of truth. Mirrors `sentry-android`'s

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.

Let's remove reference in public docs and move to header attribution if indeed necessary

Comment thread posthog/CHANGELOG.md Outdated
Comment on lines +3 to +13
### Minor Changes

- Error tracking: add `errorTrackingConfig.ignoredExceptionTypes` (a
`MutableList<Class<out Throwable>>`) so callers can suppress duplicate
captures by type. The list is consulted by `PostHog.captureException` —
both the autocapture handler and direct calls — and walks the cause
chain, so a wrapped throwable still matches. Matching is by `Class`
reference, not by name, so R8 / ProGuard renaming can't silently break
the filter. Primary use case is dropping the native rethrow of fatal JS
errors when the React Native plugin already captures them on the JS
side. Closes #567.

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.

Lets not edit changelog directly but provide a changeset entry. Refer to RELEASING.md

Comment on lines +34 to +58
* Throwable classes to skip during capture (both autocapture and manual
* [PostHog.captureException] calls).
*
* For every capture, the throwable and every entry of its cause chain
* are tested with [Class.isInstance]; if any link matches an entry in
* this list, the SDK will not emit a `$exception` event. The next
* exception handler in the chain is still invoked, so the process
* termination / RN red-box / etc. behaves as before.
*
* Matching is done by [Class] reference (not by name) so that R8 /
* ProGuard minification renaming a class can't silently break the
* filter — the call site holds the same `Class` object the SDK
* compares against.
*
* The primary use case is React Native apps that already capture
* fatal JS errors via `@posthog/react-native-plugin`'s JS-side
* autocapture: React Native rethrows the same fatal JS error on the
* native side as `com.facebook.react.common.JavascriptException`,
* and posthog-android would otherwise emit a second `$exception`
* event for the same logical error.
*
* Setting
* `ignoredExceptionTypes = mutableListOf(JavascriptException::class.java)`
* suppresses the native duplicate so the JS-captured event is the
* single source of truth. Mirrors `sentry-android`'s

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.

Let's simplify this a little but, too chatty

…s by class

Adds a `MutableList<Class<out Throwable>>` on `PostHogErrorTrackingConfig`
that `PostHog.captureException` consults before building the
`$exception` event. The throwable and every entry in its cause chain
are tested with `Class.isInstance` (so R8 / ProGuard renames don't
break the filter); on a match the SDK skips the event and the
downstream uncaught-exception handler still runs. The cause-chain walk
is bounded by `MAX_CAUSE_DEPTH = 32` to guard against cyclic chains.

The single-source-of-truth check lives inside `captureException`'s
existing try-catch, so manual `PostHog.captureException(...)` callers
and the autocapture path both go through it.

Three new tests on `PostHogTest` cover the direct-match drop, the
cause-chain drop, and the keep-when-not-matching path. Snapshot under
`posthog/api/posthog.api` regenerated to exercise the new public
getter; `apiDump` + `spotlessCheck` are green.

Closes PostHog#567.
@tsushanth tsushanth force-pushed the fix/issue-567-ignored-exception-types branch from 6370460 to 4abc99f Compare June 19, 2026 15:00
@tsushanth

Copy link
Copy Markdown
Author

Thanks @ioannisj. Squashed the branch onto current main (so the diff is now scoped to the 5 files this PR actually owns), addressed all four review threads:

  • moved the ignoredExceptionTypes check inside captureException's existing try block (4abc99f)
  • removed the React Native reference from the public docstring on errorTrackingConfig.ignoredExceptionTypes and tightened the doc to the essentials
  • dropped the direct posthog/CHANGELOG.md edit and added .changeset/error-tracking-ignored-exception-types.md per RELEASING.md
  • PostHogErrorTrackingAutoCaptureIntegration now sits unchanged on top of main — no comment churn there either

706 tests pass under ./gradlew :posthog:test; make checkFormat and apiDump are up to date. Ready for another pass whenever.

sut.close()
}

private fun exceptionStepMessages(event: com.posthog.PostHogEvent): List<Any?> {

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.

These tests should not be removed here

Comment on lines -72 to -73
exceptionStepsEnabled: Boolean = true,
exceptionStepsMaxBytes: Int = 32768,

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.

Same, probably stale commits from main

Comment on lines -97 to -98
this.errorTrackingConfig.exceptionSteps.enabled = exceptionStepsEnabled
this.errorTrackingConfig.exceptionSteps.maxBytes = exceptionStepsMaxBytes

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.

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

Need to rebase on main to remove some unrelated changes (e.g exceptionSteps + tests). Left a comment, not a blocker since this does not sit directly on RN path/case but the config option would be more complete if we gated generic capture() method as well

}
}

override fun captureException(

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.

Now that we have the concrete types, I think it should be possible to also gate the generic capture("$exception") path as well

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 (JavascriptException)

3 participants