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
5 changes: 5 additions & 0 deletions PostHog/ErrorTracking/PostHogCrashReportProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 }

Expand Down
37 changes: 31 additions & 6 deletions PostHog/ErrorTracking/PostHogExceptionProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -133,7 +134,9 @@ enum PostHogExceptionProcessor {

var current = exception
var seen = Set<ObjectIdentifier>([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)
Expand Down Expand Up @@ -183,7 +186,9 @@ enum PostHogExceptionProcessor {

var current = nsError
var seen = Set<ObjectIdentifier>([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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2930,7 +2930,17 @@ let maxRetryDelay = 30.0
additionalProperties: [String: Any]?
) {
var mergedProperties = exceptionProperties
additionalProperties?.forEach { mergedProperties[$0.key] = $0.value }
let reservedExceptionProperties: Set<String> = [
"$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)
Expand Down
12 changes: 11 additions & 1 deletion PostHogTests/PostHogExceptionProcessorTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions PostHogTests/Resources/event-shapes-batch.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@
"$exception_list" : [
{
"mechanism" : {
"exception_id" : 0,
"handled" : true,
"synthetic" : true,
"type" : "generic"
Expand Down
Loading