diff --git a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift index 22517ac9a..e18842fc3 100644 --- a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift +++ b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift @@ -27,6 +27,7 @@ import Foundation // Fatal crash properties["$exception_level"] = "fatal" + properties["$exception_source"] = "ios.crash_reporter" // Build stack frames once, reuse for both exception info and debug images let stackFrames = buildStackFrames(from: report, config: config) @@ -141,6 +142,10 @@ import Foundation exception["thread_id"] = crashedThread.threadNumber } + var mechanism = exception["mechanism"] as? [String: Any] ?? [:] + mechanism["exception_id"] = 0 + exception["mechanism"] = mechanism + // cleanup nil values exception = exception.compactMapValues { $0 } diff --git a/PostHog/ErrorTracking/PostHogExceptionProcessor.swift b/PostHog/ErrorTracking/PostHogExceptionProcessor.swift index 677cc574e..7a8bb8f8b 100644 --- a/PostHog/ErrorTracking/PostHogExceptionProcessor.swift +++ b/PostHog/ErrorTracking/PostHogExceptionProcessor.swift @@ -100,8 +100,9 @@ enum PostHogExceptionProcessor { // MARK: - Internal Exception Building private static func buildProperties(exceptions: [[String: Any]]) -> [String: Any] { + let exceptions = canonicalizeExceptions(exceptions) var properties: [String: Any] = [ - "$exception_level": "error", // TODO: figure this out from error wrapped type + "$exception_level": "error", ] attachExceptionsAndDebugImages(exceptions, to: &properties) return properties @@ -133,7 +134,9 @@ enum PostHogExceptionProcessor { var current = exception var seen = Set([ObjectIdentifier(exception)]) - while let underlying = current.userInfo?[NSUnderlyingErrorKey] as? NSException { + while nsExceptions.count < 50, + let underlying = current.userInfo?[NSUnderlyingErrorKey] as? NSException + { let id = ObjectIdentifier(underlying) guard seen.insert(id).inserted else { break } // avoid circular references nsExceptions.append(underlying) @@ -183,7 +186,9 @@ enum PostHogExceptionProcessor { var current = nsError var seen = Set([ObjectIdentifier(nsError)]) - while let underlying = current.userInfo[NSUnderlyingErrorKey] as? NSError { + while errors.count < 50, + let underlying = current.userInfo[NSUnderlyingErrorKey] as? NSError + { let id = ObjectIdentifier(underlying) guard seen.insert(id).inserted else { break } // avoid circular references errors.append(underlying) @@ -257,9 +262,7 @@ enum PostHogExceptionProcessor { exceptionDict["type"] = typeName } - if let reason = exception.reason, !reason.isEmpty { - exceptionDict["value"] = reason - } + exceptionDict["value"] = exception.reason ?? "" // Use exception's real stack if available, otherwise capture current (synthetic) let exceptionAddresses = exception.callStackReturnAddresses @@ -365,6 +368,28 @@ enum PostHogExceptionProcessor { } } + /// Final event assembly owns deterministic flattened-tree linkage. The first entry preserves + /// capture-boundary metadata; underlying entries are linked as `inner` relationships and do + /// not inherit the outer handled state. + private static func canonicalizeExceptions(_ exceptions: [[String: Any]]) -> [[String: Any]] { + Array(exceptions.prefix(50)).enumerated().map { index, original in + var exception = original + var mechanism = exception["mechanism"] as? [String: Any] ?? [:] + mechanism["exception_id"] = index + if index == 0 { + mechanism.removeValue(forKey: "parent_id") + mechanism.removeValue(forKey: "source") + } else { + mechanism["type"] = "chained" + mechanism["source"] = "inner" + mechanism["parent_id"] = index - 1 + mechanism.removeValue(forKey: "handled") + } + exception["mechanism"] = mechanism + return exception + } + } + // MARK: - Stack Trace Capture /// Build stacktrace dictionary from current thread (synthetic) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 2b3b7681d..1f7155ae0 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2930,7 +2930,17 @@ let maxRetryDelay = 30.0 additionalProperties: [String: Any]? ) { var mergedProperties = exceptionProperties - additionalProperties?.forEach { mergedProperties[$0.key] = $0.value } + let reservedExceptionProperties: Set = [ + "$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", + ] + additionalProperties?.forEach { + if !reservedExceptionProperties.contains($0.key) { + mergedProperties[$0.key] = $0.value + } + } // ignoredExceptionTypes is enforced in captureInternal, the chokepoint for every $exception path capture("$exception", properties: mergedProperties) diff --git a/PostHogTests/PostHogExceptionProcessorTest.swift b/PostHogTests/PostHogExceptionProcessorTest.swift index 0ccd24c6a..a3760e674 100644 --- a/PostHogTests/PostHogExceptionProcessorTest.swift +++ b/PostHogTests/PostHogExceptionProcessorTest.swift @@ -47,6 +47,8 @@ struct PostHogExceptionProcessorTest { #expect(mechHandled == true) let mechSynthetic = mechanism?["synthetic"] as? Bool #expect(mechSynthetic == true) + #expect(mechanism?["exception_id"] as? Int == 0) + #expect(mechanism?["parent_id"] == nil) let stacktrace = exception?["stacktrace"] as? [String: Any] #expect(stacktrace != nil) @@ -110,6 +112,14 @@ struct PostHogExceptionProcessorTest { #expect(type0 == "WrapperDomain") let type1 = exceptionList?[1]["type"] as? String #expect(type1 == "RootDomain") + let rootMechanism = exceptionList?[0]["mechanism"] as? [String: Any] + let innerMechanism = exceptionList?[1]["mechanism"] as? [String: Any] + #expect(rootMechanism?["exception_id"] as? Int == 0) + #expect(innerMechanism?["exception_id"] as? Int == 1) + #expect(innerMechanism?["parent_id"] as? Int == 0) + #expect(innerMechanism?["type"] as? String == "chained") + #expect(innerMechanism?["source"] as? String == "inner") + #expect(innerMechanism?["handled"] == nil) } @Test("handles circular error references") @@ -211,7 +221,7 @@ struct PostHogExceptionProcessorTest { let exc = exceptionList?.first let excType = exc?["type"] as? String #expect(excType == "NoReasonException") - #expect(exc?["value"] == nil) + #expect(exc?["value"] as? String == "") } @Test("marks exception as unhandled") diff --git a/PostHogTests/Resources/event-shapes-batch.json b/PostHogTests/Resources/event-shapes-batch.json index 40546bdc3..7aae3be7f 100644 --- a/PostHogTests/Resources/event-shapes-batch.json +++ b/PostHogTests/Resources/event-shapes-batch.json @@ -208,6 +208,7 @@ "$exception_list" : [ { "mechanism" : { + "exception_id" : 0, "handled" : true, "synthetic" : true, "type" : "generic"