From d078145f0a6fd0360facaa8b6269baebf4e44352 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Wed, 26 Aug 2026 10:21:17 +0200 Subject: [PATCH] feat(error-tracking): standardize exception metadata --- .changeset/canonical-exception-metadata.md | 7 ++++ .../errortracking/NativeCrashEventCoercer.kt | 2 ++ .../NativeCrashEventCoercerTest.kt | 3 +- .../main/java/com/posthog/server/PostHog.kt | 4 +-- .../com/posthog/server/PostHogInterface.kt | 4 +-- .../java/com/posthog/server/PostHogTest.kt | 8 ++--- .../main/java/com/posthog/PostHogStateless.kt | 33 ++++++++++++++----- .../errortracking/PostHogThrowable.kt | 3 +- .../errortracking/ThrowableCoercer.kt | 32 +++++++++--------- .../src/test/java/com/posthog/PostHogTest.kt | 9 ++--- .../errortracking/ThrowableCoercerTest.kt | 19 ++++++----- 11 files changed, 78 insertions(+), 46 deletions(-) create mode 100644 .changeset/canonical-exception-metadata.md diff --git a/.changeset/canonical-exception-metadata.md b/.changeset/canonical-exception-metadata.md new file mode 100644 index 000000000..aa763806d --- /dev/null +++ b/.changeset/canonical-exception-metadata.md @@ -0,0 +1,7 @@ +--- +"posthog": minor +"posthog-android": minor +"posthog-server": minor +--- + +Standardize exception capture metadata, including severity, capture source, mechanism semantics, deterministic cause and suppressed linkage, and reserved property ownership. diff --git a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercer.kt b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercer.kt index fe262aa54..ffe0137c4 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercer.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercer.kt @@ -71,6 +71,7 @@ internal class NativeCrashEventCoercer( "handled" to false, "synthetic" to false, "type" to "signal", + "exception_id" to 0, ) tombstone.tid?.let { exception["thread_id"] = it } if (frames.isNotEmpty()) { @@ -84,6 +85,7 @@ internal class NativeCrashEventCoercer( val properties = mutableMapOf() properties["\$exception_list"] = listOf(exception) properties["\$exception_level"] = "fatal" + properties["\$exception_source"] = "android.native_crash_reporter" if (debugImages.isNotEmpty()) { properties["\$debug_images"] = debugImages.values.toList() } diff --git a/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercerTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercerTest.kt index aaf373ec3..a3c3c4949 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercerTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/errortracking/NativeCrashEventCoercerTest.kt @@ -82,10 +82,11 @@ internal class NativeCrashEventCoercerTest { assertEquals("SEGV_MAPERR at 0xdead", exception["value"]) assertEquals(4343L, exception["thread_id"]) assertEquals( - mapOf("handled" to false, "synthetic" to false, "type" to "signal"), + mapOf("handled" to false, "synthetic" to false, "type" to "signal", "exception_id" to 0), exception["mechanism"], ) assertEquals("fatal", properties["\$exception_level"]) + assertEquals("android.native_crash_reporter", properties["\$exception_source"]) } @Suppress("UNCHECKED_CAST") diff --git a/posthog-server/src/main/java/com/posthog/server/PostHog.kt b/posthog-server/src/main/java/com/posthog/server/PostHog.kt index 1cd9a97c7..7488eb2ff 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHog.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHog.kt @@ -283,8 +283,8 @@ public class PostHog : PostHogStateless(), PostHogInterface { // captureExceptionStateless cannot carry groups/timestamp, so this overload takes the // shared core route instead: it runs the same enabled/opt-out and ignoredExceptionTypes - // gates and merges the provided properties AFTER the coerced exception properties (so - // options can still override reserved keys like $exception_level). The merge is passed + // gates and merges non-reserved provided properties into the coerced exception properties. + // The merge is passed // as a provider so `appendFeatureFlags` cannot fire a /flags request for an event the // gates then drop; options.userProperties feeds flag evaluation only — $exception // events do not perform person updates. diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt b/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt index e82d13102..24893893d 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt @@ -827,8 +827,8 @@ public sealed interface PostHogInterface { * @param distinctId the distinctId. When null or blank, the current [PostHogRequestContext] * distinct ID is used; if none exists, a personless UUID is generated. * @param options capture options containing properties, groups, timestamp, and feature flag - * snapshot settings. Reserved exception properties such as `$exception_level` can be - * overridden via the options properties. + * snapshot settings. Generic properties cannot override SDK- or processor-owned exception + * metadata; `$exception_fingerprint` remains the documented generic override. * * `$exception` events do not perform person updates: they are ingested by a separate * error-tracking pipeline with no ordering guarantee against the person pipeline, so diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt index 7746e09f2..9b4fd7afd 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt @@ -865,7 +865,7 @@ internal class PostHogTest { } @Test - fun `captureException with options allows overriding exception level via properties`() { + fun `captureException with options protects canonical metadata and preserves fingerprint`() { val mockServer = MockWebServer() mockServer.enqueue(MockResponse().setResponseCode(200)) mockServer.start() @@ -892,7 +892,7 @@ internal class PostHogTest { assertNotNull(batchRequest, "Expected /batch request within 5 seconds") val props = batchRequest.parseBatch().eventProperties("\$exception") - assertEquals("warning", props["\$exception_level"]) + assertEquals("error", props["\$exception_level"]) assertEquals("custom-fingerprint", props["\$exception_fingerprint"]) postHog.close() @@ -1053,7 +1053,7 @@ internal class PostHogTest { } @Test - fun `captureException with options merges options for types outside ignoredExceptionTypes`() { + fun `captureException with options merges non-reserved options for types outside ignoredExceptionTypes`() { val mockServer = MockWebServer() mockServer.enqueue(MockResponse().setResponseCode(200)) mockServer.start() @@ -1077,7 +1077,7 @@ internal class PostHogTest { val props = batchRequest.parseBatch().eventProperties("\$exception") assertEquals("value", props["custom"]) - assertEquals("warning", props["\$exception_level"]) + assertEquals("error", props["\$exception_level"]) assertNotNull(props["\$exception_list"], "The coerced exception properties should still be present") @Suppress("UNCHECKED_CAST") diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index 249c0e4d9..e9705675a 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -665,21 +665,20 @@ public open class PostHogStateless protected constructor( * Subclasses that need event fields [captureExceptionStateless] cannot carry (groups, an * explicit timestamp) must go through here rather than coercing the throwable and calling * [captureStateless] themselves, so the `ignoredExceptionTypes` prefilter, the - * caller-properties-win merge order and the personless fallback stay in one place instead of + * canonical exception metadata precedence and the personless fallback stay in one place instead of * being re-implemented (and drifting) per capture path. * * [properties] is a provider invoked only once the enabled/opt-out state and the * `ignoredExceptionTypes` prefilter have all passed, so callers can build expensive enrichment * (e.g. a feature-flag evaluation that hits `/flags`) inside it without paying for an event - * that is about to be dropped. Its result is merged AFTER the coerced exception properties, so - * callers can override reserved keys such as `$exception_level`. + * that is about to be dropped. Generic caller properties cannot override SDK- or + * processor-owned exception metadata; `$exception_fingerprint` remains the documented generic + * override. * * This route never adds `$set`/`$set_once` of its own: `$exception` events are ingested by a * separate error-tracking pipeline with no ordering guarantee against the person pipeline, so * person updates are dropped server-side. Person properties therefore have no parameter here — - * send them with a regular [captureStateless] call or `identify`. (A caller that writes the - * reserved keys straight into [properties] still gets them serialized, same as any other - * reserved key it chooses to override; they simply have no effect.) + * send them with a regular [captureStateless] call or `identify`. */ @PostHogInternal protected fun captureExceptionEvent( @@ -713,8 +712,8 @@ public open class PostHogStateless protected constructor( releaseIdentifier = config?.releaseIdentifier, ) - properties()?.let { - exceptionProperties.putAll(it) + properties()?.let { supplied -> + exceptionProperties.putAll(supplied.filterKeys { it !in RESERVED_EXCEPTION_PROPERTIES }) } var id = distinctId @@ -743,6 +742,24 @@ public open class PostHogStateless protected constructor( private const val GROUP_IDENTIFY = "\$groupidentify" + private val RESERVED_EXCEPTION_PROPERTIES = + setOf( + "\$exception_list", + "\$exception_level", + "\$exception_source", + "\$debug_images", + "\$exception_handled", + "\$exception_types", + "\$exception_values", + "\$exception_sources", + "\$exception_functions", + "\$exception_fingerprint_version", + "\$exception_fingerprint_record", + "\$exception_issue_id", + "\$exception_release", + "\$cymbal_errors", + ) + // Strict allowlist for minimal $feature_flag_called events, defined by the cross-SDK // contract: everything not listed is stripped, including registered super properties, the // static and dynamic context envelope, and the $feature_flag_bootstrapped_* / diff --git a/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt b/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt index 8223502b0..8da1231fb 100644 --- a/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt +++ b/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt @@ -3,5 +3,6 @@ package com.posthog.internal.errortracking internal class PostHogThrowable(throwable: Throwable, val thread: Thread = Thread.currentThread()) : Throwable(throwable) { val handled: Boolean = false val isFatal: Boolean = true - val mechanism: String = "UncaughtExceptionHandler" + val mechanism: String = "onuncaughtexception" + val source: String = "android.uncaught_exception_handler" } diff --git a/posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt b/posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt index 89b18a828..cca81e8b5 100644 --- a/posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt +++ b/posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt @@ -194,8 +194,9 @@ public class ThrowableCoercer { throwable: Throwable, exceptionId: Int?, parentId: Int?, - handled: Boolean, + handled: Boolean?, mechanismType: String, + relationshipSource: String?, threadId: Long, inAppIncludes: List, inAppExcludes: List, @@ -208,10 +209,11 @@ public class ThrowableCoercer { val mechanism = mutableMapOf( - "handled" to handled, "synthetic" to false, "type" to mechanismType, ) + handled?.let { mechanism["handled"] = it } + relationshipSource?.let { mechanism["source"] = it } if (exceptionId != null) { mechanism["exception_id"] = exceptionId } @@ -222,16 +224,11 @@ public class ThrowableCoercer { val exception = mutableMapOf( "type" to className, + "value" to throwable.message.orEmpty(), "mechanism" to mechanism, "thread_id" to threadId, ) - throwable.message?.let { - if (it.isNotEmpty()) { - exception["value"] = it - } - } - if (exceptionPackage?.isNotEmpty() == true) { exception["module"] = exceptionPackage } @@ -259,6 +256,7 @@ public class ThrowableCoercer { var handled = true var isFatal = false var mechanismType = "generic" + var exceptionSource: String? = null var currentThrowable: Throwable? = throwable val threadId: Long @@ -267,6 +265,7 @@ public class ThrowableCoercer { handled = throwable.handled isFatal = throwable.isFatal mechanismType = throwable.mechanism + exceptionSource = throwable.source currentThrowable = throwable.cause threadId = getThreadId(throwable.thread) } else { @@ -291,6 +290,7 @@ public class ThrowableCoercer { throwable = link, parentId = if (index == 0) null else index - 1, mechanismType = if (index == 0) mechanismType else "chained", + relationshipSource = if (index == 0) null else "cause", ), ) } @@ -308,25 +308,23 @@ public class ThrowableCoercer { ExceptionRef( throwable = suppressed, parentId = holderId, - mechanismType = "suppressed", + mechanismType = "chained", + relationshipSource = "suppressed", ), ) } } } - // A single-item list needs no chain ids at all (parity with the other SDKs, which only link - // a chain when there is more than one exception). - val linkChain = items.size > 1 - val cappedExceptions = items.mapIndexed { index, ref -> buildExceptionItem( throwable = ref.throwable, - exceptionId = if (linkChain) index else null, - parentId = if (linkChain) ref.parentId else null, - handled = handled, + exceptionId = index, + parentId = ref.parentId, + handled = if (index == 0) handled else null, mechanismType = ref.mechanismType, + relationshipSource = ref.relationshipSource, threadId = threadId, inAppIncludes = inAppIncludes, inAppExcludes = inAppExcludes, @@ -342,6 +340,7 @@ public class ThrowableCoercer { if (cappedExceptions.isNotEmpty()) { exceptionProperties["\$exception_list"] = cappedExceptions } + exceptionSource?.let { exceptionProperties["\$exception_source"] = it } return exceptionProperties } @@ -351,6 +350,7 @@ public class ThrowableCoercer { val throwable: Throwable, val parentId: Int?, val mechanismType: String, + val relationshipSource: String?, ) internal companion object { diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 332385ebf..c2c94268b 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -2824,9 +2824,10 @@ internal class PostHogTest { // Verify mechanism structure for cause exception: item 1, chained, parent is item 0 val causeMechanism = causeExceptionData["mechanism"] as Map<*, *> - assertEquals(true, causeMechanism["handled"]) + assertFalse(causeMechanism.containsKey("handled")) assertEquals(false, causeMechanism["synthetic"]) assertEquals("chained", causeMechanism["type"]) + assertEquals("cause", causeMechanism["source"]) assertEquals(1, (causeMechanism["exception_id"] as Number).toInt()) assertEquals(0, (causeMechanism["parent_id"] as Number).toInt()) @@ -2930,10 +2931,10 @@ internal class PostHogTest { val mechanism = mainException["mechanism"] as Map<*, *> assertEquals(false, mechanism["handled"]) assertEquals(false, mechanism["synthetic"]) - assertEquals("UncaughtExceptionHandler", mechanism["type"]) - // A single-item list carries no chain ids at all. - assertFalse(mechanism.containsKey("exception_id")) + assertEquals("onuncaughtexception", mechanism["type"]) + assertEquals(0, (mechanism["exception_id"] as Number).toInt()) assertFalse(mechanism.containsKey("parent_id")) + assertEquals("android.uncaught_exception_handler", properties["\$exception_source"]) // Verify stack trace structure for main exception val stackTraceMainException = mainException["stacktrace"] as Map<*, *> diff --git a/posthog/src/test/java/com/posthog/internal/errortracking/ThrowableCoercerTest.kt b/posthog/src/test/java/com/posthog/internal/errortracking/ThrowableCoercerTest.kt index 795e19e39..8455cb91c 100644 --- a/posthog/src/test/java/com/posthog/internal/errortracking/ThrowableCoercerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/errortracking/ThrowableCoercerTest.kt @@ -37,15 +37,14 @@ internal class ThrowableCoercerTest { (this["stacktrace"] as Map)["frames"] as List> @Test - fun `omits chain ids for a single exception`() { + fun `emits required linkage for a single exception`() { val t = throwableWith("boom", arrayOf(frame("com.app.Only", "only"))) val list = coercer.fromThrowableToPostHogProperties(t).exceptionList() assertEquals(1, list.size) - // nothing to link: no exception_id, no parent_id val mechanism = list[0].mechanism() - assertFalse(mechanism.containsKey("exception_id")) + assertEquals(0, (mechanism["exception_id"] as Number).toInt()) assertFalse(mechanism.containsKey("parent_id")) assertEquals("generic", mechanism["type"]) } @@ -69,12 +68,15 @@ internal class ThrowableCoercerTest { assertEquals("middle", list[1]["value"]) assertEquals(1, (list[1].mechanism()["exception_id"] as Number).toInt()) assertEquals("chained", list[1].mechanism()["type"]) + assertEquals("cause", list[1].mechanism()["source"]) assertEquals(0, (list[1].mechanism()["parent_id"] as Number).toInt()) + assertFalse(list[1].mechanism().containsKey("handled")) // item 2: cause of item 1 assertEquals("root", list[2]["value"]) assertEquals(2, (list[2].mechanism()["exception_id"] as Number).toInt()) assertEquals("chained", list[2].mechanism()["type"]) + assertEquals("cause", list[2].mechanism()["source"]) assertEquals(1, (list[2].mechanism()["parent_id"] as Number).toInt()) } @@ -89,7 +91,8 @@ internal class ThrowableCoercerTest { // suppressed item is appended after the chain, attributed to its holder (id 0) assertEquals("suppressed", list[1]["value"]) - assertEquals("suppressed", list[1].mechanism()["type"]) + assertEquals("chained", list[1].mechanism()["type"]) + assertEquals("suppressed", list[1].mechanism()["source"]) assertEquals(1, (list[1].mechanism()["exception_id"] as Number).toInt()) assertEquals(0, (list[1].mechanism()["parent_id"] as Number).toInt()) } @@ -104,8 +107,8 @@ internal class ThrowableCoercerTest { val list = coercer.fromThrowableToPostHogProperties(top).exceptionList() assertEquals(3, list.size) - assertEquals("suppressed", list[1].mechanism()["type"]) - assertEquals("suppressed", list[2].mechanism()["type"]) + assertEquals("suppressed", list[1].mechanism()["source"]) + assertEquals("suppressed", list[2].mechanism()["source"]) } @Test @@ -155,9 +158,9 @@ internal class ThrowableCoercerTest { // 2 chain items first, then suppressed items attributed to the holder (id 0) fill the rest assertEquals("generic", list[0].mechanism()["type"]) assertEquals("chained", list[1].mechanism()["type"]) - assertEquals("suppressed", list[2].mechanism()["type"]) + assertEquals("suppressed", list[2].mechanism()["source"]) assertEquals(0, (list[2].mechanism()["parent_id"] as Number).toInt()) - assertEquals("suppressed", list.last().mechanism()["type"]) + assertEquals("suppressed", list.last().mechanism()["source"]) assertEquals( ThrowableCoercer.MAX_EXCEPTION_LIST_SIZE - 1, (list.last().mechanism()["exception_id"] as Number).toInt(),