Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/error-tracking-ignored-exception-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog": minor
---

Add `errorTrackingConfig.ignoredExceptionTypes`, a `MutableList<Class<out Throwable>>` 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.
4 changes: 3 additions & 1 deletion posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -507,9 +507,11 @@ public final class com/posthog/errortracking/PostHogErrorTrackingConfig {
public fun <init> (Z)V
public fun <init> (ZLjava/util/List;)V
public fun <init> (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;)V
public synthetic fun <init> (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (ZLjava/util/List;Lcom/posthog/errortracking/PostHogExceptionStepsConfig;Ljava/util/List;)V
public synthetic fun <init> (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
}
Expand Down
30 changes: 30 additions & 0 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -708,6 +719,21 @@ public class PostHog private constructor(
}
}

private fun findIgnoredTypeInCauseChain(
throwable: Throwable,
ignored: List<Class<out Throwable>>,
): Class<out Throwable>? {
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<String, Any>?,
Expand Down Expand Up @@ -1804,6 +1830,10 @@ public class PostHog private constructor(

private val apiKeys = mutableSetOf<String>()

// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Class<out Throwable>> = mutableListOf(),
)
262 changes: 63 additions & 199 deletions posthog/src/test/java/com/posthog/PostHogTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,6 @@ internal class PostHogTest {
evaluationContexts: List<String>? = null,
context: PostHogContext? = null,
personProfiles: PersonProfiles = PersonProfiles.IDENTIFIED_ONLY,
exceptionStepsEnabled: Boolean = true,
exceptionStepsMaxBytes: Int = 32768,
Comment on lines -72 to -73

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

): PostHogInterface {
config =
PostHogConfig(API_KEY, host).apply {
Expand All @@ -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
Comment on lines -97 to -98

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.

this.context = context
this.personProfiles = personProfiles
}
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -3381,199 +3440,4 @@ internal class PostHogTest {

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

@Suppress("UNCHECKED_CAST")
val steps = event.properties!!["\$exception_steps"] as? List<Map<String, Any>> ?: 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<PostHogBatchEvent>(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<PostHogBatchEvent>(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<PostHogBatchEvent>(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<PostHogBatchEvent>(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<PostHogBatchEvent>(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<PostHogBatchEvent>(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<PostHogBatchEvent>(content.reader())

val theEvent = batch.batch.first()
assertEquals("\$exception", theEvent.event)
assertEquals(listOf("A", "B"), exceptionStepMessages(theEvent))

sut.close()
}
}
Loading