Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/canonical-exception-metadata.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -84,6 +85,7 @@ internal class NativeCrashEventCoercer(
val properties = mutableMapOf<String, Any>()
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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions posthog-server/src/main/java/com/posthog/server/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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")
Expand Down
33 changes: 25 additions & 8 deletions posthog/src/main/java/com/posthog/PostHogStateless.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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_* /
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
inAppExcludes: List<String>,
Expand All @@ -208,10 +209,11 @@ public class ThrowableCoercer {

val mechanism =
mutableMapOf<String, Any>(
"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
}
Expand All @@ -222,16 +224,11 @@ public class ThrowableCoercer {
val exception =
mutableMapOf<String, Any>(
"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
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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",
),
)
}
Expand All @@ -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,
Expand All @@ -342,6 +340,7 @@ public class ThrowableCoercer {
if (cappedExceptions.isNotEmpty()) {
exceptionProperties["\$exception_list"] = cappedExceptions
}
exceptionSource?.let { exceptionProperties["\$exception_source"] = it }

return exceptionProperties
}
Expand All @@ -351,6 +350,7 @@ public class ThrowableCoercer {
val throwable: Throwable,
val parentId: Int?,
val mechanismType: String,
val relationshipSource: String?,
)

internal companion object {
Expand Down
9 changes: 5 additions & 4 deletions posthog/src/test/java/com/posthog/PostHogTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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<*, *>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,14 @@ internal class ThrowableCoercerTest {
(this["stacktrace"] as Map<String, Any>)["frames"] as List<Map<String, Any>>

@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"])
}
Expand All @@ -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())
}

Expand All @@ -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())
}
Expand All @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
Loading