From 4abc99f5cf1c9948aaca5ea13a52fb7fc00be35c Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:00:28 -0700 Subject: [PATCH] feat(errortracking): add ignoredExceptionTypes config to skip captures by class Adds a `MutableList>` 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 #567. --- .../error-tracking-ignored-exception-types.md | 5 + posthog/api/posthog.api | 4 +- posthog/src/main/java/com/posthog/PostHog.kt | 30 ++ .../PostHogErrorTrackingConfig.kt | 10 + .../src/test/java/com/posthog/PostHogTest.kt | 262 +++++------------- 5 files changed, 111 insertions(+), 200 deletions(-) create mode 100644 .changeset/error-tracking-ignored-exception-types.md diff --git a/.changeset/error-tracking-ignored-exception-types.md b/.changeset/error-tracking-ignored-exception-types.md new file mode 100644 index 00000000..2280dcaf --- /dev/null +++ b/.changeset/error-tracking-ignored-exception-types.md @@ -0,0 +1,5 @@ +--- +"posthog": minor +--- + +Add `errorTrackingConfig.ignoredExceptionTypes`, a `MutableList>` consulted by `PostHog.captureException` (both autocapture and direct callers). The throwable and every entry in its cause chain are tested with `Class.isInstance`; if any link matches, the SDK skips the `$exception` event. Matching by `Class` reference is safe under R8 / ProGuard renames. The downstream uncaught-exception handler still runs. Defaults to empty. diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 3b18a7db..e8a6750b 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -507,9 +507,11 @@ public final class com/posthog/errortracking/PostHogErrorTrackingConfig { public fun (Z)V public fun (ZLjava/util/List;)V public fun (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;)V - public synthetic fun (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;Ljava/util/List;)V + public synthetic fun (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getAutoCapture ()Z public final fun getExceptionSteps ()Lcom/posthog/errortracking/PostHogExceptionStepsConfig; + public final fun getIgnoredExceptionTypes ()Ljava/util/List; public final fun getInAppIncludes ()Ljava/util/List; public final fun setAutoCapture (Z)V } diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index db489165..3fd96f84 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -687,6 +687,17 @@ public class PostHog private constructor( } try { + 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 + } + } + val exceptionProperties = throwableCoercer.fromThrowableToPostHogProperties( throwable, @@ -708,6 +719,21 @@ public class PostHog private constructor( } } + private fun findIgnoredTypeInCauseChain( + throwable: Throwable, + ignored: List>, + ): Class? { + var current: Throwable? = throwable + var depth = 0 + while (current != null && depth < MAX_CAUSE_DEPTH) { + val link = current + ignored.firstOrNull { it.isInstance(link) }?.let { return it } + current = if (link.cause === link) null else link.cause + depth++ + } + return null + } + override fun addExceptionStep( message: String, properties: Map?, @@ -1804,6 +1830,10 @@ public class PostHog private constructor( private val apiKeys = mutableSetOf() + // Cap on the cause-chain walk used by ignoredExceptionTypes. + // Guards against self-referential or cyclic causes. + private const val MAX_CAUSE_DEPTH: Int = 32 + /** * Captures application log records into PostHog's logs product * (separate from product analytics events). Forwards to the shared diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt index 38bd7eda..8a500c0d 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt @@ -37,4 +37,14 @@ public class PostHogErrorTrackingConfig * Record steps with [PostHog.addExceptionStep]. */ public val exceptionSteps: PostHogExceptionStepsConfig = PostHogExceptionStepsConfig(), + /** + * Throwable classes to skip during capture. For each captured throwable, the + * SDK walks the cause chain and drops the `$exception` event when any link is + * an instance of an entry in this list. Matching uses [Class.isInstance], so + * R8 / ProGuard renames don't break the filter. The downstream uncaught + * exception handler still runs. + * + * Defaults to empty. + */ + public val ignoredExceptionTypes: MutableList> = mutableListOf(), ) diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 9bb47c37..dce79caf 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -69,8 +69,6 @@ internal class PostHogTest { evaluationContexts: List? = null, context: PostHogContext? = null, personProfiles: PersonProfiles = PersonProfiles.IDENTIFIED_ONLY, - exceptionStepsEnabled: Boolean = true, - exceptionStepsMaxBytes: Int = 32768, ): PostHogInterface { config = PostHogConfig(API_KEY, host).apply { @@ -94,8 +92,6 @@ internal class PostHogTest { addBeforeSend(beforeSend) } this.errorTrackingConfig.inAppIncludes.add("com.posthog") - this.errorTrackingConfig.exceptionSteps.enabled = exceptionStepsEnabled - this.errorTrackingConfig.exceptionSteps.maxBytes = exceptionStepsMaxBytes this.context = context this.personProfiles = personProfiles } @@ -2541,6 +2537,69 @@ internal class PostHogTest { sut.close() } + /** + * Local stand-in for `com.facebook.react.common.JavascriptException` (the + * production target). Using a class defined here lets the test register + * the same `Class` reference at config time and at capture time without + * pulling React Native onto the classpath. + */ + private class JavascriptExceptionStub(message: String) : RuntimeException(message) + + @Test + fun `captureException drops events whose throwable matches ignoredExceptionTypes`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + config.errorTrackingConfig.ignoredExceptionTypes.add(JavascriptExceptionStub::class.java) + + sut.captureException(JavascriptExceptionStub("Unhandled JS Exception")) + + queueExecutor.shutdownAndAwaitTermination() + assertEquals(0, http.requestCount) + + sut.close() + } + + @Test + fun `captureException drops events whose cause chain contains an ignored type`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + config.errorTrackingConfig.ignoredExceptionTypes.add(JavascriptExceptionStub::class.java) + + // PostHogThrowable / RuntimeException wrappers must not defeat the filter: + // the cause chain still surfaces the ignored type. + val wrapped = + RuntimeException( + "wrapper", + JavascriptExceptionStub("Unhandled JS Exception"), + ) + sut.captureException(wrapped) + + queueExecutor.shutdownAndAwaitTermination() + assertEquals(0, http.requestCount) + + sut.close() + } + + @Test + fun `captureException keeps events whose throwable is not in ignoredExceptionTypes`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + config.errorTrackingConfig.ignoredExceptionTypes.add(JavascriptExceptionStub::class.java) + + sut.captureException(IllegalStateException("normal failure")) + + queueExecutor.shutdownAndAwaitTermination() + assertEquals(1, http.requestCount) + + sut.close() + } + @Test fun `sets default person properties on SDK setup when enabled`() { val http = @@ -3381,199 +3440,4 @@ internal class PostHogTest { sut.close() } - - private fun exceptionStepMessages(event: com.posthog.PostHogEvent): List { - @Suppress("UNCHECKED_CAST") - val steps = event.properties!!["\$exception_steps"] as? List> ?: return emptyList() - return steps.map { it["\$message"] } - } - - @Test - fun `addExceptionStep attaches ordered steps to the exception event`() { - val http = mockHttp() - val url = http.url("/") - - val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) - - sut.addExceptionStep("A", mapOf("screen" to "cart")) - sut.addExceptionStep("B") - sut.addExceptionStep("C") - - sut.captureException(RuntimeException("boom")) - - queueExecutor.shutdownAndAwaitTermination() - - val request = http.takeRequest() - val content = request.body.unGzip() - val batch = serializer.deserialize(content.reader()) - - val theEvent = batch.batch.first() - assertEquals("\$exception", theEvent.event) - assertEquals(listOf("A", "B", "C"), exceptionStepMessages(theEvent)) - - sut.close() - } - - @Test - fun `addExceptionStep does not overwrite caller-provided exception steps`() { - val http = mockHttp() - val url = http.url("/") - - val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) - - sut.addExceptionStep("buffered") - - sut.captureException( - RuntimeException("boom"), - properties = mapOf("\$exception_steps" to listOf(mapOf("\$message" to "caller"))), - ) - - queueExecutor.shutdownAndAwaitTermination() - - val request = http.takeRequest() - val content = request.body.unGzip() - val batch = serializer.deserialize(content.reader()) - - assertEquals(listOf("caller"), exceptionStepMessages(batch.batch.first())) - - sut.close() - } - - @Test - fun `exception steps persist across captures and identity changes`() { - val http = mockHttp() - val url = http.url("/") - - val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, flushAt = 100) - - sut.addExceptionStep("A") - sut.addExceptionStep("B") - sut.captureException(RuntimeException("first")) - - sut.reset() - - sut.addExceptionStep("C") - sut.captureException(RuntimeException("second")) - - // flushAt is high so neither capture triggers a flush on its own; flush explicitly - // so both events land in a single batch - sut.flush() - queueExecutor.shutdownAndAwaitTermination() - - val request = http.takeRequest() - val content = request.body.unGzip() - val batch = serializer.deserialize(content.reader()) - - val exceptions = batch.batch.filter { it.event == "\$exception" } - assertEquals(listOf("A", "B"), exceptionStepMessages(exceptions[0])) - assertEquals(listOf("A", "B", "C"), exceptionStepMessages(exceptions[1])) - - sut.close() - } - - @Test - fun `addExceptionStep is a no-op when exception steps are disabled`() { - val http = mockHttp() - val url = http.url("/") - - val sut = - getSut( - url.toString(), - preloadFeatureFlags = false, - reloadFeatureFlags = false, - exceptionStepsEnabled = false, - ) - - sut.addExceptionStep("A") - sut.captureException(RuntimeException("boom")) - - queueExecutor.shutdownAndAwaitTermination() - - val request = http.takeRequest() - val content = request.body.unGzip() - val batch = serializer.deserialize(content.reader()) - - assertTrue(exceptionStepMessages(batch.batch.first()).isEmpty()) - - sut.close() - } - - @Test - fun `addExceptionStep is a no-op when maxBytes is not positive`() { - val http = mockHttp() - val url = http.url("/") - - val sut = - getSut( - url.toString(), - preloadFeatureFlags = false, - reloadFeatureFlags = false, - exceptionStepsMaxBytes = 0, - ) - - sut.addExceptionStep("A") - sut.captureException(RuntimeException("boom")) - - queueExecutor.shutdownAndAwaitTermination() - - val request = http.takeRequest() - val content = request.body.unGzip() - val batch = serializer.deserialize(content.reader()) - - assertTrue(exceptionStepMessages(batch.batch.first()).isEmpty()) - - sut.close() - } - - @Test - fun `addExceptionStep clears the buffer on optOut and stops buffering while opted out`() { - val http = mockHttp() - val url = http.url("/") - - val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) - - sut.addExceptionStep("before opt-out") - sut.optOut() - sut.addExceptionStep("while opted out") - sut.optIn() - - sut.captureException(RuntimeException("boom")) - - queueExecutor.shutdownAndAwaitTermination() - - val request = http.takeRequest() - val content = request.body.unGzip() - val batch = serializer.deserialize(content.reader()) - - assertTrue(exceptionStepMessages(batch.batch.first()).isEmpty()) - - sut.close() - } - - @Test - fun `exception steps attach to a generic capture of the exception event`() { - // Hybrid SDKs (RN/Flutter) forward steps via addExceptionStep and emit the - // exception through the generic capture() entry point rather than captureException. - val http = mockHttp() - val url = http.url("/") - - val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) - - sut.addExceptionStep("A") - sut.addExceptionStep("B") - - sut.capture("\$exception", properties = mapOf("\$exception_message" to "boom")) - - queueExecutor.shutdownAndAwaitTermination() - - val request = http.takeRequest() - val content = request.body.unGzip() - val batch = serializer.deserialize(content.reader()) - - val theEvent = batch.batch.first() - assertEquals("\$exception", theEvent.event) - assertEquals(listOf("A", "B"), exceptionStepMessages(theEvent)) - - sut.close() - } }