From 28a3d2b4efe0392bee6d35f72e7563b81974fc38 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 13 Nov 2025 20:56:37 +0200 Subject: [PATCH 01/45] feat: add config --- PostHog.xcodeproj/project.pbxproj | 12 +++ .../PostHogErrorTrackingConfig.swift | 85 +++++++++++++++++++ PostHog/PostHogConfig.swift | 3 + 3 files changed, 100 insertions(+) create mode 100644 PostHog/Error Tracking/PostHogErrorTrackingConfig.swift diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index ace2f04e1..9ea65e2d4 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -210,6 +210,7 @@ DA71854D2D07E11200396388 /* input_1.png in Resources */ = {isa = PBXBuildFile; fileRef = DA7185422D07E11200396388 /* input_1.png */; }; DA74644F2DC8D58100C7D394 /* PostHogConsoleLogInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA74644E2DC8D58100C7D394 /* PostHogConsoleLogInterceptor.swift */; }; DA86D0E32E02D78C00CF3065 /* PostHogSurveysDefaultDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA86D0E22E02D78C00CF3065 /* PostHogSurveysDefaultDelegate.swift */; }; + DA8C9B982EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */; }; DA929C082D0B3A1D0018369C /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA929C092D0B3A1D0018369C /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DA92C6412DCE09C700C02F3E /* PostHogLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6402DCE09C700C02F3E /* PostHogLogEntry.swift */; }; @@ -738,6 +739,7 @@ DA7185472D07E11200396388 /* output_3.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = output_3.webp; sourceTree = ""; }; DA74644E2DC8D58100C7D394 /* PostHogConsoleLogInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogConsoleLogInterceptor.swift; sourceTree = ""; }; DA86D0E22E02D78C00CF3065 /* PostHogSurveysDefaultDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveysDefaultDelegate.swift; sourceTree = ""; }; + DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingConfig.swift; sourceTree = ""; }; DA8D37242CBEAC02005EBD27 /* PostHogExampleAutocapture.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleAutocapture.xcodeproj; path = PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj; sourceTree = ""; }; DA92C6402DCE09C700C02F3E /* PostHogLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogLogEntry.swift; sourceTree = ""; }; DA92C6482DCE09DE00C02F3E /* PostHogLogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogLogLevel.swift; sourceTree = ""; }; @@ -1073,6 +1075,7 @@ DA26419B2CC0499300CB427B /* Autocapture */, 69EE82B82BA9C4DA00EB9542 /* Replay */, DA30AE872D40173D00465A64 /* Surveys */, + DA8C9B972EC633FA00C6EADB /* Error Tracking */, 69BA38E62B893F2200AA69D6 /* Resources */, 69779BED2AE6B29E00D7A48E /* Models */, 3AA4C09B2988315D006C4731 /* Utils */, @@ -1377,6 +1380,14 @@ path = "App Life Cycle"; sourceTree = ""; }; + DA8C9B972EC633FA00C6EADB /* Error Tracking */ = { + isa = PBXGroup; + children = ( + DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */, + ); + path = "Error Tracking"; + sourceTree = ""; + }; DA8D37252CBEAC02005EBD27 /* Products */ = { isa = PBXGroup; children = ( @@ -2106,6 +2117,7 @@ 69261D252AD9787A00232EC7 /* PostHogExtensions.swift in Sources */, DA1D295E2D10B7B2003A31DA /* ApplicationLifecyclePublisher.swift in Sources */, 3AE3FB4E2993D1D600AFFC18 /* PostHogStorageManager.swift in Sources */, + DA8C9B982EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift in Sources */, 3AE3FB49299391DF00AFFC18 /* PostHogStorage.swift in Sources */, DA9AE8D12D835BEA002F1B44 /* QuestionHeader.swift in Sources */, 69261D232AD9784200232EC7 /* PostHogVersion.swift in Sources */, diff --git a/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift b/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift new file mode 100644 index 000000000..c1e26f547 --- /dev/null +++ b/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift @@ -0,0 +1,85 @@ +// +// PostHogErrorTrackingConfig.swift +// PostHog +// +// Created by Ioannis Josephides on 11/11/2025. +// + +import Foundation + +/// Configuration for error tracking and exception capture +/// +/// This class controls how exceptions are captured and processed, +/// including which stack trace frames are marked as "in-app" code. +@objc public class PostHogErrorTrackingConfig: NSObject { + // MARK: - In-App Detection Configuration + + /// List of package/bundle identifiers to be considered in-app frames + /// + /// Takes precedence over `inAppExcludes`. + /// If a frame's module matches any prefix in this list, + /// it will be marked as in-app. + /// + /// Example: + /// ```swift + /// config.errorTrackingConfig.inAppIncludes = [ + /// "com.mycompany.MyApp", + /// "com.mycompany.SharedUtils" + /// ] + /// ``` + /// + /// **Default behavior:** + /// - Automatically includes main bundle identifier + /// - Automatically includes executable name + /// + /// **Precedence:** Priority 1 (highest) + @objc public var inAppIncludes: [String] = [] + + /// List of package/bundle identifiers to be excluded from in-app frames + /// + /// Frames matching these prefixes will be marked as not in-app, + /// unless they also match `inAppIncludes` (which takes precedence). + /// + /// Example: + /// ```swift + /// config.errorTrackingConfig.inAppExcludes = [ + /// "ThirdPartySDK", + /// "AnalyticsLib" + /// ] + /// ``` + /// + /// **Precedence:** Priority 2 (after inAppIncludes) + @objc public var inAppExcludes: [String] = [] + + /// Configures whether stack trace frames are considered in-app by default + /// when the origin cannot be determined or no explicit includes/excludes match. + /// + /// - If `true` (default): Frames are in-app unless explicitly excluded (allowlist approach) + /// - If `false`: Frames are external unless explicitly included (denylist approach) + /// + /// **Default behavior when true:** + /// - Known system frameworks (Foundation, UIKit, etc.) are excluded + /// - All other packages are in-app unless in `inAppExcludes` + /// + /// **Precedence:** Priority 4 (final fallback) + /// + /// Default: true + @objc public var inAppByDefault: Bool = true + + // MARK: - Initialization + + override public init() { + super.init() + + // Auto-add main bundle identifier + if let bundleId = Bundle.main.bundleIdentifier { + inAppIncludes.append(bundleId) + } + + // Auto-add executable name + // This helps catch app code when bundle ID might not be in module name + if let executableName = Bundle.main.object(forInfoDictionaryKey: "CFBundleExecutable") as? String { + inAppIncludes.append(executableName) + } + } +} diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 2542efdbd..a1d6e73f0 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -146,6 +146,9 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? @objc public let sessionReplayConfig: PostHogSessionReplayConfig = .init() #endif + /// Configuration for error tracking + @objc public let errorTrackingConfig: PostHogErrorTrackingConfig = .init() + /// Enable mobile surveys /// /// Default: true From f74ed8b6a8bfa5196bc36ddb88aaab1c3cd88e40 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 27 Nov 2025 21:34:24 +0200 Subject: [PATCH 02/45] feat: add base error capture --- PostHog.xcodeproj/project.pbxproj | 35 ++ .../PostHogExceptionProcessor.swift | 313 ++++++++++++ .../Utils/PostHogStackTrace.swift | 481 ++++++++++++++++++ PostHog/PostHogSDK.swift | 74 +++ PostHogExample/AppDelegate.swift | 6 +- PostHogExample/ContentView.swift | 93 ++++ PostHogExample/ExceptionHandler.h | 44 ++ PostHogExample/ExceptionHandler.m | 120 +++++ .../PostHogExample-Bridging-Header.h | 8 + 9 files changed, 1172 insertions(+), 2 deletions(-) create mode 100644 PostHog/Error Tracking/PostHogExceptionProcessor.swift create mode 100644 PostHog/Error Tracking/Utils/PostHogStackTrace.swift create mode 100644 PostHogExample/ExceptionHandler.h create mode 100644 PostHogExample/ExceptionHandler.m create mode 100644 PostHogExample/PostHogExample-Bridging-Header.h diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 9ea65e2d4..918a046d4 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -172,6 +172,9 @@ DA30AE692D3EFB4F00465A64 /* Optional+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA30AE682D3EFB4F00465A64 /* Optional+Util.swift */; }; DA30AE812D3FE63F00465A64 /* PostHogSurvey.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA30AE802D3FE63F00465A64 /* PostHogSurvey.swift */; }; DA37933C2DBA571D005C6AA3 /* PostHogSurveysConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3793352DBA5718005C6AA3 /* PostHogSurveysConfig.swift */; }; + DA3BB4AA2ED82E410097A97A /* PostHogStackTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4A62ED82E410097A97A /* PostHogStackTrace.swift */; }; + DA3BB4AC2ED82EE80097A97A /* PostHogExceptionProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */; }; + DA3BB4AF2ED88DE90097A97A /* ExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */; }; DA4AF61F2D1195D20053EA38 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA4AF6202D1195D20053EA38 /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DA4AF6242D119FC60053EA38 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; @@ -709,6 +712,12 @@ DA30AE682D3EFB4F00465A64 /* Optional+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Util.swift"; sourceTree = ""; }; DA30AE802D3FE63F00465A64 /* PostHogSurvey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurvey.swift; sourceTree = ""; }; DA3793352DBA5718005C6AA3 /* PostHogSurveysConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveysConfig.swift; sourceTree = ""; }; + DA3BB4A52ED82E410097A97A /* PostHogBinaryImages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogBinaryImages.swift; sourceTree = ""; }; + DA3BB4A62ED82E410097A97A /* PostHogStackTrace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStackTrace.swift; sourceTree = ""; }; + DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogExceptionProcessor.swift; sourceTree = ""; }; + DA3BB4AD2ED88DE90097A97A /* ExceptionHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExceptionHandler.h; sourceTree = ""; }; + DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExceptionHandler.m; sourceTree = ""; }; + DA3BB4B02ED88DEC0097A97A /* PostHogExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "PostHogExample-Bridging-Header.h"; sourceTree = ""; }; DA4FFB142DA93C78006BAEEA /* PostHogSessionReplayTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayTest.swift; sourceTree = ""; }; DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogIdentityTests.swift; sourceTree = ""; }; DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_remote_config.json; sourceTree = ""; }; @@ -993,6 +1002,9 @@ 3AA34CFB296D951A003398F4 /* ContentView.swift */, 3AA34CFD296D951B003398F4 /* Assets.xcassets */, 3AA34CFF296D951B003398F4 /* Preview Content */, + DA3BB4AD2ED88DE90097A97A /* ExceptionHandler.h */, + DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */, + DA3BB4B02ED88DEC0097A97A /* PostHogExample-Bridging-Header.h */, ); path = PostHogExample; sourceTree = ""; @@ -1349,6 +1361,15 @@ path = Surveys; sourceTree = ""; }; + DA3BB49E2ED82E250097A97A /* Utils */ = { + isa = PBXGroup; + children = ( + DA3BB4A52ED82E410097A97A /* PostHogBinaryImages.swift */, + DA3BB4A62ED82E410097A97A /* PostHogStackTrace.swift */, + ); + path = Utils; + sourceTree = ""; + }; DA578E912D6768A100B3A56C /* Screen Views */ = { isa = PBXGroup; children = ( @@ -1383,7 +1404,9 @@ DA8C9B972EC633FA00C6EADB /* Error Tracking */ = { isa = PBXGroup; children = ( + DA3BB49E2ED82E250097A97A /* Utils */, DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */, + DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */, ); path = "Error Tracking"; sourceTree = ""; @@ -1863,6 +1886,7 @@ TargetAttributes = { 3AA34CF6296D951A003398F4 = { CreatedOnToolsVersion = 14.2; + LastSwiftMigration = 2610; }; 3AC745B4296D6FE60025C109 = { CreatedOnToolsVersion = 14.2; @@ -2087,6 +2111,7 @@ files = ( 3AA34D17296D9993003398F4 /* AppDelegate.swift in Sources */, 3AA34CFC296D951A003398F4 /* ContentView.swift in Sources */, + DA3BB4AF2ED88DE90097A97A /* ExceptionHandler.m in Sources */, 3AE3FB2C2991320300AFFC18 /* Api.swift in Sources */, 3A0F108329C47940002C0084 /* UIViewExample.swift in Sources */, 3AA34CFA296D951A003398F4 /* PostHogExampleApp.swift in Sources */, @@ -2121,6 +2146,7 @@ 3AE3FB49299391DF00AFFC18 /* PostHogStorage.swift in Sources */, DA9AE8D12D835BEA002F1B44 /* QuestionHeader.swift in Sources */, 69261D232AD9784200232EC7 /* PostHogVersion.swift in Sources */, + DA3BB4AA2ED82E410097A97A /* PostHogStackTrace.swift in Sources */, 69779BEC2AE68E6900D7A48E /* UIViewController.swift in Sources */, 3A0F108929C9BD76002C0084 /* Errors.swift in Sources */, 3AE3FB37299162EA00AFFC18 /* PostHogApi.swift in Sources */, @@ -2240,6 +2266,7 @@ DA9CE3792D3108AD00DFE652 /* huffman_utils.c in Sources */, DA9CE37A2D3108AD00DFE652 /* alpha_processing.c in Sources */, DA9CE37B2D3108AD00DFE652 /* picture_rescale_enc.c in Sources */, + DA3BB4AC2ED82EE80097A97A /* PostHogExceptionProcessor.swift in Sources */, DA9CE37C2D3108AD00DFE652 /* cost.c in Sources */, DA9CE37D2D3108AD00DFE652 /* thread_utils.c in Sources */, DA9CE37E2D3108AD00DFE652 /* filters_neon.c in Sources */, @@ -2465,6 +2492,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = PostHogExample/PostHogExample.entitlements; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; @@ -2491,6 +2519,8 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "PostHogExample/PostHogExample-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; TVOS_DEPLOYMENT_TARGET = 13.0; @@ -2503,6 +2533,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = PostHogExample/PostHogExample.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -2529,6 +2560,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "PostHogExample/PostHogExample-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; TVOS_DEPLOYMENT_TARGET = 13.0; @@ -3260,6 +3292,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = PostHogExample/PostHogExample.entitlements; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; @@ -3286,6 +3319,8 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "PostHogExample/PostHogExample-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; TVOS_DEPLOYMENT_TARGET = 13.0; diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift new file mode 100644 index 000000000..067d2dccf --- /dev/null +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -0,0 +1,313 @@ +// +// PostHogExceptionProcessor.swift +// PostHog +// +// Created by Ioannis Josephides on 14/11/2025. +// + +import Darwin +import Foundation + +/// Processes errors and exceptions into PostHog's $exception event format +/// +/// This class converts Swift Error and NSException instances into PostHog exception event properties. +/// +/// **Note on Binary Images:** +/// Currently, binary images (debug metadata) are NOT included in exception events. +/// This is intentional until server-side symbolication is implemented. Binary images +/// would be needed for server-side symbolication of raw instruction addresses, but +/// without that capability, we won't know exactly what's needed or if there's something that can be reused from PLCrashReporter lib. +/// When server-side symbolication is added, we should include binary images +/// +enum PostHogExceptionProcessor { + // MARK: - Public API + + /// Convert Error/NSError to properties + /// + /// - Parameters: + /// - error: The error to convert + /// - handled: Whether the error was caught/handled + /// - mechanismType: The mechanism that captured the error (e.g., "generic", "NSException") + /// - config: Error tracking configuration for in-app detection + /// - Returns: Dictionary of properties in PostHog's $exception event format + static func errorToProperties( + _ error: Error, + handled: Bool, + mechanismType: String, + config: PostHogErrorTrackingConfig + ) -> [String: Any] { + var properties: [String: Any] = [:] + + properties["$exception_level"] = "error" // TODO: figure this out from error wrapped type + + let exceptions = buildExceptionList( + from: error, + handled: handled, + mechanismType: mechanismType, + config: config + ) + + if !exceptions.isEmpty { + properties["$exception_list"] = exceptions + } + + return properties + } + + /// Convert NSException to properties + /// + /// Note: Uses the exception's own stack trace (`callStackReturnAddresses`) if available, + /// otherwise falls back to capturing the current thread's stack (synthetic). + /// + /// - Parameters: + /// - exception: The NSException to convert + /// - handled: Whether the exception was caught/handled + /// - mechanismType: The mechanism type for categorizing the exception + /// - config: Error tracking configuration for in-app detection + /// - Returns: Dictionary of properties in PostHog's $exception event format + static func exceptionToProperties( + _ exception: NSException, + handled: Bool, + mechanismType: String, + config: PostHogErrorTrackingConfig + ) -> [String: Any] { + var properties: [String: Any] = [:] + properties["$exception_level"] = "error" // TODO: figure this out from error wrapped type + + let exceptions = buildExceptionList( + from: exception, + handled: handled, + mechanismType: mechanismType, + config: config + ) + + if !exceptions.isEmpty { + properties["$exception_list"] = exceptions + } + + return properties + } + + // MARK: - Internal Exception Building + + /// Build list of exceptions from NSException chain + /// + /// Walks the NSException chain via NSUnderlyingErrorKey to capture all related exceptions. + /// The list is ordered from root exception to underlying exceptions (same as Android). + private static func buildExceptionList( + from exception: NSException, + handled: Bool, + mechanismType: String, + config: PostHogErrorTrackingConfig + ) -> [[String: Any]] { + var exceptions: [[String: Any]] = [] + var nsExceptions: [NSException] = [] + + // Walk exception chain via NSUnderlyingErrorKey + nsExceptions.append(exception) + + var current = exception + while let underlying = current.userInfo?[NSUnderlyingErrorKey] as? NSException { + nsExceptions.append(underlying) + current = underlying + } + + // Build exceptions (same order as Android) + for exc in nsExceptions { + if let exceptionDict = buildException( + from: exc, + handled: handled, + mechanismType: mechanismType, + config: config + ) { + exceptions.append(exceptionDict) + } + } + + return exceptions + } + + /// Build list of exceptions from error chain + /// + /// Walks the error chain via NSUnderlyingErrorKey to capture all related errors. + /// The list is ordered from root error to underlying errors (same as Android). + private static func buildExceptionList( + from error: Error, + handled: Bool, + mechanismType: String, + config: PostHogErrorTrackingConfig + ) -> [[String: Any]] { + var exceptions: [[String: Any]] = [] + var errors: [NSError] = [] + + // Walk error chain via NSUnderlyingErrorKey + let nsError = error as NSError + errors.append(nsError) + + var current = nsError + while let underlying = current.userInfo[NSUnderlyingErrorKey] as? NSError { + errors.append(underlying) + current = underlying + } + + // Build exceptions (same order as Android) + for err in errors { + if let exception = buildException( + from: err, + handled: handled, + mechanismType: mechanismType, + config: config + ) { + exceptions.append(exception) + } + } + + return exceptions + } + + /// Build a single exception dictionary from an NSError + private static func buildException( + from error: NSError, + handled: Bool, + mechanismType: String, + config: PostHogErrorTrackingConfig + ) -> [String: Any]? { + var exception: [String: Any] = [:] + + exception["type"] = error.domain + + if let message = extractErrorMessage(from: error) { + exception["value"] = message + } + + if let module = extractModule(from: error) { + exception["module"] = module + } + + exception["thread_id"] = Thread.current.threadId + + exception["mechanism"] = [ + "type": mechanismType, + "handled": handled, + "synthetic": false, + ] + + if let stacktrace = buildStacktrace(config: config) { + exception["stacktrace"] = stacktrace + } + + return exception + } + + /// Build a single exception dictionary from an NSException + private static func buildException( + from exception: NSException, + handled: Bool, + mechanismType: String, + config: PostHogErrorTrackingConfig + ) -> [String: Any]? { + var exceptionDict: [String: Any] = [:] + + exceptionDict["type"] = exception.name.rawValue + exceptionDict["value"] = exception.reason ?? "Unknown exception" + exceptionDict["thread_id"] = Thread.current.threadId + + // Use exception's real stack if available, otherwise capture current (synthetic) + let exceptionAddresses = exception.callStackReturnAddresses + let isSynthetic: Bool + let stacktrace: [String: Any]? + + if !exceptionAddresses.isEmpty { + // Use exception's actual stack trace (captured when exception was raised) + stacktrace = buildStacktraceFromAddresses(exceptionAddresses, config: config) + isSynthetic = false + } else { + // Fall back to current stack (synthetic - captured at reporting site) + stacktrace = buildStacktrace(config: config) + isSynthetic = true + } + + exceptionDict["mechanism"] = [ + "type": mechanismType, + "handled": handled, + "synthetic": isSynthetic, + ] + + if let stacktrace = stacktrace { + exceptionDict["stacktrace"] = stacktrace + } + + return exceptionDict + } + + // MARK: - Error Message Extraction + + /// Extract user-friendly error message + /// + /// Priority: + /// 1. NSDebugDescriptionErrorKeyf + /// 2. NSLocalizedDescriptionKey + /// 3. Code only + private static func extractErrorMessage(from error: NSError) -> String? { + if let debugDesc = error.userInfo[NSDebugDescriptionErrorKey] as? String { + return "\(debugDesc) (Code: \(error.code))" + } + + if let localizedDesc = error.userInfo[NSLocalizedDescriptionKey] as? String { + return "\(localizedDesc) (Code: \(error.code))" + } + + return "Code: \(error.code)" + } + + /// Extract module name from error domain + private static func extractModule(from error: NSError) -> String? { + let domain = error.domain + return domain.contains(".") ? domain : nil + } + + // MARK: - Stack Trace Capture + + /// Build stacktrace dictionary from current thread (synthetic) + static func buildStacktrace(config: PostHogErrorTrackingConfig) -> [String: Any]? { + let frames = PostHogStackTrace.captureCurrentStackTraceWithMetadata(config: config, skipFrames: 3) + + guard !frames.isEmpty else { return nil } + + return [ + "frames": frames, + "type": "raw", + ] + } + + /// Build stacktrace dictionary from raw addresses (e.g., NSException.callStackReturnAddresses) + /// + /// This produces a non-synthetic stack trace since the addresses come from the actual + /// exception rather than being captured at the reporting site. + /// + /// - Parameters: + /// - addresses: Array of return addresses (from NSException.callStackReturnAddresses) + /// - config: Error tracking configuration for in-app detection + /// - Returns: Stacktrace dictionary or nil if no frames + static func buildStacktraceFromAddresses( + _ addresses: [NSNumber], + config: PostHogErrorTrackingConfig + ) -> [String: Any]? { + let frames = PostHogStackTrace.symbolicateAddresses(addresses, config: config, skipFrames: 0) + + guard !frames.isEmpty else { return nil } + + return [ + "frames": frames, + "type": "raw", + ] + } + +} + +private extension Thread { + /// Get the current thread's Mach thread ID + var threadId: Int { + Int(pthread_mach_thread_np(pthread_self())) + } +} diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift new file mode 100644 index 000000000..4e6cbda30 --- /dev/null +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -0,0 +1,481 @@ +// +// PostHogStackTrace.swift +// PostHog +// +// Created by Ioannis Josephides on 13/11/2025. +// + +import Darwin +import Foundation +import MachO + +/// Represents a single stack trace frame +/// +struct PostHogStackFrame { + /// Instruction address (e.g., "0x0000000104e5c123") + /// This is the actual program counter address where the frame is executing + let instructionAddr: String? + + /// Symbol address - start address of the function (for calculating offset) + /// Used to compute: instructionOffset = instructionAddr - symbolAddr + let symbolAddr: String? + + /// Image (binary) base address where the module is loaded in memory + let imageAddr: String? + + /// UUID of the binary image (for server-side symbolication with dSYM files) + let imageUUID: String? + + /// Module/binary name (e.g., "MyApp", "Foundation", "UIKit") + let module: String? + + /// Function/method name (e.g., "myMethod()", "-[NSException raise]") + let function: String? + + /// Source file name (e.g., "MyClass.swift") + let filename: String? + + /// Line number in source file + let lineno: Int? + + /// Column number in source file (optional) + let colno: Int? + + /// Platform identifier ("swift", "objc") + let platform: String + + /// Whether this frame is in-app code (set by in-app detection) + var inApp: Bool + + /// Convert to dictionary format for JSON serialization + func toDictionary() -> [String: Any] { + var dict: [String: Any] = [ + "platform": platform, + "in_app": inApp, + ] + + if let instructionAddr = instructionAddr { + dict["instruction_addr"] = instructionAddr + } + + if let symbolAddr = symbolAddr { + dict["symbol_addr"] = symbolAddr + } + + if let imageAddr = imageAddr { + dict["image_addr"] = imageAddr + } + + if let imageUUID = imageUUID { + dict["image_uuid"] = imageUUID + } + + if let module = module { + dict["module"] = module + } + + if let function = function { + dict["function"] = function + } + + if let filename = filename { + dict["filename"] = filename + } + + if let lineno = lineno { + dict["lineno"] = lineno + } + + if let colno = colno { + dict["colno"] = colno + } + + return dict + } +} + + +/// Utility for extracting and parsing stack traces +/// +/// This class provides methods to extract stack traces from various sources +/// (NSException, Swift Error, raw strings) and format them consistently +/// for error tracking. +/// +class PostHogStackTrace { + // MARK: - Current Thread Stack Trace (Primary Method) + + /// Capture stack trace from current thread using raw addresses + /// + /// This is the primary method for capturing stack traces for Swift Errors. + /// Uses Thread.callStackReturnAddresses to get raw instruction addresses, + /// then symbolicates them using dladdr(). + /// + /// Reference: Sentry iOS uses this approach for manual error capture + /// + /// - Parameter skipFrames: Number of frames to skip from the top (default 2 to skip capture methods) + /// - Returns: Array of stack frames with symbolication information + static func captureCurrentThreadStackTrace(skipFrames: Int = 2) -> [PostHogStackFrame] { + // Get raw return addresses from the call stack + let addresses = Thread.callStackReturnAddresses + + guard addresses.count > skipFrames else { + return [] + } + + // Skip the top frames (this method and its callers) + let relevantAddresses = addresses.dropFirst(skipFrames) + + // Symbolicate each address using dladdr() + return relevantAddresses.map { addressNumber in + symbolicateAddress(addressNumber) + } + } + + // MARK: - Address Symbolication using dladdr() + + /// Symbolicate a single address using dladdr() + /// + /// This performs on-device symbolication to extract symbol information + /// from a raw instruction address. The symbolication may be limited for + /// stripped binaries, but raw addresses are preserved for server-side + /// symbolication. + /// + static func symbolicateAddress(_ addressNumber: NSNumber) -> PostHogStackFrame { + let address = addressNumber.uintValue + let pointer = UnsafeRawPointer(bitPattern: address) + + var info = Dl_info() + var instructionAddr: String? + var symbolAddr: String? + var imageAddr: String? + var module: String? + var function: String? + + // Store the raw instruction address (always available) + instructionAddr = String(format: "0x%016lx", address) + + // Use dladdr() to get symbol information + if let ptr = pointer, dladdr(ptr, &info) != 0 { + // Extract image (binary) base address + if let dlifbase = info.dli_fbase { + imageAddr = String(format: "0x%016lx", UInt(bitPattern: dlifbase)) + } + + // Extract module/binary name + if let dlifname = info.dli_fname { + let path = String(cString: dlifname) + module = (path as NSString).lastPathComponent + } + + // Extract symbol address and name + if let dlisaddr = info.dli_saddr { + symbolAddr = String(format: "0x%016lx", UInt(bitPattern: dlisaddr)) + } + + if let dlisname = info.dli_sname { + let symbolName = String(cString: dlisname) + function = demangle(symbolName) + + // Detect platform based on symbol naming +// if symbolName.hasPrefix("_$s") || symbolName.hasPrefix("$s") || symbolName.contains("Swift") { +// platform = "swift" +// } else if symbolName.hasPrefix("-[") || symbolName.hasPrefix("+[") { +// platform = "objc" +// } + } + } + + return PostHogStackFrame( + instructionAddr: instructionAddr, + symbolAddr: symbolAddr, + imageAddr: imageAddr, + imageUUID: nil, // Will be populated by matching with binary images + module: module, + function: function, + filename: nil, // Not available from dladdr() + lineno: nil, // Not available from dladdr() + colno: nil, + platform: "ios", + inApp: false // Will be set by in-app detection later + ) + } + + /// Attempt to demangle a symbol name + /// + /// Swift symbols are usually mangled (e.g., "_$s4MyApp0A5ClassC6methodyyF"). + /// This attempts basic demangling for readability. + private static func demangle(_ symbolName: String) -> String { + // For now, return the mangled name as-is + // TODO: Could use _stdlib_demangleName or swift-demangle for full demangling + symbolName + } + + + // MARK: - NSException Stack Trace Extraction (Fallback) + + /// Extract stack trace from NSException + /// + /// NSException provides callStackSymbols (formatted strings) which we parse. + /// This is a fallback method since NSException doesn't expose raw addresses easily. + /// + static func extractStackTrace(from exception: NSException) -> [PostHogStackFrame] { + let symbols = exception.callStackSymbols + guard !symbols.isEmpty else { + return [] + } + + return symbols.enumerated().map { index, symbol in + parseStackSymbol(symbol, frameIndex: index) + } + } + + // MARK: - String Parsing (Fallback) + + /// Parse a multi-line stack trace string + /// + /// Useful for parsing crash logs or pre-formatted stack traces. + /// + /// - Parameter stackTrace: Multi-line string containing stack trace + static func parseStackTraceString(_ stackTrace: String) -> [PostHogStackFrame] { + let lines = stackTrace.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + return lines.enumerated().map { index, line in + parseStackSymbol(line, frameIndex: index) + } + } + + // MARK: - Symbol Parsing (Fallback for formatted strings) + + /// Parse a single stack trace symbol string + /// + /// Parses various formats: + /// - "0 MyApp 0x00000001045a8f40 MyApp + 12345" + /// - "2 Foundation 0x00007fff2e4f6a9c -[NSException raise] + 123" + /// - "4 MyApp 0x0000000104e5c123 MyClass.myMethod() -> () (MyFile.swift:42)" + /// + /// This is kept as a fallback for NSException.callStackSymbols + private static func parseStackSymbol(_ symbol: String, frameIndex _: Int) -> PostHogStackFrame { + var instructionAddr: String? + var module: String? + var function: String? + var filename: String? + var lineno: Int? + + // Extract address using regex + if let addressMatch = symbol.range(of: "0x[0-9a-fA-F]+", options: .regularExpression) { + instructionAddr = String(symbol[addressMatch]) + } + + // Split by whitespace to extract components + let components = symbol.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + + // Typical format: "frameNumber moduleName address function + offset" + if components.count >= 2 { + module = components[1] + } + + // Extract function name (everything after address, before +offset) + if let addrMatch = symbol.range(of: "0x[0-9a-fA-F]+", options: .regularExpression), + let plusMatch = symbol.range(of: " \\+ \\d+", options: .regularExpression) + { + let functionStart = symbol.index(after: addrMatch.upperBound) + let functionEnd = plusMatch.lowerBound + + if functionStart < functionEnd { + let functionPart = symbol[functionStart ..< functionEnd].trimmingCharacters(in: .whitespaces) + if !functionPart.isEmpty { + function = functionPart + } + } + } else if let addrMatch = symbol.range(of: "0x[0-9a-fA-F]+", options: .regularExpression) { + // No +offset, take everything after address + let functionStart = symbol.index(after: addrMatch.upperBound) + let functionPart = symbol[functionStart...].trimmingCharacters(in: .whitespaces) + if !functionPart.isEmpty { + function = functionPart + } + } + + // Extract Swift file and line number: (FileName.swift:lineNumber) + if let fileMatch = symbol.range(of: "\\([^)]+\\.swift:\\d+\\)", options: .regularExpression) { + let fileInfo = String(symbol[fileMatch]) + // Remove parentheses + let cleaned = fileInfo.trimmingCharacters(in: CharacterSet(charactersIn: "()")) + + // Split by colon + let parts = cleaned.components(separatedBy: ":") + if parts.count == 2 { + filename = parts[0] + lineno = Int(parts[1]) + } + } + + return PostHogStackFrame( + instructionAddr: instructionAddr, + symbolAddr: nil, + imageAddr: nil, + imageUUID: nil, + module: module, + function: function, + filename: filename, + lineno: lineno, + colno: nil, + platform: "ios", + inApp: false // Will be set by in-app detection later + ) + } + + // MARK: - Comprehensive Stack Trace Capture (Primary Implementation) + + /// Captures current stack trace using dladdr() for rich metadata + /// + /// This approach is inspired by Sentry's implementation and provides: + /// - Instruction addresses (critical for server-side symbolication) + /// - Binary image addresses and names + /// - Symbol addresses for function resolution + /// - Proper in-app detection based on binary images + /// + /// - Parameters: + /// - config: Error tracking configuration for in-app detection + /// - skipFrames: Number of frames to skip from the beginning (default 3) + /// - Returns: Array of frame dictionaries with metadata + static func captureCurrentStackTraceWithMetadata( + config: PostHogErrorTrackingConfig, + skipFrames: Int = 3 + ) -> [[String: Any]] { + let addresses = Thread.callStackReturnAddresses + return symbolicateAddresses(addresses, config: config, skipFrames: skipFrames) + } + + /// Symbolicate an array of return addresses using dladdr() + /// + /// - Parameters: + /// - addresses: Array of return addresses as NSNumber + /// - config: Error tracking configuration for in-app detection + /// - skipFrames: Number of frames to skip from the beginning + /// - Returns: Array of frame dictionaries with metadata + static func symbolicateAddresses( + _ addresses: [NSNumber], + config: PostHogErrorTrackingConfig, + skipFrames: Int + ) -> [[String: Any]] { + var frames: [[String: Any]] = [] + + for (index, addressNum) in addresses.enumerated() { + guard index >= skipFrames else { continue } + + let address = addressNum.uintValue + var info = Dl_info() + + guard dladdr(UnsafeRawPointer(bitPattern: UInt(address)), &info) != 0 else { + continue + } + + var frame: [String: Any] = [:] + + // Instruction address (hex format for compatibility with PostHog backend) + frame["instruction_addr"] = String(format: "0x%016llx", address) + + // Binary image info + if let imageName = info.dli_fname { + let path = String(cString: imageName) + let moduleName = (path as NSString).lastPathComponent + + frame["module"] = moduleName + frame["package"] = moduleName + frame["image_addr"] = String(format: "0x%016llx", UInt(bitPattern: info.dli_fbase)) + + // In-app detection based on binary image + frame["in_app"] = isInApp(module: moduleName, config: config) + } + + // Function/symbol info + if let symbolName = info.dli_sname { + frame["function"] = String(cString: symbolName) + frame["symbol_addr"] = String(format: "0x%016llx", UInt(bitPattern: info.dli_saddr)) + } + + // Platform detection (native for objective-c/swift compiled code) + frame["platform"] = "ios" + + frames.append(frame) + } + + return frames + } + + // MARK: - In-App Detection + + /// Determines if a frame is considered in-app + /// + /// Priority system (matches posthog-flutter's _isInAppFrame): + /// 1. inAppIncludes (highest priority) + /// 2. inAppExcludes + /// 3. Known system frameworks (hardcoded) + /// 4. inAppByDefault (final fallback) + /// + /// Note: Uses prefix matching, unlike Flutter which uses exact package matching. + /// This matches Android's behavior for consistency. + /// + /// - Parameters: + /// - module: The module/binary name to check + /// - config: Error tracking configuration + /// - Returns: true if the frame should be marked as in-app + static func isInApp(module: String, config: PostHogErrorTrackingConfig) -> Bool { + // Priority 1: Check includes (highest priority) + if config.inAppIncludes.contains(where: { module.hasPrefix($0) }) { + return true + } + + // Priority 2: Check excludes + if config.inAppExcludes.contains(where: { module.hasPrefix($0) }) { + return false + } + + // Priority 3: Check known system frameworks (hardcoded) + if isSystemFramework(module) { + return false + } + + // Priority 4: Use default (final fallback) + return config.inAppByDefault + } + + /// Known system framework prefixes + /// + /// Note: This list is based on common system frameworks and dylibs on iOS. + /// It may need to be updated based on real-world usage, or moved to cymbal + /// which can further categorize frames and override in-app frames based on module paths + private static let systemPrefixes = [ + "Foundation", + "UIKit", + "CoreFoundation", + "libsystem_kernel.dylib", + "libsystem_pthread.dylib", + "libdispatch.dylib", + "CoreGraphics", + "QuartzCore", + "Security", + "SystemConfiguration", + "CFNetwork", + "CoreData", + "CoreLocation", + "AVFoundation", + "Metal", + "MetalKit", + "SwiftUI", + "Combine", + "AppKit", + "libswift", + "IOKit", + "WebKit", + "GraphicsServices" + ] + + /// Check if a module is a known system framework + private static func isSystemFramework(_ module: String) -> Bool { + return systemPrefixes.contains { module.hasPrefix($0) } + } +} diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index d316326cb..66d22cccf 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1438,6 +1438,80 @@ let maxRetryDelay = 30.0 } #endif + // MARK: - Error Tracking + + /// Capture an Error or NSError + /// + /// Captures an error as a `$exception` event with full stack trace and error chain information. + /// The error will be marked as handled by default. + /// + /// Example: + /// ```swift + /// do { + /// try FileManager.default.removeItem(at: badFileUrl) + /// } catch { + /// PostHog.shared.captureError(error) + /// } + /// ``` + /// + /// - Parameters: + /// - error: The error to capture (can be any Error or NSError) + /// - properties: Optional additional properties to attach to the event + @objc public func captureError( + _ error: Error, + properties: [String: Any]? = nil + ) { + guard isEnabled() else { return } + + let errorProperties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + mechanismType: "generic-error", + config: config.errorTrackingConfig + ) + + var mergedProperties = errorProperties + properties?.forEach { mergedProperties[$0.key] = $0.value } + + capture("$exception", properties: mergedProperties) + } + + /// Capture an NSException + /// + /// Captures an NSException as a `$exception` event with full stack trace. + /// This is useful for Objective-C code that uses NSException. + /// + /// Example: + /// ```objc + /// @try { + /// [self riskyOperation]; + /// } @catch (NSException *exception) { + /// [[[PostHog shared] captureException:exception properties:nil]]; + /// } + /// ``` + /// + /// - Parameters: + /// - exception: The NSException to capture + /// - properties: Optional additional properties to attach to the event + @objc public func captureException( + _ exception: NSException, + properties: [String: Any]? = nil + ) { + guard isEnabled() else { return } + + let exceptionProperties = PostHogExceptionProcessor.exceptionToProperties( + exception, + handled: true, + mechanismType: "generic-nsexception", + config: config.errorTrackingConfig + ) + + var mergedProperties = exceptionProperties + properties?.forEach { mergedProperties[$0.key] = $0.value } + + capture("$exception", properties: mergedProperties) + } + private func installIntegrations() { guard installedIntegrations.isEmpty else { hedgeLog("Integrations already installed. Call uninstallIntegrations() first.") diff --git a/PostHogExample/AppDelegate.swift b/PostHogExample/AppDelegate.swift index dc9cd83db..64dc8842b 100644 --- a/PostHogExample/AppDelegate.swift +++ b/PostHogExample/AppDelegate.swift @@ -12,7 +12,8 @@ import UIKit class AppDelegate: NSObject, UIApplicationDelegate { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { let config = PostHogConfig( - apiKey: "phc_WKfvDfedaJEDCoUmt9pVa3OWtbbUP1W2ctxwXkt3A3n" + apiKey: "phc_DOkauJvMj0YFtJsPHqzH6BgpFm79CvU9DPE5E22yRMk", + host: "http://localhost:8010" ) // the ScreenViews for SwiftUI does not work, the names are not useful config.captureScreenViews = false @@ -20,10 +21,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { // config.flushAt = 1 // config.flushIntervalSeconds = 30 config.debug = true + config.flushAt = 1 config.sendFeatureFlagEvent = false #if os(iOS) - config.sessionReplay = true + config.sessionReplay = false config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.maskAllTextInputs = true config.sessionReplayConfig.maskAllImages = true diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 7b8722825..3cc0862c4 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -265,6 +265,85 @@ struct ContentView: View { } } + Section("Error tracking") { + Button("Capture Swift Error") { + do { + throw SampleError.generic + } catch { + PostHogSDK.shared.captureError(error, properties: [ + "is_test": true, + "error_type": "swift_error", + ]) + } + } + + Button("Capture NSException (Constructed)") { + let exception = NSException( + name: NSExceptionName("PostHogTestException"), + reason: "Manual test exception for error tracking validation", + userInfo: [ + "test_scenario": "manual_button_press", + "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown", + ] + ) + + PostHogSDK.shared.captureException(exception, properties: [ + "is_test": true, + "user_initiated": true, + "exception_type": "safe_nsexception", + ]) + } + + Button("Trigger Real NSRangeException") { + ExceptionHandler.try({ + ExceptionHandler.triggerSampleRangeException() + }, catch: { exception in + PostHogSDK.shared.captureException(exception, properties: [ + "is_test": true, + "exception_type": "real_nsrange_exception", + "caught_by": "objective_c_wrapper", + ]) + }) + } + + Button("Trigger Real NSInvalidArgumentException") { + ExceptionHandler.try({ + ExceptionHandler.triggerSampleInvalidArgumentException() + }, catch: { exception in + PostHogSDK.shared.captureException(exception, properties: [ + "is_test": true, + "exception_type": "real_invalid_argument_exception", + "caught_by": "objective_c_wrapper", + ]) + }) + } + + Button("Trigger Custom NSException") { + ExceptionHandler.try({ + ExceptionHandler.triggerSampleGenericException() + }, catch: { exception in + PostHogSDK.shared.captureException(exception, properties: [ + "is_test": true, + "exception_type": "real_custom_exception", + "caught_by": "objective_c_wrapper", + ]) + }) + } + + Button("Trigger Chained NSException") { + ExceptionHandler.try({ + ExceptionHandler.triggerChainedException() + }, catch: { exception in + PostHogSDK.shared.captureException(exception, properties: [ + "is_test": true, + "exception_type": "chained_exception", + "caught_by": "objective_c_wrapper", + "scenario": "network_database_business_chain" + ]) + }) + } + } + Section("PostHog beers") { if !api.beers.isEmpty { ForEach(api.beers) { beer in @@ -304,3 +383,17 @@ struct ContentView_Previews: PreviewProvider { ContentView() } } + +private enum SampleError: Error { + case generic + + var localizedDescription: String { + switch self { + case .generic: + return "This is a generic error" + + @unknown default: + return "An unknown error occurred." + } + } +} diff --git a/PostHogExample/ExceptionHandler.h b/PostHogExample/ExceptionHandler.h new file mode 100644 index 000000000..fb2520275 --- /dev/null +++ b/PostHogExample/ExceptionHandler.h @@ -0,0 +1,44 @@ +// +// ExceptionHandler.h +// PostHogExample +// +// Created for NSException handling in Swift +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ExceptionHandler : NSObject + +/// Execute a block and catch any NSExceptions that occur +/// @param tryBlock The block to execute that might throw an NSException +/// @param catchBlock The block to execute if an NSException is caught ++ (void)tryBlock:(void(^)(void))tryBlock + catch:(void(^)(NSException *exception))catchBlock; + +/// Execute a block and catch any NSExceptions, with optional finally block +/// @param tryBlock The block to execute that might throw an NSException +/// @param catchBlock The block to execute if an NSException is caught +/// @param finallyBlock The block to execute regardless of whether an exception occurred ++ (void)tryBlock:(void(^)(void))tryBlock + catch:(void(^)(NSException *exception))catchBlock + finally:(nullable void(^)(void))finallyBlock; + +/// Trigger a sample NSRangeException for testing purposes ++ (void)triggerSampleRangeException; + +/// Trigger a sample NSInvalidArgumentException for testing purposes ++ (void)triggerSampleInvalidArgumentException; + +/// Trigger a sample NSGenericException for testing purposes ++ (void)triggerSampleGenericException; + +/// Trigger a chained exception scenario for testing exception chaining +/// This demonstrates how exceptions can be caught and rethrown with additional context ++ (void)triggerChainedException; + + +@end + +NS_ASSUME_NONNULL_END diff --git a/PostHogExample/ExceptionHandler.m b/PostHogExample/ExceptionHandler.m new file mode 100644 index 000000000..f96a46c81 --- /dev/null +++ b/PostHogExample/ExceptionHandler.m @@ -0,0 +1,120 @@ +// +// ExceptionHandler.m +// PostHogExample +// +// Created for NSException handling in Swift +// + +#import "ExceptionHandler.h" + +@implementation ExceptionHandler + ++ (void)tryBlock:(void(^)(void))tryBlock + catch:(void(^)(NSException *exception))catchBlock { + [self tryBlock:tryBlock catch:catchBlock finally:nil]; +} + ++ (void)tryBlock:(void(^)(void))tryBlock + catch:(void(^)(NSException *exception))catchBlock + finally:(nullable void(^)(void))finallyBlock { + @try { + if (tryBlock) { + tryBlock(); + } + } + @catch (NSException *exception) { + if (catchBlock) { + catchBlock(exception); + } + } + @finally { + if (finallyBlock) { + finallyBlock(); + } + } +} + ++ (void)triggerSampleRangeException { + // This will throw an NSRangeException + NSArray *array = @[@"item1", @"item2", @"item3"]; + [array objectAtIndex:10]; // Index out of bounds +} + ++ (void)triggerSampleInvalidArgumentException { + // This will throw an NSInvalidArgumentException + NSMutableArray *mutableArray = [[NSMutableArray alloc] init]; + [mutableArray insertObject:nil atIndex:0]; // Inserting nil object +} + ++ (void)triggerSampleGenericException { + // This will throw a custom NSException + @throw [NSException exceptionWithName:@"CustomTestException" + reason:@"This is a manually triggered exception for testing" + userInfo:@{ + @"test_type": @"manual_trigger", + @"timestamp": [NSDate date], + @"source": @"ExceptionHandler.triggerSampleGenericException" + }]; +} + ++ (void)triggerChainedException { + // Start the chained exception scenario + [self performDatabaseOperation]; +} + +// MARK: - Private Helper Methods for Exception Chaining + +/// Simulates a high-level business operation that calls lower-level functions ++ (void)performDatabaseOperation { + @try { + [self connectToDatabase]; + } + @catch (NSException *exception) { + // Catch the lower-level exception and wrap it with business context + NSException *businessException = [NSException exceptionWithName:@"DatabaseOperationException" + reason:@"Failed to perform user data synchronization" + userInfo:@{ + @"operation": @"user_sync", + @"retry_count": @3, + @"timestamp": [NSDate date], + NSUnderlyingErrorKey: exception // This creates the exception chain + }]; + @throw businessException; + } +} + +/// Simulates a database connection that calls even lower-level network functions ++ (void)connectToDatabase { + @try { + [self establishNetworkConnection]; + } + @catch (NSException *exception) { + // Catch the network exception and wrap it with database context + NSException *dbException = [NSException exceptionWithName:@"DatabaseConnectionException" + reason:@"Unable to establish database connection" + userInfo:@{ + @"database_host": @"db.example.com", + @"connection_timeout": @30, + @"retry_attempts": @2, + NSUnderlyingErrorKey: exception // Chain the network exception + }]; + @throw dbException; + } +} + +/// Simulates the lowest-level network operation that throws the original exception ++ (void)establishNetworkConnection { + // This is the root cause - a network connectivity issue + @throw [NSException exceptionWithName:@"NetworkException" + reason:@"Connection refused by remote server" + userInfo:@{ + @"error_code": @"ECONNREFUSED", + @"host": @"api.example.com", + @"port": @443, + @"protocol": @"HTTPS", + @"timestamp": [NSDate date] + }]; +} + + +@end diff --git a/PostHogExample/PostHogExample-Bridging-Header.h b/PostHogExample/PostHogExample-Bridging-Header.h new file mode 100644 index 000000000..e0566fd55 --- /dev/null +++ b/PostHogExample/PostHogExample-Bridging-Header.h @@ -0,0 +1,8 @@ +// +// PostHogExample-Bridging-Header.h +// PostHogExample +// +// Bridging header to expose Objective-C code to Swift +// + +#import "ExceptionHandler.h" From 250df1a346c74f7b9f48f26579e0263e7dfa84e6 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 27 Nov 2025 21:58:23 +0200 Subject: [PATCH 03/45] fix: package path --- PostHog/Error Tracking/Utils/PostHogStackTrace.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index 4e6cbda30..0e4f82baf 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -381,14 +381,14 @@ class PostHogStackTrace { // Binary image info if let imageName = info.dli_fname { let path = String(cString: imageName) - let moduleName = (path as NSString).lastPathComponent + let module = (path as NSString).lastPathComponent - frame["module"] = moduleName - frame["package"] = moduleName + frame["module"] = module + frame["package"] = path // Full binary path for symbolication frame["image_addr"] = String(format: "0x%016llx", UInt(bitPattern: info.dli_fbase)) // In-app detection based on binary image - frame["in_app"] = isInApp(module: moduleName, config: config) + frame["in_app"] = isInApp(module: module, config: config) } // Function/symbol info From fd35022cb186869c87569c9631661a40f8ed6794 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 27 Nov 2025 22:11:42 +0200 Subject: [PATCH 04/45] feat: add client-side swift demangling --- .../Utils/PostHogStackTrace.swift | 62 +++++++++++++++---- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index 0e4f82baf..b88ccbc77 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -175,13 +175,6 @@ class PostHogStackTrace { if let dlisname = info.dli_sname { let symbolName = String(cString: dlisname) function = demangle(symbolName) - - // Detect platform based on symbol naming -// if symbolName.hasPrefix("_$s") || symbolName.hasPrefix("$s") || symbolName.contains("Swift") { -// platform = "swift" -// } else if symbolName.hasPrefix("-[") || symbolName.hasPrefix("+[") { -// platform = "objc" -// } } } @@ -200,14 +193,57 @@ class PostHogStackTrace { ) } - /// Attempt to demangle a symbol name + // MARK: - Swift Symbol Demangling + + /// Type alias for the swift_demangle function signature + private typealias SwiftDemangleFunc = @convention(c) ( + _ mangledName: UnsafePointer?, + _ mangledNameLength: Int, + _ outputBuffer: UnsafeMutablePointer?, + _ outputBufferSize: UnsafeMutablePointer?, + _ flags: UInt32 + ) -> UnsafeMutablePointer? + + /// Cached reference to the swift_demangle function + private static let swiftDemangleFunc: SwiftDemangleFunc? = { + guard let handle = dlopen(nil, RTLD_NOW), + let sym = dlsym(handle, "swift_demangle") + else { + return nil + } + return unsafeBitCast(sym, to: SwiftDemangleFunc.self) + }() + + /// Attempt to demangle a Swift symbol name /// - /// Swift symbols are usually mangled (e.g., "_$s4MyApp0A5ClassC6methodyyF"). - /// This attempts basic demangling for readability. + /// Swift symbols are mangled (e.g., "_$s4MyApp0A5ClassC6methodyyF"). + /// This uses the Swift runtime's swift_demangle function to convert + /// them to human-readable form (e.g., "MyApp.MyClass.method() -> ()"). + /// + /// - Parameter symbolName: The mangled symbol name + /// - Returns: The demangled name if successful, otherwise the original name private static func demangle(_ symbolName: String) -> String { - // For now, return the mangled name as-is - // TODO: Could use _stdlib_demangleName or swift-demangle for full demangling - symbolName + // Only attempt to demangle Swift symbols + // Swift mangled names start with "$s", "_$s", "$S", or "_$S" + guard symbolName.hasPrefix("$s") || + symbolName.hasPrefix("_$s") || + symbolName.hasPrefix("$S") || + symbolName.hasPrefix("_$S") + else { + return symbolName + } + + guard let demangleFunc = swiftDemangleFunc else { + return symbolName + } + + // Call swift_demangle + if let demangledCString = demangleFunc(symbolName, symbolName.utf8.count, nil, nil, 0) { + defer { demangledCString.deallocate() } + return String(cString: demangledCString) + } + + return symbolName } From 660e8391437353b21d2ea6d639d35c8f36409187 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 28 Nov 2025 00:33:34 +0200 Subject: [PATCH 05/45] feat: improve public api --- PostHog.xcodeproj/project.pbxproj | 22 ++ .../PostHogExceptionProcessor.swift | 35 ++ .../Utils/PostHogStackTrace.swift | 322 ++---------------- PostHog/PostHogSDK.swift | 45 ++- PostHogExample/ContentView.swift | 9 +- PostHogExample/scripts/strip-example-app.sh | 145 ++++++++ 6 files changed, 269 insertions(+), 309 deletions(-) create mode 100755 PostHogExample/scripts/strip-example-app.sh diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 918a046d4..5cdf54890 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -1741,6 +1741,7 @@ 3AA34CF4296D951A003398F4 /* Frameworks */, 3AA34CF5296D951A003398F4 /* Resources */, DA1044D82D0B34F200C4ACF3 /* Embed Frameworks */, + 8204AFBF2A600CDF749AB4C1 /* Strip Symbols if Release (To simulate App Store distribution) */, ); buildRules = ( ); @@ -2104,6 +2105,24 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 8204AFBF2A600CDF749AB4C1 /* Strip Symbols if Release (To simulate App Store distribution) */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 8; + files = ( + ); + inputPaths = ( + ); + name = "Strip Symbols if Release (To simulate App Store distribution)"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 1; + shellPath = /bin/sh; + shellScript = "# Only strip in Release builds\nif [ \"$CONFIGURATION\" == \"Release\" ]; then\n echo \"Stripping symbols to simulate App Store distribution...\"\n \"${SRCROOT}/PostHogExample/scripts/strip-example-app.sh\" \"${CONFIGURATION}-${PLATFORM_NAME}\"\nelse\n echo \"Skipping symbol stripping (Debug build)\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 3AA34CF3296D951A003398F4 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -2500,6 +2519,7 @@ DEVELOPMENT_ASSET_PATHS = "\"PostHogExample/Preview Content\""; DEVELOPMENT_TEAM = PNC2XCH2XP; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = PostHogExample; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -2541,6 +2561,7 @@ DEVELOPMENT_ASSET_PATHS = "\"PostHogExample/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = PostHogExample; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -3300,6 +3321,7 @@ DEVELOPMENT_ASSET_PATHS = "\"PostHogExample/Preview Content\""; DEVELOPMENT_TEAM = PNC2XCH2XP; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = PostHogExample; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 067d2dccf..15a97ffc0 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -88,6 +88,41 @@ enum PostHogExceptionProcessor { return properties } + /// Convert a message string to properties + /// + /// - Parameters: + /// - message: The error message to convert + /// - type: Optional exception type name (defaults to "String") + /// - config: Error tracking configuration for in-app detection + /// - Returns: Dictionary of properties in PostHog's $exception event format + static func messageToProperties( + _ message: String, + config: PostHogErrorTrackingConfig + ) -> [String: Any] { + var properties: [String: Any] = [:] + + properties["$exception_level"] = "error" + + var exception: [String: Any] = [:] + exception["type"] = "Message" + exception["value"] = message + exception["thread_id"] = Thread.current.threadId + + exception["mechanism"] = [ + "type": "generic-message", + "handled": true, + "synthetic": true, + ] + + if let stacktrace = buildStacktrace(config: config) { + exception["stacktrace"] = stacktrace + } + + properties["$exception_list"] = [exception] + + return properties + } + // MARK: - Internal Exception Building /// Build list of exceptions from NSException chain diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index b88ccbc77..687bc360b 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -9,190 +9,12 @@ import Darwin import Foundation import MachO -/// Represents a single stack trace frame +/// Utility for capturing and processing stack traces /// -struct PostHogStackFrame { - /// Instruction address (e.g., "0x0000000104e5c123") - /// This is the actual program counter address where the frame is executing - let instructionAddr: String? - - /// Symbol address - start address of the function (for calculating offset) - /// Used to compute: instructionOffset = instructionAddr - symbolAddr - let symbolAddr: String? - - /// Image (binary) base address where the module is loaded in memory - let imageAddr: String? - - /// UUID of the binary image (for server-side symbolication with dSYM files) - let imageUUID: String? - - /// Module/binary name (e.g., "MyApp", "Foundation", "UIKit") - let module: String? - - /// Function/method name (e.g., "myMethod()", "-[NSException raise]") - let function: String? - - /// Source file name (e.g., "MyClass.swift") - let filename: String? - - /// Line number in source file - let lineno: Int? - - /// Column number in source file (optional) - let colno: Int? - - /// Platform identifier ("swift", "objc") - let platform: String - - /// Whether this frame is in-app code (set by in-app detection) - var inApp: Bool - - /// Convert to dictionary format for JSON serialization - func toDictionary() -> [String: Any] { - var dict: [String: Any] = [ - "platform": platform, - "in_app": inApp, - ] - - if let instructionAddr = instructionAddr { - dict["instruction_addr"] = instructionAddr - } - - if let symbolAddr = symbolAddr { - dict["symbol_addr"] = symbolAddr - } - - if let imageAddr = imageAddr { - dict["image_addr"] = imageAddr - } - - if let imageUUID = imageUUID { - dict["image_uuid"] = imageUUID - } - - if let module = module { - dict["module"] = module - } - - if let function = function { - dict["function"] = function - } - - if let filename = filename { - dict["filename"] = filename - } - - if let lineno = lineno { - dict["lineno"] = lineno - } - - if let colno = colno { - dict["colno"] = colno - } - - return dict - } -} - - -/// Utility for extracting and parsing stack traces -/// -/// This class provides methods to extract stack traces from various sources -/// (NSException, Swift Error, raw strings) and format them consistently -/// for error tracking. +/// This class provides methods to capture stack traces from the current thread +/// and format them consistently for error tracking. /// class PostHogStackTrace { - // MARK: - Current Thread Stack Trace (Primary Method) - - /// Capture stack trace from current thread using raw addresses - /// - /// This is the primary method for capturing stack traces for Swift Errors. - /// Uses Thread.callStackReturnAddresses to get raw instruction addresses, - /// then symbolicates them using dladdr(). - /// - /// Reference: Sentry iOS uses this approach for manual error capture - /// - /// - Parameter skipFrames: Number of frames to skip from the top (default 2 to skip capture methods) - /// - Returns: Array of stack frames with symbolication information - static func captureCurrentThreadStackTrace(skipFrames: Int = 2) -> [PostHogStackFrame] { - // Get raw return addresses from the call stack - let addresses = Thread.callStackReturnAddresses - - guard addresses.count > skipFrames else { - return [] - } - - // Skip the top frames (this method and its callers) - let relevantAddresses = addresses.dropFirst(skipFrames) - - // Symbolicate each address using dladdr() - return relevantAddresses.map { addressNumber in - symbolicateAddress(addressNumber) - } - } - - // MARK: - Address Symbolication using dladdr() - - /// Symbolicate a single address using dladdr() - /// - /// This performs on-device symbolication to extract symbol information - /// from a raw instruction address. The symbolication may be limited for - /// stripped binaries, but raw addresses are preserved for server-side - /// symbolication. - /// - static func symbolicateAddress(_ addressNumber: NSNumber) -> PostHogStackFrame { - let address = addressNumber.uintValue - let pointer = UnsafeRawPointer(bitPattern: address) - - var info = Dl_info() - var instructionAddr: String? - var symbolAddr: String? - var imageAddr: String? - var module: String? - var function: String? - - // Store the raw instruction address (always available) - instructionAddr = String(format: "0x%016lx", address) - - // Use dladdr() to get symbol information - if let ptr = pointer, dladdr(ptr, &info) != 0 { - // Extract image (binary) base address - if let dlifbase = info.dli_fbase { - imageAddr = String(format: "0x%016lx", UInt(bitPattern: dlifbase)) - } - - // Extract module/binary name - if let dlifname = info.dli_fname { - let path = String(cString: dlifname) - module = (path as NSString).lastPathComponent - } - - // Extract symbol address and name - if let dlisaddr = info.dli_saddr { - symbolAddr = String(format: "0x%016lx", UInt(bitPattern: dlisaddr)) - } - - if let dlisname = info.dli_sname { - let symbolName = String(cString: dlisname) - function = demangle(symbolName) - } - } - - return PostHogStackFrame( - instructionAddr: instructionAddr, - symbolAddr: symbolAddr, - imageAddr: imageAddr, - imageUUID: nil, // Will be populated by matching with binary images - module: module, - function: function, - filename: nil, // Not available from dladdr() - lineno: nil, // Not available from dladdr() - colno: nil, - platform: "ios", - inApp: false // Will be set by in-app detection later - ) - } - // MARK: - Swift Symbol Demangling /// Type alias for the swift_demangle function signature @@ -237,133 +59,21 @@ class PostHogStackTrace { return symbolName } - // Call swift_demangle - if let demangledCString = demangleFunc(symbolName, symbolName.utf8.count, nil, nil, 0) { + // Call swift_demangle - must use withCString to get proper pointer + let demangled = symbolName.withCString { cString -> String? in + // swift_demangle expects UnsafePointer, convert from Int8 + let result = cString.withMemoryRebound(to: UInt8.self, capacity: symbolName.utf8.count) { ptr in + demangleFunc(ptr, symbolName.utf8.count, nil, nil, 0) + } + guard let demangledCString = result else { return nil } defer { demangledCString.deallocate() } return String(cString: demangledCString) } - return symbolName - } - - - // MARK: - NSException Stack Trace Extraction (Fallback) - - /// Extract stack trace from NSException - /// - /// NSException provides callStackSymbols (formatted strings) which we parse. - /// This is a fallback method since NSException doesn't expose raw addresses easily. - /// - static func extractStackTrace(from exception: NSException) -> [PostHogStackFrame] { - let symbols = exception.callStackSymbols - guard !symbols.isEmpty else { - return [] - } - - return symbols.enumerated().map { index, symbol in - parseStackSymbol(symbol, frameIndex: index) - } - } - - // MARK: - String Parsing (Fallback) - - /// Parse a multi-line stack trace string - /// - /// Useful for parsing crash logs or pre-formatted stack traces. - /// - /// - Parameter stackTrace: Multi-line string containing stack trace - static func parseStackTraceString(_ stackTrace: String) -> [PostHogStackFrame] { - let lines = stackTrace.components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - - return lines.enumerated().map { index, line in - parseStackSymbol(line, frameIndex: index) - } - } - - // MARK: - Symbol Parsing (Fallback for formatted strings) - - /// Parse a single stack trace symbol string - /// - /// Parses various formats: - /// - "0 MyApp 0x00000001045a8f40 MyApp + 12345" - /// - "2 Foundation 0x00007fff2e4f6a9c -[NSException raise] + 123" - /// - "4 MyApp 0x0000000104e5c123 MyClass.myMethod() -> () (MyFile.swift:42)" - /// - /// This is kept as a fallback for NSException.callStackSymbols - private static func parseStackSymbol(_ symbol: String, frameIndex _: Int) -> PostHogStackFrame { - var instructionAddr: String? - var module: String? - var function: String? - var filename: String? - var lineno: Int? - - // Extract address using regex - if let addressMatch = symbol.range(of: "0x[0-9a-fA-F]+", options: .regularExpression) { - instructionAddr = String(symbol[addressMatch]) - } - - // Split by whitespace to extract components - let components = symbol.components(separatedBy: .whitespaces).filter { !$0.isEmpty } - - // Typical format: "frameNumber moduleName address function + offset" - if components.count >= 2 { - module = components[1] - } - - // Extract function name (everything after address, before +offset) - if let addrMatch = symbol.range(of: "0x[0-9a-fA-F]+", options: .regularExpression), - let plusMatch = symbol.range(of: " \\+ \\d+", options: .regularExpression) - { - let functionStart = symbol.index(after: addrMatch.upperBound) - let functionEnd = plusMatch.lowerBound - - if functionStart < functionEnd { - let functionPart = symbol[functionStart ..< functionEnd].trimmingCharacters(in: .whitespaces) - if !functionPart.isEmpty { - function = functionPart - } - } - } else if let addrMatch = symbol.range(of: "0x[0-9a-fA-F]+", options: .regularExpression) { - // No +offset, take everything after address - let functionStart = symbol.index(after: addrMatch.upperBound) - let functionPart = symbol[functionStart...].trimmingCharacters(in: .whitespaces) - if !functionPart.isEmpty { - function = functionPart - } - } - - // Extract Swift file and line number: (FileName.swift:lineNumber) - if let fileMatch = symbol.range(of: "\\([^)]+\\.swift:\\d+\\)", options: .regularExpression) { - let fileInfo = String(symbol[fileMatch]) - // Remove parentheses - let cleaned = fileInfo.trimmingCharacters(in: CharacterSet(charactersIn: "()")) - - // Split by colon - let parts = cleaned.components(separatedBy: ":") - if parts.count == 2 { - filename = parts[0] - lineno = Int(parts[1]) - } - } - - return PostHogStackFrame( - instructionAddr: instructionAddr, - symbolAddr: nil, - imageAddr: nil, - imageUUID: nil, - module: module, - function: function, - filename: filename, - lineno: lineno, - colno: nil, - platform: "ios", - inApp: false // Will be set by in-app detection later - ) + return demangled ?? symbolName } - // MARK: - Comprehensive Stack Trace Capture (Primary Implementation) + // MARK: - Stack Trace Capture /// Captures current stack trace using dladdr() for rich metadata /// @@ -428,8 +138,14 @@ class PostHogStackTrace { } // Function/symbol info + // NOTE: dladdr() returns the nearest symbol it can find, which may be INCORRECT + // for stripped binaries. In production App Store builds, symbols are often stripped + // and dladdr() may return a wrong symbol (like a type metadata accessor) or nothing. + // Server-side symbolication with dSYMs is required for accurate function names + // in production crash reports. if let symbolName = info.dli_sname { - frame["function"] = String(cString: symbolName) + let rawSymbol = String(cString: symbolName) + frame["function"] = demangle(rawSymbol) frame["symbol_addr"] = String(format: "0x%016llx", UInt(bitPattern: info.dli_saddr)) } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 66d22cccf..23239ea33 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1440,7 +1440,7 @@ let maxRetryDelay = 30.0 // MARK: - Error Tracking - /// Capture an Error or NSError + /// Capture a Swift Error or NSError /// /// Captures an error as a `$exception` event with full stack trace and error chain information. /// The error will be marked as handled by default. @@ -1450,14 +1450,15 @@ let maxRetryDelay = 30.0 /// do { /// try FileManager.default.removeItem(at: badFileUrl) /// } catch { - /// PostHog.shared.captureError(error) + /// PostHog.shared.captureException(error) /// } /// ``` /// /// - Parameters: /// - error: The error to capture (can be any Error or NSError) /// - properties: Optional additional properties to attach to the event - @objc public func captureError( + @objc(captureExceptionWithError:properties:) + public func captureException( _ error: Error, properties: [String: Any]? = nil ) { @@ -1486,14 +1487,15 @@ let maxRetryDelay = 30.0 /// @try { /// [self riskyOperation]; /// } @catch (NSException *exception) { - /// [[[PostHog shared] captureException:exception properties:nil]]; + /// [[PostHog shared] captureExceptionWithNSException:exception properties:nil]; /// } /// ``` /// /// - Parameters: /// - exception: The NSException to capture /// - properties: Optional additional properties to attach to the event - @objc public func captureException( + @objc(captureExceptionWithNSException:properties:) + public func captureException( _ exception: NSException, properties: [String: Any]? = nil ) { @@ -1512,6 +1514,39 @@ let maxRetryDelay = 30.0 capture("$exception", properties: mergedProperties) } + /// Capture an error message as an exception + /// + /// Captures a string message as a `$exception` event with stack trace from the capture point. + /// Useful when you want to report an error condition without an actual Error object. + /// + /// Example: + /// ```swift + /// if unexpectedCondition { + /// PostHog.shared.captureException("Unexpected state detected") + /// } + /// ``` + /// + /// - Parameters: + /// - message: The error message to capture + /// - properties: Optional additional properties to attach to the event + @objc(captureExceptionWithMessage:properties:) + public func captureException( + _ message: String, + properties: [String: Any]? = nil + ) { + guard isEnabled() else { return } + + let messageProperties = PostHogExceptionProcessor.messageToProperties( + message, + config: config.errorTrackingConfig + ) + + var mergedProperties = messageProperties + properties?.forEach { mergedProperties[$0.key] = $0.value } + + capture("$exception", properties: mergedProperties) + } + private func installIntegrations() { guard installedIntegrations.isEmpty else { hedgeLog("Integrations already installed. Call uninstallIntegrations() first.") diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 3cc0862c4..7134c1918 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -270,7 +270,7 @@ struct ContentView: View { do { throw SampleError.generic } catch { - PostHogSDK.shared.captureError(error, properties: [ + PostHogSDK.shared.captureException(error, properties: [ "is_test": true, "error_type": "swift_error", ]) @@ -342,6 +342,13 @@ struct ContentView: View { ]) }) } + + Button("Trigger with Message") { + PostHogSDK.shared.captureException("Unexpected state detected", properties: [ + "is_test": true, + "app_state": "some_state" + ]) + } } Section("PostHog beers") { diff --git a/PostHogExample/scripts/strip-example-app.sh b/PostHogExample/scripts/strip-example-app.sh new file mode 100755 index 000000000..f02486de1 --- /dev/null +++ b/PostHogExample/scripts/strip-example-app.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# +# Strips symbols from PostHogExample app and PostHog framework +# to simulate App Store distribution for testing symbolication +# + +set -e + +# When run from Xcode build phase, use environment variables +# When run manually, find derived data +if [ -n "$BUILT_PRODUCTS_DIR" ]; then + # Running as Xcode build phase + BUILD_DIR="$BUILT_PRODUCTS_DIR" + APP_BINARY="$TARGET_BUILD_DIR/$EXECUTABLE_PATH" + echo "📁 Running as Xcode build phase" + echo " Build dir: $BUILD_DIR" +else + # Running manually + DERIVED_DATA_BASE="$HOME/Library/Developer/Xcode/DerivedData" + POSTHOG_DERIVED=$(find "$DERIVED_DATA_BASE" -maxdepth 1 -name "PostHog-*" -type d | head -1) + + if [ -z "$POSTHOG_DERIVED" ]; then + echo "❌ Could not find PostHog derived data folder" + echo " Make sure you've built the project first" + exit 1 + fi + + echo "📁 Found derived data: $POSTHOG_DERIVED" + + # Configuration (default to Release-iphonesimulator) + CONFIG="${1:-Release-iphonesimulator}" + BUILD_DIR="$POSTHOG_DERIVED/Build/Products/$CONFIG" + + if [ ! -d "$BUILD_DIR" ]; then + echo "❌ Build directory not found: $BUILD_DIR" + echo " Available configurations:" + ls "$POSTHOG_DERIVED/Build/Products/" 2>/dev/null || echo " (none)" + exit 1 + fi + + APP_BINARY="$BUILD_DIR/PostHogExample.app/PostHogExample" +fi + +# Framework paths +FRAMEWORK_BINARY="$BUILD_DIR/PostHog.framework/PostHog" + +echo "" +echo "🔍 Checking binaries..." + +# Check app binary +if [ -f "$APP_BINARY" ]; then + SYMBOLS_BEFORE=$(nm "$APP_BINARY" 2>/dev/null | wc -l | tr -d ' ') + echo " App binary: $APP_BINARY" + echo " Symbols before: $SYMBOLS_BEFORE" +else + echo "❌ App binary not found: $APP_BINARY" + exit 1 +fi + +# Check framework binary +if [ -f "$FRAMEWORK_BINARY" ]; then + FW_SYMBOLS_BEFORE=$(nm "$FRAMEWORK_BINARY" 2>/dev/null | wc -l | tr -d ' ') + echo " Framework binary: $FRAMEWORK_BINARY" + echo " Symbols before: $FW_SYMBOLS_BEFORE" +else + echo "⚠️ Framework binary not found (embedded in app?)" + FRAMEWORK_BINARY="" +fi + +# Also check for embedded framework +if [ -n "$TARGET_BUILD_DIR" ]; then + EMBEDDED_FRAMEWORK="$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Frameworks/PostHog.framework/PostHog" +else + EMBEDDED_FRAMEWORK="$BUILD_DIR/PostHogExample.app/Frameworks/PostHog.framework/PostHog" +fi +if [ -f "$EMBEDDED_FRAMEWORK" ]; then + EMB_SYMBOLS_BEFORE=$(nm "$EMBEDDED_FRAMEWORK" 2>/dev/null | wc -l | tr -d ' ') + echo " Embedded framework: $EMBEDDED_FRAMEWORK" + echo " Symbols before: $EMB_SYMBOLS_BEFORE" +fi + +echo "" +echo "✂️ Stripping symbols..." + +# Strip app binary +strip -x "$APP_BINARY" +SYMBOLS_AFTER=$(nm "$APP_BINARY" 2>/dev/null | wc -l | tr -d ' ') +echo " App: $SYMBOLS_BEFORE → $SYMBOLS_AFTER symbols" + +# Strip framework binary (if exists) +if [ -n "$FRAMEWORK_BINARY" ] && [ -f "$FRAMEWORK_BINARY" ]; then + strip -x "$FRAMEWORK_BINARY" + FW_SYMBOLS_AFTER=$(nm "$FRAMEWORK_BINARY" 2>/dev/null | wc -l | tr -d ' ') + echo " Framework: $FW_SYMBOLS_BEFORE → $FW_SYMBOLS_AFTER symbols" +fi + +# Strip embedded framework (if exists) +if [ -f "$EMBEDDED_FRAMEWORK" ]; then + strip -x "$EMBEDDED_FRAMEWORK" + EMB_SYMBOLS_AFTER=$(nm "$EMBEDDED_FRAMEWORK" 2>/dev/null | wc -l | tr -d ' ') + echo " Embedded framework: $EMB_SYMBOLS_BEFORE → $EMB_SYMBOLS_AFTER symbols" +fi + +echo "" +echo "🔏 Re-signing binaries (required after stripping)..." + +# Re-sign app binary +codesign --force --sign - "$APP_BINARY" +echo " App re-signed" + +# Re-sign framework binary (if exists) +if [ -n "$FRAMEWORK_BINARY" ] && [ -f "$FRAMEWORK_BINARY" ]; then + codesign --force --sign - "$FRAMEWORK_BINARY" + echo " Framework re-signed" +fi + +# Re-sign embedded framework (if exists) +if [ -f "$EMBEDDED_FRAMEWORK" ]; then + # Need to sign the framework bundle, not just the binary + EMBEDDED_FRAMEWORK_DIR=$(dirname "$EMBEDDED_FRAMEWORK") + codesign --force --sign - "$EMBEDDED_FRAMEWORK_DIR" + echo " Embedded framework re-signed" +fi + +# Re-sign the whole app bundle +if [ -n "$TARGET_BUILD_DIR" ] && [ -n "$FULL_PRODUCT_NAME" ]; then + APP_BUNDLE="$TARGET_BUILD_DIR/$FULL_PRODUCT_NAME" +else + APP_BUNDLE="$BUILD_DIR/PostHogExample.app" +fi + +if [ -d "$APP_BUNDLE" ]; then + codesign --force --sign - "$APP_BUNDLE" + echo " App bundle re-signed" +fi + +echo "" +echo "✅ Done! Symbols stripped and binaries re-signed." +echo "" +echo "📝 To test:" +echo " 1. Run the app from Xcode (it will use the stripped binary)" +echo " 2. Trigger an error capture" +echo " 3. Check that stack frames show only addresses (no function names)" +echo "" +echo "⚠️ Note: Rebuilding will restore symbols. Run this script again after each build." From 530bd393af63d2badf1db75847f21a6cdab1549e Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 28 Nov 2025 17:16:57 +0200 Subject: [PATCH 06/45] feat: add binary images --- PostHog.xcodeproj/project.pbxproj | 22 +- .../Models/PostHogBinaryImageInfo.swift | 51 ++++ .../PostHogExceptionProcessor.swift | 73 ++++-- .../Utils/PostHogDebugImageProvider.swift | 224 ++++++++++++++++++ .../Utils/PostHogStackTrace.swift | 6 +- PostHogExample/ContentView.swift | 7 +- 6 files changed, 358 insertions(+), 25 deletions(-) create mode 100644 PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift create mode 100644 PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 5cdf54890..8c05c1270 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 3AE3FB49299391DF00AFFC18 /* PostHogStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE3FB48299391DF00AFFC18 /* PostHogStorage.swift */; }; 3AE3FB4B2993A68500AFFC18 /* PostHogStorageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE3FB4A2993A68500AFFC18 /* PostHogStorageTest.swift */; }; 3AE3FB4E2993D1D600AFFC18 /* PostHogStorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE3FB4D2993D1D600AFFC18 /* PostHogStorageManager.swift */; }; + 628564DE224D783306683EEE /* PostHogDebugImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34EA6EED2443DFD84061990F /* PostHogDebugImageProvider.swift */; }; 690B2DF32C205B5600AE3B45 /* TimeBasedEpochGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690B2DF22C205B5600AE3B45 /* TimeBasedEpochGenerator.swift */; }; 690FF05F2AE7E2D400A0B06B /* Data+Gzip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF05E2AE7E2D400A0B06B /* Data+Gzip.swift */; }; 690FF0AF2AEB9C1400A0B06B /* DateUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0AE2AEB9C1400A0B06B /* DateUtils.swift */; }; @@ -175,6 +176,8 @@ DA3BB4AA2ED82E410097A97A /* PostHogStackTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4A62ED82E410097A97A /* PostHogStackTrace.swift */; }; DA3BB4AC2ED82EE80097A97A /* PostHogExceptionProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */; }; DA3BB4AF2ED88DE90097A97A /* ExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */; }; + DA3BB4DF2ED992780097A97A /* PostHogBinaryImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DD2ED992780097A97A /* PostHogBinaryImageInfo.swift */; }; + DA3BB4E02ED992780097A97A /* PostHogDebugImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */; }; DA4AF61F2D1195D20053EA38 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA4AF6202D1195D20053EA38 /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DA4AF6242D119FC60053EA38 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; @@ -536,6 +539,7 @@ /* Begin PBXFileReference section */ 1F55581F2DFC47E8007643C0 /* PostHogStorageMergeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStorageMergeTest.swift; sourceTree = ""; }; + 34EA6EED2443DFD84061990F /* PostHogDebugImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PostHogDebugImageProvider.swift; path = "PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift"; sourceTree = ""; }; 3A0F108229C47940002C0084 /* UIViewExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExample.swift; sourceTree = ""; }; 3A0F108429C9ABB6002C0084 /* ReadWriteLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadWriteLock.swift; sourceTree = ""; }; 3A0F108829C9BD76002C0084 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; @@ -712,12 +716,13 @@ DA30AE682D3EFB4F00465A64 /* Optional+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Util.swift"; sourceTree = ""; }; DA30AE802D3FE63F00465A64 /* PostHogSurvey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurvey.swift; sourceTree = ""; }; DA3793352DBA5718005C6AA3 /* PostHogSurveysConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveysConfig.swift; sourceTree = ""; }; - DA3BB4A52ED82E410097A97A /* PostHogBinaryImages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogBinaryImages.swift; sourceTree = ""; }; DA3BB4A62ED82E410097A97A /* PostHogStackTrace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStackTrace.swift; sourceTree = ""; }; DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogExceptionProcessor.swift; sourceTree = ""; }; DA3BB4AD2ED88DE90097A97A /* ExceptionHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExceptionHandler.h; sourceTree = ""; }; DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExceptionHandler.m; sourceTree = ""; }; DA3BB4B02ED88DEC0097A97A /* PostHogExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "PostHogExample-Bridging-Header.h"; sourceTree = ""; }; + DA3BB4DD2ED992780097A97A /* PostHogBinaryImageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogBinaryImageInfo.swift; sourceTree = ""; }; + DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogDebugImageProvider.swift; sourceTree = ""; }; DA4FFB142DA93C78006BAEEA /* PostHogSessionReplayTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayTest.swift; sourceTree = ""; }; DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogIdentityTests.swift; sourceTree = ""; }; DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_remote_config.json; sourceTree = ""; }; @@ -1058,6 +1063,7 @@ 3AC745B6296D6FE60025C109 /* Products */, 69261D152AD92D6C00232EC7 /* Frameworks */, DAE8AEC02D9D0E7700A1FE3A /* Recovered References */, + 34EA6EED2443DFD84061990F /* PostHogDebugImageProvider.swift */, ); sourceTree = ""; }; @@ -1364,12 +1370,20 @@ DA3BB49E2ED82E250097A97A /* Utils */ = { isa = PBXGroup; children = ( - DA3BB4A52ED82E410097A97A /* PostHogBinaryImages.swift */, DA3BB4A62ED82E410097A97A /* PostHogStackTrace.swift */, + DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */, ); path = Utils; sourceTree = ""; }; + DA3BB4E72ED9929C0097A97A /* Models */ = { + isa = PBXGroup; + children = ( + DA3BB4DD2ED992780097A97A /* PostHogBinaryImageInfo.swift */, + ); + path = Models; + sourceTree = ""; + }; DA578E912D6768A100B3A56C /* Screen Views */ = { isa = PBXGroup; children = ( @@ -1404,6 +1418,7 @@ DA8C9B972EC633FA00C6EADB /* Error Tracking */ = { isa = PBXGroup; children = ( + DA3BB4E72ED9929C0097A97A /* Models */, DA3BB49E2ED82E250097A97A /* Utils */, DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */, DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */, @@ -2158,6 +2173,8 @@ DA30AE692D3EFB4F00465A64 /* Optional+Util.swift in Sources */, DA9AE8D52D841994002F1B44 /* Survey+Util.swift in Sources */, 690FF0BF2AEFA97F00A0B06B /* FileUtils.swift in Sources */, + DA3BB4DF2ED992780097A97A /* PostHogBinaryImageInfo.swift in Sources */, + DA3BB4E02ED992780097A97A /* PostHogDebugImageProvider.swift in Sources */, 69261D252AD9787A00232EC7 /* PostHogExtensions.swift in Sources */, DA1D295E2D10B7B2003A31DA /* ApplicationLifecyclePublisher.swift in Sources */, 3AE3FB4E2993D1D600AFFC18 /* PostHogStorageManager.swift in Sources */, @@ -2344,6 +2361,7 @@ 69261D1D2AD967CD00232EC7 /* PostHogFileBackedQueue.swift in Sources */, 3AE3FB432992985A00AFFC18 /* Reachability.swift in Sources */, 69F518122BAC783300F52C14 /* CGColor+Util.swift in Sources */, + 628564DE224D783306683EEE /* PostHogDebugImageProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift b/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift new file mode 100644 index 000000000..32617e3cb --- /dev/null +++ b/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift @@ -0,0 +1,51 @@ +// +// PostHogBinaryImageInfo.swift +// PostHog +// +// Created by Ioannis Josephides on 27/11/2025. +// + +import Foundation + +/// Information about a loaded binary image (executable or dynamic library) +/// +/// This struct contains the metadata needed for server-side symbolication: +/// - UUID for matching uploaded dSYM files +/// - Load addresses for calculating symbol offsets +/// - Size for determining address ranges +struct PostHogBinaryImageInfo { + /// Full path to the binary image (e.g., "/usr/lib/system/libsystem_kernel.dylib") + let name: String + + /// UUID of the binary image, used for dSYM matching + /// Format: "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + let uuid: String? + + /// Virtual memory address from the Mach-O headers (preferred load address) + let vmAddress: UInt64 + + /// Actual load address in memory (may differ from vmAddress due to ASLR) + let address: UInt64 + + /// Size of the binary image in bytes + let size: UInt64 + + func toDictionary() -> [String: Any] { + var dict: [String: Any] = [ + "type": "macho", + "code_file": name, + "image_addr": String(format: "0x%llx", address), + "image_size": size, + ] + + if let uuid = uuid { + dict["debug_id"] = uuid + } + + if vmAddress > 0 { + dict["image_vmaddr"] = String(format: "0x%llx", vmAddress) + } + + return dict + } +} diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 15a97ffc0..00a7d8678 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -11,13 +11,7 @@ import Foundation /// Processes errors and exceptions into PostHog's $exception event format /// /// This class converts Swift Error and NSException instances into PostHog exception event properties. -/// -/// **Note on Binary Images:** -/// Currently, binary images (debug metadata) are NOT included in exception events. -/// This is intentional until server-side symbolication is implemented. Binary images -/// would be needed for server-side symbolication of raw instruction addresses, but -/// without that capability, we won't know exactly what's needed or if there's something that can be reused from PLCrashReporter lib. -/// When server-side symbolication is added, we should include binary images +/// It automatically attaches binary image metadata (`$debug_images`) needed for server-side symbolication. /// enum PostHogExceptionProcessor { // MARK: - Public API @@ -49,6 +43,12 @@ enum PostHogExceptionProcessor { if !exceptions.isEmpty { properties["$exception_list"] = exceptions + + // Attach debug images for server-side symbolication + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + if !debugImages.isEmpty { + properties["$debug_images"] = debugImages + } } return properties @@ -83,6 +83,12 @@ enum PostHogExceptionProcessor { if !exceptions.isEmpty { properties["$exception_list"] = exceptions + + // Attach debug images for server-side symbolication + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + if !debugImages.isEmpty { + properties["$debug_images"] = debugImages + } } return properties @@ -118,7 +124,14 @@ enum PostHogExceptionProcessor { exception["stacktrace"] = stacktrace } - properties["$exception_list"] = [exception] + let exceptions = [exception] + properties["$exception_list"] = exceptions + + // Attach debug images for server-side symbolication + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + if !debugImages.isEmpty { + properties["$debug_images"] = debugImages + } return properties } @@ -209,14 +222,14 @@ enum PostHogExceptionProcessor { ) -> [String: Any]? { var exception: [String: Any] = [:] - exception["type"] = error.domain + exception["type"] = extractTypeName(from: error) if let message = extractErrorMessage(from: error) { exception["value"] = message } - if let module = extractModule(from: error) { - exception["module"] = module + if let moduleName = extractModule(from: error) { + exception["module"] = moduleName } exception["thread_id"] = Thread.current.threadId @@ -280,7 +293,7 @@ enum PostHogExceptionProcessor { /// Extract user-friendly error message /// /// Priority: - /// 1. NSDebugDescriptionErrorKeyf + /// 1. NSDebugDescriptionErrorKey /// 2. NSLocalizedDescriptionKey /// 3. Code only private static func extractErrorMessage(from error: NSError) -> String? { @@ -295,10 +308,39 @@ enum PostHogExceptionProcessor { return "Code: \(error.code)" } + /// Extract clean type name from error + /// + /// Uses Swift's type reflection to get the actual type name. + /// Falls back to error domain for Objective-C errors. + private static func extractTypeName(from error: NSError) -> String { + // Get the actual Swift type name using reflection + let typeName = String(describing: type(of: error as Error)) + + // If it's a plain NSError (not a Swift error bridged to NSError), + // the type will just be "NSError" - use domain instead + if typeName == "NSError" { + return error.domain + } + + return typeName + } + /// Extract module name from error domain + /// + /// For Swift errors, the domain contains the full module path (e.g., "MyApp.Networking.APIError"). + /// We extract everything except the type name at the end. private static func extractModule(from error: NSError) -> String? { let domain = error.domain - return domain.contains(".") ? domain : nil + + // For domains without dots (e.g., NSCocoaErrorDomain), return nil + guard let lastDot = domain.lastIndex(of: ".") else { + return nil + } + + // For dotted domains, extract everything before the last component (the type name) + let module = String(domain[.. [String: Any]? { let frames = PostHogStackTrace.captureCurrentStackTraceWithMetadata(config: config, skipFrames: 3) - + guard !frames.isEmpty else { return nil } return [ @@ -329,7 +371,7 @@ enum PostHogExceptionProcessor { config: PostHogErrorTrackingConfig ) -> [String: Any]? { let frames = PostHogStackTrace.symbolicateAddresses(addresses, config: config, skipFrames: 0) - + guard !frames.isEmpty else { return nil } return [ @@ -337,7 +379,6 @@ enum PostHogExceptionProcessor { "type": "raw", ] } - } private extension Thread { diff --git a/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift b/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift new file mode 100644 index 000000000..a12e85907 --- /dev/null +++ b/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift @@ -0,0 +1,224 @@ +// +// PostHogDebugImageProvider.swift +// PostHog +// +// Created by Ioannis Josephides on 28/11/2025. +// + +import Foundation +import MachO + +/// Utility for extracting debug image metadata from loaded binary images +/// +/// This provider extracts information needed for server-side symbolication: +/// - UUID from LC_UUID load command (for dSYM matching) +/// - Text segment address and size (for address range calculation) +/// - Load addresses (for offset calculation) +/// +enum PostHogDebugImageProvider { + private static let segmentText = "__TEXT" + + /// Get all currently loaded binary images with their metadata + /// + /// Uses dyld APIs to enumerate all loaded images and parse their Mach-O headers. + /// + /// NOTE: + /// This will do for now, but we should eventually use dyld callbacks to track + /// image loading/unloading and cache images via + /// `_dyld_register_func_for_add_image` / `_dyld_register_func_for_remove_image` + /// for quicker lookups (this is what Sentry is doing as well). + /// + /// - Returns: Array of binary image info for all loaded images + static func getAllBinaryImages() -> [PostHogBinaryImageInfo] { + var images: [PostHogBinaryImageInfo] = [] + + let imageCount = _dyld_image_count() + + for index in 0 ..< imageCount { + guard let header = _dyld_get_image_header(index) else { continue } + let slide = _dyld_get_image_vmaddr_slide(index) + let name = _dyld_get_image_name(index).map { String(cString: $0) } ?? "unknown" + + if let imageInfo = parseImageInfo(header: header, slide: slide, name: name) { + images.append(imageInfo) + } + } + + return images + } + + /// Get debug images for stack frames + /// + /// Extracts unique image addresses from frames and returns their binary metadata. + /// + /// - Parameter frames: Array of stack frame dictionaries containing "image_addr" keys + /// - Returns: Array of debug image dictionaries + static func getDebugImages(for frames: [[String: Any]]) -> [[String: Any]] { + let addresses = Set(frames.compactMap { $0["image_addr"] as? String }) + guard !addresses.isEmpty else { return [] } + return getImages(for: addresses).map { $0.toDictionary() } + } + + /// Get debug images for exception list + /// + /// Extracts frames from all exceptions and returns their binary metadata. + /// + /// - Parameter exceptions: Array of exception dictionaries (from $exception_list) + /// - Returns: Array of debug image dictionaries + static func getDebugImages(fromExceptions exceptions: [[String: Any]]) -> [[String: Any]] { + let frames = exceptions.flatMap { exception -> [[String: Any]] in + guard let stacktrace = exception["stacktrace"] as? [String: Any], + let frames = stacktrace["frames"] as? [[String: Any]] else { return [] } + return frames + } + return getDebugImages(for: frames) + } + + // MARK: - Internal + + /// Get binary images for a set of image addresses + private static func getImages(for imageAddresses: Set) -> [PostHogBinaryImageInfo] { + guard !imageAddresses.isEmpty else { return [] } + + // Convert hex strings to UInt64 for comparison + let addressValues = Set(imageAddresses.compactMap { hexToUInt64($0) }) + guard !addressValues.isEmpty else { return [] } + + var matchedImages: [PostHogBinaryImageInfo] = [] + let imageCount = _dyld_image_count() + + for index in 0 ..< imageCount { + guard let header = _dyld_get_image_header(index) else { continue } + let slide = _dyld_get_image_vmaddr_slide(index) + let name = _dyld_get_image_name(index).map { String(cString: $0) } ?? "unknown" + + if let imageInfo = parseImageInfo(header: header, slide: slide, name: name), + addressValues.contains(imageInfo.address) + { + matchedImages.append(imageInfo) + } + } + + return matchedImages + } + + /// Parse binary image info from a Mach-O header + /// + /// Supports both 32-bit and 64-bit Mach-O formats by detecting the magic number + /// and using the appropriate header size and segment command type. + private static func parseImageInfo( + header: UnsafePointer, + slide: Int, + name: String + ) -> PostHogBinaryImageInfo? { + let is64Bit = header.pointee.magic == MH_MAGIC_64 || header.pointee.magic == MH_CIGAM_64 + + // Configuration based on architecture + let headerSize = is64Bit ? MemoryLayout.size : MemoryLayout.size + let segmentCmd = is64Bit ? UInt32(LC_SEGMENT_64) : UInt32(LC_SEGMENT) + + var uuid: String? + var textVMAddr: UInt64 = 0 + var textSize: UInt64 = 0 + + // Start of load commands (right after header) + var cmdPtr = UnsafeRawPointer(header).advanced(by: headerSize) + let ncmds = header.pointee.ncmds + + for _ in 0 ..< ncmds { + let cmd = cmdPtr.assumingMemoryBound(to: load_command.self).pointee + + switch cmd.cmd { + case UInt32(LC_UUID): + uuid = extractUUID(from: cmdPtr) + + case segmentCmd: + let segment = extractTextSegment(from: cmdPtr, is64Bit: is64Bit) + if let segment = segment { + textVMAddr = segment.vmaddr + textSize = segment.vmsize + } + + default: + break + } + + // Early exit once we have both UUID and __TEXT segment + if uuid != nil, textSize > 0 { + break + } + + cmdPtr = cmdPtr.advanced(by: Int(cmd.cmdsize)) + } + + // Calculate actual load address (vmaddr + slide) + let loadAddress = UInt64(Int64(textVMAddr) + Int64(slide)) + + return PostHogBinaryImageInfo( + name: name, + uuid: uuid, + vmAddress: textVMAddr, + address: loadAddress, + size: textSize + ) + } + + /// Extract __TEXT segment info from a segment command + /// + /// - Parameters: + /// - cmdPtr: Pointer to the segment command + /// - is64Bit: Whether this is a 64-bit segment command + /// - Returns: Tuple of (vmaddr, vmsize) if this is the __TEXT segment, nil otherwise + private static func extractTextSegment( + from cmdPtr: UnsafeRawPointer, + is64Bit: Bool + ) -> (vmaddr: UInt64, vmsize: UInt64)? { + if is64Bit { + let segCmd = cmdPtr.assumingMemoryBound(to: segment_command_64.self).pointee + let segName = withUnsafeBytes(of: segCmd.segname) { ptr -> String in + let bytes = ptr.bindMemory(to: CChar.self) + return String(cString: bytes.baseAddress!) + } + guard segName == segmentText else { return nil } + return (segCmd.vmaddr, segCmd.vmsize) + } else { + let segCmd = cmdPtr.assumingMemoryBound(to: segment_command.self).pointee + let segName = withUnsafeBytes(of: segCmd.segname) { ptr -> String in + let bytes = ptr.bindMemory(to: CChar.self) + return String(cString: bytes.baseAddress!) + } + guard segName == segmentText else { return nil } + return (UInt64(segCmd.vmaddr), UInt64(segCmd.vmsize)) + } + } + + /// Extract UUID from LC_UUID load command + /// + /// The UUID is stored as 16 bytes in the uuid_command structure. + /// We format it as a standard UUID string with hyphens. + private static func extractUUID(from cmdPtr: UnsafeRawPointer) -> String? { + let uuidCmd = cmdPtr.assumingMemoryBound(to: uuid_command.self).pointee + let uuid = uuidCmd.uuid + + // Format as UUID string: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + return String( + format: "%02X%02X%02X%02X-%02X%02X-%02X%02X-%02X%02X-%02X%02X%02X%02X%02X%02X", + uuid.0, uuid.1, uuid.2, uuid.3, + uuid.4, uuid.5, + uuid.6, uuid.7, + uuid.8, uuid.9, + uuid.10, uuid.11, uuid.12, uuid.13, uuid.14, uuid.15 + ) + } + + // MARK: - Helpers + + /// Convert hex string (e.g., "0x100abc000") to UInt64 + private static func hexToUInt64(_ hex: String) -> UInt64? { + var hexString = hex + if hexString.hasPrefix("0x") || hexString.hasPrefix("0X") { + hexString = String(hexString.dropFirst(2)) + } + return UInt64(hexString, radix: 16) + } +} diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index 687bc360b..065aff3e4 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -14,7 +14,7 @@ import MachO /// This class provides methods to capture stack traces from the current thread /// and format them consistently for error tracking. /// -class PostHogStackTrace { +enum PostHogStackTrace { // MARK: - Swift Symbol Demangling /// Type alias for the swift_demangle function signature @@ -223,11 +223,11 @@ class PostHogStackTrace { "libswift", "IOKit", "WebKit", - "GraphicsServices" + "GraphicsServices", ] /// Check if a module is a known system framework private static func isSystemFramework(_ module: String) -> Bool { - return systemPrefixes.contains { module.hasPrefix($0) } + systemPrefixes.contains { module.hasPrefix($0) } } } diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 7134c1918..c63f201e7 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -272,7 +272,6 @@ struct ContentView: View { } catch { PostHogSDK.shared.captureException(error, properties: [ "is_test": true, - "error_type": "swift_error", ]) } } @@ -338,7 +337,7 @@ struct ContentView: View { "is_test": true, "exception_type": "chained_exception", "caught_by": "objective_c_wrapper", - "scenario": "network_database_business_chain" + "scenario": "network_database_business_chain", ]) }) } @@ -346,7 +345,7 @@ struct ContentView: View { Button("Trigger with Message") { PostHogSDK.shared.captureException("Unexpected state detected", properties: [ "is_test": true, - "app_state": "some_state" + "app_state": "some_state", ]) } } @@ -391,7 +390,7 @@ struct ContentView_Previews: PreviewProvider { } } -private enum SampleError: Error { +enum SampleError: Error { case generic var localizedDescription: String { From eb51920120214bba5c3df66b53f31d270bd2d2a1 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 28 Nov 2025 20:10:19 +0200 Subject: [PATCH 07/45] fix: swift error message --- .../Error Tracking/PostHogExceptionProcessor.swift | 11 +++-------- PostHogExample/ContentView.swift | 4 ++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 00a7d8678..dcff17ac6 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -293,19 +293,14 @@ enum PostHogExceptionProcessor { /// Extract user-friendly error message /// /// Priority: - /// 1. NSDebugDescriptionErrorKey - /// 2. NSLocalizedDescriptionKey - /// 3. Code only + /// 1. Debug description (NSDebugDescriptionErrorKey) + /// 2. Localized description (NSLocalizedDescriptionKey) private static func extractErrorMessage(from error: NSError) -> String? { if let debugDesc = error.userInfo[NSDebugDescriptionErrorKey] as? String { return "\(debugDesc) (Code: \(error.code))" } - if let localizedDesc = error.userInfo[NSLocalizedDescriptionKey] as? String { - return "\(localizedDesc) (Code: \(error.code))" - } - - return "Code: \(error.code)" + return "\(error.localizedDescription) (Code: \(error.code))" } /// Extract clean type name from error diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index c63f201e7..8c364f0c1 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -390,10 +390,10 @@ struct ContentView_Previews: PreviewProvider { } } -enum SampleError: Error { +enum SampleError: LocalizedError { case generic - var localizedDescription: String { + var errorDescription: String? { switch self { case .generic: return "This is a generic error" From 1f053f9ed0730949241e85218d14d4d9cb40512c Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 2 Dec 2025 14:11:49 +0200 Subject: [PATCH 08/45] fix: --- .../Utils/PostHogStackTrace.swift | 1 - PostHogExample/ContentView.swift | 20 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index 065aff3e4..573556160 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -149,7 +149,6 @@ enum PostHogStackTrace { frame["symbol_addr"] = String(format: "0x%016llx", UInt(bitPattern: info.dli_saddr)) } - // Platform detection (native for objective-c/swift compiled code) frame["platform"] = "ios" frames.append(frame) diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 8c364f0c1..73ac9d959 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -266,9 +266,9 @@ struct ContentView: View { } Section("Error tracking") { - Button("Capture Swift Error") { + Button("Capture Swift Enum Error (with associated value)") { do { - throw SampleError.generic + throw SampleAppError.generalAppError(ErrorDetails(code: 10, reason: "some reason")) } catch { PostHogSDK.shared.captureException(error, properties: [ "is_test": true, @@ -390,16 +390,18 @@ struct ContentView_Previews: PreviewProvider { } } -enum SampleError: LocalizedError { - case generic +enum SampleAppError: LocalizedError { + case generalAppError(ErrorDetails) var errorDescription: String? { switch self { - case .generic: - return "This is a generic error" - - @unknown default: - return "An unknown error occurred." + case let .generalAppError(details): + return "Custom error description for SampleAppError.generalAppError with details: \(details)" } } } + +struct ErrorDetails { + let code: Int + let reason: String +} From e97cf3b21dbff1fb7e6925d80e70478d9d7414b8 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 2 Dec 2025 15:05:04 +0200 Subject: [PATCH 09/45] feat: add PostHogStackFrame --- PostHog.xcodeproj/project.pbxproj | 4 ++ .../Models/PostHogBinaryImageInfo.swift | 2 +- .../Models/PostHogStackFrame.swift | 62 ++++++++++++++++++ .../PostHogExceptionProcessor.swift | 12 +--- .../Utils/PostHogDebugImageProvider.swift | 2 +- .../Utils/PostHogStackTrace.swift | 64 +++++++------------ 6 files changed, 94 insertions(+), 52 deletions(-) create mode 100644 PostHog/Error Tracking/Models/PostHogStackFrame.swift diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 8c05c1270..60eda226f 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -178,6 +178,7 @@ DA3BB4AF2ED88DE90097A97A /* ExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */; }; DA3BB4DF2ED992780097A97A /* PostHogBinaryImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DD2ED992780097A97A /* PostHogBinaryImageInfo.swift */; }; DA3BB4E02ED992780097A97A /* PostHogDebugImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */; }; + DA3BB5052EDF15320097A97A /* PostHogStackFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB5042EDF15320097A97A /* PostHogStackFrame.swift */; }; DA4AF61F2D1195D20053EA38 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA4AF6202D1195D20053EA38 /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DA4AF6242D119FC60053EA38 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; @@ -723,6 +724,7 @@ DA3BB4B02ED88DEC0097A97A /* PostHogExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "PostHogExample-Bridging-Header.h"; sourceTree = ""; }; DA3BB4DD2ED992780097A97A /* PostHogBinaryImageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogBinaryImageInfo.swift; sourceTree = ""; }; DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogDebugImageProvider.swift; sourceTree = ""; }; + DA3BB5042EDF15320097A97A /* PostHogStackFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStackFrame.swift; sourceTree = ""; }; DA4FFB142DA93C78006BAEEA /* PostHogSessionReplayTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayTest.swift; sourceTree = ""; }; DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogIdentityTests.swift; sourceTree = ""; }; DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_remote_config.json; sourceTree = ""; }; @@ -1380,6 +1382,7 @@ isa = PBXGroup; children = ( DA3BB4DD2ED992780097A97A /* PostHogBinaryImageInfo.swift */, + DA3BB5042EDF15320097A97A /* PostHogStackFrame.swift */, ); path = Models; sourceTree = ""; @@ -2326,6 +2329,7 @@ 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */, DA9AE8CF2D8357D4002F1B44 /* ConfirmationMessage.swift in Sources */, DA263D472D801117004C100D /* NumberRating.swift in Sources */, + DA3BB5052EDF15320097A97A /* PostHogStackFrame.swift in Sources */, DACC08BB2D6E3C6D00115E46 /* PostHogIntegration.swift in Sources */, DAD5DD0C2CB6DEF30087387B /* PostHogMaskViewModifier.swift in Sources */, 690FF0C52AEFAE8200A0B06B /* PostHogLegacyQueue.swift in Sources */, diff --git a/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift b/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift index 32617e3cb..5e7a9e2e8 100644 --- a/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift +++ b/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift @@ -30,7 +30,7 @@ struct PostHogBinaryImageInfo { /// Size of the binary image in bytes let size: UInt64 - func toDictionary() -> [String: Any] { + var toDictionary: [String: Any] { var dict: [String: Any] = [ "type": "macho", "code_file": name, diff --git a/PostHog/Error Tracking/Models/PostHogStackFrame.swift b/PostHog/Error Tracking/Models/PostHogStackFrame.swift new file mode 100644 index 000000000..91fd4d9a7 --- /dev/null +++ b/PostHog/Error Tracking/Models/PostHogStackFrame.swift @@ -0,0 +1,62 @@ +// +// PostHogStackFrame.swift +// PostHog +// +// Created by Ioannis Josephides on 02/12/2025. +// + +import Foundation + +/// Information about a single stack frame +struct PostHogStackFrame { + /// The instruction address where the frame was executing + let instructionAddress: UInt64 + + /// Name of the binary module (e.g., "MyApp", "Foundation") + let module: String? + + /// Corresponding package + let package: String? + + /// Load address of the binary image in memory + let imageAddress: UInt64? + + /// Whether this frame is considered part of the application code + let inApp: Bool + + /// Function or symbol name (demangled for Swift symbols) + let function: String? + + /// Address of the symbol/function + let symbolAddress: UInt64? + + var toDictionary: [String: Any] { + var dict: [String: Any] = [:] + + dict["instruction_addr"] = String(format: "0x%016llx", instructionAddress) + dict["platform"] = "ios" // always the same for iOS SDK (may need to revisit) + dict["in_app"] = inApp + + if let module = module { + dict["module"] = module + } + + if let package = package { + dict["package"] = package + } + + if let imageAddress = imageAddress { + dict["image_addr"] = String(format: "0x%016llx", imageAddress) + } + + if let function = function { + dict["function"] = function + } + + if let symbolAddress = symbolAddress { + dict["symbol_addr"] = String(format: "0x%016llx", symbolAddress) + } + + return dict + } +} diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index dcff17ac6..184769891 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -347,20 +347,12 @@ enum PostHogExceptionProcessor { guard !frames.isEmpty else { return nil } return [ - "frames": frames, + "frames": frames.map(\.toDictionary), "type": "raw", ] } /// Build stacktrace dictionary from raw addresses (e.g., NSException.callStackReturnAddresses) - /// - /// This produces a non-synthetic stack trace since the addresses come from the actual - /// exception rather than being captured at the reporting site. - /// - /// - Parameters: - /// - addresses: Array of return addresses (from NSException.callStackReturnAddresses) - /// - config: Error tracking configuration for in-app detection - /// - Returns: Stacktrace dictionary or nil if no frames static func buildStacktraceFromAddresses( _ addresses: [NSNumber], config: PostHogErrorTrackingConfig @@ -370,7 +362,7 @@ enum PostHogExceptionProcessor { guard !frames.isEmpty else { return nil } return [ - "frames": frames, + "frames": frames.map(\.toDictionary), "type": "raw", ] } diff --git a/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift b/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift index a12e85907..c2b9653ae 100644 --- a/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift +++ b/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift @@ -56,7 +56,7 @@ enum PostHogDebugImageProvider { static func getDebugImages(for frames: [[String: Any]]) -> [[String: Any]] { let addresses = Set(frames.compactMap { $0["image_addr"] as? String }) guard !addresses.isEmpty else { return [] } - return getImages(for: addresses).map { $0.toDictionary() } + return getImages(for: addresses).map(\.toDictionary) } /// Get debug images for exception list diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index 573556160..4df1cc32f 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -76,38 +76,21 @@ enum PostHogStackTrace { // MARK: - Stack Trace Capture /// Captures current stack trace using dladdr() for rich metadata - /// - /// This approach is inspired by Sentry's implementation and provides: - /// - Instruction addresses (critical for server-side symbolication) - /// - Binary image addresses and names - /// - Symbol addresses for function resolution - /// - Proper in-app detection based on binary images - /// - /// - Parameters: - /// - config: Error tracking configuration for in-app detection - /// - skipFrames: Number of frames to skip from the beginning (default 3) - /// - Returns: Array of frame dictionaries with metadata static func captureCurrentStackTraceWithMetadata( config: PostHogErrorTrackingConfig, skipFrames: Int = 3 - ) -> [[String: Any]] { + ) -> [PostHogStackFrame] { let addresses = Thread.callStackReturnAddresses return symbolicateAddresses(addresses, config: config, skipFrames: skipFrames) } /// Symbolicate an array of return addresses using dladdr() - /// - /// - Parameters: - /// - addresses: Array of return addresses as NSNumber - /// - config: Error tracking configuration for in-app detection - /// - skipFrames: Number of frames to skip from the beginning - /// - Returns: Array of frame dictionaries with metadata static func symbolicateAddresses( _ addresses: [NSNumber], config: PostHogErrorTrackingConfig, skipFrames: Int - ) -> [[String: Any]] { - var frames: [[String: Any]] = [] + ) -> [PostHogStackFrame] { + var frames: [PostHogStackFrame] = [] for (index, addressNum) in addresses.enumerated() { guard index >= skipFrames else { continue } @@ -119,37 +102,38 @@ enum PostHogStackTrace { continue } - var frame: [String: Any] = [:] - - // Instruction address (hex format for compatibility with PostHog backend) - frame["instruction_addr"] = String(format: "0x%016llx", address) + var module: String? + var package: String? + var imageAddress: UInt64? + var inApp = false // Binary image info if let imageName = info.dli_fname { let path = String(cString: imageName) - let module = (path as NSString).lastPathComponent - - frame["module"] = module - frame["package"] = path // Full binary path for symbolication - frame["image_addr"] = String(format: "0x%016llx", UInt(bitPattern: info.dli_fbase)) - - // In-app detection based on binary image - frame["in_app"] = isInApp(module: module, config: config) + module = (path as NSString).lastPathComponent + package = path + imageAddress = UInt64(UInt(bitPattern: info.dli_fbase)) + inApp = isInApp(module: module!, config: config) } // Function/symbol info - // NOTE: dladdr() returns the nearest symbol it can find, which may be INCORRECT - // for stripped binaries. In production App Store builds, symbols are often stripped - // and dladdr() may return a wrong symbol (like a type metadata accessor) or nothing. - // Server-side symbolication with dSYMs is required for accurate function names - // in production crash reports. + var function: String? + var symbolAddress: UInt64? if let symbolName = info.dli_sname { let rawSymbol = String(cString: symbolName) - frame["function"] = demangle(rawSymbol) - frame["symbol_addr"] = String(format: "0x%016llx", UInt(bitPattern: info.dli_saddr)) + function = demangle(rawSymbol) + symbolAddress = UInt64(UInt(bitPattern: info.dli_saddr)) } - frame["platform"] = "ios" + let frame = PostHogStackFrame( + instructionAddress: UInt64(address), + module: module, + package: package, + imageAddress: imageAddress, + inApp: inApp, + function: function, + symbolAddress: symbolAddress + ) frames.append(frame) } From 2a77d54fe84963b3fb22fa573d7e19e29a3fcdf2 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 2 Dec 2025 15:13:20 +0200 Subject: [PATCH 10/45] fix: synthetic --- PostHog/Error Tracking/PostHogExceptionProcessor.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 184769891..00be4df49 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -117,7 +117,7 @@ enum PostHogExceptionProcessor { exception["mechanism"] = [ "type": "generic-message", "handled": true, - "synthetic": true, + "synthetic": true, // always true for message exceptions - we capture current stack ] if let stacktrace = buildStacktrace(config: config) { @@ -237,7 +237,7 @@ enum PostHogExceptionProcessor { exception["mechanism"] = [ "type": mechanismType, "handled": handled, - "synthetic": false, + "synthetic": true, // Always true for NSError - we capture current stack ] if let stacktrace = buildStacktrace(config: config) { From f15c3be0d418869acdb05b33baaad25a3e5c6038 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 4 Dec 2025 11:41:14 +0200 Subject: [PATCH 11/45] feat: add capture async error example --- PostHogExample/ContentView.swift | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 73ac9d959..7de839891 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -117,6 +117,42 @@ struct ContentView: View { } } + /// Creates a multi-level async call chain to test stack trace capture + func captureAsyncError() async { + do { + await try asyncLevel1() + } catch { + PostHogSDK.shared.captureException(error, properties: [ + "is_test": true, + "error_type": "async_await_chain", + "capture_point": "top_level_catch", + ]) + } + } + + private func asyncLevel1() async throws { + // Simulate some async work + await Task.sleep(10_000_000) // 0.01 seconds + try await asyncLevel2() + } + + private func asyncLevel2() async throws { + // Simulate more async work + await Task.sleep(20_000_000) // 0.02 seconds + try await asyncLevel3() + } + + private func asyncLevel3() async throws { + // Simulate final async work before error + await Task.sleep(30_000_000) // 0.03 seconds + + // Throw an error from deep in the async chain + throw AsyncTestError.deepAsyncError( + message: "Error occurred in async level 3", + context: ["level": 3, "operation": "data_processing"] + ) + } + func triggerAuthentication() { signInViewModel.triggerAuthentication() } @@ -348,6 +384,12 @@ struct ContentView: View { "app_state": "some_state", ]) } + + Button("Capture Async/Await Error") { + Task { + await captureAsyncError() + } + } } Section("PostHog beers") { @@ -405,3 +447,14 @@ struct ErrorDetails { let code: Int let reason: String } + +enum AsyncTestError: LocalizedError { + case deepAsyncError(message: String, context: [String: Any]) + + var errorDescription: String? { + switch self { + case let .deepAsyncError(message, context): + return "Async Error: \(message) | Context: \(context)" + } + } +} From 1d0a159398f8abcccf0a0fd029844cbe7ff8d9fe Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 4 Dec 2025 11:58:55 +0200 Subject: [PATCH 12/45] feat: remove swift symbol demangling --- .../Models/PostHogStackFrame.swift | 2 +- .../Utils/PostHogStackTrace.swift | 62 +------------------ 2 files changed, 3 insertions(+), 61 deletions(-) diff --git a/PostHog/Error Tracking/Models/PostHogStackFrame.swift b/PostHog/Error Tracking/Models/PostHogStackFrame.swift index 91fd4d9a7..58007adfc 100644 --- a/PostHog/Error Tracking/Models/PostHogStackFrame.swift +++ b/PostHog/Error Tracking/Models/PostHogStackFrame.swift @@ -24,7 +24,7 @@ struct PostHogStackFrame { /// Whether this frame is considered part of the application code let inApp: Bool - /// Function or symbol name (demangled for Swift symbols) + /// Function or symbol name (raw symbol without demangling) let function: String? /// Address of the symbol/function diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index 4df1cc32f..7bd34a214 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -15,63 +15,6 @@ import MachO /// and format them consistently for error tracking. /// enum PostHogStackTrace { - // MARK: - Swift Symbol Demangling - - /// Type alias for the swift_demangle function signature - private typealias SwiftDemangleFunc = @convention(c) ( - _ mangledName: UnsafePointer?, - _ mangledNameLength: Int, - _ outputBuffer: UnsafeMutablePointer?, - _ outputBufferSize: UnsafeMutablePointer?, - _ flags: UInt32 - ) -> UnsafeMutablePointer? - - /// Cached reference to the swift_demangle function - private static let swiftDemangleFunc: SwiftDemangleFunc? = { - guard let handle = dlopen(nil, RTLD_NOW), - let sym = dlsym(handle, "swift_demangle") - else { - return nil - } - return unsafeBitCast(sym, to: SwiftDemangleFunc.self) - }() - - /// Attempt to demangle a Swift symbol name - /// - /// Swift symbols are mangled (e.g., "_$s4MyApp0A5ClassC6methodyyF"). - /// This uses the Swift runtime's swift_demangle function to convert - /// them to human-readable form (e.g., "MyApp.MyClass.method() -> ()"). - /// - /// - Parameter symbolName: The mangled symbol name - /// - Returns: The demangled name if successful, otherwise the original name - private static func demangle(_ symbolName: String) -> String { - // Only attempt to demangle Swift symbols - // Swift mangled names start with "$s", "_$s", "$S", or "_$S" - guard symbolName.hasPrefix("$s") || - symbolName.hasPrefix("_$s") || - symbolName.hasPrefix("$S") || - symbolName.hasPrefix("_$S") - else { - return symbolName - } - - guard let demangleFunc = swiftDemangleFunc else { - return symbolName - } - - // Call swift_demangle - must use withCString to get proper pointer - let demangled = symbolName.withCString { cString -> String? in - // swift_demangle expects UnsafePointer, convert from Int8 - let result = cString.withMemoryRebound(to: UInt8.self, capacity: symbolName.utf8.count) { ptr in - demangleFunc(ptr, symbolName.utf8.count, nil, nil, 0) - } - guard let demangledCString = result else { return nil } - defer { demangledCString.deallocate() } - return String(cString: demangledCString) - } - - return demangled ?? symbolName - } // MARK: - Stack Trace Capture @@ -116,12 +59,11 @@ enum PostHogStackTrace { inApp = isInApp(module: module!, config: config) } - // Function/symbol info + // Function/symbol info (raw symbols without demangling) var function: String? var symbolAddress: UInt64? if let symbolName = info.dli_sname { - let rawSymbol = String(cString: symbolName) - function = demangle(rawSymbol) + function = String(cString: symbolName) // Use raw symbol symbolAddress = UInt64(UInt(bitPattern: info.dli_saddr)) } From bc6171b0a4e21285d618e5b8fa69a87eef7446a4 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 4 Dec 2025 13:44:48 +0200 Subject: [PATCH 13/45] fix: skip top posthog frames --- .../PostHogExceptionProcessor.swift | 5 ++- .../Utils/PostHogStackTrace.swift | 38 +++++++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 00be4df49..4e9cf6ce4 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -342,7 +342,7 @@ enum PostHogExceptionProcessor { /// Build stacktrace dictionary from current thread (synthetic) static func buildStacktrace(config: PostHogErrorTrackingConfig) -> [String: Any]? { - let frames = PostHogStackTrace.captureCurrentStackTraceWithMetadata(config: config, skipFrames: 3) + let frames = PostHogStackTrace.captureCurrentStackTraceWithMetadata(config: config) guard !frames.isEmpty else { return nil } @@ -357,7 +357,8 @@ enum PostHogExceptionProcessor { _ addresses: [NSNumber], config: PostHogErrorTrackingConfig ) -> [String: Any]? { - let frames = PostHogStackTrace.symbolicateAddresses(addresses, config: config, skipFrames: 0) + // Don't strip PostHog frames for NSException - the addresses are from the exception itself + let frames = PostHogStackTrace.symbolicateAddresses(addresses, config: config, stripTopPostHogFrames: false) guard !frames.isEmpty else { return nil } diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index 7bd34a214..ddbd7b03e 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -19,25 +19,31 @@ enum PostHogStackTrace { // MARK: - Stack Trace Capture /// Captures current stack trace using dladdr() for rich metadata + /// + /// Automatically strips PostHog SDK frames from the top of the stack trace + /// so the trace starts at user code. static func captureCurrentStackTraceWithMetadata( - config: PostHogErrorTrackingConfig, - skipFrames: Int = 3 + config: PostHogErrorTrackingConfig ) -> [PostHogStackFrame] { let addresses = Thread.callStackReturnAddresses - return symbolicateAddresses(addresses, config: config, skipFrames: skipFrames) + return symbolicateAddresses(addresses, config: config, stripTopPostHogFrames: true) } - /// Symbolicate an array of return addresses using dladdr() + /// Symbolicate an array of return addresses using dladdr()r + /// + /// - Parameters: + /// - addresses: Array of return addresses to symbolicate + /// - config: Error tracking configuration + /// - stripTopPostHogFrames: If true, strips PostHog SDK frames from the top of the stack static func symbolicateAddresses( _ addresses: [NSNumber], config: PostHogErrorTrackingConfig, - skipFrames: Int + stripTopPostHogFrames: Bool = false ) -> [PostHogStackFrame] { var frames: [PostHogStackFrame] = [] + var shouldCollectFrame = !stripTopPostHogFrames - for (index, addressNum) in addresses.enumerated() { - guard index >= skipFrames else { continue } - + for addressNum in addresses { let address = addressNum.uintValue var info = Dl_info() @@ -59,6 +65,14 @@ enum PostHogStackTrace { inApp = isInApp(module: module!, config: config) } + // Skip PostHog frames at the top of the stack + if !shouldCollectFrame { + if isPostHogModule(module) { + continue + } + shouldCollectFrame = true + } + // Function/symbol info (raw symbols without demangling) var function: String? var symbolAddress: UInt64? @@ -155,4 +169,12 @@ enum PostHogStackTrace { private static func isSystemFramework(_ module: String) -> Bool { systemPrefixes.contains { module.hasPrefix($0) } } + + // MARK: - PostHog Frame Detection + + /// Check if a module belongs to the PostHog SDK + private static func isPostHogModule(_ module: String?) -> Bool { + guard let module = module else { return false } + return module == "PostHog" || module.hasPrefix("PostHog.") + } } From 62e0a8d1d59db2350f342a5584939e4948372e4c Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 4 Dec 2025 14:08:28 +0200 Subject: [PATCH 14/45] fix: remove strip symbols script --- PostHog.xcodeproj/project.pbxproj | 19 --- PostHogExample/scripts/strip-example-app.sh | 145 -------------------- 2 files changed, 164 deletions(-) delete mode 100755 PostHogExample/scripts/strip-example-app.sh diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 60eda226f..a132269b9 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -1759,7 +1759,6 @@ 3AA34CF4296D951A003398F4 /* Frameworks */, 3AA34CF5296D951A003398F4 /* Resources */, DA1044D82D0B34F200C4ACF3 /* Embed Frameworks */, - 8204AFBF2A600CDF749AB4C1 /* Strip Symbols if Release (To simulate App Store distribution) */, ); buildRules = ( ); @@ -2123,24 +2122,6 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 8204AFBF2A600CDF749AB4C1 /* Strip Symbols if Release (To simulate App Store distribution) */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 8; - files = ( - ); - inputPaths = ( - ); - name = "Strip Symbols if Release (To simulate App Store distribution)"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 1; - shellPath = /bin/sh; - shellScript = "# Only strip in Release builds\nif [ \"$CONFIGURATION\" == \"Release\" ]; then\n echo \"Stripping symbols to simulate App Store distribution...\"\n \"${SRCROOT}/PostHogExample/scripts/strip-example-app.sh\" \"${CONFIGURATION}-${PLATFORM_NAME}\"\nelse\n echo \"Skipping symbol stripping (Debug build)\"\nfi\n"; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 3AA34CF3296D951A003398F4 /* Sources */ = { isa = PBXSourcesBuildPhase; diff --git a/PostHogExample/scripts/strip-example-app.sh b/PostHogExample/scripts/strip-example-app.sh deleted file mode 100755 index f02486de1..000000000 --- a/PostHogExample/scripts/strip-example-app.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/bin/bash -# -# Strips symbols from PostHogExample app and PostHog framework -# to simulate App Store distribution for testing symbolication -# - -set -e - -# When run from Xcode build phase, use environment variables -# When run manually, find derived data -if [ -n "$BUILT_PRODUCTS_DIR" ]; then - # Running as Xcode build phase - BUILD_DIR="$BUILT_PRODUCTS_DIR" - APP_BINARY="$TARGET_BUILD_DIR/$EXECUTABLE_PATH" - echo "📁 Running as Xcode build phase" - echo " Build dir: $BUILD_DIR" -else - # Running manually - DERIVED_DATA_BASE="$HOME/Library/Developer/Xcode/DerivedData" - POSTHOG_DERIVED=$(find "$DERIVED_DATA_BASE" -maxdepth 1 -name "PostHog-*" -type d | head -1) - - if [ -z "$POSTHOG_DERIVED" ]; then - echo "❌ Could not find PostHog derived data folder" - echo " Make sure you've built the project first" - exit 1 - fi - - echo "📁 Found derived data: $POSTHOG_DERIVED" - - # Configuration (default to Release-iphonesimulator) - CONFIG="${1:-Release-iphonesimulator}" - BUILD_DIR="$POSTHOG_DERIVED/Build/Products/$CONFIG" - - if [ ! -d "$BUILD_DIR" ]; then - echo "❌ Build directory not found: $BUILD_DIR" - echo " Available configurations:" - ls "$POSTHOG_DERIVED/Build/Products/" 2>/dev/null || echo " (none)" - exit 1 - fi - - APP_BINARY="$BUILD_DIR/PostHogExample.app/PostHogExample" -fi - -# Framework paths -FRAMEWORK_BINARY="$BUILD_DIR/PostHog.framework/PostHog" - -echo "" -echo "🔍 Checking binaries..." - -# Check app binary -if [ -f "$APP_BINARY" ]; then - SYMBOLS_BEFORE=$(nm "$APP_BINARY" 2>/dev/null | wc -l | tr -d ' ') - echo " App binary: $APP_BINARY" - echo " Symbols before: $SYMBOLS_BEFORE" -else - echo "❌ App binary not found: $APP_BINARY" - exit 1 -fi - -# Check framework binary -if [ -f "$FRAMEWORK_BINARY" ]; then - FW_SYMBOLS_BEFORE=$(nm "$FRAMEWORK_BINARY" 2>/dev/null | wc -l | tr -d ' ') - echo " Framework binary: $FRAMEWORK_BINARY" - echo " Symbols before: $FW_SYMBOLS_BEFORE" -else - echo "⚠️ Framework binary not found (embedded in app?)" - FRAMEWORK_BINARY="" -fi - -# Also check for embedded framework -if [ -n "$TARGET_BUILD_DIR" ]; then - EMBEDDED_FRAMEWORK="$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Frameworks/PostHog.framework/PostHog" -else - EMBEDDED_FRAMEWORK="$BUILD_DIR/PostHogExample.app/Frameworks/PostHog.framework/PostHog" -fi -if [ -f "$EMBEDDED_FRAMEWORK" ]; then - EMB_SYMBOLS_BEFORE=$(nm "$EMBEDDED_FRAMEWORK" 2>/dev/null | wc -l | tr -d ' ') - echo " Embedded framework: $EMBEDDED_FRAMEWORK" - echo " Symbols before: $EMB_SYMBOLS_BEFORE" -fi - -echo "" -echo "✂️ Stripping symbols..." - -# Strip app binary -strip -x "$APP_BINARY" -SYMBOLS_AFTER=$(nm "$APP_BINARY" 2>/dev/null | wc -l | tr -d ' ') -echo " App: $SYMBOLS_BEFORE → $SYMBOLS_AFTER symbols" - -# Strip framework binary (if exists) -if [ -n "$FRAMEWORK_BINARY" ] && [ -f "$FRAMEWORK_BINARY" ]; then - strip -x "$FRAMEWORK_BINARY" - FW_SYMBOLS_AFTER=$(nm "$FRAMEWORK_BINARY" 2>/dev/null | wc -l | tr -d ' ') - echo " Framework: $FW_SYMBOLS_BEFORE → $FW_SYMBOLS_AFTER symbols" -fi - -# Strip embedded framework (if exists) -if [ -f "$EMBEDDED_FRAMEWORK" ]; then - strip -x "$EMBEDDED_FRAMEWORK" - EMB_SYMBOLS_AFTER=$(nm "$EMBEDDED_FRAMEWORK" 2>/dev/null | wc -l | tr -d ' ') - echo " Embedded framework: $EMB_SYMBOLS_BEFORE → $EMB_SYMBOLS_AFTER symbols" -fi - -echo "" -echo "🔏 Re-signing binaries (required after stripping)..." - -# Re-sign app binary -codesign --force --sign - "$APP_BINARY" -echo " App re-signed" - -# Re-sign framework binary (if exists) -if [ -n "$FRAMEWORK_BINARY" ] && [ -f "$FRAMEWORK_BINARY" ]; then - codesign --force --sign - "$FRAMEWORK_BINARY" - echo " Framework re-signed" -fi - -# Re-sign embedded framework (if exists) -if [ -f "$EMBEDDED_FRAMEWORK" ]; then - # Need to sign the framework bundle, not just the binary - EMBEDDED_FRAMEWORK_DIR=$(dirname "$EMBEDDED_FRAMEWORK") - codesign --force --sign - "$EMBEDDED_FRAMEWORK_DIR" - echo " Embedded framework re-signed" -fi - -# Re-sign the whole app bundle -if [ -n "$TARGET_BUILD_DIR" ] && [ -n "$FULL_PRODUCT_NAME" ]; then - APP_BUNDLE="$TARGET_BUILD_DIR/$FULL_PRODUCT_NAME" -else - APP_BUNDLE="$BUILD_DIR/PostHogExample.app" -fi - -if [ -d "$APP_BUNDLE" ]; then - codesign --force --sign - "$APP_BUNDLE" - echo " App bundle re-signed" -fi - -echo "" -echo "✅ Done! Symbols stripped and binaries re-signed." -echo "" -echo "📝 To test:" -echo " 1. Run the app from Xcode (it will use the stripped binary)" -echo " 2. Trigger an error capture" -echo " 3. Check that stack frames show only addresses (no function names)" -echo "" -echo "⚠️ Note: Rebuilding will restore symbols. Run this script again after each build." From fce9076099e5ca8edf154cb0c4ccc78bd1de2107 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 4 Dec 2025 14:23:15 +0200 Subject: [PATCH 15/45] fix: update sample project release config --- PostHog.xcodeproj/project.pbxproj | 4 ++++ PostHog/Error Tracking/Utils/PostHogStackTrace.swift | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index a132269b9..94c28c6c4 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -2561,6 +2561,8 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 10; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEPLOYMENT_POSTPROCESSING = YES; DEVELOPMENT_ASSET_PATHS = "\"PostHogExample/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; @@ -2579,6 +2581,8 @@ PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + STRIP_INSTALLED_PRODUCT = YES; + STRIP_STYLE = all; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index ddbd7b03e..571102734 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -29,7 +29,7 @@ enum PostHogStackTrace { return symbolicateAddresses(addresses, config: config, stripTopPostHogFrames: true) } - /// Symbolicate an array of return addresses using dladdr()r + /// Symbolicate an array of return addresses using dladdr() /// /// - Parameters: /// - addresses: Array of return addresses to symbolicate From 315530cabf7fb0a92ef689ff76232b9883f8e023 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 4 Dec 2025 14:45:03 +0200 Subject: [PATCH 16/45] fix: avoid force unwrap --- .../Utils/PostHogDebugImageProvider.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift b/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift index c2b9653ae..e3922864a 100644 --- a/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift +++ b/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift @@ -175,17 +175,19 @@ enum PostHogDebugImageProvider { ) -> (vmaddr: UInt64, vmsize: UInt64)? { if is64Bit { let segCmd = cmdPtr.assumingMemoryBound(to: segment_command_64.self).pointee - let segName = withUnsafeBytes(of: segCmd.segname) { ptr -> String in + let segName = withUnsafeBytes(of: segCmd.segname) { ptr -> String? in let bytes = ptr.bindMemory(to: CChar.self) - return String(cString: bytes.baseAddress!) + guard let baseAddress = bytes.baseAddress else { return nil } + return String(cString: baseAddress) } guard segName == segmentText else { return nil } return (segCmd.vmaddr, segCmd.vmsize) } else { let segCmd = cmdPtr.assumingMemoryBound(to: segment_command.self).pointee - let segName = withUnsafeBytes(of: segCmd.segname) { ptr -> String in + let segName = withUnsafeBytes(of: segCmd.segname) { ptr -> String? in let bytes = ptr.bindMemory(to: CChar.self) - return String(cString: bytes.baseAddress!) + guard let baseAddress = bytes.baseAddress else { return nil } + return String(cString: baseAddress) } guard segName == segmentText else { return nil } return (UInt64(segCmd.vmaddr), UInt64(segCmd.vmsize)) From b121d02c8637c9c2cf81368117defcc5df98c40d Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 4 Dec 2025 15:47:24 +0200 Subject: [PATCH 17/45] fix: add helper --- .../Models/PostHogStackFrame.swift | 6 +-- .../PostHogExceptionProcessor.swift | 48 ++++++++----------- .../Utils/PostHogStackTrace.swift | 5 +- 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/PostHog/Error Tracking/Models/PostHogStackFrame.swift b/PostHog/Error Tracking/Models/PostHogStackFrame.swift index 58007adfc..37f473c1e 100644 --- a/PostHog/Error Tracking/Models/PostHogStackFrame.swift +++ b/PostHog/Error Tracking/Models/PostHogStackFrame.swift @@ -33,7 +33,7 @@ struct PostHogStackFrame { var toDictionary: [String: Any] { var dict: [String: Any] = [:] - dict["instruction_addr"] = String(format: "0x%016llx", instructionAddress) + dict["instruction_addr"] = String(format: "0x%llx", instructionAddress) dict["platform"] = "ios" // always the same for iOS SDK (may need to revisit) dict["in_app"] = inApp @@ -46,7 +46,7 @@ struct PostHogStackFrame { } if let imageAddress = imageAddress { - dict["image_addr"] = String(format: "0x%016llx", imageAddress) + dict["image_addr"] = String(format: "0x%llx", imageAddress) } if let function = function { @@ -54,7 +54,7 @@ struct PostHogStackFrame { } if let symbolAddress = symbolAddress { - dict["symbol_addr"] = String(format: "0x%016llx", symbolAddress) + dict["symbol_addr"] = String(format: "0x%llx", symbolAddress) } return dict diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 4e9cf6ce4..80b03576a 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -41,15 +41,7 @@ enum PostHogExceptionProcessor { config: config ) - if !exceptions.isEmpty { - properties["$exception_list"] = exceptions - - // Attach debug images for server-side symbolication - let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) - if !debugImages.isEmpty { - properties["$debug_images"] = debugImages - } - } + attachExceptionsAndDebugImages(exceptions, to: &properties) return properties } @@ -81,15 +73,7 @@ enum PostHogExceptionProcessor { config: config ) - if !exceptions.isEmpty { - properties["$exception_list"] = exceptions - - // Attach debug images for server-side symbolication - let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) - if !debugImages.isEmpty { - properties["$debug_images"] = debugImages - } - } + attachExceptionsAndDebugImages(exceptions, to: &properties) return properties } @@ -125,13 +109,7 @@ enum PostHogExceptionProcessor { } let exceptions = [exception] - properties["$exception_list"] = exceptions - - // Attach debug images for server-side symbolication - let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) - if !debugImages.isEmpty { - properties["$debug_images"] = debugImages - } + attachExceptionsAndDebugImages(exceptions, to: &properties) return properties } @@ -338,10 +316,26 @@ enum PostHogExceptionProcessor { return module.isEmpty ? nil : module } + // MARK: - Helpers + + /// Attach exceptions and debug images to properties dictionary + private static func attachExceptionsAndDebugImages( + _ exceptions: [[String: Any]], + to properties: inout [String: Any] + ) { + guard !exceptions.isEmpty else { return } + properties["$exception_list"] = exceptions + + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + if !debugImages.isEmpty { + properties["$debug_images"] = debugImages + } + } + // MARK: - Stack Trace Capture /// Build stacktrace dictionary from current thread (synthetic) - static func buildStacktrace(config: PostHogErrorTrackingConfig) -> [String: Any]? { + private static func buildStacktrace(config: PostHogErrorTrackingConfig) -> [String: Any]? { let frames = PostHogStackTrace.captureCurrentStackTraceWithMetadata(config: config) guard !frames.isEmpty else { return nil } @@ -353,7 +347,7 @@ enum PostHogExceptionProcessor { } /// Build stacktrace dictionary from raw addresses (e.g., NSException.callStackReturnAddresses) - static func buildStacktraceFromAddresses( + private static func buildStacktraceFromAddresses( _ addresses: [NSNumber], config: PostHogErrorTrackingConfig ) -> [String: Any]? { diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index 571102734..0a6edaeca 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -59,10 +59,11 @@ enum PostHogStackTrace { // Binary image info if let imageName = info.dli_fname { let path = String(cString: imageName) - module = (path as NSString).lastPathComponent + let moduleName = (path as NSString).lastPathComponent + module = moduleName package = path imageAddress = UInt64(UInt(bitPattern: info.dli_fbase)) - inApp = isInApp(module: module!, config: config) + inApp = isInApp(module: moduleName, config: config) } // Skip PostHog frames at the top of the stack From cc4abb88ac1f1ff6d3966945eee1d80c1245ca4e Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 11 Dec 2025 01:43:03 +0200 Subject: [PATCH 18/45] fix: hex constant --- .../Error Tracking/Models/PostHogBinaryImageInfo.swift | 4 ++-- PostHog/Error Tracking/Models/PostHogStackFrame.swift | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift b/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift index 5e7a9e2e8..32afd0911 100644 --- a/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift +++ b/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift @@ -34,7 +34,7 @@ struct PostHogBinaryImageInfo { var dict: [String: Any] = [ "type": "macho", "code_file": name, - "image_addr": String(format: "0x%llx", address), + "image_addr": String(format: PostHogStackFrame.hexAddressFormat, address), "image_size": size, ] @@ -43,7 +43,7 @@ struct PostHogBinaryImageInfo { } if vmAddress > 0 { - dict["image_vmaddr"] = String(format: "0x%llx", vmAddress) + dict["image_vmaddr"] = String(format: PostHogStackFrame.hexAddressFormat, vmAddress) } return dict diff --git a/PostHog/Error Tracking/Models/PostHogStackFrame.swift b/PostHog/Error Tracking/Models/PostHogStackFrame.swift index 37f473c1e..7399bb635 100644 --- a/PostHog/Error Tracking/Models/PostHogStackFrame.swift +++ b/PostHog/Error Tracking/Models/PostHogStackFrame.swift @@ -9,6 +9,9 @@ import Foundation /// Information about a single stack frame struct PostHogStackFrame { + /// Format string for converting UInt64 addresses to hex strings (e.g., "0x7fff12345678") + static let hexAddressFormat = "0x%llx" + /// The instruction address where the frame was executing let instructionAddress: UInt64 @@ -33,7 +36,7 @@ struct PostHogStackFrame { var toDictionary: [String: Any] { var dict: [String: Any] = [:] - dict["instruction_addr"] = String(format: "0x%llx", instructionAddress) + dict["instruction_addr"] = String(format: Self.hexAddressFormat, instructionAddress) dict["platform"] = "ios" // always the same for iOS SDK (may need to revisit) dict["in_app"] = inApp @@ -46,7 +49,7 @@ struct PostHogStackFrame { } if let imageAddress = imageAddress { - dict["image_addr"] = String(format: "0x%llx", imageAddress) + dict["image_addr"] = String(format: Self.hexAddressFormat, imageAddress) } if let function = function { @@ -54,7 +57,7 @@ struct PostHogStackFrame { } if let symbolAddress = symbolAddress { - dict["symbol_addr"] = String(format: "0x%llx", symbolAddress) + dict["symbol_addr"] = String(format: Self.hexAddressFormat, symbolAddress) } return dict From 72566dd3e39276beced9bde9abf31598c763d249 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 11 Dec 2025 01:43:57 +0200 Subject: [PATCH 19/45] fix: update platform --- PostHog/Error Tracking/Models/PostHogStackFrame.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PostHog/Error Tracking/Models/PostHogStackFrame.swift b/PostHog/Error Tracking/Models/PostHogStackFrame.swift index 7399bb635..5013cd8f5 100644 --- a/PostHog/Error Tracking/Models/PostHogStackFrame.swift +++ b/PostHog/Error Tracking/Models/PostHogStackFrame.swift @@ -37,7 +37,7 @@ struct PostHogStackFrame { var dict: [String: Any] = [:] dict["instruction_addr"] = String(format: Self.hexAddressFormat, instructionAddress) - dict["platform"] = "ios" // always the same for iOS SDK (may need to revisit) + dict["platform"] = "apple" // always the same for posthog-ios (may need to revisit) dict["in_app"] = inApp if let module = module { From 6c836a4b76c49c17280fd8e5655bafd6b0a7e714 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 11 Dec 2025 02:09:19 +0200 Subject: [PATCH 20/45] fix: update some comments --- .../Models/PostHogStackFrame.swift | 30 ++++++++--------- .../PostHogErrorTrackingConfig.swift | 12 +++---- .../PostHogExceptionProcessor.swift | 32 +++++++++++++++---- .../Utils/PostHogStackTrace.swift | 3 +- PostHogExample/ContentView.swift | 4 +-- 5 files changed, 49 insertions(+), 32 deletions(-) diff --git a/PostHog/Error Tracking/Models/PostHogStackFrame.swift b/PostHog/Error Tracking/Models/PostHogStackFrame.swift index 5013cd8f5..60412feea 100644 --- a/PostHog/Error Tracking/Models/PostHogStackFrame.swift +++ b/PostHog/Error Tracking/Models/PostHogStackFrame.swift @@ -11,55 +11,55 @@ import Foundation struct PostHogStackFrame { /// Format string for converting UInt64 addresses to hex strings (e.g., "0x7fff12345678") static let hexAddressFormat = "0x%llx" - + /// The instruction address where the frame was executing let instructionAddress: UInt64 - + /// Name of the binary module (e.g., "MyApp", "Foundation") let module: String? - + /// Corresponding package let package: String? - + /// Load address of the binary image in memory let imageAddress: UInt64? - + /// Whether this frame is considered part of the application code let inApp: Bool - + /// Function or symbol name (raw symbol without demangling) let function: String? - + /// Address of the symbol/function let symbolAddress: UInt64? - + var toDictionary: [String: Any] { var dict: [String: Any] = [:] - + dict["instruction_addr"] = String(format: Self.hexAddressFormat, instructionAddress) dict["platform"] = "apple" // always the same for posthog-ios (may need to revisit) dict["in_app"] = inApp - + if let module = module { dict["module"] = module } - + if let package = package { dict["package"] = package } - + if let imageAddress = imageAddress { dict["image_addr"] = String(format: Self.hexAddressFormat, imageAddress) } - + if let function = function { dict["function"] = function } - + if let symbolAddress = symbolAddress { dict["symbol_addr"] = String(format: Self.hexAddressFormat, symbolAddress) } - + return dict } } diff --git a/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift b/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift index c1e26f547..e97bf1b2b 100644 --- a/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift +++ b/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift @@ -23,8 +23,8 @@ import Foundation /// Example: /// ```swift /// config.errorTrackingConfig.inAppIncludes = [ - /// "com.mycompany.MyApp", - /// "com.mycompany.SharedUtils" + /// "MyApp", + /// "SharedUtils" /// ] /// ``` /// @@ -43,8 +43,8 @@ import Foundation /// Example: /// ```swift /// config.errorTrackingConfig.inAppExcludes = [ - /// "ThirdPartySDK", - /// "AnalyticsLib" + /// "Alamofire", + /// "SDWebImage" /// ] /// ``` /// @@ -72,9 +72,7 @@ import Foundation super.init() // Auto-add main bundle identifier - if let bundleId = Bundle.main.bundleIdentifier { - inAppIncludes.append(bundleId) - } + inAppIncludes.append(getBundleIdentifier()) // Auto-add executable name // This helps catch app code when bundle ID might not be in module name diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 80b03576a..3cb46c681 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -119,7 +119,15 @@ enum PostHogExceptionProcessor { /// Build list of exceptions from NSException chain /// /// Walks the NSException chain via NSUnderlyingErrorKey to capture all related exceptions. - /// The list is ordered from root exception to underlying exceptions (same as Android). + /// The list is ordered root-first, matching iOS console output format where the outermost + /// exception is displayed first with underlying exceptions nested inside. + /// + /// Example iOS console output: + /// ``` + /// Error Domain=OuterErrorDomain Code=300 "Outer wrapper" UserInfo={ + /// NSUnderlyingError=0x... {Error Domain=InnerErrorDomain Code=100 "Root cause" ...} + /// } + /// ``` private static func buildExceptionList( from exception: NSException, handled: Bool, @@ -138,7 +146,8 @@ enum PostHogExceptionProcessor { current = underlying } - // Build exceptions (same order as Android) + // Build exceptions in order: root first, deepest underlying last + // This matches iOS console output format for exc in nsExceptions { if let exceptionDict = buildException( from: exc, @@ -156,7 +165,15 @@ enum PostHogExceptionProcessor { /// Build list of exceptions from error chain /// /// Walks the error chain via NSUnderlyingErrorKey to capture all related errors. - /// The list is ordered from root error to underlying errors (same as Android). + /// The list is ordered root-first, matching iOS console output format where the outermost + /// error is displayed first with underlying errors nested inside. + /// + /// Example iOS console output: + /// ``` + /// Error Domain=OuterErrorDomain Code=300 "Outer wrapper" UserInfo={ + /// NSUnderlyingError=0x... {Error Domain=InnerErrorDomain Code=100 "Root cause" ...} + /// } + /// ``` private static func buildExceptionList( from error: Error, handled: Bool, @@ -176,7 +193,8 @@ enum PostHogExceptionProcessor { current = underlying } - // Build exceptions (same order as Android) + // Build exceptions in order: root first, deepest underlying last + // This matches iOS console output format for err in errors { if let exception = buildException( from: err, @@ -215,7 +233,7 @@ enum PostHogExceptionProcessor { exception["mechanism"] = [ "type": mechanismType, "handled": handled, - "synthetic": true, // Always true for NSError - we capture current stack + "synthetic": true, // Always true for NSError - we capture current stack ] if let stacktrace = buildStacktrace(config: config) { @@ -235,8 +253,10 @@ enum PostHogExceptionProcessor { var exceptionDict: [String: Any] = [:] exceptionDict["type"] = exception.name.rawValue - exceptionDict["value"] = exception.reason ?? "Unknown exception" exceptionDict["thread_id"] = Thread.current.threadId + if let reason = exception.reason { + exceptionDict["value"] = reason + } // Use exception's real stack if available, otherwise capture current (synthetic) let exceptionAddresses = exception.callStackReturnAddresses diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift index 0a6edaeca..b1ac77fc0 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTrace.swift @@ -15,7 +15,6 @@ import MachO /// and format them consistently for error tracking. /// enum PostHogStackTrace { - // MARK: - Stack Trace Capture /// Captures current stack trace using dladdr() for rich metadata @@ -78,7 +77,7 @@ enum PostHogStackTrace { var function: String? var symbolAddress: UInt64? if let symbolName = info.dli_sname { - function = String(cString: symbolName) // Use raw symbol + function = String(cString: symbolName) // Use raw symbol symbolAddress = UInt64(UInt(bitPattern: info.dli_saddr)) } diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 7de839891..ec280fc09 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -145,7 +145,7 @@ struct ContentView: View { private func asyncLevel3() async throws { // Simulate final async work before error await Task.sleep(30_000_000) // 0.03 seconds - + // Throw an error from deep in the async chain throw AsyncTestError.deepAsyncError( message: "Error occurred in async level 3", @@ -450,7 +450,7 @@ struct ErrorDetails { enum AsyncTestError: LocalizedError { case deepAsyncError(message: String, context: [String: Any]) - + var errorDescription: String? { switch self { case let .deepAsyncError(message, context): From 37f4d54a85c1fec859dba966b15c0d2b52410777 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 11 Dec 2025 02:34:03 +0200 Subject: [PATCH 21/45] fix: rename PostHogStackTrace --- PostHog.xcodeproj/project.pbxproj | 8 ++++---- ...gStackTrace.swift => PostHogStackTraceProcessor.swift} | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) rename PostHog/Error Tracking/Utils/{PostHogStackTrace.swift => PostHogStackTraceProcessor.swift} (97%) diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 94c28c6c4..3abf60e7d 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -173,7 +173,7 @@ DA30AE692D3EFB4F00465A64 /* Optional+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA30AE682D3EFB4F00465A64 /* Optional+Util.swift */; }; DA30AE812D3FE63F00465A64 /* PostHogSurvey.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA30AE802D3FE63F00465A64 /* PostHogSurvey.swift */; }; DA37933C2DBA571D005C6AA3 /* PostHogSurveysConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3793352DBA5718005C6AA3 /* PostHogSurveysConfig.swift */; }; - DA3BB4AA2ED82E410097A97A /* PostHogStackTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4A62ED82E410097A97A /* PostHogStackTrace.swift */; }; + DA3BB4AA2ED82E410097A97A /* PostHogStackTraceProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4A62ED82E410097A97A /* PostHogStackTraceProcessor.swift */; }; DA3BB4AC2ED82EE80097A97A /* PostHogExceptionProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */; }; DA3BB4AF2ED88DE90097A97A /* ExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */; }; DA3BB4DF2ED992780097A97A /* PostHogBinaryImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DD2ED992780097A97A /* PostHogBinaryImageInfo.swift */; }; @@ -717,7 +717,7 @@ DA30AE682D3EFB4F00465A64 /* Optional+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Util.swift"; sourceTree = ""; }; DA30AE802D3FE63F00465A64 /* PostHogSurvey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurvey.swift; sourceTree = ""; }; DA3793352DBA5718005C6AA3 /* PostHogSurveysConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveysConfig.swift; sourceTree = ""; }; - DA3BB4A62ED82E410097A97A /* PostHogStackTrace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStackTrace.swift; sourceTree = ""; }; + DA3BB4A62ED82E410097A97A /* PostHogStackTraceProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStackTraceProcessor.swift; sourceTree = ""; }; DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogExceptionProcessor.swift; sourceTree = ""; }; DA3BB4AD2ED88DE90097A97A /* ExceptionHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExceptionHandler.h; sourceTree = ""; }; DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExceptionHandler.m; sourceTree = ""; }; @@ -1372,7 +1372,7 @@ DA3BB49E2ED82E250097A97A /* Utils */ = { isa = PBXGroup; children = ( - DA3BB4A62ED82E410097A97A /* PostHogStackTrace.swift */, + DA3BB4A62ED82E410097A97A /* PostHogStackTraceProcessor.swift */, DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */, ); path = Utils; @@ -2166,7 +2166,7 @@ 3AE3FB49299391DF00AFFC18 /* PostHogStorage.swift in Sources */, DA9AE8D12D835BEA002F1B44 /* QuestionHeader.swift in Sources */, 69261D232AD9784200232EC7 /* PostHogVersion.swift in Sources */, - DA3BB4AA2ED82E410097A97A /* PostHogStackTrace.swift in Sources */, + DA3BB4AA2ED82E410097A97A /* PostHogStackTraceProcessor.swift in Sources */, 69779BEC2AE68E6900D7A48E /* UIViewController.swift in Sources */, 3A0F108929C9BD76002C0084 /* Errors.swift in Sources */, 3AE3FB37299162EA00AFFC18 /* PostHogApi.swift in Sources */, diff --git a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift b/PostHog/Error Tracking/Utils/PostHogStackTraceProcessor.swift similarity index 97% rename from PostHog/Error Tracking/Utils/PostHogStackTrace.swift rename to PostHog/Error Tracking/Utils/PostHogStackTraceProcessor.swift index b1ac77fc0..6261fe723 100644 --- a/PostHog/Error Tracking/Utils/PostHogStackTrace.swift +++ b/PostHog/Error Tracking/Utils/PostHogStackTraceProcessor.swift @@ -1,5 +1,5 @@ // -// PostHogStackTrace.swift +// PostHogStackTraceProcessor.swift // PostHog // // Created by Ioannis Josephides on 13/11/2025. @@ -9,12 +9,12 @@ import Darwin import Foundation import MachO -/// Utility for capturing and processing stack traces +/// Captures and processes stack traces for error tracking /// /// This class provides methods to capture stack traces from the current thread /// and format them consistently for error tracking. /// -enum PostHogStackTrace { +enum PostHogStackTraceProcessor { // MARK: - Stack Trace Capture /// Captures current stack trace using dladdr() for rich metadata From 7c137b2705552f04bec2b1c21c499e44022e4e92 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 11 Dec 2025 02:34:25 +0200 Subject: [PATCH 22/45] fix: default to generic mechanism type --- .../PostHogExceptionProcessor.swift | 15 ++++++++------- PostHog/PostHogSDK.swift | 2 -- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 3cb46c681..6a6006c72 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -21,18 +21,18 @@ enum PostHogExceptionProcessor { /// - Parameters: /// - error: The error to convert /// - handled: Whether the error was caught/handled - /// - mechanismType: The mechanism that captured the error (e.g., "generic", "NSException") + /// - mechanismType: The mechanism that captured the error (e.g., "generic", "onunhandledexception") /// - config: Error tracking configuration for in-app detection /// - Returns: Dictionary of properties in PostHog's $exception event format static func errorToProperties( _ error: Error, handled: Bool, - mechanismType: String, + mechanismType: String = "generic", config: PostHogErrorTrackingConfig ) -> [String: Any] { var properties: [String: Any] = [:] - properties["$exception_level"] = "error" // TODO: figure this out from error wrapped type + properties["$exception_level"] = "error" // TODO: figure if error or fatal based on wrapper error type when let exceptions = buildExceptionList( from: error, @@ -60,7 +60,7 @@ enum PostHogExceptionProcessor { static func exceptionToProperties( _ exception: NSException, handled: Bool, - mechanismType: String, + mechanismType: String = "generic", config: PostHogErrorTrackingConfig ) -> [String: Any] { var properties: [String: Any] = [:] @@ -87,6 +87,7 @@ enum PostHogExceptionProcessor { /// - Returns: Dictionary of properties in PostHog's $exception event format static func messageToProperties( _ message: String, + mechanismType: String = "generic", config: PostHogErrorTrackingConfig ) -> [String: Any] { var properties: [String: Any] = [:] @@ -99,7 +100,7 @@ enum PostHogExceptionProcessor { exception["thread_id"] = Thread.current.threadId exception["mechanism"] = [ - "type": "generic-message", + "type": mechanismType, "handled": true, "synthetic": true, // always true for message exceptions - we capture current stack ] @@ -356,7 +357,7 @@ enum PostHogExceptionProcessor { /// Build stacktrace dictionary from current thread (synthetic) private static func buildStacktrace(config: PostHogErrorTrackingConfig) -> [String: Any]? { - let frames = PostHogStackTrace.captureCurrentStackTraceWithMetadata(config: config) + let frames = PostHogStackTraceProcessor.captureCurrentStackTraceWithMetadata(config: config) guard !frames.isEmpty else { return nil } @@ -372,7 +373,7 @@ enum PostHogExceptionProcessor { config: PostHogErrorTrackingConfig ) -> [String: Any]? { // Don't strip PostHog frames for NSException - the addresses are from the exception itself - let frames = PostHogStackTrace.symbolicateAddresses(addresses, config: config, stripTopPostHogFrames: false) + let frames = PostHogStackTraceProcessor.symbolicateAddresses(addresses, config: config, stripTopPostHogFrames: false) guard !frames.isEmpty else { return nil } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 23239ea33..810ddfb69 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1467,7 +1467,6 @@ let maxRetryDelay = 30.0 let errorProperties = PostHogExceptionProcessor.errorToProperties( error, handled: true, - mechanismType: "generic-error", config: config.errorTrackingConfig ) @@ -1504,7 +1503,6 @@ let maxRetryDelay = 30.0 let exceptionProperties = PostHogExceptionProcessor.exceptionToProperties( exception, handled: true, - mechanismType: "generic-nsexception", config: config.errorTrackingConfig ) From a78f078c70a9d8babdd833f16c0a9a0b6ecf1260 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 11 Dec 2025 15:11:48 +0200 Subject: [PATCH 23/45] feat: avoid circular chains --- .../PostHogExceptionProcessor.swift | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 6a6006c72..06f887daa 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -142,7 +142,10 @@ enum PostHogExceptionProcessor { nsExceptions.append(exception) var current = exception + var seen = Set([ObjectIdentifier(exception)]) while let underlying = current.userInfo?[NSUnderlyingErrorKey] as? NSException { + let id = ObjectIdentifier(underlying) + guard seen.insert(id).inserted else { break } // avoid circular references nsExceptions.append(underlying) current = underlying } @@ -189,7 +192,10 @@ enum PostHogExceptionProcessor { errors.append(nsError) var current = nsError + var seen = Set([ObjectIdentifier(nsError)]) while let underlying = current.userInfo[NSUnderlyingErrorKey] as? NSError { + let id = ObjectIdentifier(underlying) + guard seen.insert(id).inserted else { break } // avoid circular references errors.append(underlying) current = underlying } @@ -253,9 +259,15 @@ enum PostHogExceptionProcessor { ) -> [String: Any]? { var exceptionDict: [String: Any] = [:] - exceptionDict["type"] = exception.name.rawValue exceptionDict["thread_id"] = Thread.current.threadId - if let reason = exception.reason { + + let typeName = exception.name.rawValue + // NSExceptionName should always return something, but let's be safe jic + if !typeName.isEmpty { + exceptionDict["type"] = typeName + } + + if let reason = exception.reason, !reason.isEmpty { exceptionDict["value"] = reason } @@ -295,11 +307,15 @@ enum PostHogExceptionProcessor { /// 1. Debug description (NSDebugDescriptionErrorKey) /// 2. Localized description (NSLocalizedDescriptionKey) private static func extractErrorMessage(from error: NSError) -> String? { - if let debugDesc = error.userInfo[NSDebugDescriptionErrorKey] as? String { + if let debugDesc = error.userInfo[NSDebugDescriptionErrorKey] as? String, !debugDesc.isEmpty { return "\(debugDesc) (Code: \(error.code))" } - return "\(error.localizedDescription) (Code: \(error.code))" + // localizedDescription should always return something, but let's be safe jic + let localizedDesc = error.localizedDescription + let errorDesc = localizedDesc.isEmpty ? "Error" : localizedDesc + + return "\(errorDesc) (Code: \(error.code))" } /// Extract clean type name from error @@ -326,6 +342,11 @@ enum PostHogExceptionProcessor { private static func extractModule(from error: NSError) -> String? { let domain = error.domain + // Guard against empty domain + guard !domain.isEmpty else { + return nil + } + // For domains without dots (e.g., NSCocoaErrorDomain), return nil guard let lastDot = domain.lastIndex(of: ".") else { return nil @@ -340,6 +361,7 @@ enum PostHogExceptionProcessor { // MARK: - Helpers /// Attach exceptions and debug images to properties dictionary + /// private static func attachExceptionsAndDebugImages( _ exceptions: [[String: Any]], to properties: inout [String: Any] @@ -356,29 +378,41 @@ enum PostHogExceptionProcessor { // MARK: - Stack Trace Capture /// Build stacktrace dictionary from current thread (synthetic) + /// private static func buildStacktrace(config: PostHogErrorTrackingConfig) -> [String: Any]? { let frames = PostHogStackTraceProcessor.captureCurrentStackTraceWithMetadata(config: config) guard !frames.isEmpty else { return nil } + let frameDicts = frames.compactMap(\.toDictionary) + + guard !frameDicts.isEmpty else { return nil } + return [ - "frames": frames.map(\.toDictionary), + "frames": frameDicts, "type": "raw", ] } /// Build stacktrace dictionary from raw addresses (e.g., NSException.callStackReturnAddresses) + /// private static func buildStacktraceFromAddresses( _ addresses: [NSNumber], config: PostHogErrorTrackingConfig ) -> [String: Any]? { + guard !addresses.isEmpty else { return nil } + // Don't strip PostHog frames for NSException - the addresses are from the exception itself let frames = PostHogStackTraceProcessor.symbolicateAddresses(addresses, config: config, stripTopPostHogFrames: false) guard !frames.isEmpty else { return nil } + let frameDicts = frames.compactMap(\.toDictionary) + + guard !frameDicts.isEmpty else { return nil } + return [ - "frames": frames.map(\.toDictionary), + "frames": frameDicts, "type": "raw", ] } From 336935e2fb00440769b18f2c8cbb609ec4614ae7 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 11 Dec 2025 16:03:11 +0200 Subject: [PATCH 24/45] fix: format --- .../PostHogExceptionProcessor.swift | 2 +- PostHogExample/ContentView.swift | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 06f887daa..5c106ce79 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -314,7 +314,7 @@ enum PostHogExceptionProcessor { // localizedDescription should always return something, but let's be safe jic let localizedDesc = error.localizedDescription let errorDesc = localizedDesc.isEmpty ? "Error" : localizedDesc - + return "\(errorDesc) (Code: \(error.code))" } diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index ec280fc09..37341044b 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -330,52 +330,52 @@ struct ContentView: View { } Button("Trigger Real NSRangeException") { - ExceptionHandler.try({ + ExceptionHandler.try { ExceptionHandler.triggerSampleRangeException() - }, catch: { exception in + } catch: { exception in PostHogSDK.shared.captureException(exception, properties: [ "is_test": true, "exception_type": "real_nsrange_exception", "caught_by": "objective_c_wrapper", ]) - }) + } } Button("Trigger Real NSInvalidArgumentException") { - ExceptionHandler.try({ + ExceptionHandler.try { ExceptionHandler.triggerSampleInvalidArgumentException() - }, catch: { exception in + } catch: { exception in PostHogSDK.shared.captureException(exception, properties: [ "is_test": true, "exception_type": "real_invalid_argument_exception", "caught_by": "objective_c_wrapper", ]) - }) + } } Button("Trigger Custom NSException") { - ExceptionHandler.try({ + ExceptionHandler.try { ExceptionHandler.triggerSampleGenericException() - }, catch: { exception in + } catch: { exception in PostHogSDK.shared.captureException(exception, properties: [ "is_test": true, "exception_type": "real_custom_exception", "caught_by": "objective_c_wrapper", ]) - }) + } } Button("Trigger Chained NSException") { - ExceptionHandler.try({ + ExceptionHandler.try { ExceptionHandler.triggerChainedException() - }, catch: { exception in + } catch: { exception in PostHogSDK.shared.captureException(exception, properties: [ "is_test": true, "exception_type": "chained_exception", "caught_by": "objective_c_wrapper", "scenario": "network_database_business_chain", ]) - }) + } } Button("Trigger with Message") { From 6418356e91886a6f5a2afdeebaf9737ad8f34e4e Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 19 Dec 2025 21:52:28 +0200 Subject: [PATCH 25/45] feat: base implementation --- Package.resolved | 9 + Package.swift | 6 +- PostHog.podspec | 6 + PostHog.xcodeproj/project.pbxproj | 37 ++- .../Models/PostHogBinaryImageInfo.swift | 20 +- .../PostHogCrashReportIntegration.swift | 170 ++++++++++++ .../PostHogCrashReportProcessor.swift | 250 ++++++++++++++++++ .../PostHogErrorTrackingConfig.swift | 18 ++ .../Utils/PostHogErrorTrackingUtils.swift | 77 ++++++ PostHog/PostHogConfig.swift | 6 + PostHog/PostHogIntegration.swift | 17 ++ PostHog/PostHogSDK.swift | 41 +++ 12 files changed, 648 insertions(+), 9 deletions(-) create mode 100644 PostHog/Error Tracking/PostHogCrashReportIntegration.swift create mode 100644 PostHog/Error Tracking/PostHogCrashReportProcessor.swift create mode 100644 PostHog/Error Tracking/Utils/PostHogErrorTrackingUtils.swift diff --git a/Package.resolved b/Package.resolved index 113b208ec..a99cc4395 100644 --- a/Package.resolved +++ b/Package.resolved @@ -37,6 +37,15 @@ "version": "9.1.0" } }, + { + "package": "PLCrashReporter", + "repositoryURL": "https://github.com/microsoft/plcrashreporter.git", + "state": { + "branch": null, + "revision": "0254f941c646b1ed17b243654723d0f071e990d0", + "version": "1.12.2" + } + }, { "package": "Quick", "repositoryURL": "https://github.com/Quick/Quick.git", diff --git a/Package.swift b/Package.swift index 91d46cb4c..b21bfe7fb 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. + .package(url: "https://github.com/microsoft/plcrashreporter.git", from: "1.8.0"), .package(url: "https://github.com/Quick/Quick.git", from: "6.0.0"), .package(url: "https://github.com/Quick/Nimble.git", from: "12.0.0"), .package(url: "https://github.com/AliSoftware/OHHTTPStubs.git", from: "9.0.0"), @@ -25,7 +26,10 @@ let package = Package( // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "PostHog", - dependencies: ["phlibwebp"], + dependencies: [ + "phlibwebp", + .product(name: "CrashReporter", package: "plcrashreporter", condition: .when(platforms: [.iOS, .macOS, .tvOS])), + ], path: "PostHog", resources: [ .copy("Resources/PrivacyInfo.xcprivacy"), diff --git a/PostHog.podspec b/PostHog.podspec index 2380b6221..c44d6e2ea 100644 --- a/PostHog.podspec +++ b/PostHog.podspec @@ -25,6 +25,12 @@ Pod::Spec.new do |s| s.frameworks = 'Foundation' + # PLCrashReporter dependency (not available on watchOS) + # Using ~> 1.8 for minimum compatibility with host apps + s.ios.dependency 'PLCrashReporter', '~> 1.8' + s.osx.dependency 'PLCrashReporter', '~> 1.8' + s.tvos.dependency 'PLCrashReporter', '~> 1.8' + s.source_files = [ 'PostHog/**/*.{swift,h,hpp,m,mm,c,cpp}', 'vendor/libwebp/**/*.{h,c}' diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 3abf60e7d..081c6466a 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -177,7 +177,6 @@ DA3BB4AC2ED82EE80097A97A /* PostHogExceptionProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */; }; DA3BB4AF2ED88DE90097A97A /* ExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */; }; DA3BB4DF2ED992780097A97A /* PostHogBinaryImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DD2ED992780097A97A /* PostHogBinaryImageInfo.swift */; }; - DA3BB4E02ED992780097A97A /* PostHogDebugImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */; }; DA3BB5052EDF15320097A97A /* PostHogStackFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB5042EDF15320097A97A /* PostHogStackFrame.swift */; }; DA4AF61F2D1195D20053EA38 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA4AF6202D1195D20053EA38 /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -187,10 +186,14 @@ DA4AF62A2D119FCD0053EA38 /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DA4FFB152DA93C78006BAEEA /* PostHogSessionReplayTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4FFB142DA93C78006BAEEA /* PostHogSessionReplayTest.swift */; }; DA4FFBB52DAD5B01006BAEEA /* PostHogIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */; }; + DA5063C92EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */; }; + DA5063D22EF5918200C51DA0 /* PostHogCrashReportProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */; }; + DA5063D32EF5918200C51DA0 /* PostHogCrashReportIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063D02EF5918200C51DA0 /* PostHogCrashReportIntegration.swift */; }; + DA5063D62EF591CB00C51DA0 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = DA5063D52EF591CB00C51DA0 /* CrashReporter */; }; + DA5064412EF5E40300C51DA0 /* ApplicationScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */; }; DA53DE762D3E29A900C38DCA /* fixture_remote_config.json in Resources */ = {isa = PBXBuildFile; fileRef = DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */; }; DA53DE7E2D3E66AA00C38DCA /* PostHogRemoteConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */; }; DA578E982D6768BC00B3A56C /* PostHogScreenViewIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */; }; - DA578E9A2D676AF100B3A56C /* ApplicationScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */; }; DA578E9C2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9B2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift */; }; DA578E9E2D6858BA00B3A56C /* PostHogScreenViewIntegrationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9D2D6858B200B3A56C /* PostHogScreenViewIntegrationTest.swift */; }; DA578EA02D6858CE00B3A56C /* MockScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9F2D6858C900B3A56C /* MockScreenViewPublisher.swift */; }; @@ -727,6 +730,9 @@ DA3BB5042EDF15320097A97A /* PostHogStackFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStackFrame.swift; sourceTree = ""; }; DA4FFB142DA93C78006BAEEA /* PostHogSessionReplayTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayTest.swift; sourceTree = ""; }; DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogIdentityTests.swift; sourceTree = ""; }; + DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingUtils.swift; sourceTree = ""; }; + DA5063D02EF5918200C51DA0 /* PostHogCrashReportIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReportIntegration.swift; sourceTree = ""; }; + DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReportProcessor.swift; sourceTree = ""; }; DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_remote_config.json; sourceTree = ""; }; DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfigTest.swift; sourceTree = ""; }; DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenViewIntegration.swift; sourceTree = ""; }; @@ -927,6 +933,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DA5063D62EF591CB00C51DA0 /* CrashReporter in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1374,6 +1381,7 @@ children = ( DA3BB4A62ED82E410097A97A /* PostHogStackTraceProcessor.swift */, DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */, + DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */, ); path = Utils; sourceTree = ""; @@ -1425,6 +1433,8 @@ DA3BB49E2ED82E250097A97A /* Utils */, DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */, DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */, + DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */, + DA5063D02EF5918200C51DA0 /* PostHogCrashReportIntegration.swift */, ); path = "Error Tracking"; sourceTree = ""; @@ -1785,6 +1795,7 @@ ); name = PostHog; packageProductDependencies = ( + DA5063D52EF591CB00C51DA0 /* CrashReporter */, ); productName = PostHog; productReference = 3AC745B5296D6FE60025C109 /* PostHog.framework */; @@ -1941,6 +1952,7 @@ 3A867B6E29C1DF73009D0852 /* XCRemoteSwiftPackageReference "Quick" */, 3A867B7129C1DFEF009D0852 /* XCRemoteSwiftPackageReference "Nimble" */, 3A580B3D29E481F200C5C6F3 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */, + DA5063D42EF591CB00C51DA0 /* XCRemoteSwiftPackageReference "plcrashreporter" */, ); productRefGroup = 3AC745B6296D6FE60025C109 /* Products */; projectDirPath = ""; @@ -2158,7 +2170,6 @@ DA9AE8D52D841994002F1B44 /* Survey+Util.swift in Sources */, 690FF0BF2AEFA97F00A0B06B /* FileUtils.swift in Sources */, DA3BB4DF2ED992780097A97A /* PostHogBinaryImageInfo.swift in Sources */, - DA3BB4E02ED992780097A97A /* PostHogDebugImageProvider.swift in Sources */, 69261D252AD9787A00232EC7 /* PostHogExtensions.swift in Sources */, DA1D295E2D10B7B2003A31DA /* ApplicationLifecyclePublisher.swift in Sources */, 3AE3FB4E2993D1D600AFFC18 /* PostHogStorageManager.swift in Sources */, @@ -2264,6 +2275,7 @@ DA9CE3682D3108AD00DFE652 /* sharpyuv_cpu.c in Sources */, DA9CE3692D3108AD00DFE652 /* syntax_enc.c in Sources */, DA263D4F2D8075D7004C100D /* SwiftUI+Util.swift in Sources */, + DA5063C92EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift in Sources */, DA9CE36A2D3108AD00DFE652 /* enc_neon.c in Sources */, DACB3ED22E0193B20061FC7D /* PostHogSurvey+Display.swift in Sources */, DABDF9C32E02994000C7E498 /* PostHogDisplaySurvey.swift in Sources */, @@ -2274,6 +2286,8 @@ DA9CE36E2D3108AD00DFE652 /* dec_neon.c in Sources */, DA9CE36F2D3108AD00DFE652 /* predictor_enc.c in Sources */, DA9CE3702D3108AD00DFE652 /* muxedit.c in Sources */, + DA5063D22EF5918200C51DA0 /* PostHogCrashReportProcessor.swift in Sources */, + DA5063D32EF5918200C51DA0 /* PostHogCrashReportIntegration.swift in Sources */, DA9CE3712D3108AD00DFE652 /* cpu.c in Sources */, DA9CE3722D3108AD00DFE652 /* sharpyuv_dsp.c in Sources */, DA9CE3732D3108AD00DFE652 /* lossless_enc_sse41.c in Sources */, @@ -2335,12 +2349,10 @@ 3AE3FB472992AB0000AFFC18 /* Hedgelog.swift in Sources */, 69B7F60C2CF7703400A48BCC /* UIImage+Util.swift in Sources */, DA703C1C2D6616FD0069097B /* PostHogAppLifeCycleIntegration.swift in Sources */, + DA5064412EF5E40300C51DA0 /* ApplicationScreenViewPublisher.swift in Sources */, DAB565CA2D142F8F0088F720 /* PostHogNoMaskViewModifier.swift in Sources */, - DA578E9A2D676AF100B3A56C /* ApplicationScreenViewPublisher.swift in Sources */, 69261D132AD5685B00232EC7 /* PostHogRemoteConfig.swift in Sources */, DA9AE8F42D89AFD7002F1B44 /* BottomSection.swift in Sources */, - DA578E9A2D676AF100B3A56C /* ApplicationScreenViewPublisher.swift in Sources */, - 69261D132AD5685B00232EC7 /* PostHogRemoteConfig.swift in Sources */, 699C5FE62C20178E007DB818 /* UUIDUtils.swift in Sources */, 690B2DF32C205B5600AE3B45 /* TimeBasedEpochGenerator.swift in Sources */, 69261D1D2AD967CD00232EC7 /* PostHogFileBackedQueue.swift in Sources */, @@ -3609,6 +3621,14 @@ minimumVersion = 13.7.0; }; }; + DA5063D42EF591CB00C51DA0 /* XCRemoteSwiftPackageReference "plcrashreporter" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/microsoft/plcrashreporter.git"; + requirement = { + kind = exactVersion; + version = 1.8.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3632,6 +3652,11 @@ package = 3A580B3D29E481F200C5C6F3 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */; productName = OHHTTPStubs; }; + DA5063D52EF591CB00C51DA0 /* CrashReporter */ = { + isa = XCSwiftPackageProductDependency; + package = DA5063D42EF591CB00C51DA0 /* XCRemoteSwiftPackageReference "plcrashreporter" */; + productName = CrashReporter; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 3AC745AC296D6FE60025C109 /* Project object */; diff --git a/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift b/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift index 32afd0911..80e29d20a 100644 --- a/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift +++ b/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift @@ -22,7 +22,7 @@ struct PostHogBinaryImageInfo { let uuid: String? /// Virtual memory address from the Mach-O headers (preferred load address) - let vmAddress: UInt64 + let vmAddress: UInt64? /// Actual load address in memory (may differ from vmAddress due to ASLR) let address: UInt64 @@ -30,6 +30,18 @@ struct PostHogBinaryImageInfo { /// Size of the binary image in bytes let size: UInt64 + /// CPU architecture (e.g., "arm64", "x86_64") + let arch: String? + + init(name: String, uuid: String?, vmAddress: UInt64?, address: UInt64, size: UInt64, arch: String? = nil) { + self.name = name + self.uuid = uuid + self.vmAddress = vmAddress + self.address = address + self.size = size + self.arch = arch + } + var toDictionary: [String: Any] { var dict: [String: Any] = [ "type": "macho", @@ -42,10 +54,14 @@ struct PostHogBinaryImageInfo { dict["debug_id"] = uuid } - if vmAddress > 0 { + if let vmAddress, vmAddress > 0 { dict["image_vmaddr"] = String(format: PostHogStackFrame.hexAddressFormat, vmAddress) } + if let arch = arch { + dict["arch"] = arch + } + return dict } } diff --git a/PostHog/Error Tracking/PostHogCrashReportIntegration.swift b/PostHog/Error Tracking/PostHogCrashReportIntegration.swift new file mode 100644 index 000000000..8338b072f --- /dev/null +++ b/PostHog/Error Tracking/PostHogCrashReportIntegration.swift @@ -0,0 +1,170 @@ +// +// PostHogCrashReportIntegration.swift +// PostHog +// +// Created by Ioannis Josephides on 14/12/2025. +// + +import Foundation + +#if os(iOS) || os(macOS) || os(tvOS) + import CrashReporter + + class PostHogCrashReportIntegration: PostHogIntegration { + private static let integrationInstalledLock = NSLock() + private static var integrationInstalled = false + + var requiresSwizzling: Bool { false } + + private weak var postHog: PostHogSDK? + private var crashReporter: PLCrashReporter? + + func install(_ postHog: PostHogSDK) throws { + try PostHogCrashReportIntegration.integrationInstalledLock.withLock { + if PostHogCrashReportIntegration.integrationInstalled { + throw InternalPostHogError(description: "Crash report integration already installed to another PostHogSDK instance.") + } + PostHogCrashReportIntegration.integrationInstalled = true + } + + self.postHog = postHog + if let crashReporter = setupCrashReporter() { + // Note: Order here matters, we need to process any pending crash report before enabling the crash reporter + processPendingCrashReportIfNeeded(reporter: crashReporter) + enableCrashReporter(reporter: crashReporter) + self.crashReporter = crashReporter + } + } + + private func setupCrashReporter() -> PLCrashReporter? { + // Check for debugger - crash handler won't work when debugging + if PostHogDebugUtils.isDebuggerAttached() { + hedgeLog("Crash handler is disabled because a debugger is attached. Crashes will be caught by the debugger instead.") + return nil + } + + // Configure PLCrashReporter + let config = PLCrashReporterConfig( + signalHandlerType: .mach, + symbolicationStrategy: [], // No local symbolication, we'll be doing server-side + shouldRegisterUncaughtExceptionHandler: true + ) + + guard let reporter = PLCrashReporter(configuration: config) else { + hedgeLog("Failed to create PLCrashReporter instance") + return nil + } + + return reporter + } + + private func processPendingCrashReportIfNeeded(reporter: PLCrashReporter) { + // Check for pending crash report FIRST (before enabling for new crashes) + if reporter.hasPendingCrashReport() { + processPendingCrashReport() + } + } + + private func enableCrashReporter(reporter: PLCrashReporter) { + // Enable crash reporter for this session + do { + try reporter.enableAndReturnError() + hedgeLog("PLCrashReporter enabled successfully") + } catch { + hedgeLog("Failed to enable PLCrashReporter: \(error)") + } + } + + func uninstall(_ postHog: PostHogSDK) { + if self.postHog === postHog || self.postHog == nil { + stop() + crashReporter = nil + self.postHog = nil + PostHogCrashReportIntegration.integrationInstalledLock.withLock { + PostHogCrashReportIntegration.integrationInstalled = false + } + } + } + + func start() { + // No-op for crash reporting. Always active once installed + } + + func stop() { + // No-op for crash reporting. Always active once installed + } + + func contextDidChange(_ context: [String: Any]) { + guard let crashReporter else { return } + + // Serialize context to JSON and set as customData + do { + let jsonData = try JSONSerialization.data(withJSONObject: context, options: []) + crashReporter.customData = jsonData + } catch { + hedgeLog("Failed to serialize crash context: \(error)") + } + } + + // MARK: - Private Methods + + private func processPendingCrashReport() { + guard let crashReporter, let postHog else { + return + } + + do { + let crashData = try crashReporter.loadPendingCrashReportDataAndReturnError() + let crashReport = try PLCrashReport(data: crashData) + + // Extract context from crash report's customData + var crashContext: [String: Any] = [:] + if let customData = crashReport.customData { + crashContext = (try? JSONSerialization.jsonObject(with: customData, options: [])) as? [String: Any] ?? [:] + } + + // Process crash report and create $exception event + let exceptionProperties = PostHogCrashReportProcessor.processReport( + crashReport, + crashContext: crashContext + ) + + // Capture the crash event + postHog.capture( + "$exception", + properties: exceptionProperties, + userProperties: nil, + userPropertiesSetOnce: nil, + groups: nil + ) + + hedgeLog("Crash report processed and captured") + + } catch { + // Best effort for now. We log and ignore and let the crash report be purged. + // - On a new crash, old report will be overwritten anyway + // - Keeping the report around could risk infinite retry loop until next crash if it's corrupt + // - This could fail because of a transient error though, in the future we could check the returned error + // and only purge if PLCrashReporterErrorCrashReportInvalid + hedgeLog("Failed to process crash report: \(error)") + } + + // Always purge the crash report after processing + crashReporter.purgePendingCrashReport() + } + } + +#else + // watchOS stub - crash reporting is not available + class PostHogCrashReportIntegration: PostHogIntegration { + var requiresSwizzling: Bool { false } + + func install(_: PostHogSDK) throws { + hedgeLog("Crash reporting is not available on watchOS") + } + + func uninstall(_: PostHogSDK) { /* no-op */ } + func start() { /* no-op */ } + func stop() { /* no-op */ } + } +#endif diff --git a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift new file mode 100644 index 000000000..5730394b1 --- /dev/null +++ b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift @@ -0,0 +1,250 @@ +// +// PostHogCrashReportProcessor.swift +// PostHog +// +// Created by Ioannis Josephides on 14/12/2025. +// + +import Foundation + +#if os(iOS) || os(macOS) || os(tvOS) + import CrashReporter + + enum PostHogCrashReportProcessor { + /// Process a PLCrashReport and convert it to PostHog $exception event properties + /// + /// - Parameters: + /// - report: The PLCrashReport to process + /// - crashContext: The event context captured at crash time (from customData) + /// - Returns: Dictionary of properties for the $exception event + static func processReport( + _ report: PLCrashReport, + crashContext: [String: Any] + ) -> [String: Any] { + var properties: [String: Any] = crashContext + + // Fatal crash + properties["$exception_level"] = "fatal" + + // Build exception list + var exceptions: [[String: Any]] = [] + + if let exceptionInfo = buildExceptionInfo(from: report) { + exceptions.append(exceptionInfo) + } + + if !exceptions.isEmpty { + properties["$exception_list"] = exceptions + } + + // Build debug images for symbolication + let debugImages = buildDebugImages(from: report) + if !debugImages.isEmpty { + properties["$debug_images"] = debugImages + } + + // Add crash metadata + if let uuidRef = report.uuidRef { + properties["$crash_report_id"] = CFUUIDCreateString(nil, uuidRef) as String + } + if let timestamp = report.systemInfo?.timestamp { + properties["$app_crashed_at"] = toISO8601String(timestamp) + } + + return properties + } + + // MARK: - Exception Building + + private static func buildExceptionInfo(from report: PLCrashReport) -> [String: Any]? { + var exception: [String: Any] = [:] + + // Determine exception type and value based on crash type + if let machException = report.machExceptionInfo { + // Mach exception + exception["type"] = machExceptionName(machException.type) + exception["value"] = machExceptionMessage(machException) + + exception["mechanism"] = [ + "type": "mach_exception", + "handled": false, + "synthetic": false, + "meta": [ + "mach": [ + "exception": machException.type, + "code": machException.codes.first ?? 0, + "subcode": machException.codes.count > 1 ? machException.codes[1] : 0, + ], + ], + ] + } else if let signalInfo = report.signalInfo { + // POSIX signal + exception["type"] = signalInfo.name ?? "Unknown Signal" + exception["value"] = signalMessage(signalInfo) + + exception["mechanism"] = [ + "type": "signal", + "handled": false, + "synthetic": false, + "meta": [ + "signal": [ + "code": signalInfo.code ?? "Unknown", + "name": signalInfo.name ?? "Unknown", + ], + ], + ] + } else if report.hasExceptionInfo, let nsExceptionInfo = report.exceptionInfo { + // NSException + exception["type"] = nsExceptionInfo.exceptionName ?? "NSException" + exception["value"] = nsExceptionInfo.exceptionReason ?? "Unknown reason" + + exception["mechanism"] = [ + "type": "nsexception", + "handled": false, + "synthetic": false, + ] + } else { + return nil + } + + // Add stack trace from crashed thread + if let stacktrace = buildStacktrace(from: report) { + exception["stacktrace"] = stacktrace + } + + // Add thread ID of crashed thread + if let crashedThread = findCrashedThread(in: report) { + exception["thread_id"] = crashedThread.threadNumber + } + + return exception + } + + // MARK: - Stack Trace Building + + private static func buildStacktrace(from report: PLCrashReport) -> [String: Any]? { + guard let crashedThread = findCrashedThread(in: report) else { + return nil + } + + var frames: [PostHogStackFrame] = [] + + for case let frame as PLCrashReportStackFrameInfo in crashedThread.stackFrames { + var module: String? + var package: String? + var imageAddress: UInt64? + var function: String? + var symbolAddress: UInt64? + + // Try to find the binary image for this frame + if let image = report.image(forAddress: frame.instructionPointer) { + imageAddress = image.imageBaseAddress + + if let imageName = image.imageName { + package = (imageName as NSString).lastPathComponent + module = package + } + } + + // Add symbol info if available + if let symbolInfo = frame.symbolInfo { + function = symbolInfo.symbolName + symbolAddress = symbolInfo.startAddress + } + + let stackFrame = PostHogStackFrame( + instructionAddress: frame.instructionPointer, + module: module, + package: package, + imageAddress: imageAddress, + inApp: false, // Cannot determine in-app status from crash report + function: function, + symbolAddress: symbolAddress + ) + frames.append(stackFrame) + } + + guard !frames.isEmpty else { return nil } + + let frameDicts = frames.map(\.toDictionary) + + return [ + "frames": frameDicts, + "type": "raw", + ] + } + + private static func findCrashedThread(in report: PLCrashReport) -> PLCrashReportThreadInfo? { + for case let thread as PLCrashReportThreadInfo in report.threads where thread.crashed { + return thread + } + // Fallback to first thread if none marked as crashed + return report.threads.first as? PLCrashReportThreadInfo + } + + // MARK: - Debug Images + + private static func buildDebugImages(from report: PLCrashReport) -> [[String: Any]] { + var debugImages: [PostHogBinaryImageInfo] = [] + + for case let image as PLCrashReportBinaryImageInfo in report.images { + guard let imageName = image.imageName else { continue } + + let arch: String? + if let codeType = image.codeType { + arch = PostHogCPUArchitecture.archName(cpuType: codeType.type, cpuSubtype: codeType.subtype) + } else { + arch = nil + } + + let binaryImage = PostHogBinaryImageInfo( + name: imageName, + uuid: image.imageUUID?.formattedAsUUID, + vmAddress: nil, // PLCrashReport doesn't expose vmAddress + address: image.imageBaseAddress, + size: image.imageSize, + arch: arch + ) + debugImages.append(binaryImage) + } + + return debugImages.map(\.toDictionary) + } + + // MARK: - Helpers + + private static func machExceptionName(_ type: UInt64) -> String { + switch type { + case 1: "EXC_BAD_ACCESS" + case 2: "EXC_BAD_INSTRUCTION" + case 3: "EXC_ARITHMETIC" + case 4: "EXC_EMULATION" + case 5: "EXC_SOFTWARE" + case 6: "EXC_BREAKPOINT" + case 7: "EXC_SYSCALL" + case 8: "EXC_MACH_SYSCALL" + case 9: "EXC_RPC_ALERT" + case 10: "EXC_CRASH" + case 11: "EXC_RESOURCE" + case 12: "EXC_GUARD" + case 13: "EXC_CORPSE_NOTIFY" + default: "EXC_UNKNOWN(\(type))" + } + } + + private static func machExceptionMessage(_ exception: PLCrashReportMachExceptionInfo) -> String { + let typeName = machExceptionName(exception.type) + let codesArray = exception.codes as? [NSNumber] + let codes = codesArray?.map { String(describing: $0) }.joined(separator: ", ") ?? "" + return "\(typeName) at codes: [\(codes)]" + } + + private static func signalMessage(_ signal: PLCrashReportSignalInfo) -> String { + let name = signal.name ?? "Unknown" + let code = signal.code ?? "Unknown" + let address = String(format: PostHogStackFrame.hexAddressFormat, signal.address) + + return "\(name) (code \(code)) at address \(address)" + } + } +#endif diff --git a/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift b/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift index e97bf1b2b..7c0c67ce1 100644 --- a/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift +++ b/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift @@ -12,6 +12,24 @@ import Foundation /// This class controls how exceptions are captured and processed, /// including which stack trace frames are marked as "in-app" code. @objc public class PostHogErrorTrackingConfig: NSObject { + // MARK: - Crash Reporting + + /// Enable automatic crash reporting + /// + /// When enabled, the SDK will capture the following crash types: + /// - Mach exceptions (e.g., `EXC_BAD_ACCESS`, `EXC_CRASH`) + /// - POSIX signals (e.g., `SIGSEGV`, `SIGABRT`, `SIGBUS`) + /// - Uncaught `NSException`s + /// + /// Crashes are persisted to disk and sent as `$exception` events with level "fatal" **on the next app launch** + /// + /// - Note: Crash reporting is not available on watchOS (this will be a no-op). + /// - Note: Crash reporting is automatically disabled when a debugger is attached, + /// as the debugger intercepts signals before the crash handler can process them. + /// + /// Default: false + @objc public var enableCrashHandler: Bool = false + // MARK: - In-App Detection Configuration /// List of package/bundle identifiers to be considered in-app frames diff --git a/PostHog/Error Tracking/Utils/PostHogErrorTrackingUtils.swift b/PostHog/Error Tracking/Utils/PostHogErrorTrackingUtils.swift new file mode 100644 index 000000000..f609293ec --- /dev/null +++ b/PostHog/Error Tracking/Utils/PostHogErrorTrackingUtils.swift @@ -0,0 +1,77 @@ +// +// PostHogErrorTrackingUtils.swift +// PostHog +// +// Created by Ioannis Josephides on 16/12/2025. +// + +import Foundation + +// MARK: - UUID Formatting + +extension String { + /// Formats a UUID string to the standard hyphenated format + /// Input can be with or without hyphens, output is always: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + var formattedAsUUID: String { + let clean = replacingOccurrences(of: "-", with: "").uppercased() + guard clean.count == 32 else { return self } + + let idx = clean.startIndex + let p1 = clean[idx ..< clean.index(idx, offsetBy: 8)] + let p2 = clean[clean.index(idx, offsetBy: 8) ..< clean.index(idx, offsetBy: 12)] + let p3 = clean[clean.index(idx, offsetBy: 12) ..< clean.index(idx, offsetBy: 16)] + let p4 = clean[clean.index(idx, offsetBy: 16) ..< clean.index(idx, offsetBy: 20)] + let p5 = clean[clean.index(idx, offsetBy: 20) ..< clean.index(idx, offsetBy: 32)] + + return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)" + } +} + +// MARK: - CPU Architecture Helpers + +enum PostHogCPUArchitecture { + /// Convert CPU type and subtype to architecture string + /// + /// - Parameters: + /// - cpuType: Mach-O CPU type + /// - cpuSubtype: Mach-O CPU subtype + /// - Returns: Architecture string (e.g., "arm64", "x86_64") or nil if unknown + static func archName(cpuType: UInt64, cpuSubtype: UInt64) -> String? { + // CPU_TYPE_ARM64 = 0x0100000C (16777228) + // CPU_TYPE_X86_64 = 0x01000007 (16777223) + // CPU_TYPE_ARM = 12 + + switch cpuType { + case 0x0100_000C: // CPU_TYPE_ARM64 + return "arm64" + case 0x0100_0007: // CPU_TYPE_X86_64 + return "x86_64" + case 12: // CPU_TYPE_ARM + switch cpuSubtype { + case 9: return "armv7" + case 11: return "armv7s" + default: return "arm" + } + default: + return nil + } + } +} + +// MARK: - Debug Utilities + +enum PostHogDebugUtils { + /// Check if the current process is being traced by a debugger. + /// Based on https://gist.github.com/dermotos/fde82d3eb617f5085b22893166519d51 + static func isDebuggerAttached() -> Bool { + var info = kinfo_proc() + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + var size = MemoryLayout.stride + let junk = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) + guard junk == 0 else { + hedgeLog("Failed to check for debugger. sysctl failed with error code: \(junk)") + return false + } + return (info.kp_proc.p_flag & P_TRACED) != 0 + } +} diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index a1d6e73f0..f242bd94e 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -202,6 +202,12 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? func getIntegrations() -> [PostHogIntegration] { var integrations: [PostHogIntegration] = [] + // Crash reporting should be installed FIRST to process any pending crash reports + // before other integrations modify the context + if errorTrackingConfig.enableCrashHandler { + integrations.append(PostHogCrashReportIntegration()) + } + if captureScreenViews { integrations.append(PostHogScreenViewIntegration()) } diff --git a/PostHog/PostHogIntegration.swift b/PostHog/PostHogIntegration.swift index 85c4ae42d..64dec9c2f 100644 --- a/PostHog/PostHogIntegration.swift +++ b/PostHog/PostHogIntegration.swift @@ -56,4 +56,21 @@ protocol PostHogIntegration { * while maintaining its installation status (e.g manual start/stop for session recording) */ func stop() + + /** + * Called when the event context changes (e.g., after identify, reset, group, register). + * + * Integrations can use this to react to context changes. For example, the crash reporting + * integration persists this context to disk for crash-time capture. + * + * - Parameter context: The current event context dictionary containing static context, + * dynamic context, identity info (distinct_id, groups), session_id, and registered properties. + */ + func contextDidChange(_ context: [String: Any]) +} + +extension PostHogIntegration { + func contextDidChange(_ context: [String: Any]) { + // Default empty implementation since most integrations won't need this + } } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 810ddfb69..b0e3b452b 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -144,6 +144,9 @@ let maxRetryDelay = 30.0 if !config.optOut { // don't install integrations if in opt-out state installIntegrations() + + // Notify integrations of initial context (e.g., for crash reporting) + notifyContextDidChange() } DispatchQueue.main.async { @@ -362,6 +365,9 @@ let maxRetryDelay = 30.0 // reload flags as anon user remoteConfig?.reloadFeatureFlags() + + // Notify integrations of context change (e.g., for crash reporting) + notifyContextDidChange() } private func getGroups() -> [String: String] { @@ -395,6 +401,9 @@ let maxRetryDelay = 30.0 let mergedProps = props.merging(sanitizedProps!) { _, new in new } storage?.setDictionary(forKey: .registerProperties, contents: mergedProps) } + + // Notify integrations of context change (e.g., for crash reporting) + notifyContextDidChange() } @objc(unregisterProperties:) @@ -408,6 +417,9 @@ let maxRetryDelay = 30.0 props.removeValue(forKey: key) storage?.setDictionary(forKey: .registerProperties, contents: props) } + + // Notify integrations of context change (e.g., for crash reporting) + notifyContextDidChange() } @objc public func identify(_ distinctId: String) { @@ -482,6 +494,9 @@ let maxRetryDelay = 30.0 remoteConfig?.reloadFeatureFlags() + // Notify integrations of context change (e.g., for crash reporting) + notifyContextDidChange() + // we need to make sure the user props update is for the same user // otherwise they have to reset and identify again } else if !hasDifferentDistinctId, !(userProperties?.isEmpty ?? true) || !(userPropertiesSetOnce?.isEmpty ?? true) { @@ -897,6 +912,9 @@ let maxRetryDelay = 30.0 _ = groups([type: key]) groupIdentify(type: type, key: key, groupProperties: sanitizeDictionary(groupProperties)) + + // Notify integrations of context change (e.g., for crash reporting) + notifyContextDidChange() } // FEATURE FLAGS @@ -1614,6 +1632,29 @@ let maxRetryDelay = 30.0 surveysIntegration = nil #endif } + + /// Notifies all installed integrations that the event context has changed. + /// + /// This is called after operations that modify the context (identify, reset, group, register). + /// Integrations like crash reporting use this to persist context for crash-time capture. + private func notifyContextDidChange() { + guard isEnabled() else { return } + + let distinctId = getDistinctId() + let context = buildProperties( + distinctId: distinctId, + properties: nil, + userProperties: nil, + userPropertiesSetOnce: nil, + groups: nil, + appendSharedProps: true, + timestamp: nil + ) + + for integration in installedIntegrations { + integration.contextDidChange(context) + } + } } #if TESTING From 81ea865d0e197ca62f9fea00e5c0b09f45c4a9aa Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 19 Dec 2025 22:12:51 +0200 Subject: [PATCH 26/45] fix: minor refactor --- PostHog.xcodeproj/project.pbxproj | 8 +- ...ErrorTrackingCrashReportIntegration.swift} | 78 +++++++++---------- .../Utils/PostHogDebugImageProvider.swift | 2 + PostHog/PostHogConfig.swift | 4 +- 4 files changed, 46 insertions(+), 46 deletions(-) rename PostHog/Error Tracking/{PostHogCrashReportIntegration.swift => PostHogErrorTrackingCrashReportIntegration.swift} (89%) diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 081c6466a..fc084352f 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -188,9 +188,9 @@ DA4FFBB52DAD5B01006BAEEA /* PostHogIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */; }; DA5063C92EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */; }; DA5063D22EF5918200C51DA0 /* PostHogCrashReportProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */; }; - DA5063D32EF5918200C51DA0 /* PostHogCrashReportIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063D02EF5918200C51DA0 /* PostHogCrashReportIntegration.swift */; }; DA5063D62EF591CB00C51DA0 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = DA5063D52EF591CB00C51DA0 /* CrashReporter */; }; DA5064412EF5E40300C51DA0 /* ApplicationScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */; }; + DA5064432EF5E58D00C51DA0 /* PostHogErrorTrackingCrashReportIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingCrashReportIntegration.swift */; }; DA53DE762D3E29A900C38DCA /* fixture_remote_config.json in Resources */ = {isa = PBXBuildFile; fileRef = DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */; }; DA53DE7E2D3E66AA00C38DCA /* PostHogRemoteConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */; }; DA578E982D6768BC00B3A56C /* PostHogScreenViewIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */; }; @@ -731,8 +731,8 @@ DA4FFB142DA93C78006BAEEA /* PostHogSessionReplayTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayTest.swift; sourceTree = ""; }; DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogIdentityTests.swift; sourceTree = ""; }; DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingUtils.swift; sourceTree = ""; }; - DA5063D02EF5918200C51DA0 /* PostHogCrashReportIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReportIntegration.swift; sourceTree = ""; }; DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReportProcessor.swift; sourceTree = ""; }; + DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingCrashReportIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingCrashReportIntegration.swift; sourceTree = ""; }; DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_remote_config.json; sourceTree = ""; }; DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfigTest.swift; sourceTree = ""; }; DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenViewIntegration.swift; sourceTree = ""; }; @@ -1434,7 +1434,7 @@ DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */, DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */, DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */, - DA5063D02EF5918200C51DA0 /* PostHogCrashReportIntegration.swift */, + DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingCrashReportIntegration.swift */, ); path = "Error Tracking"; sourceTree = ""; @@ -2270,6 +2270,7 @@ DA9CE3652D3108AD00DFE652 /* alpha_processing_neon.c in Sources */, DA0F989B2D94081A009AB6F5 /* ApplicationViewLayoutPublisher.swift in Sources */, DA9CE3662D3108AD00DFE652 /* sharpyuv_gamma.c in Sources */, + DA5064432EF5E58D00C51DA0 /* PostHogErrorTrackingCrashReportIntegration.swift in Sources */, DA37933C2DBA571D005C6AA3 /* PostHogSurveysConfig.swift in Sources */, DA9CE3672D3108AD00DFE652 /* picture_tools_enc.c in Sources */, DA9CE3682D3108AD00DFE652 /* sharpyuv_cpu.c in Sources */, @@ -2287,7 +2288,6 @@ DA9CE36F2D3108AD00DFE652 /* predictor_enc.c in Sources */, DA9CE3702D3108AD00DFE652 /* muxedit.c in Sources */, DA5063D22EF5918200C51DA0 /* PostHogCrashReportProcessor.swift in Sources */, - DA5063D32EF5918200C51DA0 /* PostHogCrashReportIntegration.swift in Sources */, DA9CE3712D3108AD00DFE652 /* cpu.c in Sources */, DA9CE3722D3108AD00DFE652 /* sharpyuv_dsp.c in Sources */, DA9CE3732D3108AD00DFE652 /* lossless_enc_sse41.c in Sources */, diff --git a/PostHog/Error Tracking/PostHogCrashReportIntegration.swift b/PostHog/Error Tracking/PostHogErrorTrackingCrashReportIntegration.swift similarity index 89% rename from PostHog/Error Tracking/PostHogCrashReportIntegration.swift rename to PostHog/Error Tracking/PostHogErrorTrackingCrashReportIntegration.swift index 8338b072f..785fe6e92 100644 --- a/PostHog/Error Tracking/PostHogCrashReportIntegration.swift +++ b/PostHog/Error Tracking/PostHogErrorTrackingCrashReportIntegration.swift @@ -1,5 +1,5 @@ // -// PostHogCrashReportIntegration.swift +// PostHogErrorTrackingCrashReportIntegration.swift // PostHog // // Created by Ioannis Josephides on 14/12/2025. @@ -10,7 +10,7 @@ import Foundation #if os(iOS) || os(macOS) || os(tvOS) import CrashReporter - class PostHogCrashReportIntegration: PostHogIntegration { + class PostHogErrorTrackingCrashReportIntegration: PostHogIntegration { private static let integrationInstalledLock = NSLock() private static var integrationInstalled = false @@ -20,11 +20,11 @@ import Foundation private var crashReporter: PLCrashReporter? func install(_ postHog: PostHogSDK) throws { - try PostHogCrashReportIntegration.integrationInstalledLock.withLock { - if PostHogCrashReportIntegration.integrationInstalled { + try PostHogErrorTrackingCrashReportIntegration.integrationInstalledLock.withLock { + if PostHogErrorTrackingCrashReportIntegration.integrationInstalled { throw InternalPostHogError(description: "Crash report integration already installed to another PostHogSDK instance.") } - PostHogCrashReportIntegration.integrationInstalled = true + PostHogErrorTrackingCrashReportIntegration.integrationInstalled = true } self.postHog = postHog @@ -36,6 +36,39 @@ import Foundation } } + func uninstall(_ postHog: PostHogSDK) { + if self.postHog === postHog || self.postHog == nil { + stop() + crashReporter = nil + self.postHog = nil + PostHogErrorTrackingCrashReportIntegration.integrationInstalledLock.withLock { + PostHogErrorTrackingCrashReportIntegration.integrationInstalled = false + } + } + } + + func start() { + // No-op for crash reporting. Always active once installed + } + + func stop() { + // No-op for crash reporting. Always active once installed + } + + func contextDidChange(_ context: [String: Any]) { + guard let crashReporter else { return } + + // Serialize context to JSON and set as customData + do { + let jsonData = try JSONSerialization.data(withJSONObject: context, options: []) + crashReporter.customData = jsonData + } catch { + hedgeLog("Failed to serialize crash context: \(error)") + } + } + + // MARK: - Private Methods + private func setupCrashReporter() -> PLCrashReporter? { // Check for debugger - crash handler won't work when debugging if PostHogDebugUtils.isDebuggerAttached() { @@ -75,39 +108,6 @@ import Foundation } } - func uninstall(_ postHog: PostHogSDK) { - if self.postHog === postHog || self.postHog == nil { - stop() - crashReporter = nil - self.postHog = nil - PostHogCrashReportIntegration.integrationInstalledLock.withLock { - PostHogCrashReportIntegration.integrationInstalled = false - } - } - } - - func start() { - // No-op for crash reporting. Always active once installed - } - - func stop() { - // No-op for crash reporting. Always active once installed - } - - func contextDidChange(_ context: [String: Any]) { - guard let crashReporter else { return } - - // Serialize context to JSON and set as customData - do { - let jsonData = try JSONSerialization.data(withJSONObject: context, options: []) - crashReporter.customData = jsonData - } catch { - hedgeLog("Failed to serialize crash context: \(error)") - } - } - - // MARK: - Private Methods - private func processPendingCrashReport() { guard let crashReporter, let postHog else { return @@ -156,7 +156,7 @@ import Foundation #else // watchOS stub - crash reporting is not available - class PostHogCrashReportIntegration: PostHogIntegration { + class PostHogErrorTrackingCrashReportIntegration: PostHogIntegration { var requiresSwizzling: Bool { false } func install(_: PostHogSDK) throws { diff --git a/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift b/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift index e3922864a..4165e2af3 100644 --- a/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift +++ b/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift @@ -15,6 +15,8 @@ import MachO /// - Text segment address and size (for address range calculation) /// - Load addresses (for offset calculation) /// +/// Note: Now that we have a PLCrashReporter integration, we could generate a live crash report using their API +/// and extract the debug images from there, to be consistent with crash reporting. Will keep this for now enum PostHogDebugImageProvider { private static let segmentText = "__TEXT" diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index f242bd94e..81f71a56f 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -202,10 +202,8 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? func getIntegrations() -> [PostHogIntegration] { var integrations: [PostHogIntegration] = [] - // Crash reporting should be installed FIRST to process any pending crash reports - // before other integrations modify the context if errorTrackingConfig.enableCrashHandler { - integrations.append(PostHogCrashReportIntegration()) + integrations.append(PostHogErrorTrackingCrashReportIntegration()) } if captureScreenViews { From 2f20d828cda6ca8786b9db9ce6e00f01190ef5ef Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sat, 20 Dec 2025 00:45:00 +0200 Subject: [PATCH 27/45] fix: crash report event context --- PostHog.xcodeproj/project.pbxproj | 8 +- .../PostHogCrashReportProcessor.swift | 51 ++++++------ ... => PostHogCrashReporterIntegration.swift} | 63 ++++++++------- .../Utils/PostHogErrorTrackingUtils.swift | 14 ++-- PostHog/PostHogConfig.swift | 2 +- PostHog/PostHogIntegration.swift | 2 +- PostHog/PostHogSDK.swift | 78 ++++++++++++++++--- PostHog/PostHogSessionManager.swift | 31 +++++++- PostHog/Replay/PostHogReplayIntegration.swift | 5 +- 9 files changed, 173 insertions(+), 81 deletions(-) rename PostHog/Error Tracking/{PostHogErrorTrackingCrashReportIntegration.swift => PostHogCrashReporterIntegration.swift} (68%) diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index fc084352f..eb480e3a0 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -190,7 +190,7 @@ DA5063D22EF5918200C51DA0 /* PostHogCrashReportProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */; }; DA5063D62EF591CB00C51DA0 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = DA5063D52EF591CB00C51DA0 /* CrashReporter */; }; DA5064412EF5E40300C51DA0 /* ApplicationScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */; }; - DA5064432EF5E58D00C51DA0 /* PostHogErrorTrackingCrashReportIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingCrashReportIntegration.swift */; }; + DA5064432EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064422EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift */; }; DA53DE762D3E29A900C38DCA /* fixture_remote_config.json in Resources */ = {isa = PBXBuildFile; fileRef = DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */; }; DA53DE7E2D3E66AA00C38DCA /* PostHogRemoteConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */; }; DA578E982D6768BC00B3A56C /* PostHogScreenViewIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */; }; @@ -732,7 +732,7 @@ DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogIdentityTests.swift; sourceTree = ""; }; DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingUtils.swift; sourceTree = ""; }; DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReportProcessor.swift; sourceTree = ""; }; - DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingCrashReportIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingCrashReportIntegration.swift; sourceTree = ""; }; + DA5064422EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReporterIntegration.swift; sourceTree = ""; }; DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_remote_config.json; sourceTree = ""; }; DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfigTest.swift; sourceTree = ""; }; DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenViewIntegration.swift; sourceTree = ""; }; @@ -1434,7 +1434,7 @@ DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */, DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */, DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */, - DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingCrashReportIntegration.swift */, + DA5064422EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift */, ); path = "Error Tracking"; sourceTree = ""; @@ -2270,7 +2270,7 @@ DA9CE3652D3108AD00DFE652 /* alpha_processing_neon.c in Sources */, DA0F989B2D94081A009AB6F5 /* ApplicationViewLayoutPublisher.swift in Sources */, DA9CE3662D3108AD00DFE652 /* sharpyuv_gamma.c in Sources */, - DA5064432EF5E58D00C51DA0 /* PostHogErrorTrackingCrashReportIntegration.swift in Sources */, + DA5064432EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift in Sources */, DA37933C2DBA571D005C6AA3 /* PostHogSurveysConfig.swift in Sources */, DA9CE3672D3108AD00DFE652 /* picture_tools_enc.c in Sources */, DA9CE3682D3108AD00DFE652 /* sharpyuv_cpu.c in Sources */, diff --git a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift index 5730394b1..af69131fd 100644 --- a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift +++ b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift @@ -13,15 +13,10 @@ import Foundation enum PostHogCrashReportProcessor { /// Process a PLCrashReport and convert it to PostHog $exception event properties /// - /// - Parameters: - /// - report: The PLCrashReport to process - /// - crashContext: The event context captured at crash time (from customData) - /// - Returns: Dictionary of properties for the $exception event - static func processReport( - _ report: PLCrashReport, - crashContext: [String: Any] - ) -> [String: Any] { - var properties: [String: Any] = crashContext + /// - Parameter report: The PLCrashReport to process + /// - Returns: Dictionary of exception-specific properties for the $exception event + static func processReport(_ report: PLCrashReport) -> [String: Any] { + var properties: [String: Any] = [:] // Fatal crash properties["$exception_level"] = "fatal" @@ -54,6 +49,11 @@ import Foundation return properties } + /// Get the crash timestamp from the report + static func getCrashTimestamp(_ report: PLCrashReport) -> Date? { + report.systemInfo?.timestamp + } + // MARK: - Exception Building private static func buildExceptionInfo(from report: PLCrashReport) -> [String: Any]? { @@ -213,23 +213,24 @@ import Foundation // MARK: - Helpers + private static let machExceptionNames: [UInt64: String] = [ + 1: "EXC_BAD_ACCESS", + 2: "EXC_BAD_INSTRUCTION", + 3: "EXC_ARITHMETIC", + 4: "EXC_EMULATION", + 5: "EXC_SOFTWARE", + 6: "EXC_BREAKPOINT", + 7: "EXC_SYSCALL", + 8: "EXC_MACH_SYSCALL", + 9: "EXC_RPC_ALERT", + 10: "EXC_CRASH", + 11: "EXC_RESOURCE", + 12: "EXC_GUARD", + 13: "EXC_CORPSE_NOTIFY", + ] + private static func machExceptionName(_ type: UInt64) -> String { - switch type { - case 1: "EXC_BAD_ACCESS" - case 2: "EXC_BAD_INSTRUCTION" - case 3: "EXC_ARITHMETIC" - case 4: "EXC_EMULATION" - case 5: "EXC_SOFTWARE" - case 6: "EXC_BREAKPOINT" - case 7: "EXC_SYSCALL" - case 8: "EXC_MACH_SYSCALL" - case 9: "EXC_RPC_ALERT" - case 10: "EXC_CRASH" - case 11: "EXC_RESOURCE" - case 12: "EXC_GUARD" - case 13: "EXC_CORPSE_NOTIFY" - default: "EXC_UNKNOWN(\(type))" - } + machExceptionNames[type] ?? "EXC_UNKNOWN(\(type))" } private static func machExceptionMessage(_ exception: PLCrashReportMachExceptionInfo) -> String { diff --git a/PostHog/Error Tracking/PostHogErrorTrackingCrashReportIntegration.swift b/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift similarity index 68% rename from PostHog/Error Tracking/PostHogErrorTrackingCrashReportIntegration.swift rename to PostHog/Error Tracking/PostHogCrashReporterIntegration.swift index 785fe6e92..26d5d5568 100644 --- a/PostHog/Error Tracking/PostHogErrorTrackingCrashReportIntegration.swift +++ b/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift @@ -1,5 +1,5 @@ // -// PostHogErrorTrackingCrashReportIntegration.swift +// PostHogCrashReporterIntegration.swift // PostHog // // Created by Ioannis Josephides on 14/12/2025. @@ -10,7 +10,7 @@ import Foundation #if os(iOS) || os(macOS) || os(tvOS) import CrashReporter - class PostHogErrorTrackingCrashReportIntegration: PostHogIntegration { + class PostHogCrashReporterIntegration: PostHogIntegration { private static let integrationInstalledLock = NSLock() private static var integrationInstalled = false @@ -20,11 +20,11 @@ import Foundation private var crashReporter: PLCrashReporter? func install(_ postHog: PostHogSDK) throws { - try PostHogErrorTrackingCrashReportIntegration.integrationInstalledLock.withLock { - if PostHogErrorTrackingCrashReportIntegration.integrationInstalled { + try PostHogCrashReporterIntegration.integrationInstalledLock.withLock { + if PostHogCrashReporterIntegration.integrationInstalled { throw InternalPostHogError(description: "Crash report integration already installed to another PostHogSDK instance.") } - PostHogErrorTrackingCrashReportIntegration.integrationInstalled = true + PostHogCrashReporterIntegration.integrationInstalled = true } self.postHog = postHog @@ -41,8 +41,8 @@ import Foundation stop() crashReporter = nil self.postHog = nil - PostHogErrorTrackingCrashReportIntegration.integrationInstalledLock.withLock { - PostHogErrorTrackingCrashReportIntegration.integrationInstalled = false + PostHogCrashReporterIntegration.integrationInstalledLock.withLock { + PostHogCrashReporterIntegration.integrationInstalled = false } } } @@ -68,7 +68,7 @@ import Foundation } // MARK: - Private Methods - + private func setupCrashReporter() -> PLCrashReporter? { // Check for debugger - crash handler won't work when debugging if PostHogDebugUtils.isDebuggerAttached() { @@ -94,6 +94,7 @@ import Foundation private func processPendingCrashReportIfNeeded(reporter: PLCrashReporter) { // Check for pending crash report FIRST (before enabling for new crashes) if reporter.hasPendingCrashReport() { + hedgeLog("Found pending crash report, processing...") processPendingCrashReport() } } @@ -117,35 +118,43 @@ import Foundation let crashData = try crashReporter.loadPendingCrashReportDataAndReturnError() let crashReport = try PLCrashReport(data: crashData) - // Extract context from crash report's customData - var crashContext: [String: Any] = [:] + // Extract saved context from crash report's customData + var savedContext: [String: Any] = [:] if let customData = crashReport.customData { - crashContext = (try? JSONSerialization.jsonObject(with: customData, options: [])) as? [String: Any] ?? [:] + savedContext = (try? JSONSerialization.jsonObject(with: customData, options: [])) as? [String: Any] ?? [:] } - // Process crash report and create $exception event - let exceptionProperties = PostHogCrashReportProcessor.processReport( - crashReport, - crashContext: crashContext - ) + // Extract identity and event properties from saved context + let crashDistinctId = savedContext["distinct_id"] as? String ?? postHog.getDistinctId() + let crashEventProperties = savedContext["event_properties"] as? [String: Any] ?? [:] + + // Collect crash-specific event properties (stack traces, exceptions etc) + let exceptionProperties = PostHogCrashReportProcessor.processReport(crashReport) - // Capture the crash event - postHog.capture( + // Merge: crash-time event properties as base, exception properties on top + let finalProperties = crashEventProperties.merging(exceptionProperties) { _, new in new } + + // Collect crash timestamp + let crashTimestamp = PostHogCrashReportProcessor.getCrashTimestamp(crashReport) + + // Capture using internal method and bypass buildProperties + postHog.captureInternal( "$exception", - properties: exceptionProperties, - userProperties: nil, - userPropertiesSetOnce: nil, - groups: nil + distinctId: crashDistinctId, + properties: finalProperties, + timestamp: crashTimestamp, + skipBuildProperties: true ) hedgeLog("Crash report processed and captured") - } catch { - // Best effort for now. We log and ignore and let the crash report be purged. + // Best effort for now. + // We log and ignore and let the crash report be purged. // - On a new crash, old report will be overwritten anyway // - Keeping the report around could risk infinite retry loop until next crash if it's corrupt - // - This could fail because of a transient error though, in the future we could check the returned error - // and only purge if PLCrashReporterErrorCrashReportInvalid + // + // Note: This could fail because of a transient error though, in the future we could check the returned error + // and only purge if PLCrashReporterErrorCrashReportInvalid, then keep the report around for max X retries hedgeLog("Failed to process crash report: \(error)") } @@ -156,7 +165,7 @@ import Foundation #else // watchOS stub - crash reporting is not available - class PostHogErrorTrackingCrashReportIntegration: PostHogIntegration { + class PostHogCrashReporterIntegration: PostHogIntegration { var requiresSwizzling: Bool { false } func install(_: PostHogSDK) throws { diff --git a/PostHog/Error Tracking/Utils/PostHogErrorTrackingUtils.swift b/PostHog/Error Tracking/Utils/PostHogErrorTrackingUtils.swift index f609293ec..d03498284 100644 --- a/PostHog/Error Tracking/Utils/PostHogErrorTrackingUtils.swift +++ b/PostHog/Error Tracking/Utils/PostHogErrorTrackingUtils.swift @@ -17,13 +17,13 @@ extension String { guard clean.count == 32 else { return self } let idx = clean.startIndex - let p1 = clean[idx ..< clean.index(idx, offsetBy: 8)] - let p2 = clean[clean.index(idx, offsetBy: 8) ..< clean.index(idx, offsetBy: 12)] - let p3 = clean[clean.index(idx, offsetBy: 12) ..< clean.index(idx, offsetBy: 16)] - let p4 = clean[clean.index(idx, offsetBy: 16) ..< clean.index(idx, offsetBy: 20)] - let p5 = clean[clean.index(idx, offsetBy: 20) ..< clean.index(idx, offsetBy: 32)] + let part1 = clean[idx ..< clean.index(idx, offsetBy: 8)] + let part2 = clean[clean.index(idx, offsetBy: 8) ..< clean.index(idx, offsetBy: 12)] + let part3 = clean[clean.index(idx, offsetBy: 12) ..< clean.index(idx, offsetBy: 16)] + let part4 = clean[clean.index(idx, offsetBy: 16) ..< clean.index(idx, offsetBy: 20)] + let part5 = clean[clean.index(idx, offsetBy: 20) ..< clean.index(idx, offsetBy: 32)] - return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)" + return "\(part1)-\(part2)-\(part3)-\(part4)-\(part5)" } } @@ -31,7 +31,7 @@ extension String { enum PostHogCPUArchitecture { /// Convert CPU type and subtype to architecture string - /// + /// /// - Parameters: /// - cpuType: Mach-O CPU type /// - cpuSubtype: Mach-O CPU subtype diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 81f71a56f..4cf4a8ec0 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -203,7 +203,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? var integrations: [PostHogIntegration] = [] if errorTrackingConfig.enableCrashHandler { - integrations.append(PostHogErrorTrackingCrashReportIntegration()) + integrations.append(PostHogCrashReporterIntegration()) } if captureScreenViews { diff --git a/PostHog/PostHogIntegration.swift b/PostHog/PostHogIntegration.swift index 64dec9c2f..703c80f6e 100644 --- a/PostHog/PostHogIntegration.swift +++ b/PostHog/PostHogIntegration.swift @@ -70,7 +70,7 @@ protocol PostHogIntegration { } extension PostHogIntegration { - func contextDidChange(_ context: [String: Any]) { + func contextDidChange(_: [String: Any]) { // Default empty implementation since most integrations won't need this } } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index b0e3b452b..b90de5700 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -47,6 +47,7 @@ let maxRetryDelay = 30.0 private static var apiKeys = Set() private var installedIntegrations: [PostHogIntegration] = [] let sessionManager = PostHogSessionManager() + private var sessionIdChangedToken: RegistrationToken? #if os(iOS) private weak var replayIntegration: PostHogReplayIntegration? @@ -71,6 +72,7 @@ let maxRetryDelay = 30.0 self.reachability?.stopNotifier() #endif + sessionIdChangedToken = nil uninstallIntegrations() } @@ -140,6 +142,10 @@ let maxRetryDelay = 30.0 // Create session manager instance for this PostHogSDK instance sessionManager.setup(config: config) sessionManager.startSession() + // Listen for session changes to update crash context (register before startSession) + sessionIdChangedToken = sessionManager.onSessionIdChanged { [weak self] in + self?.notifyContextDidChange() + } if !config.optOut { // don't install integrations if in opt-out state @@ -635,6 +641,33 @@ let maxRetryDelay = 30.0 groups: [String: String]? = nil, timestamp: Date? = nil) { + captureInternal( + event, + distinctId: distinctId, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + groups: groups, + timestamp: timestamp, + skipBuildProperties: false + ) + } + + /// Internal capture method that handles all event capture logic. + /// + /// - Parameters: + /// - skipBuildProperties: When true, skips buildProperties call and uses properties as-is. + /// Used by crash reporting to capture events with pre-built crash-time context. + func captureInternal( + _ event: String, + distinctId: String? = nil, + properties: [String: Any]? = nil, + userProperties: [String: Any]? = nil, + userPropertiesSetOnce: [String: Any]? = nil, + groups: [String: String]? = nil, + timestamp: Date? = nil, + skipBuildProperties: Bool = false + ) { if !isEnabled() { return } @@ -653,23 +686,35 @@ let maxRetryDelay = 30.0 // if the user isn't identified but passed userProperties, userPropertiesSetOnce or groups, // we should still enable person processing since this is intentional - if userProperties?.isEmpty == false || userPropertiesSetOnce?.isEmpty == false || groups?.isEmpty == false { + let hasPersonData = userProperties?.isEmpty == false + || userPropertiesSetOnce?.isEmpty == false + || groups?.isEmpty == false + + if !skipBuildProperties, hasPersonData { requirePersonProcessing() } - let properties = buildProperties(distinctId: eventDistinctId, - properties: sanitizeDictionary(properties), - userProperties: sanitizeDictionary(userProperties), - userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce), - groups: groups, - appendSharedProps: !isSnapshotEvent, - timestamp: timestamp) + let finalProperties: [String: Any] + if skipBuildProperties { + // Use properties as-is (already built at crash time) + finalProperties = properties ?? [:] + } else { + finalProperties = buildProperties( + distinctId: eventDistinctId, + properties: sanitizeDictionary(properties), + userProperties: sanitizeDictionary(userProperties), + userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce), + groups: groups, + appendSharedProps: !isSnapshotEvent, + timestamp: timestamp + ) + } // Sanitize is now called in buildEvent let posthogEvent = buildEvent( event: event, distinctId: eventDistinctId, - properties: properties, + properties: finalProperties, timestamp: eventTimestamp ) @@ -691,7 +736,9 @@ let maxRetryDelay = 30.0 targetQueue?.add(posthogEvent) // Automatically set person properties for feature flags during capture event - setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce: userPropertiesSetOnce) + if !skipBuildProperties { + setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce: userPropertiesSetOnce) + } #if os(iOS) surveysIntegration?.onEvent(event: posthogEvent.event) @@ -1641,7 +1688,9 @@ let maxRetryDelay = 30.0 guard isEnabled() else { return } let distinctId = getDistinctId() - let context = buildProperties( + + // Build complete event properties snapshot + let eventProperties = buildProperties( distinctId: distinctId, properties: nil, userProperties: nil, @@ -1651,6 +1700,13 @@ let maxRetryDelay = 30.0 timestamp: nil ) + // Build crash context with identity info + event properties + // This structure allows crash reporting to reconstruct events with crash-time data + let context: [String: Any] = [ + "distinct_id": distinctId, + "event_properties": eventProperties, + ] + for integration in installedIntegrations { integration.contextDidChange(context) } diff --git a/PostHog/PostHogSessionManager.swift b/PostHog/PostHogSessionManager.swift index 07de4bc4d..f0a2da198 100644 --- a/PostHog/PostHogSessionManager.swift +++ b/PostHog/PostHogSessionManager.swift @@ -56,8 +56,33 @@ import Foundation private let sessionActivityThreshold: TimeInterval = 60 * 30 // 24 hours in seconds private let sessionMaxLengthThreshold: TimeInterval = 24 * 60 * 60 - // Called when session id is cleared or changes - var onSessionIdChanged: () -> Void = {} + // Callbacks when session id is cleared or changes + private var sessionIdChangedCallbacks: [UUID: () -> Void] = [:] + private let callbacksLock = NSLock() + + /// Register a callback for session ID changes + /// - Parameter callback: Closure to call when session ID changes + /// - Returns: A RegistrationToken that removes the callback when deallocated + func onSessionIdChanged(_ callback: @escaping () -> Void) -> RegistrationToken { + let id = UUID() + callbacksLock.withLock { + sessionIdChangedCallbacks[id] = callback + } + + return RegistrationToken { [weak self] in + guard let self else { return } + self.callbacksLock.withLock { + _ = self.sessionIdChangedCallbacks.removeValue(forKey: id) + } + } + } + + private func notifySessionIdChanged() { + let callbacks = callbacksLock.withLock { Array(sessionIdChangedCallbacks.values) } + for callback in callbacks { + callback() + } + } @objc public func setSessionId(_ sessionId: String) { setSessionIdInternal(sessionId, at: now(), reason: .customSessionId) @@ -214,7 +239,7 @@ import Foundation self.sessionActivityTimestamp = timestamp } - onSessionIdChanged() + notifySessionIdChanged() if let sessionId { hedgeLog("New session id created \(sessionId) (\(reason))") diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index d3511fc83..a7162b41c 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -33,6 +33,7 @@ private var applicationBackgroundedToken: RegistrationToken? private var applicationForegroundedToken: RegistrationToken? private var viewLayoutToken: RegistrationToken? + private var sessionIdChangedToken: RegistrationToken? private var installedPlugins: [PostHogSessionReplayPlugin] = [] /** @@ -142,7 +143,7 @@ isEnabled = true // reset views when session id changes (or is cleared) so we can re-send new metadata (or full snapshot in the future) - postHog.sessionManager.onSessionIdChanged = { [weak self] in + sessionIdChangedToken = postHog.sessionManager.onSessionIdChanged { [weak self] in self?.resetViews() } @@ -185,7 +186,7 @@ guard isEnabled else { return } isEnabled = false resetViews() - postHog?.sessionManager.onSessionIdChanged = {} + sessionIdChangedToken = nil // stop listening to `UIApplication.sendEvent` applicationEventToken = nil From a470c28fac04608c3a1eca2c250cf0e630f53b3b Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sat, 20 Dec 2025 01:19:28 +0200 Subject: [PATCH 28/45] feat: add in-app detection --- .../PostHogCrashReportProcessor.swift | 159 ++++++++++++++---- .../PostHogCrashReporterIntegration.swift | 4 +- 2 files changed, 130 insertions(+), 33 deletions(-) diff --git a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift index af69131fd..938411038 100644 --- a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift +++ b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift @@ -13,9 +13,11 @@ import Foundation enum PostHogCrashReportProcessor { /// Process a PLCrashReport and convert it to PostHog $exception event properties /// - /// - Parameter report: The PLCrashReport to process + /// - Parameters: + /// - report: The PLCrashReport to process + /// - config: Error tracking configuration for in-app detection /// - Returns: Dictionary of exception-specific properties for the $exception event - static func processReport(_ report: PLCrashReport) -> [String: Any] { + static func processReport(_ report: PLCrashReport, config: PostHogErrorTrackingConfig) -> [String: Any] { var properties: [String: Any] = [:] // Fatal crash @@ -24,7 +26,7 @@ import Foundation // Build exception list var exceptions: [[String: Any]] = [] - if let exceptionInfo = buildExceptionInfo(from: report) { + if let exceptionInfo = buildExceptionInfo(from: report, config: config) { exceptions.append(exceptionInfo) } @@ -42,10 +44,7 @@ import Foundation if let uuidRef = report.uuidRef { properties["$crash_report_id"] = CFUUIDCreateString(nil, uuidRef) as String } - if let timestamp = report.systemInfo?.timestamp { - properties["$app_crashed_at"] = toISO8601String(timestamp) - } - + return properties } @@ -56,7 +55,7 @@ import Foundation // MARK: - Exception Building - private static func buildExceptionInfo(from report: PLCrashReport) -> [String: Any]? { + private static func buildExceptionInfo(from report: PLCrashReport, config: PostHogErrorTrackingConfig) -> [String: Any]? { var exception: [String: Any] = [:] // Determine exception type and value based on crash type @@ -72,31 +71,31 @@ import Foundation "meta": [ "mach": [ "exception": machException.type, - "code": machException.codes.first ?? 0, - "subcode": machException.codes.count > 1 ? machException.codes[1] : 0, - ], - ], + "code": machException.codes.first, + "subcode": machException.codes.count > 1 ? machException.codes[1] : nil, + ].compactMapValues { $0 }, + ].compactMapValues { $0 }, ] } else if let signalInfo = report.signalInfo { // POSIX signal - exception["type"] = signalInfo.name ?? "Unknown Signal" + exception["type"] = signalInfo.name exception["value"] = signalMessage(signalInfo) + let signalMeta: [String: Any?] = [ + "code": signalInfo.code, + "name": signalInfo.name, + ].compactMapValues { $0 } + exception["mechanism"] = [ "type": "signal", "handled": false, "synthetic": false, - "meta": [ - "signal": [ - "code": signalInfo.code ?? "Unknown", - "name": signalInfo.name ?? "Unknown", - ], - ], + "meta": ["signal": signalMeta].compactMapValues { $0 }, ] } else if report.hasExceptionInfo, let nsExceptionInfo = report.exceptionInfo { // NSException - exception["type"] = nsExceptionInfo.exceptionName ?? "NSException" - exception["value"] = nsExceptionInfo.exceptionReason ?? "Unknown reason" + exception["type"] = nsExceptionInfo.exceptionName + exception["value"] = nsExceptionInfo.exceptionReason exception["mechanism"] = [ "type": "nsexception", @@ -108,21 +107,26 @@ import Foundation } // Add stack trace from crashed thread - if let stacktrace = buildStacktrace(from: report) { + if let stacktrace = buildStacktrace(from: report, config: config) { exception["stacktrace"] = stacktrace } // Add thread ID of crashed thread + // Note: Uses PLCrashReporter's threadNumber (sequential index) rather than Mach thread ID, + // since the original process has terminated and pthread_mach_thread_np is not available. if let crashedThread = findCrashedThread(in: report) { exception["thread_id"] = crashedThread.threadNumber } + // cleanup nil values + exception = exception.compactMapValues { $0 } + return exception } // MARK: - Stack Trace Building - private static func buildStacktrace(from report: PLCrashReport) -> [String: Any]? { + private static func buildStacktrace(from report: PLCrashReport, config: PostHogErrorTrackingConfig) -> [String: Any]? { guard let crashedThread = findCrashedThread(in: report) else { return nil } @@ -152,12 +156,15 @@ import Foundation symbolAddress = symbolInfo.startAddress } + // Determine in-app status based on module name and config + let inApp = module.map { PostHogStackTraceProcessor.isInApp(module: $0, config: config) } ?? false + let stackFrame = PostHogStackFrame( instructionAddress: frame.instructionPointer, module: module, package: package, imageAddress: imageAddress, - inApp: false, // Cannot determine in-app status from crash report + inApp: inApp, function: function, symbolAddress: symbolAddress ) @@ -212,6 +219,9 @@ import Foundation } // MARK: - Helpers + + /// Format string for zero-padded 64-bit hex addresses (e.g., "0x00007fff12345678") + static let hexAddressPaddedFormat = "0x%016llx" private static let machExceptionNames: [UInt64: String] = [ 1: "EXC_BAD_ACCESS", @@ -233,18 +243,105 @@ import Foundation machExceptionNames[type] ?? "EXC_UNKNOWN(\(type))" } + // Kernel return codes (used as first code for EXC_BAD_ACCESS) + // From mach/kern_return.h + private static let kernelReturnCodeNames: [Int64: String] = [ + 0: "KERN_SUCCESS", + 1: "KERN_INVALID_ADDRESS", + 2: "KERN_PROTECTION_FAILURE", + 3: "KERN_NO_SPACE", + 4: "KERN_INVALID_ARGUMENT", + 5: "KERN_FAILURE", + 6: "KERN_RESOURCE_SHORTAGE", + 7: "KERN_NOT_RECEIVER", + 8: "KERN_NO_ACCESS", + 9: "KERN_MEMORY_FAILURE", + 10: "KERN_MEMORY_ERROR", + 11: "KERN_ALREADY_IN_SET", + 12: "KERN_NOT_IN_SET", + 13: "KERN_NAME_EXISTS", + 14: "KERN_ABORTED", + 15: "KERN_INVALID_NAME", + 16: "KERN_INVALID_TASK", + 17: "KERN_INVALID_RIGHT", + 18: "KERN_INVALID_VALUE", + 19: "KERN_UREFS_OVERFLOW", + 20: "KERN_INVALID_CAPABILITY", + 21: "KERN_RIGHT_EXISTS", + 22: "KERN_INVALID_HOST", + 23: "KERN_MEMORY_PRESENT", + 24: "KERN_MEMORY_DATA_MOVED", + 25: "KERN_MEMORY_RESTART_COPY", + 26: "KERN_INVALID_PROCESSOR_SET", + 27: "KERN_POLICY_LIMIT", + 28: "KERN_INVALID_POLICY", + 29: "KERN_INVALID_OBJECT", + 30: "KERN_ALREADY_WAITING", + 31: "KERN_DEFAULT_SET", + 32: "KERN_EXCEPTION_PROTECTED", + 33: "KERN_INVALID_LEDGER", + 34: "KERN_INVALID_MEMORY_CONTROL", + 35: "KERN_INVALID_SECURITY", + 36: "KERN_NOT_DEPRESSED", + 37: "KERN_TERMINATED", + 38: "KERN_LOCK_SET_DESTROYED", + 39: "KERN_LOCK_UNSTABLE", + 40: "KERN_LOCK_OWNED", + 41: "KERN_LOCK_OWNED_SELF", + 42: "KERN_SEMAPHORE_DESTROYED", + 43: "KERN_RPC_SERVER_TERMINATED", + 44: "KERN_RPC_TERMINATE_ORPHAN", + 45: "KERN_RPC_CONTINUE_ORPHAN", + 46: "KERN_NOT_SUPPORTED", + 47: "KERN_NODE_DOWN", + 48: "KERN_NOT_WAITING", + 49: "KERN_OPERATION_TIMED_OUT", + 50: "KERN_CODESIGN_ERROR", + // ARM-specific codes for EXC_BAD_ACCESS (from mach/arm/exception.h) + 0x101: "EXC_ARM_DA_ALIGN", // 257 + 0x102: "EXC_ARM_DA_DEBUG", // 258 + 0x103: "EXC_ARM_SP_ALIGN", // 259 + 0x104: "EXC_ARM_SWP", // 260 + 0x105: "EXC_ARM_PAC_FAIL", // 261 + ] + + private static func kernelReturnCodeName(_ code: Int64) -> String? { + kernelReturnCodeNames[code] + } + private static func machExceptionMessage(_ exception: PLCrashReportMachExceptionInfo) -> String { let typeName = machExceptionName(exception.type) - let codesArray = exception.codes as? [NSNumber] - let codes = codesArray?.map { String(describing: $0) }.joined(separator: ", ") ?? "" - return "\(typeName) at codes: [\(codes)]" + + guard let codesArray = exception.codes as? [NSNumber], !codesArray.isEmpty else { + return typeName + } + + let code = codesArray[0].int64Value + let subcode = codesArray.count > 1 ? codesArray[1].int64Value : nil + + // Format code with name if available + let codeStr: String + if let codeName = kernelReturnCodeName(code) { + codeStr = "\(codeName) (\(code))" + } else { + codeStr = String(code) + } + + // Format subcode as hex address if present + if let subcode = subcode { + let subcodeHex = String(format: hexAddressPaddedFormat, UInt64(bitPattern: subcode)) + return "\(typeName), Code \(codeStr), Subcode \(subcodeHex)" + } else { + return "\(typeName), Code \(codeStr)" + } } - private static func signalMessage(_ signal: PLCrashReportSignalInfo) -> String { - let name = signal.name ?? "Unknown" - let code = signal.code ?? "Unknown" - let address = String(format: PostHogStackFrame.hexAddressFormat, signal.address) + private static func signalMessage(_ signal: PLCrashReportSignalInfo) -> String? { + guard let name = signal.name, let code = signal.code else { + return nil + } + let address = String(format: PostHogStackFrame.hexAddressFormat, signal.address) return "\(name) (code \(code)) at address \(address)" } } diff --git a/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift b/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift index 26d5d5568..4913da0e8 100644 --- a/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift +++ b/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift @@ -29,10 +29,10 @@ import Foundation self.postHog = postHog if let crashReporter = setupCrashReporter() { + self.crashReporter = crashReporter // Note: Order here matters, we need to process any pending crash report before enabling the crash reporter processPendingCrashReportIfNeeded(reporter: crashReporter) enableCrashReporter(reporter: crashReporter) - self.crashReporter = crashReporter } } @@ -129,7 +129,7 @@ import Foundation let crashEventProperties = savedContext["event_properties"] as? [String: Any] ?? [:] // Collect crash-specific event properties (stack traces, exceptions etc) - let exceptionProperties = PostHogCrashReportProcessor.processReport(crashReport) + let exceptionProperties = PostHogCrashReportProcessor.processReport(crashReport, config: postHog.config.errorTrackingConfig) // Merge: crash-time event properties as base, exception properties on top let finalProperties = crashEventProperties.merging(exceptionProperties) { _, new in new } From 9262d1e906f74506c0fa770c32a814f3227171cf Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sat, 20 Dec 2025 02:43:49 +0200 Subject: [PATCH 29/45] feat: add crash triggers to sample project --- PostHog.xcodeproj/project.pbxproj | 4 + PostHogExample/ContentView.swift | 70 +++++++++++- PostHogExample/ExceptionHandler.h | 28 +++++ PostHogExample/ExceptionHandler.m | 80 ++++++++++++++ PostHogExample/SwiftCrashTriggers.swift | 140 ++++++++++++++++++++++++ 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 PostHogExample/SwiftCrashTriggers.swift diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index eb480e3a0..adf0cd919 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -191,6 +191,7 @@ DA5063D62EF591CB00C51DA0 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = DA5063D52EF591CB00C51DA0 /* CrashReporter */; }; DA5064412EF5E40300C51DA0 /* ApplicationScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */; }; DA5064432EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064422EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift */; }; + DA5064572EF6171900C51DA0 /* SwiftCrashTriggers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064562EF6171900C51DA0 /* SwiftCrashTriggers.swift */; }; DA53DE762D3E29A900C38DCA /* fixture_remote_config.json in Resources */ = {isa = PBXBuildFile; fileRef = DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */; }; DA53DE7E2D3E66AA00C38DCA /* PostHogRemoteConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */; }; DA578E982D6768BC00B3A56C /* PostHogScreenViewIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */; }; @@ -733,6 +734,7 @@ DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingUtils.swift; sourceTree = ""; }; DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReportProcessor.swift; sourceTree = ""; }; DA5064422EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReporterIntegration.swift; sourceTree = ""; }; + DA5064562EF6171900C51DA0 /* SwiftCrashTriggers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftCrashTriggers.swift; sourceTree = ""; }; DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_remote_config.json; sourceTree = ""; }; DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfigTest.swift; sourceTree = ""; }; DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenViewIntegration.swift; sourceTree = ""; }; @@ -1019,6 +1021,7 @@ DA3BB4AD2ED88DE90097A97A /* ExceptionHandler.h */, DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */, DA3BB4B02ED88DEC0097A97A /* PostHogExample-Bridging-Header.h */, + DA5064562EF6171900C51DA0 /* SwiftCrashTriggers.swift */, ); path = PostHogExample; sourceTree = ""; @@ -2144,6 +2147,7 @@ DA3BB4AF2ED88DE90097A97A /* ExceptionHandler.m in Sources */, 3AE3FB2C2991320300AFFC18 /* Api.swift in Sources */, 3A0F108329C47940002C0084 /* UIViewExample.swift in Sources */, + DA5064572EF6171900C51DA0 /* SwiftCrashTriggers.swift in Sources */, 3AA34CFA296D951A003398F4 /* PostHogExampleApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 37341044b..5c0870515 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -61,12 +61,18 @@ class FeatureFlagsModel: ObservableObject { } } +enum CrashTriggerType: String, CaseIterable { + case swift = "Swift" + case lowLevel = "Low-level" +} + struct ContentView: View { @State var counter: Int = 0 @State private var name: String = "Max" @State private var showingSheet = false @State private var showingRedactedSheet = false @State private var refreshStatusID = UUID() + @State private var crashTriggerType: CrashTriggerType = .swift @StateObject var api = Api() @StateObject var signInViewModel = SignInViewModel() @@ -120,7 +126,7 @@ struct ContentView: View { /// Creates a multi-level async call chain to test stack trace capture func captureAsyncError() async { do { - await try asyncLevel1() + try await asyncLevel1() } catch { PostHogSDK.shared.captureException(error, properties: [ "is_test": true, @@ -301,7 +307,69 @@ struct ContentView: View { } } + Section("Crash Triggers") { + Picker("Type", selection: $crashTriggerType) { + Text("Swift").tag(CrashTriggerType.swift) + Text("Low-level").tag(CrashTriggerType.lowLevel) + } + .pickerStyle(.segmented) + + if crashTriggerType == .swift { + Button("throw()") { + SwiftCrashTriggers.triggerThrowingFunction() + } + + Button("fatalError()") { + SwiftCrashTriggers.triggerFatalError() + } + Button("preconditionFailure()") { + SwiftCrashTriggers.triggerPreconditionFailure() + } + Button("assertionFailure() - Debug only") { + SwiftCrashTriggers.triggerAssertionFailure() + } + Button("Force unwrap nil") { + SwiftCrashTriggers.triggerForceUnwrapNil() + } + Button("Array out of bounds") { + SwiftCrashTriggers.triggerArrayOutOfBounds() + } + Button("Implicit unwrap nil") { + SwiftCrashTriggers.triggerImplicitUnwrapNil() + } + } else { + Button("Null Pointer (EXC_BAD_ACCESS)") { + ExceptionHandler.triggerNullPointerCrash() + } + Button("Stack Overflow (EXC_BAD_ACCESS)") { + ExceptionHandler.triggerStackOverflowCrash() + } + Button("Abort (SIGABRT)") { + ExceptionHandler.triggerAbortCrash() + } + Button("Illegal Instruction (SIGILL)") { + ExceptionHandler.triggerIllegalInstructionCrash() + } + Button("Uncaught NSException") { + ExceptionHandler.triggerUncaughtNSException() + } + Button("Segfault (SIGSEGV)") { + ExceptionHandler.triggerSegfaultCrash() + } + Button("Bus Error (SIGBUS)") { + ExceptionHandler.triggerBusErrorCrash() + } + Button("Divide by Zero (SIGFPE)") { + ExceptionHandler.triggerDivideByZeroCrash() + } + Button("Trap (SIGTRAP)") { + ExceptionHandler.triggerTrapCrash() + } + } + } + Section("Error tracking") { + Button("Capture Swift Enum Error (with associated value)") { do { throw SampleAppError.generalAppError(ErrorDetails(code: 10, reason: "some reason")) diff --git a/PostHogExample/ExceptionHandler.h b/PostHogExample/ExceptionHandler.h index fb2520275..a418be4cc 100644 --- a/PostHogExample/ExceptionHandler.h +++ b/PostHogExample/ExceptionHandler.h @@ -38,6 +38,34 @@ NS_ASSUME_NONNULL_BEGIN /// This demonstrates how exceptions can be caught and rethrown with additional context + (void)triggerChainedException; +// MARK: - Crash Triggers for Testing + +/// Trigger a null pointer dereference (EXC_BAD_ACCESS / KERN_INVALID_ADDRESS) ++ (void)triggerNullPointerCrash; + +/// Trigger a stack overflow (EXC_BAD_ACCESS / KERN_PROTECTION_FAILURE) ++ (void)triggerStackOverflowCrash; + +/// Trigger an abort signal (SIGABRT) ++ (void)triggerAbortCrash; + +/// Trigger an illegal instruction (SIGILL / EXC_BAD_INSTRUCTION) ++ (void)triggerIllegalInstructionCrash; + +/// Trigger an uncaught NSException ++ (void)triggerUncaughtNSException; + +/// Trigger a SIGSEGV (segmentation fault) ++ (void)triggerSegfaultCrash; + +/// Trigger a SIGBUS (bus error) ++ (void)triggerBusErrorCrash; + +/// Trigger a SIGFPE (floating point exception / divide by zero) ++ (void)triggerDivideByZeroCrash; + +/// Trigger a SIGTRAP (breakpoint/debugger trap) ++ (void)triggerTrapCrash; @end diff --git a/PostHogExample/ExceptionHandler.m b/PostHogExample/ExceptionHandler.m index f96a46c81..bc3cddde3 100644 --- a/PostHogExample/ExceptionHandler.m +++ b/PostHogExample/ExceptionHandler.m @@ -116,5 +116,85 @@ + (void)establishNetworkConnection { }]; } +// MARK: - Crash Triggers for Testing + ++ (void)triggerNullPointerCrash { + // Trigger a null pointer dereference (EXC_BAD_ACCESS / KERN_INVALID_ADDRESS) + int *nullPointer = NULL; + *nullPointer = 42; +} + ++ (void)triggerStackOverflowCrash { + // Trigger stack overflow via infinite recursion (EXC_BAD_ACCESS / KERN_PROTECTION_FAILURE) + [self triggerStackOverflowCrash]; +} + ++ (void)triggerAbortCrash { + // Trigger SIGABRT + abort(); +} + ++ (void)triggerIllegalInstructionCrash { + // Trigger SIGILL / EXC_BAD_INSTRUCTION by executing invalid instruction + // This uses inline assembly to execute an undefined instruction +#if defined(__arm64__) + __asm__ volatile(".word 0x00000000"); // Undefined instruction on ARM64 +#elif defined(__x86_64__) + __asm__ volatile("ud2"); // Undefined instruction on x86_64 +#else + // Fallback: raise SIGILL directly + raise(SIGILL); +#endif +} + ++ (void)triggerUncaughtNSException { + // Trigger an uncaught NSException (will be caught by PLCrashReporter) + @throw [NSException exceptionWithName:@"UncaughtTestException" + reason:@"This is an intentionally uncaught exception for crash testing" + userInfo:@{ + @"test_type": @"uncaught_exception", + @"timestamp": [NSDate date] + }]; +} + ++ (void)triggerSegfaultCrash { + // Trigger SIGSEGV by accessing unmapped memory + volatile int *badAddress = (int *)0xDEADBEEF; + *badAddress = 42; +} + ++ (void)triggerBusErrorCrash { + // Trigger SIGBUS via misaligned memory access + // On ARM, misaligned access to certain types causes SIGBUS +#if defined(__arm64__) + char *ptr = malloc(10); + volatile int *misaligned = (int *)(ptr + 1); // Misaligned address + *misaligned = 42; + free(ptr); +#else + // On x86, misaligned access is usually allowed, so raise signal directly + raise(SIGBUS); +#endif +} + ++ (void)triggerDivideByZeroCrash { + // Trigger SIGFPE via integer divide by zero + // Note: On ARM, integer divide by zero doesn't trap by default + // We use volatile to prevent compiler optimization + volatile int zero = 0; + volatile int result = 1 / zero; + (void)result; // Suppress unused variable warning +} + ++ (void)triggerTrapCrash { + // Trigger SIGTRAP (debugger trap / breakpoint) +#if defined(__arm64__) + __asm__ volatile("brk #0"); // Breakpoint on ARM64 +#elif defined(__x86_64__) + __asm__ volatile("int3"); // Breakpoint on x86_64 +#else + raise(SIGTRAP); +#endif +} @end diff --git a/PostHogExample/SwiftCrashTriggers.swift b/PostHogExample/SwiftCrashTriggers.swift new file mode 100644 index 000000000..3b7e65fe7 --- /dev/null +++ b/PostHogExample/SwiftCrashTriggers.swift @@ -0,0 +1,140 @@ +// +// SwiftCrashTriggers.swift +// PostHogExample +// +// Swift-native crash triggers for testing crash reporting +// + +import Foundation + +/// Swift crash triggers with nested call stacks for testing stack trace capture +enum SwiftCrashTriggers { + // MARK: - Public API + + static func triggerThrowingFunction() { + OuterLayer.triggerThrowingFunction() + } + + static func triggerFatalError() { + OuterLayer.processFatalError() + } + + static func triggerPreconditionFailure() { + OuterLayer.processPreconditionFailure() + } + + static func triggerAssertionFailure() { + OuterLayer.processAssertionFailure() + } + + static func triggerForceUnwrapNil() { + OuterLayer.processForceUnwrapNil() + } + + static func triggerArrayOutOfBounds() { + OuterLayer.processArrayOutOfBounds() + } + + static func triggerImplicitUnwrapNil() { + OuterLayer.processImplicitUnwrapNil() + } + + // MARK: - Nested Classes for Deeper Stack Traces + + private enum OuterLayer { + static func triggerThrowingFunction() { + MiddleLayer.triggerThrowingFunction() + } + + static func processFatalError() { + MiddleLayer.handleFatalError() + } + + static func processPreconditionFailure() { + MiddleLayer.handlePreconditionFailure() + } + + static func processAssertionFailure() { + MiddleLayer.handleAssertionFailure() + } + + static func processForceUnwrapNil() { + MiddleLayer.handleForceUnwrapNil() + } + + static func processArrayOutOfBounds() { + MiddleLayer.handleArrayOutOfBounds() + } + + static func processImplicitUnwrapNil() { + MiddleLayer.handleImplicitUnwrapNil() + } + } + + private enum MiddleLayer { + static func triggerThrowingFunction() { + InnerLayer.triggerThrowingFunction() + } + static func handleFatalError() { + InnerLayer.executeFatalError() + } + + static func handlePreconditionFailure() { + InnerLayer.executePreconditionFailure() + } + + static func handleAssertionFailure() { + InnerLayer.executeAssertionFailure() + } + + static func handleForceUnwrapNil() { + InnerLayer.executeForceUnwrapNil() + } + + static func handleArrayOutOfBounds() { + InnerLayer.executeArrayOutOfBounds() + } + + static func handleImplicitUnwrapNil() { + InnerLayer.executeImplicitUnwrapNil() + } + } + + private enum InnerLayer { + static func triggerThrowingFunction() { + try! throwingFunction() + } + static func executeFatalError() { + fatalError("Intentional fatalError for crash testing") + } + + static func executePreconditionFailure() { + preconditionFailure("Intentional preconditionFailure for crash testing") + } + + static func executeAssertionFailure() { + assertionFailure("Intentional assertionFailure for crash testing") + } + + static func executeForceUnwrapNil() { + let nilValue: String? = nil + _ = nilValue! + } + + static func executeArrayOutOfBounds() { + let array = [1, 2, 3] + _ = array[10] + } + + static func executeImplicitUnwrapNil() { + let nilValue: String! = nil + _ = nilValue.count + } + + static func throwingFunction() throws -> Void { + throw MyCustomError() + } + } +} + +struct MyCustomError: Error {} From 5e873e36db8a355fb5c8bb13405a4866ec3225da Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sun, 21 Dec 2025 23:27:37 +0200 Subject: [PATCH 30/45] fix: cleanup debug images --- .../PostHogCrashReportProcessor.swift | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift index 938411038..31dc19596 100644 --- a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift +++ b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift @@ -23,10 +23,13 @@ import Foundation // Fatal crash properties["$exception_level"] = "fatal" + // Build stack frames once, reuse for both exception info and debug images + let stackFrames = buildStackFrames(from: report, config: config) + // Build exception list var exceptions: [[String: Any]] = [] - if let exceptionInfo = buildExceptionInfo(from: report, config: config) { + if let exceptionInfo = buildExceptionInfo(from: report, stackFrames: stackFrames) { exceptions.append(exceptionInfo) } @@ -34,8 +37,8 @@ import Foundation properties["$exception_list"] = exceptions } - // Build debug images for symbolication - let debugImages = buildDebugImages(from: report) + // Build debug images for symbolication (only images referenced in stack trace) + let debugImages = buildDebugImages(from: report, stackFrames: stackFrames) if !debugImages.isEmpty { properties["$debug_images"] = debugImages } @@ -55,7 +58,7 @@ import Foundation // MARK: - Exception Building - private static func buildExceptionInfo(from report: PLCrashReport, config: PostHogErrorTrackingConfig) -> [String: Any]? { + private static func buildExceptionInfo(from report: PLCrashReport, stackFrames: [PostHogStackFrame]) -> [String: Any]? { var exception: [String: Any] = [:] // Determine exception type and value based on crash type @@ -106,9 +109,12 @@ import Foundation return nil } - // Add stack trace from crashed thread - if let stacktrace = buildStacktrace(from: report, config: config) { - exception["stacktrace"] = stacktrace + // Add stack trace from frames + if !stackFrames.isEmpty { + exception["stacktrace"] = [ + "frames": stackFrames.map(\.toDictionary), + "type": "raw", + ] } // Add thread ID of crashed thread @@ -124,11 +130,16 @@ import Foundation return exception } - // MARK: - Stack Trace Building + // MARK: - Stack Frames - private static func buildStacktrace(from report: PLCrashReport, config: PostHogErrorTrackingConfig) -> [String: Any]? { + /// Builds stack frames from the crashed thread. + + private static func buildStackFrames( + from report: PLCrashReport, + config: PostHogErrorTrackingConfig + ) -> [PostHogStackFrame] { guard let crashedThread = findCrashedThread(in: report) else { - return nil + return [] } var frames: [PostHogStackFrame] = [] @@ -171,14 +182,7 @@ import Foundation frames.append(stackFrame) } - guard !frames.isEmpty else { return nil } - - let frameDicts = frames.map(\.toDictionary) - - return [ - "frames": frameDicts, - "type": "raw", - ] + return frames } private static func findCrashedThread(in report: PLCrashReport) -> PLCrashReportThreadInfo? { @@ -191,11 +195,21 @@ import Foundation // MARK: - Debug Images - private static func buildDebugImages(from report: PLCrashReport) -> [[String: Any]] { + /// Build debug images for symbolication, including only images referenced in the stack frames. + private static func buildDebugImages( + from report: PLCrashReport, + stackFrames: [PostHogStackFrame] + ) -> [[String: Any]] { + // Extract unique image addresses from stack frames + let referencedImageAddresses = Set(stackFrames.compactMap(\.imageAddress)) + guard !referencedImageAddresses.isEmpty else { return [] } + var debugImages: [PostHogBinaryImageInfo] = [] for case let image as PLCrashReportBinaryImageInfo in report.images { - guard let imageName = image.imageName else { continue } + guard referencedImageAddresses.contains(image.imageBaseAddress), + let imageName = image.imageName + else { continue } let arch: String? if let codeType = image.codeType { From 75b2a49eac4b4c88d071c467cba1711a30cbfc67 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 22 Dec 2025 00:39:54 +0200 Subject: [PATCH 31/45] feat: exception type order --- .../PostHogCrashReportProcessor.swift | 94 ++++++++++++++----- .../PostHogCrashReporterIntegration.swift | 12 +-- PostHogExample/ContentView.swift | 81 ++++------------ PostHogExample/ExceptionHandler.h | 28 +----- PostHogExample/ExceptionHandler.m | 71 +------------- PostHogExample/SwiftCrashTriggers.swift | 88 +---------------- 6 files changed, 104 insertions(+), 270 deletions(-) diff --git a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift index 31dc19596..473acb1bb 100644 --- a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift +++ b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift @@ -62,25 +62,19 @@ import Foundation var exception: [String: Any] = [:] // Determine exception type and value based on crash type - if let machException = report.machExceptionInfo { - // Mach exception - exception["type"] = machExceptionName(machException.type) - exception["value"] = machExceptionMessage(machException) + // Priority: NSException (richest info) → Signal (more familiar) → Mach (lowest level) + if report.hasExceptionInfo, let nsExceptionInfo = report.exceptionInfo { + // NSException - has actual exception name and reason + exception["type"] = nsExceptionInfo.exceptionName + exception["value"] = nsExceptionInfo.exceptionReason exception["mechanism"] = [ - "type": "mach_exception", + "type": "nsexception", "handled": false, "synthetic": false, - "meta": [ - "mach": [ - "exception": machException.type, - "code": machException.codes.first, - "subcode": machException.codes.count > 1 ? machException.codes[1] : nil, - ].compactMapValues { $0 }, - ].compactMapValues { $0 }, ] } else if let signalInfo = report.signalInfo { - // POSIX signal + // POSIX signal - more familiar to developers (SIGTRAP, SIGABRT, etc.) exception["type"] = signalInfo.name exception["value"] = signalMessage(signalInfo) @@ -95,15 +89,22 @@ import Foundation "synthetic": false, "meta": ["signal": signalMeta].compactMapValues { $0 }, ] - } else if report.hasExceptionInfo, let nsExceptionInfo = report.exceptionInfo { - // NSException - exception["type"] = nsExceptionInfo.exceptionName - exception["value"] = nsExceptionInfo.exceptionReason + } else if let machException = report.machExceptionInfo { + // Mach exception - lowest level, fallback + exception["type"] = machExceptionName(machException.type) + exception["value"] = machExceptionMessage(machException) exception["mechanism"] = [ - "type": "nsexception", + "type": "mach_exception", "handled": false, "synthetic": false, + "meta": [ + "mach": [ + "exception": machException.type, + "code": machException.codes.first, + "subcode": machException.codes.count > 1 ? machException.codes[1] : nil, + ].compactMapValues { $0 }, + ].compactMapValues { $0 }, ] } else { return nil @@ -257,6 +258,28 @@ import Foundation machExceptionNames[type] ?? "EXC_UNKNOWN(\(type))" } + // Exception codes for EXC_BREAKPOINT (from mach/arm/exception.h) + private static let breakpointCodeNames: [Int64: String] = [ + 1: "EXC_ARM_BREAKPOINT", + ] + + // Exception codes for EXC_BAD_INSTRUCTION (from mach/arm/exception.h) + private static let badInstructionCodeNames: [Int64: String] = [ + 1: "EXC_ARM_UNDEFINED", + 2: "EXC_ARM_SME_DISALLOWED", + ] + + // Exception codes for EXC_ARITHMETIC (from mach/arm/exception.h) + private static let arithmeticCodeNames: [Int64: String] = [ + 0: "EXC_ARM_FP_UNDEFINED", + 1: "EXC_ARM_FP_IO", + 2: "EXC_ARM_FP_DZ", + 3: "EXC_ARM_FP_OF", + 4: "EXC_ARM_FP_UF", + 5: "EXC_ARM_FP_IX", + 6: "EXC_ARM_FP_ID", + ] + // Kernel return codes (used as first code for EXC_BAD_ACCESS) // From mach/kern_return.h private static let kernelReturnCodeNames: [Int64: String] = [ @@ -319,10 +342,6 @@ import Foundation 0x105: "EXC_ARM_PAC_FAIL", // 261 ] - private static func kernelReturnCodeName(_ code: Int64) -> String? { - kernelReturnCodeNames[code] - } - private static func machExceptionMessage(_ exception: PLCrashReportMachExceptionInfo) -> String { let typeName = machExceptionName(exception.type) @@ -333,11 +352,34 @@ import Foundation let code = codesArray[0].int64Value let subcode = codesArray.count > 1 ? codesArray[1].int64Value : nil - // Format code with name if available + // Format code with name if available (exception-type-specific) let codeStr: String - if let codeName = kernelReturnCodeName(code) { - codeStr = "\(codeName) (\(code))" - } else { + switch exception.type { + case 1: // EXC_BAD_ACCESS + if let codeName = kernelReturnCodeNames[code] { + codeStr = "\(codeName) (\(code))" + } else { + codeStr = String(code) + } + case 2: // EXC_BAD_INSTRUCTION + if let codeName = badInstructionCodeNames[code] { + codeStr = "\(codeName) (\(code))" + } else { + codeStr = String(code) + } + case 3: // EXC_ARITHMETIC + if let codeName = arithmeticCodeNames[code] { + codeStr = "\(codeName) (\(code))" + } else { + codeStr = String(code) + } + case 6: // EXC_BREAKPOINT + if let codeName = breakpointCodeNames[code] { + codeStr = "\(codeName) (\(code))" + } else { + codeStr = String(code) + } + default: codeStr = String(code) } diff --git a/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift b/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift index 4913da0e8..2bde187e7 100644 --- a/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift +++ b/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift @@ -70,12 +70,6 @@ import Foundation // MARK: - Private Methods private func setupCrashReporter() -> PLCrashReporter? { - // Check for debugger - crash handler won't work when debugging - if PostHogDebugUtils.isDebuggerAttached() { - hedgeLog("Crash handler is disabled because a debugger is attached. Crashes will be caught by the debugger instead.") - return nil - } - // Configure PLCrashReporter let config = PLCrashReporterConfig( signalHandlerType: .mach, @@ -100,6 +94,12 @@ import Foundation } private func enableCrashReporter(reporter: PLCrashReporter) { + // Check for debugger first. Crash handler won't work when debugging + if PostHogDebugUtils.isDebuggerAttached() { + hedgeLog("Crash handler is disabled because a debugger is attached. Crashes will be caught by the debugger instead.") + return + } + // Enable crash reporter for this session do { try reporter.enableAndReturnError() diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 5c0870515..7f6564abe 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -61,18 +61,12 @@ class FeatureFlagsModel: ObservableObject { } } -enum CrashTriggerType: String, CaseIterable { - case swift = "Swift" - case lowLevel = "Low-level" -} - struct ContentView: View { @State var counter: Int = 0 @State private var name: String = "Max" @State private var showingSheet = false @State private var showingRedactedSheet = false @State private var refreshStatusID = UUID() - @State private var crashTriggerType: CrashTriggerType = .swift @StateObject var api = Api() @StateObject var signInViewModel = SignInViewModel() @@ -308,63 +302,26 @@ struct ContentView: View { } Section("Crash Triggers") { - Picker("Type", selection: $crashTriggerType) { - Text("Swift").tag(CrashTriggerType.swift) - Text("Low-level").tag(CrashTriggerType.lowLevel) + // NSException - richest info with name + reason + Button("Uncaught NSException") { + ExceptionHandler.triggerUncaughtNSException() } - .pickerStyle(.segmented) - - if crashTriggerType == .swift { - Button("throw()") { - SwiftCrashTriggers.triggerThrowingFunction() - } - - Button("fatalError()") { - SwiftCrashTriggers.triggerFatalError() - } - Button("preconditionFailure()") { - SwiftCrashTriggers.triggerPreconditionFailure() - } - Button("assertionFailure() - Debug only") { - SwiftCrashTriggers.triggerAssertionFailure() - } - Button("Force unwrap nil") { - SwiftCrashTriggers.triggerForceUnwrapNil() - } - Button("Array out of bounds") { - SwiftCrashTriggers.triggerArrayOutOfBounds() - } - Button("Implicit unwrap nil") { - SwiftCrashTriggers.triggerImplicitUnwrapNil() - } - } else { - Button("Null Pointer (EXC_BAD_ACCESS)") { - ExceptionHandler.triggerNullPointerCrash() - } - Button("Stack Overflow (EXC_BAD_ACCESS)") { - ExceptionHandler.triggerStackOverflowCrash() - } - Button("Abort (SIGABRT)") { - ExceptionHandler.triggerAbortCrash() - } - Button("Illegal Instruction (SIGILL)") { - ExceptionHandler.triggerIllegalInstructionCrash() - } - Button("Uncaught NSException") { - ExceptionHandler.triggerUncaughtNSException() - } - Button("Segfault (SIGSEGV)") { - ExceptionHandler.triggerSegfaultCrash() - } - Button("Bus Error (SIGBUS)") { - ExceptionHandler.triggerBusErrorCrash() - } - Button("Divide by Zero (SIGFPE)") { - ExceptionHandler.triggerDivideByZeroCrash() - } - Button("Trap (SIGTRAP)") { - ExceptionHandler.triggerTrapCrash() - } + // SIGTRAP - Swift prints message to stderr then triggers trap + // Note: Message is NOT captured. See: https://github.com/getsentry/sentry-cocoa/issues/662 + Button("fatalError()") { + SwiftCrashTriggers.triggerFatalError() + } + // SIGTRAP - Swift runtime trap on nil unwrap + Button("Force unwrap nil") { + SwiftCrashTriggers.triggerForceUnwrapNil() + } + // EXC_BAD_ACCESS - null pointer dereference + Button("Null Pointer") { + ExceptionHandler.triggerNullPointerCrash() + } + // SIGABRT - explicit abort() call + Button("Abort") { + ExceptionHandler.triggerAbortCrash() } } diff --git a/PostHogExample/ExceptionHandler.h b/PostHogExample/ExceptionHandler.h index a418be4cc..445a9c089 100644 --- a/PostHogExample/ExceptionHandler.h +++ b/PostHogExample/ExceptionHandler.h @@ -25,7 +25,7 @@ NS_ASSUME_NONNULL_BEGIN catch:(void(^)(NSException *exception))catchBlock finally:(nullable void(^)(void))finallyBlock; -/// Trigger a sample NSRangeException for testing purposes +/// Trigger a sample NSRangeException for testing purposes + (void)triggerSampleRangeException; /// Trigger a sample NSInvalidArgumentException for testing purposes @@ -40,32 +40,14 @@ NS_ASSUME_NONNULL_BEGIN // MARK: - Crash Triggers for Testing -/// Trigger a null pointer dereference (EXC_BAD_ACCESS / KERN_INVALID_ADDRESS) -+ (void)triggerNullPointerCrash; - -/// Trigger a stack overflow (EXC_BAD_ACCESS / KERN_PROTECTION_FAILURE) -+ (void)triggerStackOverflowCrash; - -/// Trigger an abort signal (SIGABRT) -+ (void)triggerAbortCrash; - -/// Trigger an illegal instruction (SIGILL / EXC_BAD_INSTRUCTION) -+ (void)triggerIllegalInstructionCrash; - /// Trigger an uncaught NSException + (void)triggerUncaughtNSException; -/// Trigger a SIGSEGV (segmentation fault) -+ (void)triggerSegfaultCrash; - -/// Trigger a SIGBUS (bus error) -+ (void)triggerBusErrorCrash; - -/// Trigger a SIGFPE (floating point exception / divide by zero) -+ (void)triggerDivideByZeroCrash; +/// Trigger a null pointer dereference (EXC_BAD_ACCESS) ++ (void)triggerNullPointerCrash; -/// Trigger a SIGTRAP (breakpoint/debugger trap) -+ (void)triggerTrapCrash; +/// Trigger an abort signal (SIGABRT) ++ (void)triggerAbortCrash; @end diff --git a/PostHogExample/ExceptionHandler.m b/PostHogExample/ExceptionHandler.m index bc3cddde3..371fb405a 100644 --- a/PostHogExample/ExceptionHandler.m +++ b/PostHogExample/ExceptionHandler.m @@ -118,37 +118,7 @@ + (void)establishNetworkConnection { // MARK: - Crash Triggers for Testing -+ (void)triggerNullPointerCrash { - // Trigger a null pointer dereference (EXC_BAD_ACCESS / KERN_INVALID_ADDRESS) - int *nullPointer = NULL; - *nullPointer = 42; -} - -+ (void)triggerStackOverflowCrash { - // Trigger stack overflow via infinite recursion (EXC_BAD_ACCESS / KERN_PROTECTION_FAILURE) - [self triggerStackOverflowCrash]; -} - -+ (void)triggerAbortCrash { - // Trigger SIGABRT - abort(); -} - -+ (void)triggerIllegalInstructionCrash { - // Trigger SIGILL / EXC_BAD_INSTRUCTION by executing invalid instruction - // This uses inline assembly to execute an undefined instruction -#if defined(__arm64__) - __asm__ volatile(".word 0x00000000"); // Undefined instruction on ARM64 -#elif defined(__x86_64__) - __asm__ volatile("ud2"); // Undefined instruction on x86_64 -#else - // Fallback: raise SIGILL directly - raise(SIGILL); -#endif -} - + (void)triggerUncaughtNSException { - // Trigger an uncaught NSException (will be caught by PLCrashReporter) @throw [NSException exceptionWithName:@"UncaughtTestException" reason:@"This is an intentionally uncaught exception for crash testing" userInfo:@{ @@ -157,44 +127,13 @@ + (void)triggerUncaughtNSException { }]; } -+ (void)triggerSegfaultCrash { - // Trigger SIGSEGV by accessing unmapped memory - volatile int *badAddress = (int *)0xDEADBEEF; - *badAddress = 42; -} - -+ (void)triggerBusErrorCrash { - // Trigger SIGBUS via misaligned memory access - // On ARM, misaligned access to certain types causes SIGBUS -#if defined(__arm64__) - char *ptr = malloc(10); - volatile int *misaligned = (int *)(ptr + 1); // Misaligned address - *misaligned = 42; - free(ptr); -#else - // On x86, misaligned access is usually allowed, so raise signal directly - raise(SIGBUS); -#endif -} - -+ (void)triggerDivideByZeroCrash { - // Trigger SIGFPE via integer divide by zero - // Note: On ARM, integer divide by zero doesn't trap by default - // We use volatile to prevent compiler optimization - volatile int zero = 0; - volatile int result = 1 / zero; - (void)result; // Suppress unused variable warning ++ (void)triggerNullPointerCrash { + int *nullPointer = NULL; + *nullPointer = 42; } -+ (void)triggerTrapCrash { - // Trigger SIGTRAP (debugger trap / breakpoint) -#if defined(__arm64__) - __asm__ volatile("brk #0"); // Breakpoint on ARM64 -#elif defined(__x86_64__) - __asm__ volatile("int3"); // Breakpoint on x86_64 -#else - raise(SIGTRAP); -#endif ++ (void)triggerAbortCrash { + abort(); } @end diff --git a/PostHogExample/SwiftCrashTriggers.swift b/PostHogExample/SwiftCrashTriggers.swift index 3b7e65fe7..a99f23264 100644 --- a/PostHogExample/SwiftCrashTriggers.swift +++ b/PostHogExample/SwiftCrashTriggers.swift @@ -10,131 +10,45 @@ import Foundation /// Swift crash triggers with nested call stacks for testing stack trace capture enum SwiftCrashTriggers { // MARK: - Public API - - static func triggerThrowingFunction() { - OuterLayer.triggerThrowingFunction() - } static func triggerFatalError() { OuterLayer.processFatalError() } - static func triggerPreconditionFailure() { - OuterLayer.processPreconditionFailure() - } - - static func triggerAssertionFailure() { - OuterLayer.processAssertionFailure() - } - static func triggerForceUnwrapNil() { OuterLayer.processForceUnwrapNil() } - static func triggerArrayOutOfBounds() { - OuterLayer.processArrayOutOfBounds() - } - - static func triggerImplicitUnwrapNil() { - OuterLayer.processImplicitUnwrapNil() - } - - // MARK: - Nested Classes for Deeper Stack Traces + // MARK: - Nested Layers for Deeper Stack Traces private enum OuterLayer { - static func triggerThrowingFunction() { - MiddleLayer.triggerThrowingFunction() - } - static func processFatalError() { MiddleLayer.handleFatalError() } - static func processPreconditionFailure() { - MiddleLayer.handlePreconditionFailure() - } - - static func processAssertionFailure() { - MiddleLayer.handleAssertionFailure() - } - static func processForceUnwrapNil() { MiddleLayer.handleForceUnwrapNil() } - - static func processArrayOutOfBounds() { - MiddleLayer.handleArrayOutOfBounds() - } - - static func processImplicitUnwrapNil() { - MiddleLayer.handleImplicitUnwrapNil() - } } private enum MiddleLayer { - static func triggerThrowingFunction() { - InnerLayer.triggerThrowingFunction() - } static func handleFatalError() { InnerLayer.executeFatalError() } - static func handlePreconditionFailure() { - InnerLayer.executePreconditionFailure() - } - - static func handleAssertionFailure() { - InnerLayer.executeAssertionFailure() - } - static func handleForceUnwrapNil() { InnerLayer.executeForceUnwrapNil() } - - static func handleArrayOutOfBounds() { - InnerLayer.executeArrayOutOfBounds() - } - - static func handleImplicitUnwrapNil() { - InnerLayer.executeImplicitUnwrapNil() - } } private enum InnerLayer { - static func triggerThrowingFunction() { - try! throwingFunction() - } static func executeFatalError() { fatalError("Intentional fatalError for crash testing") } - static func executePreconditionFailure() { - preconditionFailure("Intentional preconditionFailure for crash testing") - } - - static func executeAssertionFailure() { - assertionFailure("Intentional assertionFailure for crash testing") - } - static func executeForceUnwrapNil() { let nilValue: String? = nil _ = nilValue! } - - static func executeArrayOutOfBounds() { - let array = [1, 2, 3] - _ = array[10] - } - - static func executeImplicitUnwrapNil() { - let nilValue: String! = nil - _ = nilValue.count - } - - static func throwingFunction() throws -> Void { - throw MyCustomError() - } } } - -struct MyCustomError: Error {} From 1e7a3a63ec88ca1d7bf4e83d6f18eb58a35d24c7 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 22 Dec 2025 00:53:54 +0200 Subject: [PATCH 32/45] feat: add a note on fatalError --- PostHog/Error Tracking/PostHogCrashReportProcessor.swift | 6 ++++++ PostHogExample/ContentView.swift | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift index 473acb1bb..76bef3449 100644 --- a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift +++ b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift @@ -75,6 +75,12 @@ import Foundation ] } else if let signalInfo = report.signalInfo { // POSIX signal - more familiar to developers (SIGTRAP, SIGABRT, etc.) + // + // Note: Swift crashes (fatalError, preconditionFailure, force unwrap, etc.) appear as SIGTRAP. + // The actual error message is stored in the __crash_info Mach-O section of libswiftCore.dylib, + // which PLCrashReporter doesn't expose. Sentry/Bugsnag parse this section to get the message. + // See: https://github.com/getsentry/sentry-cocoa/pull/1596 + // Future enhancement: implement __crash_info parsing for richer Swift crash messages. exception["type"] = signalInfo.name exception["value"] = signalMessage(signalInfo) diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 7f6564abe..3e55a3028 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -306,8 +306,7 @@ struct ContentView: View { Button("Uncaught NSException") { ExceptionHandler.triggerUncaughtNSException() } - // SIGTRAP - Swift prints message to stderr then triggers trap - // Note: Message is NOT captured. See: https://github.com/getsentry/sentry-cocoa/issues/662 + // SIGTRAP - message not captured (see PostHogCrashReportProcessor for details) Button("fatalError()") { SwiftCrashTriggers.triggerFatalError() } From e9b9d8e481934f4073ef04f9d79108a8e9d2de3d Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 22 Dec 2025 01:03:18 +0200 Subject: [PATCH 33/45] feat: add comment on chained exceptions --- PostHog/Error Tracking/PostHogCrashReportProcessor.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift index 76bef3449..eaed7e214 100644 --- a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift +++ b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift @@ -65,6 +65,10 @@ import Foundation // Priority: NSException (richest info) → Signal (more familiar) → Mach (lowest level) if report.hasExceptionInfo, let nsExceptionInfo = report.exceptionInfo { // NSException - has actual exception name and reason + // + // Limitation: Unfortunately we cannot walk the exception chain via NSUnderlyingErrorKey because + // PLCrashReportExceptionInfo only exposes name, reason, and stackFrames. The original userInfo dictionary is not serialized. + // The chain information is lost at crash time. exception["type"] = nsExceptionInfo.exceptionName exception["value"] = nsExceptionInfo.exceptionReason @@ -76,11 +80,12 @@ import Foundation } else if let signalInfo = report.signalInfo { // POSIX signal - more familiar to developers (SIGTRAP, SIGABRT, etc.) // - // Note: Swift crashes (fatalError, preconditionFailure, force unwrap, etc.) appear as SIGTRAP. + // Limitation: Swift crashes (fatalError, preconditionFailure, force unwrap, etc.) appear as SIGTRAP. // The actual error message is stored in the __crash_info Mach-O section of libswiftCore.dylib, // which PLCrashReporter doesn't expose. Sentry/Bugsnag parse this section to get the message. // See: https://github.com/getsentry/sentry-cocoa/pull/1596 - // Future enhancement: implement __crash_info parsing for richer Swift crash messages. + // https://github.com/bugsnag/bugsnag-cocoa/pull/948 + // Future enhancement: implement __crash_info parsing in PLCrashReporter for richer Swift crash messages. exception["type"] = signalInfo.name exception["value"] = signalMessage(signalInfo) From a95db65748d091dfe518aa0644ff531c8fb1acc0 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 22 Dec 2025 04:00:04 +0200 Subject: [PATCH 34/45] fix: format --- .../PostHogCrashReportProcessor.swift | 48 +++++++------------ .../PostHogCrashReporterIntegration.swift | 2 +- .../PostHogExceptionProcessor.swift | 2 +- PostHogExample/ContentView.swift | 1 - 4 files changed, 19 insertions(+), 34 deletions(-) diff --git a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift index eaed7e214..62cb414f7 100644 --- a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift +++ b/PostHog/Error Tracking/PostHogCrashReportProcessor.swift @@ -47,7 +47,7 @@ import Foundation if let uuidRef = report.uuidRef { properties["$crash_report_id"] = CFUUIDCreateString(nil, uuidRef) as String } - + return properties } @@ -66,8 +66,8 @@ import Foundation if report.hasExceptionInfo, let nsExceptionInfo = report.exceptionInfo { // NSException - has actual exception name and reason // - // Limitation: Unfortunately we cannot walk the exception chain via NSUnderlyingErrorKey because - // PLCrashReportExceptionInfo only exposes name, reason, and stackFrames. The original userInfo dictionary is not serialized. + // Limitation: Unfortunately we cannot walk the exception chain via NSUnderlyingErrorKey because + // PLCrashReportExceptionInfo only exposes name, reason, and stackFrames. The original userInfo dictionary is not serialized. // The chain information is lost at crash time. exception["type"] = nsExceptionInfo.exceptionName exception["value"] = nsExceptionInfo.exceptionReason @@ -245,7 +245,7 @@ import Foundation } // MARK: - Helpers - + /// Format string for zero-padded 64-bit hex addresses (e.g., "0x00007fff12345678") static let hexAddressPaddedFormat = "0x%016llx" @@ -353,6 +353,13 @@ import Foundation 0x105: "EXC_ARM_PAC_FAIL", // 261 ] + private static let exceptionCodeNameMappings: [Int64: [Int64: String]] = [ + 1: kernelReturnCodeNames, // EXC_BAD_ACCESS + 2: badInstructionCodeNames, // EXC_BAD_INSTRUCTION + 3: arithmeticCodeNames, // EXC_ARITHMETIC + 6: breakpointCodeNames, // EXC_BREAKPOINT + ] + private static func machExceptionMessage(_ exception: PLCrashReportMachExceptionInfo) -> String { let typeName = machExceptionName(exception.type) @@ -365,32 +372,11 @@ import Foundation // Format code with name if available (exception-type-specific) let codeStr: String - switch exception.type { - case 1: // EXC_BAD_ACCESS - if let codeName = kernelReturnCodeNames[code] { - codeStr = "\(codeName) (\(code))" - } else { - codeStr = String(code) - } - case 2: // EXC_BAD_INSTRUCTION - if let codeName = badInstructionCodeNames[code] { - codeStr = "\(codeName) (\(code))" - } else { - codeStr = String(code) - } - case 3: // EXC_ARITHMETIC - if let codeName = arithmeticCodeNames[code] { - codeStr = "\(codeName) (\(code))" - } else { - codeStr = String(code) - } - case 6: // EXC_BREAKPOINT - if let codeName = breakpointCodeNames[code] { - codeStr = "\(codeName) (\(code))" - } else { - codeStr = String(code) - } - default: + if let codeNames = exceptionCodeNameMappings[Int64(exception.type)], + let codeName = codeNames[code] + { + codeStr = "\(codeName) (\(code))" + } else { codeStr = String(code) } @@ -404,7 +390,7 @@ import Foundation } private static func signalMessage(_ signal: PLCrashReportSignalInfo) -> String? { - guard let name = signal.name, let code = signal.code else { + guard let name = signal.name, let code = signal.code else { return nil } diff --git a/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift b/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift index 2bde187e7..299d38c01 100644 --- a/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift +++ b/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift @@ -99,7 +99,7 @@ import Foundation hedgeLog("Crash handler is disabled because a debugger is attached. Crashes will be caught by the debugger instead.") return } - + // Enable crash reporter for this session do { try reporter.enableAndReturnError() diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/Error Tracking/PostHogExceptionProcessor.swift index 5c106ce79..bb8f9a7c9 100644 --- a/PostHog/Error Tracking/PostHogExceptionProcessor.swift +++ b/PostHog/Error Tracking/PostHogExceptionProcessor.swift @@ -32,7 +32,7 @@ enum PostHogExceptionProcessor { ) -> [String: Any] { var properties: [String: Any] = [:] - properties["$exception_level"] = "error" // TODO: figure if error or fatal based on wrapper error type when + properties["$exception_level"] = "error" let exceptions = buildExceptionList( from: error, diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 3e55a3028..a52c3743d 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -325,7 +325,6 @@ struct ContentView: View { } Section("Error tracking") { - Button("Capture Swift Enum Error (with associated value)") { do { throw SampleAppError.generalAppError(ErrorDetails(code: 10, reason: "some reason")) From e02cc583504d81d5f9deb0152bd24bc7bfc4d7fa Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 22 Dec 2025 10:41:18 +0200 Subject: [PATCH 35/45] feat: add unit tests --- PostHog.xcodeproj/project.pbxproj | 20 + .../PostHogCrashReporterIntegration.swift | 2 +- PostHog/PostHogSDK.swift | 2 +- .../PostHogCrashReportProcessorTest.swift | 298 +++++++++++++ .../PostHogDebugImageProviderTest.swift | 264 ++++++++++++ .../PostHogErrorTrackingUtilsTest.swift | 99 +++++ .../PostHogExceptionProcessorTest.swift | 400 ++++++++++++++++++ .../PostHogStackTraceProcessorTest.swift | 241 +++++++++++ 8 files changed, 1324 insertions(+), 2 deletions(-) create mode 100644 PostHogTests/PostHogCrashReportProcessorTest.swift create mode 100644 PostHogTests/PostHogDebugImageProviderTest.swift create mode 100644 PostHogTests/PostHogErrorTrackingUtilsTest.swift create mode 100644 PostHogTests/PostHogExceptionProcessorTest.swift create mode 100644 PostHogTests/PostHogStackTraceProcessorTest.swift diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index adf0cd919..2cf25e98e 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -207,6 +207,11 @@ DA690C6C2DA54BD70045FF4E /* PostHogSurveyConditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA690C6B2DA54BD10045FF4E /* PostHogSurveyConditions.swift */; }; DA690C6E2DA54BEC0045FF4E /* PostHogSurveyQuestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA690C6D2DA54BE40045FF4E /* PostHogSurveyQuestion.swift */; }; DA690C752DA54C520045FF4E /* PostHogSurveyEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA690C742DA54C4B0045FF4E /* PostHogSurveyEnums.swift */; }; + DA697CB12EF935490019640F /* PostHogDebugImageProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA697CAB2EF935490019640F /* PostHogDebugImageProviderTest.swift */; }; + DA697CB22EF935490019640F /* PostHogErrorTrackingUtilsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA697CAD2EF935490019640F /* PostHogErrorTrackingUtilsTest.swift */; }; + DA697CB32EF935490019640F /* PostHogStackTraceProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA697CAF2EF935490019640F /* PostHogStackTraceProcessorTest.swift */; }; + DA697CB42EF935490019640F /* PostHogCrashReportProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA697CAA2EF935490019640F /* PostHogCrashReportProcessorTest.swift */; }; + DA697CB52EF935490019640F /* PostHogExceptionProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA697CAE2EF935490019640F /* PostHogExceptionProcessorTest.swift */; }; DA6B7C0B2D118C4E0024419F /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA6B7C0C2D118C4E0024419F /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DA6F24822D4A6CA100CA2777 /* PostHogApiTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6F24812D4A6CA100CA2777 /* PostHogApiTest.swift */; }; @@ -751,6 +756,11 @@ DA690C6B2DA54BD10045FF4E /* PostHogSurveyConditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveyConditions.swift; sourceTree = ""; }; DA690C6D2DA54BE40045FF4E /* PostHogSurveyQuestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveyQuestion.swift; sourceTree = ""; }; DA690C742DA54C4B0045FF4E /* PostHogSurveyEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveyEnums.swift; sourceTree = ""; }; + DA697CAA2EF935490019640F /* PostHogCrashReportProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReportProcessorTest.swift; sourceTree = ""; }; + DA697CAB2EF935490019640F /* PostHogDebugImageProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogDebugImageProviderTest.swift; sourceTree = ""; }; + DA697CAD2EF935490019640F /* PostHogErrorTrackingUtilsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingUtilsTest.swift; sourceTree = ""; }; + DA697CAE2EF935490019640F /* PostHogExceptionProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogExceptionProcessorTest.swift; sourceTree = ""; }; + DA697CAF2EF935490019640F /* PostHogStackTraceProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStackTraceProcessorTest.swift; sourceTree = ""; }; DA6F24812D4A6CA100CA2777 /* PostHogApiTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogApiTest.swift; sourceTree = ""; }; DA703C042D6606E50069097B /* PostHogIntegrationInstallationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogIntegrationInstallationTest.swift; sourceTree = ""; }; DA703C1B2D6616F20069097B /* PostHogAppLifeCycleIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAppLifeCycleIntegration.swift; sourceTree = ""; }; @@ -1168,6 +1178,11 @@ DAF95F602D077C1C001E82BB /* PostHogWebPTest.swift */, 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */, 69ED1AB52C90711D00FE7A91 /* PostHogSDKPersonProfilesTest.swift */, + DA697CAA2EF935490019640F /* PostHogCrashReportProcessorTest.swift */, + DA697CAB2EF935490019640F /* PostHogDebugImageProviderTest.swift */, + DA697CAD2EF935490019640F /* PostHogErrorTrackingUtilsTest.swift */, + DA697CAE2EF935490019640F /* PostHogExceptionProcessorTest.swift */, + DA697CAF2EF935490019640F /* PostHogStackTraceProcessorTest.swift */, ); path = PostHogTests; sourceTree = ""; @@ -2405,6 +2420,11 @@ DA1D29602D10C810003A31DA /* PostHogSessionManagerTest.swift in Sources */, DA703C1E2D6634770069097B /* PostHogAppLifeCycleIntegrationTest.swift in Sources */, DA578EA02D6858CE00B3A56C /* MockScreenViewPublisher.swift in Sources */, + DA697CB12EF935490019640F /* PostHogDebugImageProviderTest.swift in Sources */, + DA697CB22EF935490019640F /* PostHogErrorTrackingUtilsTest.swift in Sources */, + DA697CB32EF935490019640F /* PostHogStackTraceProcessorTest.swift in Sources */, + DA697CB42EF935490019640F /* PostHogCrashReportProcessorTest.swift in Sources */, + DA697CB52EF935490019640F /* PostHogExceptionProcessorTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift b/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift index 299d38c01..2c18821ee 100644 --- a/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift +++ b/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift @@ -125,7 +125,7 @@ import Foundation } // Extract identity and event properties from saved context - let crashDistinctId = savedContext["distinct_id"] as? String ?? postHog.getDistinctId() + let crashDistinctId = savedContext["distinct_id"] as? String let crashEventProperties = savedContext["event_properties"] as? [String: Any] ?? [:] // Collect crash-specific event properties (stack traces, exceptions etc) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index b90de5700..964591301 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -142,7 +142,7 @@ let maxRetryDelay = 30.0 // Create session manager instance for this PostHogSDK instance sessionManager.setup(config: config) sessionManager.startSession() - // Listen for session changes to update crash context (register before startSession) + // Listen for session changes to update crash context sessionIdChangedToken = sessionManager.onSessionIdChanged { [weak self] in self?.notifyContextDidChange() } diff --git a/PostHogTests/PostHogCrashReportProcessorTest.swift b/PostHogTests/PostHogCrashReportProcessorTest.swift new file mode 100644 index 000000000..224b5c478 --- /dev/null +++ b/PostHogTests/PostHogCrashReportProcessorTest.swift @@ -0,0 +1,298 @@ +// +// PostHogCrashReportProcessorTest.swift +// PostHogTests +// +// Created by Ioannis Josephides on 22/12/2025. +// + +import Foundation +import Testing + +@testable import PostHog + +#if os(iOS) || os(macOS) || os(tvOS) + import CrashReporter + + @Suite("PostHogCrashReportProcessor Tests") + struct PostHogCrashReportProcessorTest { + // MARK: - Live Report Tests + + @Suite("Process Live Report") + struct ProcessLiveReportTests { + let config = PostHogErrorTrackingConfig() + + @Test("processes live crash report") + func processesLiveCrashReport() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + #expect(properties["$exception_level"] as? String == "fatal") + #expect(properties["$exception_list"] != nil) + } + + @Test("live report contains exception list") + func liveReportContainsExceptionList() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList != nil) + #expect(exceptionList!.count > 0) + } + + @Test("live report exception has type and mechanism") + func liveReportExceptionHasTypeAndMechanism() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exception = exceptionList?.first + + #expect(exception?["type"] != nil) + + let mechanism = exception?["mechanism"] as? [String: Any] + #expect(mechanism != nil) + #expect(mechanism?["handled"] as? Bool == false) + #expect(mechanism?["synthetic"] as? Bool == false) + } + + @Test("live report contains stack trace") + func liveReportContainsStackTrace() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exception = exceptionList?.first + let stacktrace = exception?["stacktrace"] as? [String: Any] + + #expect(stacktrace != nil) + #expect(stacktrace?["type"] as? String == "raw") + + let frames = stacktrace?["frames"] as? [[String: Any]] + #expect(frames != nil) + #expect(frames!.count > 0) + } + + @Test("live report frames have instruction addresses") + func liveReportFramesHaveInstructionAddresses() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + + for frame in frames ?? [] { + let addr = frame["instruction_addr"] as? String + #expect(addr != nil) + #expect(addr?.hasPrefix("0x") == true) + } + } + + @Test("live report contains debug images") + func liveReportContainsDebugImages() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let debugImages = properties["$debug_images"] as? [[String: Any]] + #expect(debugImages != nil) + #expect(debugImages!.count > 0) + } + + @Test("debug images have required fields") + func debugImagesHaveRequiredFields() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let debugImages = properties["$debug_images"] as? [[String: Any]] + let image = debugImages?.first + + #expect(image?["type"] as? String == "macho") + #expect(image?["code_file"] as? String != nil) + #expect(image?["image_addr"] as? String != nil) + #expect(image?["image_size"] as? UInt64 != nil) + } + + @Test("live report has crash report ID") + func liveReportHasCrashReportId() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let crashReportId = properties["$crash_report_id"] as? String + #expect(crashReportId != nil) + #expect(crashReportId!.count > 0) + } + } + + // MARK: - Crash Timestamp Tests + + @Suite("Crash Timestamp") + struct CrashTimestampTests { + @Test("extracts crash timestamp from report") + func extractsCrashTimestamp() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let timestamp = PostHogCrashReportProcessor.getCrashTimestamp(report) + + #expect(timestamp != nil) + #expect(timestamp!.timeIntervalSinceNow < 60) + #expect(timestamp!.timeIntervalSinceNow > -60) + } + } + + // MARK: - In-App Detection Tests + + @Suite("In-App Detection") + struct InAppDetectionTests { + @Test("marks frames as in-app based on config") + func marksFramesAsInApp() throws { + let config = PostHogErrorTrackingConfig() + config.inAppIncludes = ["xctest"] + + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + + let inAppFrames = frames?.filter { $0["in_app"] as? Bool == true } + #expect(inAppFrames != nil) + } + + @Test("marks system frames as not in-app") + func marksSystemFramesAsNotInApp() throws { + let config = PostHogErrorTrackingConfig() + + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + + let systemFrames = frames?.filter { frame in + let module = frame["module"] as? String ?? "" + return module.hasPrefix("libsystem") || module == "Foundation" + } + + for frame in systemFrames ?? [] { + #expect(frame["in_app"] as? Bool == false) + } + } + } + + // MARK: - Thread ID Tests + + @Suite("Thread ID") + struct ThreadIDTests { + @Test("exception has thread ID") + func exceptionHasThreadId() throws { + let config = PostHogErrorTrackingConfig() + + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exception = exceptionList?.first + + #expect(exception?["thread_id"] != nil) + } + } + } +#endif diff --git a/PostHogTests/PostHogDebugImageProviderTest.swift b/PostHogTests/PostHogDebugImageProviderTest.swift new file mode 100644 index 000000000..e0085fe7f --- /dev/null +++ b/PostHogTests/PostHogDebugImageProviderTest.swift @@ -0,0 +1,264 @@ +// +// PostHogDebugImageProviderTest.swift +// PostHogTests +// +// Created by Ioannis Josephides on 22/12/2025. +// + +import Foundation +import Testing + +@testable import PostHog + +@Suite("PostHogDebugImageProvider Tests") +struct PostHogDebugImageProviderTest { + // MARK: - Get All Binary Images Tests + + @Suite("Get All Binary Images") + struct GetAllBinaryImagesTests { + @Test("returns non-empty list of binary images") + func returnsNonEmptyList() { + let images = PostHogDebugImageProvider.getAllBinaryImages() + + #expect(images.count > 0) + } + + @Test("includes main executable") + func includesMainExecutable() { + let images = PostHogDebugImageProvider.getAllBinaryImages() + + let hasExecutable = images.contains { image in + image.name.contains("xctest") || image.name.contains("PostHog") + } + + #expect(hasExecutable == true) + } + + @Test("images have valid addresses") + func imagesHaveValidAddresses() { + let images = PostHogDebugImageProvider.getAllBinaryImages() + + for image in images { + #expect(image.address > 0) + #expect(image.size > 0) + } + } + + @Test("images have UUIDs") + func imagesHaveUUIDs() { + let images = PostHogDebugImageProvider.getAllBinaryImages() + + let imagesWithUUID = images.filter { $0.uuid != nil } + #expect(imagesWithUUID.count > 0) + } + + @Test("UUIDs are in correct format") + func uuidsAreInCorrectFormat() { + let images = PostHogDebugImageProvider.getAllBinaryImages() + + for image in images where image.uuid != nil { + let uuid = image.uuid! + let uuidPattern = #"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$"# + let regex = try? NSRegularExpression(pattern: uuidPattern) + let range = NSRange(uuid.startIndex..., in: uuid) + #expect(regex?.firstMatch(in: uuid, range: range) != nil) + } + } + } + + // MARK: - Get Debug Images for Frames Tests + + @Suite("Get Debug Images for Frames") + struct GetDebugImagesForFramesTests { + @Test("returns debug images for valid frame addresses") + func returnsDebugImagesForValidAddresses() { + let allImages = PostHogDebugImageProvider.getAllBinaryImages() + guard let firstImage = allImages.first else { + Issue.record("No binary images found") + return + } + + let frames: [[String: Any]] = [ + ["image_addr": String(format: "0x%llx", firstImage.address)], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(for: frames) + + #expect(debugImages.count >= 1) + } + + @Test("returns empty array for invalid addresses") + func returnsEmptyForInvalidAddresses() { + let frames: [[String: Any]] = [ + ["image_addr": "0x0"], + ["image_addr": "0xdeadbeef"], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(for: frames) + + #expect(debugImages.count == 0) + } + + @Test("returns empty array for empty frames") + func returnsEmptyForEmptyFrames() { + let frames: [[String: Any]] = [] + + let debugImages = PostHogDebugImageProvider.getDebugImages(for: frames) + + #expect(debugImages.count == 0) + } + + @Test("deduplicates images by address") + func deduplicatesImagesByAddress() { + let allImages = PostHogDebugImageProvider.getAllBinaryImages() + guard let firstImage = allImages.first else { + Issue.record("No binary images found") + return + } + + let address = String(format: "0x%llx", firstImage.address) + let frames: [[String: Any]] = [ + ["image_addr": address], + ["image_addr": address], + ["image_addr": address], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(for: frames) + + #expect(debugImages.count == 1) + } + } + + // MARK: - Get Debug Images from Exceptions Tests + + @Suite("Get Debug Images from Exceptions") + struct GetDebugImagesFromExceptionsTests { + @Test("extracts debug images from exception list") + func extractsDebugImagesFromExceptionList() { + let allImages = PostHogDebugImageProvider.getAllBinaryImages() + guard let firstImage = allImages.first else { + Issue.record("No binary images found") + return + } + + let exceptions: [[String: Any]] = [ + [ + "type": "TestException", + "stacktrace": [ + "type": "raw", + "frames": [ + ["image_addr": String(format: "0x%llx", firstImage.address)], + ], + ] as [String: Any], + ], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + + #expect(debugImages.count == 1) + } + + @Test("handles exceptions without stacktrace") + func handlesExceptionsWithoutStacktrace() { + let exceptions: [[String: Any]] = [ + ["type": "TestException", "value": "No stacktrace"], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + + #expect(debugImages.count == 0) + } + + @Test("handles empty exception list") + func handlesEmptyExceptionList() { + let exceptions: [[String: Any]] = [] + + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + + #expect(debugImages.count == 0) + } + + @Test("collects images from multiple exceptions") + func collectsFromMultipleExceptions() { + let allImages = PostHogDebugImageProvider.getAllBinaryImages() + guard allImages.count >= 2 else { + Issue.record("Need at least 2 binary images for this test") + return + } + + let exceptions: [[String: Any]] = [ + [ + "type": "Exception1", + "stacktrace": [ + "frames": [ + ["image_addr": String(format: "0x%llx", allImages[0].address)], + ], + ] as [String: Any], + ], + [ + "type": "Exception2", + "stacktrace": [ + "frames": [ + ["image_addr": String(format: "0x%llx", allImages[1].address)], + ], + ] as [String: Any], + ], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + + #expect(debugImages.count >= 2) + } + } + + // MARK: - Binary Image Info Dictionary Tests + + @Suite("Binary Image Info Dictionary") + struct BinaryImageInfoDictionaryTests { + @Test("omits nil UUID from dictionary") + func omitsNilUUID() { + let imageInfo = PostHogBinaryImageInfo( + name: "/test.dylib", + uuid: nil, + vmAddress: 0x100_0000, + address: 0x100_0000, + size: 0x1000 + ) + + let dict = imageInfo.toDictionary + + #expect(dict["debug_id"] == nil) + } + + @Test("omits zero vmAddress from dictionary") + func omitsZeroVmAddress() { + let imageInfo = PostHogBinaryImageInfo( + name: "/test.dylib", + uuid: nil, + vmAddress: 0, + address: 0x100_0000, + size: 0x1000 + ) + + let dict = imageInfo.toDictionary + + #expect(dict["image_vmaddr"] == nil) + } + + @Test("omits nil arch from dictionary") + func omitsNilArch() { + let imageInfo = PostHogBinaryImageInfo( + name: "/test.dylib", + uuid: nil, + vmAddress: nil, + address: 0x100_0000, + size: 0x1000, + arch: nil + ) + + let dict = imageInfo.toDictionary + + #expect(dict["arch"] == nil) + } + } +} diff --git a/PostHogTests/PostHogErrorTrackingUtilsTest.swift b/PostHogTests/PostHogErrorTrackingUtilsTest.swift new file mode 100644 index 000000000..7b4656abd --- /dev/null +++ b/PostHogTests/PostHogErrorTrackingUtilsTest.swift @@ -0,0 +1,99 @@ +// +// PostHogErrorTrackingUtilsTest.swift +// PostHogTests +// +// Created by Ioannis Josephides on 22/12/2025. +// + +import Foundation +import Testing + +@testable import PostHog + +@Suite("PostHogErrorTrackingUtils Tests") +struct PostHogErrorTrackingUtilsTest { + // MARK: - UUID Formatting Tests + + @Suite("UUID Formatting") + struct UUIDFormattingTests { + @Test("formats UUID without hyphens") + func formatsUUIDWithoutHyphens() { + let input = "A1B2C3D4E5F67890ABCDEF1234567890" + let expected = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + + #expect(input.formattedAsUUID == expected) + } + + @Test("preserves already formatted UUID") + func preservesFormattedUUID() { + let input = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + let expected = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + + #expect(input.formattedAsUUID == expected) + } + + @Test("uppercases lowercase UUID") + func uppercasesLowercaseUUID() { + let input = "a1b2c3d4e5f67890abcdef1234567890" + let expected = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + + #expect(input.formattedAsUUID == expected) + } + + @Test("returns original for invalid length") + func returnsOriginalForInvalidLength() { + let input = "tooshort" + + #expect(input.formattedAsUUID == input) + } + + @Test("handles mixed case UUID") + func handlesMixedCaseUUID() { + let input = "a1B2c3D4-e5F6-7890-AbCd-Ef1234567890" + let expected = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + + #expect(input.formattedAsUUID == expected) + } + } + + // MARK: - CPU Architecture Tests + + @Suite("CPU Architecture") + struct CPUArchitectureTests { + @Test("returns arm64 for ARM64 CPU type") + func returnsArm64() { + let arch = PostHogCPUArchitecture.archName(cpuType: 0x0100_000C, cpuSubtype: 0) + #expect(arch == "arm64") + } + + @Test("returns x86_64 for x86_64 CPU type") + func returnsX86_64() { + let arch = PostHogCPUArchitecture.archName(cpuType: 0x0100_0007, cpuSubtype: 0) + #expect(arch == "x86_64") + } + + @Test("returns armv7 for ARM CPU type with subtype 9") + func returnsArmv7() { + let arch = PostHogCPUArchitecture.archName(cpuType: 12, cpuSubtype: 9) + #expect(arch == "armv7") + } + + @Test("returns armv7s for ARM CPU type with subtype 11") + func returnsArmv7s() { + let arch = PostHogCPUArchitecture.archName(cpuType: 12, cpuSubtype: 11) + #expect(arch == "armv7s") + } + + @Test("returns arm for ARM CPU type with unknown subtype") + func returnsArmForUnknownSubtype() { + let arch = PostHogCPUArchitecture.archName(cpuType: 12, cpuSubtype: 99) + #expect(arch == "arm") + } + + @Test("returns nil for unknown CPU type") + func returnsNilForUnknownType() { + let arch = PostHogCPUArchitecture.archName(cpuType: 999, cpuSubtype: 0) + #expect(arch == nil) + } + } +} diff --git a/PostHogTests/PostHogExceptionProcessorTest.swift b/PostHogTests/PostHogExceptionProcessorTest.swift new file mode 100644 index 000000000..2db472a74 --- /dev/null +++ b/PostHogTests/PostHogExceptionProcessorTest.swift @@ -0,0 +1,400 @@ +// +// PostHogExceptionProcessorTest.swift +// PostHogTests +// +// Created by Ioannis Josephides on 22/12/2025. +// + +import Foundation +import Testing + +@testable import PostHog + +@Suite("PostHogExceptionProcessor Tests") +struct PostHogExceptionProcessorTest { + let config = PostHogErrorTrackingConfig() + + // MARK: - Error to Properties Tests + + @Suite("Error to Properties") + struct ErrorToPropertiesTests { + let config = PostHogErrorTrackingConfig() + + @Test("converts simple Swift error to properties") + func convertsSimpleSwiftError() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + #expect(properties["$exception_level"] as? String == "error") + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList != nil) + #expect(exceptionList?.count == 1) + + let exception = exceptionList?.first + #expect(exception?["type"] as? String == "TestSwiftError") + #expect(exception?["thread_id"] as? Int != nil) + + let mechanism = exception?["mechanism"] as? [String: Any] + #expect(mechanism?["type"] as? String == "generic") + #expect(mechanism?["handled"] as? Bool == true) + #expect(mechanism?["synthetic"] as? Bool == true) + + let stacktrace = exception?["stacktrace"] as? [String: Any] + #expect(stacktrace != nil) + #expect(stacktrace?["type"] as? String == "raw") + } + + @Test("converts NSError to properties with domain as type") + func convertsNSErrorWithDomain() { + let error = NSError( + domain: "com.posthog.TestDomain", + code: 500, + userInfo: [NSLocalizedDescriptionKey: "Test error message"] + ) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: false, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exception = exceptionList?.first + + #expect(exception?["type"] as? String == "com.posthog.TestDomain") + #expect((exception?["value"] as? String)?.contains("Test error message") == true) + #expect((exception?["value"] as? String)?.contains("500") == true) + + let mechanism = exception?["mechanism"] as? [String: Any] + #expect(mechanism?["handled"] as? Bool == false) + } + + @Test("walks error chain via NSUnderlyingErrorKey") + func walksErrorChain() { + let rootError = NSError( + domain: "RootDomain", + code: 100, + userInfo: [NSLocalizedDescriptionKey: "Root cause"] + ) + let wrapperError = NSError( + domain: "WrapperDomain", + code: 200, + userInfo: [ + NSLocalizedDescriptionKey: "Wrapper error", + NSUnderlyingErrorKey: rootError, + ] + ) + + let properties = PostHogExceptionProcessor.errorToProperties( + wrapperError, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList?.count == 2) + + #expect(exceptionList?[0]["type"] as? String == "WrapperDomain") + #expect(exceptionList?[1]["type"] as? String == "RootDomain") + } + + @Test("handles circular error references") + func handlesCircularReferences() { + let error1 = NSError(domain: "Domain1", code: 1, userInfo: nil) + let error2 = NSError(domain: "Domain2", code: 2, userInfo: [NSUnderlyingErrorKey: error1]) + + let properties = PostHogExceptionProcessor.errorToProperties( + error2, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList != nil) + #expect(exceptionList!.count <= 2) + } + + @Test("uses custom mechanism type") + func usesCustomMechanismType() { + let error = TestSwiftError.validationError(field: "email") + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + mechanismType: "custom_handler", + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let mechanism = exceptionList?.first?["mechanism"] as? [String: Any] + #expect(mechanism?["type"] as? String == "custom_handler") + } + + @Test("extracts module from error domain") + func extractsModuleFromDomain() { + let error = TestSwiftError.networkError(code: 500) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exception = exceptionList?.first + #expect(exception?["module"] as? String != nil) + } + } + + // MARK: - NSException to Properties Tests + + @Suite("NSException to Properties") + struct NSExceptionToPropertiesTests { + let config = PostHogErrorTrackingConfig() + + @Test("converts NSException to properties") + func convertsNSException() { + let exception = NSException( + name: NSExceptionName("TestException"), + reason: "Test exception reason", + userInfo: nil + ) + + let properties = PostHogExceptionProcessor.exceptionToProperties( + exception, + handled: true, + config: config + ) + + #expect(properties["$exception_level"] as? String == "error") + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList?.count == 1) + + let exc = exceptionList?.first + #expect(exc?["type"] as? String == "TestException") + #expect(exc?["value"] as? String == "Test exception reason") + } + + @Test("handles NSException without reason") + func handlesExceptionWithoutReason() { + let exception = NSException( + name: NSExceptionName("NoReasonException"), + reason: nil, + userInfo: nil + ) + + let properties = PostHogExceptionProcessor.exceptionToProperties( + exception, + handled: false, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exc = exceptionList?.first + #expect(exc?["type"] as? String == "NoReasonException") + #expect(exc?["value"] == nil) + } + + @Test("marks exception as unhandled") + func marksUnhandled() { + let exception = NSException( + name: .genericException, + reason: "Unhandled", + userInfo: nil + ) + + let properties = PostHogExceptionProcessor.exceptionToProperties( + exception, + handled: false, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let mechanism = exceptionList?.first?["mechanism"] as? [String: Any] + #expect(mechanism?["handled"] as? Bool == false) + } + } + + // MARK: - Message to Properties Tests + + @Suite("Message to Properties") + struct MessageToPropertiesTests { + let config = PostHogErrorTrackingConfig() + + @Test("converts message string to properties") + func convertsMessageString() { + let message = "Something went wrong" + let properties = PostHogExceptionProcessor.messageToProperties( + message, + config: config + ) + + #expect(properties["$exception_level"] as? String == "error") + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList?.count == 1) + + let exception = exceptionList?.first + #expect(exception?["type"] as? String == "Message") + #expect(exception?["value"] as? String == "Something went wrong") + } + + @Test("message exceptions are always synthetic and handled") + func messageExceptionsAreSyntheticAndHandled() { + let properties = PostHogExceptionProcessor.messageToProperties( + "Test message", + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let mechanism = exceptionList?.first?["mechanism"] as? [String: Any] + #expect(mechanism?["synthetic"] as? Bool == true) + #expect(mechanism?["handled"] as? Bool == true) + } + + @Test("message uses custom mechanism type") + func usesCustomMechanismType() { + let properties = PostHogExceptionProcessor.messageToProperties( + "Test", + mechanismType: "custom", + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let mechanism = exceptionList?.first?["mechanism"] as? [String: Any] + #expect(mechanism?["type"] as? String == "custom") + } + } + + // MARK: - Debug Images Tests + + @Suite("Debug Images") + struct DebugImagesTests { + let config = PostHogErrorTrackingConfig() + + @Test("attaches debug images to error properties") + func attachesDebugImages() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let debugImages = properties["$debug_images"] as? [[String: Any]] + #expect(debugImages != nil) + #expect(debugImages!.count > 0) + + let image = debugImages?.first + #expect(image?["type"] as? String == "macho") + #expect(image?["code_file"] as? String != nil) + #expect(image?["image_addr"] as? String != nil) + #expect(image?["image_size"] as? UInt64 != nil) + } + + @Test("debug images have valid UUID format") + func debugImagesHaveValidUUID() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let debugImages = properties["$debug_images"] as? [[String: Any]] + let imageWithUUID = debugImages?.first { $0["debug_id"] != nil } + + if let uuid = imageWithUUID?["debug_id"] as? String { + let uuidPattern = #"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$"# + let regex = try? NSRegularExpression(pattern: uuidPattern) + let range = NSRange(uuid.startIndex..., in: uuid) + #expect(regex?.firstMatch(in: uuid, range: range) != nil) + } + } + } + + // MARK: - Stack Trace Tests + + @Suite("Stack Trace") + struct StackTraceTests { + let config = PostHogErrorTrackingConfig() + + @Test("stack trace has raw type") + func stackTraceHasRawType() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + #expect(stacktrace?["type"] as? String == "raw") + } + + @Test("stack trace contains frames") + func stackTraceContainsFrames() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + + #expect(frames != nil) + #expect(frames!.count > 0) + } + + @Test("frames have required fields") + func framesHaveRequiredFields() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + let frame = frames?.first + + #expect(frame?["instruction_addr"] as? String != nil) + #expect(frame?["platform"] as? String == "apple") + #expect(frame?["in_app"] as? Bool != nil) + } + + @Test("frames have hex address format") + func framesHaveHexAddressFormat() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + let instructionAddr = frames?.first?["instruction_addr"] as? String + + #expect(instructionAddr?.hasPrefix("0x") == true) + } + } +} + +// MARK: - Test Helpers + +enum TestSwiftError: Error { + case networkError(code: Int) + case validationError(field: String) + case unknownError +} diff --git a/PostHogTests/PostHogStackTraceProcessorTest.swift b/PostHogTests/PostHogStackTraceProcessorTest.swift new file mode 100644 index 000000000..152712cc3 --- /dev/null +++ b/PostHogTests/PostHogStackTraceProcessorTest.swift @@ -0,0 +1,241 @@ +// +// PostHogStackTraceProcessorTest.swift +// PostHogTests +// +// Created by Ioannis Josephides on 22/12/2025. +// + +import Foundation +import Testing + +@testable import PostHog + +@Suite("PostHogStackTraceProcessor Tests") +struct PostHogStackTraceProcessorTest { + // MARK: - In-App Detection Tests + + @Suite("In-App Detection") + struct InAppDetectionTests { + @Test("marks module as in-app when in inAppIncludes") + func marksInAppWhenInIncludes() { + let config = PostHogErrorTrackingConfig() + config.inAppIncludes = ["MyApp", "SharedModule"] + + #expect(PostHogStackTraceProcessor.isInApp(module: "MyApp", config: config) == true) + #expect(PostHogStackTraceProcessor.isInApp(module: "MyAppExtension", config: config) == true) + #expect(PostHogStackTraceProcessor.isInApp(module: "SharedModule", config: config) == true) + } + + @Test("marks module as not in-app when in inAppExcludes") + func marksNotInAppWhenInExcludes() { + let config = PostHogErrorTrackingConfig() + config.inAppExcludes = ["Alamofire", "SDWebImage"] + + #expect(PostHogStackTraceProcessor.isInApp(module: "Alamofire", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "SDWebImage", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "SDWebImageSwiftUI", config: config) == false) + } + + @Test("inAppIncludes takes precedence over inAppExcludes") + func includesTakesPrecedenceOverExcludes() { + let config = PostHogErrorTrackingConfig() + config.inAppIncludes = ["MyModule"] + config.inAppExcludes = ["MyModule"] + + #expect(PostHogStackTraceProcessor.isInApp(module: "MyModule", config: config) == true) + } + + @Test("marks system frameworks as not in-app") + func marksSystemFrameworksAsNotInApp() { + let config = PostHogErrorTrackingConfig() + + #expect(PostHogStackTraceProcessor.isInApp(module: "Foundation", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "UIKit", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "CoreFoundation", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "SwiftUI", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "libsystem_kernel.dylib", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "libswiftCore.dylib", config: config) == false) + } + + @Test("uses inAppByDefault for unknown modules") + func usesInAppByDefault() { + let config = PostHogErrorTrackingConfig() + + config.inAppByDefault = true + #expect(PostHogStackTraceProcessor.isInApp(module: "UnknownModule", config: config) == true) + + config.inAppByDefault = false + #expect(PostHogStackTraceProcessor.isInApp(module: "UnknownModule", config: config) == false) + } + + @Test("uses prefix matching for includes") + func usesPrefixMatchingForIncludes() { + let config = PostHogErrorTrackingConfig() + config.inAppIncludes = ["com.posthog"] + config.inAppByDefault = false + + #expect(PostHogStackTraceProcessor.isInApp(module: "com.posthog.sdk", config: config) == true) + #expect(PostHogStackTraceProcessor.isInApp(module: "com.posthog", config: config) == true) + #expect(PostHogStackTraceProcessor.isInApp(module: "com.other", config: config) == false) + } + + @Test("uses prefix matching for excludes") + func usesPrefixMatchingForExcludes() { + let config = PostHogErrorTrackingConfig() + config.inAppExcludes = ["Firebase"] + + #expect(PostHogStackTraceProcessor.isInApp(module: "FirebaseCore", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "FirebaseAnalytics", config: config) == false) + } + } + + // MARK: - Stack Trace Capture Tests + + @Suite("Stack Trace Capture") + struct StackTraceCaptureTests { + @Test("captures current stack trace") + func capturesCurrentStackTrace() { + let config = PostHogErrorTrackingConfig() + let frames = PostHogStackTraceProcessor.captureCurrentStackTraceWithMetadata(config: config) + + #expect(frames.count > 0) + } + + @Test("captured frames have instruction addresses") + func framesHaveInstructionAddresses() { + let config = PostHogErrorTrackingConfig() + let frames = PostHogStackTraceProcessor.captureCurrentStackTraceWithMetadata(config: config) + + for frame in frames { + #expect(frame.instructionAddress > 0) + } + } + + @Test("captured frames have module info") + func framesHaveModuleInfo() { + let config = PostHogErrorTrackingConfig() + let frames = PostHogStackTraceProcessor.captureCurrentStackTraceWithMetadata(config: config) + + let framesWithModule = frames.filter { $0.module != nil } + #expect(framesWithModule.count > 0) + } + + @Test("strips PostHog frames from top of stack") + func stripsPostHogFrames() { + let config = PostHogErrorTrackingConfig() + let frames = PostHogStackTraceProcessor.captureCurrentStackTraceWithMetadata(config: config) + + let topFrame = frames.first + #expect(topFrame?.module != "PostHog") + } + } + + // MARK: - Symbolicate Addresses Tests + + @Suite("Symbolicate Addresses") + struct SymbolicateAddressesTests { + @Test("symbolicates array of addresses") + func symbolicatesAddresses() { + let config = PostHogErrorTrackingConfig() + let addresses = Thread.callStackReturnAddresses + + let frames = PostHogStackTraceProcessor.symbolicateAddresses( + addresses, + config: config, + stripTopPostHogFrames: false + ) + + #expect(frames.count > 0) + } + + @Test("respects stripTopPostHogFrames parameter") + func respectsStripParameter() { + let config = PostHogErrorTrackingConfig() + let addresses = Thread.callStackReturnAddresses + + let framesWithStrip = PostHogStackTraceProcessor.symbolicateAddresses( + addresses, + config: config, + stripTopPostHogFrames: true + ) + + let framesWithoutStrip = PostHogStackTraceProcessor.symbolicateAddresses( + addresses, + config: config, + stripTopPostHogFrames: false + ) + + #expect(framesWithStrip.count <= framesWithoutStrip.count) + } + } + + // MARK: - Frame Dictionary Tests + + @Suite("Frame Dictionary Conversion") + struct FrameDictionaryTests { + @Test("converts frame to dictionary with required fields") + func convertsToDictionaryWithRequiredFields() { + let frame = PostHogStackFrame( + instructionAddress: 0x100_0000, + module: "TestModule", + package: "/path/to/TestModule", + imageAddress: 0x100_0000, + inApp: true, + function: "testFunction", + symbolAddress: 0x100_0000 + ) + + let dict = frame.toDictionary + + #expect(dict["instruction_addr"] as? String == "0x1000000") + #expect(dict["platform"] as? String == "apple") + #expect(dict["in_app"] as? Bool == true) + #expect(dict["module"] as? String == "TestModule") + #expect(dict["package"] as? String == "/path/to/TestModule") + #expect(dict["function"] as? String == "testFunction") + } + + @Test("omits nil fields from dictionary") + func omitsNilFields() { + let frame = PostHogStackFrame( + instructionAddress: 0x100_0000, + module: nil, + package: nil, + imageAddress: nil, + inApp: false, + function: nil, + symbolAddress: nil + ) + + let dict = frame.toDictionary + + #expect(dict["instruction_addr"] != nil) + #expect(dict["platform"] != nil) + #expect(dict["in_app"] != nil) + #expect(dict["module"] == nil) + #expect(dict["package"] == nil) + #expect(dict["function"] == nil) + #expect(dict["image_addr"] == nil) + #expect(dict["symbol_addr"] == nil) + } + + @Test("formats addresses as hex strings") + func formatsAddressesAsHex() { + let frame = PostHogStackFrame( + instructionAddress: 0x7FFF_1234_5678, + module: "Test", + package: nil, + imageAddress: 0x7FFF_0000_0000, + inApp: true, + function: nil, + symbolAddress: 0x7FFF_1234_5000 + ) + + let dict = frame.toDictionary + + #expect((dict["instruction_addr"] as? String)?.hasPrefix("0x") == true) + #expect((dict["image_addr"] as? String)?.hasPrefix("0x") == true) + #expect((dict["symbol_addr"] as? String)?.hasPrefix("0x") == true) + } + } +} From b0e8fb32a0700b69362ffa5a65c0788653aaafe4 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 30 Jan 2026 03:13:42 +0200 Subject: [PATCH 36/45] fix: rename config option and integration --- ...HogErrorTrackingAutoCaptureIntegration.swift} | 16 ++++++++-------- .../PostHogErrorTrackingConfig.swift | 4 ++-- PostHog/PostHogConfig.swift | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) rename PostHog/Error Tracking/{PostHogCrashReporterIntegration.swift => PostHogErrorTrackingAutoCaptureIntegration.swift} (91%) diff --git a/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift b/PostHog/Error Tracking/PostHogErrorTrackingAutoCaptureIntegration.swift similarity index 91% rename from PostHog/Error Tracking/PostHogCrashReporterIntegration.swift rename to PostHog/Error Tracking/PostHogErrorTrackingAutoCaptureIntegration.swift index 2c18821ee..2141da523 100644 --- a/PostHog/Error Tracking/PostHogCrashReporterIntegration.swift +++ b/PostHog/Error Tracking/PostHogErrorTrackingAutoCaptureIntegration.swift @@ -1,5 +1,5 @@ // -// PostHogCrashReporterIntegration.swift +// PostHogErrorTrackingAutoCaptureIntegration.swift // PostHog // // Created by Ioannis Josephides on 14/12/2025. @@ -10,7 +10,7 @@ import Foundation #if os(iOS) || os(macOS) || os(tvOS) import CrashReporter - class PostHogCrashReporterIntegration: PostHogIntegration { + class PostHogErrorTrackingAutoCaptureIntegration: PostHogIntegration { private static let integrationInstalledLock = NSLock() private static var integrationInstalled = false @@ -20,11 +20,11 @@ import Foundation private var crashReporter: PLCrashReporter? func install(_ postHog: PostHogSDK) throws { - try PostHogCrashReporterIntegration.integrationInstalledLock.withLock { - if PostHogCrashReporterIntegration.integrationInstalled { + try PostHogErrorTrackingAutoCaptureIntegration.integrationInstalledLock.withLock { + if PostHogErrorTrackingAutoCaptureIntegration.integrationInstalled { throw InternalPostHogError(description: "Crash report integration already installed to another PostHogSDK instance.") } - PostHogCrashReporterIntegration.integrationInstalled = true + PostHogErrorTrackingAutoCaptureIntegration.integrationInstalled = true } self.postHog = postHog @@ -41,8 +41,8 @@ import Foundation stop() crashReporter = nil self.postHog = nil - PostHogCrashReporterIntegration.integrationInstalledLock.withLock { - PostHogCrashReporterIntegration.integrationInstalled = false + PostHogErrorTrackingAutoCaptureIntegration.integrationInstalledLock.withLock { + PostHogErrorTrackingAutoCaptureIntegration.integrationInstalled = false } } } @@ -165,7 +165,7 @@ import Foundation #else // watchOS stub - crash reporting is not available - class PostHogCrashReporterIntegration: PostHogIntegration { + class PostHogErrorTrackingAutoCaptureIntegration: PostHogIntegration { var requiresSwizzling: Bool { false } func install(_: PostHogSDK) throws { diff --git a/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift b/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift index 7c0c67ce1..8f50309b3 100644 --- a/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift +++ b/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift @@ -14,7 +14,7 @@ import Foundation @objc public class PostHogErrorTrackingConfig: NSObject { // MARK: - Crash Reporting - /// Enable automatic crash reporting + /// Enable crash autocapture /// /// When enabled, the SDK will capture the following crash types: /// - Mach exceptions (e.g., `EXC_BAD_ACCESS`, `EXC_CRASH`) @@ -28,7 +28,7 @@ import Foundation /// as the debugger intercepts signals before the crash handler can process them. /// /// Default: false - @objc public var enableCrashHandler: Bool = false + @objc public var autoCapture: Bool = false // MARK: - In-App Detection Configuration diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 4cf4a8ec0..9c0a5fb4b 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -202,8 +202,8 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? func getIntegrations() -> [PostHogIntegration] { var integrations: [PostHogIntegration] = [] - if errorTrackingConfig.enableCrashHandler { - integrations.append(PostHogCrashReporterIntegration()) + if errorTrackingConfig.autoCapture { + integrations.append(PostHogErrorTrackingAutoCaptureIntegration()) } if captureScreenViews { From 40b3c506cc347523c61c438f5c539157e2f9a738 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 30 Jan 2026 03:15:16 +0200 Subject: [PATCH 37/45] fix: format --- PostHogTests/PostHogCrashReportProcessorTest.swift | 3 +-- PostHogTests/PostHogDebugImageProviderTest.swift | 3 +-- PostHogTests/PostHogErrorTrackingUtilsTest.swift | 3 +-- PostHogTests/PostHogExceptionProcessorTest.swift | 3 +-- PostHogTests/PostHogStackTraceProcessorTest.swift | 3 +-- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/PostHogTests/PostHogCrashReportProcessorTest.swift b/PostHogTests/PostHogCrashReportProcessorTest.swift index 224b5c478..22e0c38b1 100644 --- a/PostHogTests/PostHogCrashReportProcessorTest.swift +++ b/PostHogTests/PostHogCrashReportProcessorTest.swift @@ -6,9 +6,8 @@ // import Foundation -import Testing - @testable import PostHog +import Testing #if os(iOS) || os(macOS) || os(tvOS) import CrashReporter diff --git a/PostHogTests/PostHogDebugImageProviderTest.swift b/PostHogTests/PostHogDebugImageProviderTest.swift index e0085fe7f..41a9785a1 100644 --- a/PostHogTests/PostHogDebugImageProviderTest.swift +++ b/PostHogTests/PostHogDebugImageProviderTest.swift @@ -6,9 +6,8 @@ // import Foundation -import Testing - @testable import PostHog +import Testing @Suite("PostHogDebugImageProvider Tests") struct PostHogDebugImageProviderTest { diff --git a/PostHogTests/PostHogErrorTrackingUtilsTest.swift b/PostHogTests/PostHogErrorTrackingUtilsTest.swift index 7b4656abd..8ad522e33 100644 --- a/PostHogTests/PostHogErrorTrackingUtilsTest.swift +++ b/PostHogTests/PostHogErrorTrackingUtilsTest.swift @@ -6,9 +6,8 @@ // import Foundation -import Testing - @testable import PostHog +import Testing @Suite("PostHogErrorTrackingUtils Tests") struct PostHogErrorTrackingUtilsTest { diff --git a/PostHogTests/PostHogExceptionProcessorTest.swift b/PostHogTests/PostHogExceptionProcessorTest.swift index 2db472a74..c0f0cdbd0 100644 --- a/PostHogTests/PostHogExceptionProcessorTest.swift +++ b/PostHogTests/PostHogExceptionProcessorTest.swift @@ -6,9 +6,8 @@ // import Foundation -import Testing - @testable import PostHog +import Testing @Suite("PostHogExceptionProcessor Tests") struct PostHogExceptionProcessorTest { diff --git a/PostHogTests/PostHogStackTraceProcessorTest.swift b/PostHogTests/PostHogStackTraceProcessorTest.swift index 152712cc3..4031779ad 100644 --- a/PostHogTests/PostHogStackTraceProcessorTest.swift +++ b/PostHogTests/PostHogStackTraceProcessorTest.swift @@ -6,9 +6,8 @@ // import Foundation -import Testing - @testable import PostHog +import Testing @Suite("PostHogStackTraceProcessor Tests") struct PostHogStackTraceProcessorTest { From 2d39504fb90dc04d92fff0570121f5dde73a2f81 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 30 Jan 2026 03:32:42 +0200 Subject: [PATCH 38/45] fix: folder structure --- PostHog.xcodeproj/project.pbxproj | 20 +++++++++---------- .../Models/PostHogBinaryImageInfo.swift | 0 .../Models/PostHogStackFrame.swift | 0 .../PostHogCrashReportProcessor.swift | 2 +- ...gErrorTrackingAutoCaptureIntegration.swift | 0 .../PostHogErrorTrackingConfig.swift | 0 .../PostHogExceptionProcessor.swift | 0 .../Utils/PostHogDebugImageProvider.swift | 0 .../Utils/PostHogErrorTrackingUtils.swift | 0 .../Utils/PostHogStackTraceProcessor.swift | 0 10 files changed, 10 insertions(+), 12 deletions(-) rename PostHog/{Error Tracking => ErrorTracking}/Models/PostHogBinaryImageInfo.swift (100%) rename PostHog/{Error Tracking => ErrorTracking}/Models/PostHogStackFrame.swift (100%) rename PostHog/{Error Tracking => ErrorTracking}/PostHogCrashReportProcessor.swift (99%) rename PostHog/{Error Tracking => ErrorTracking}/PostHogErrorTrackingAutoCaptureIntegration.swift (100%) rename PostHog/{Error Tracking => ErrorTracking}/PostHogErrorTrackingConfig.swift (100%) rename PostHog/{Error Tracking => ErrorTracking}/PostHogExceptionProcessor.swift (100%) rename PostHog/{Error Tracking => ErrorTracking}/Utils/PostHogDebugImageProvider.swift (100%) rename PostHog/{Error Tracking => ErrorTracking}/Utils/PostHogErrorTrackingUtils.swift (100%) rename PostHog/{Error Tracking => ErrorTracking}/Utils/PostHogStackTraceProcessor.swift (100%) diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index d3c70f56b..eb91652fc 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -31,7 +31,6 @@ 3AE3FB49299391DF00AFFC18 /* PostHogStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE3FB48299391DF00AFFC18 /* PostHogStorage.swift */; }; 3AE3FB4B2993A68500AFFC18 /* PostHogStorageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE3FB4A2993A68500AFFC18 /* PostHogStorageTest.swift */; }; 3AE3FB4E2993D1D600AFFC18 /* PostHogStorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE3FB4D2993D1D600AFFC18 /* PostHogStorageManager.swift */; }; - 628564DE224D783306683EEE /* PostHogDebugImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34EA6EED2443DFD84061990F /* PostHogDebugImageProvider.swift */; }; 690B2DF32C205B5600AE3B45 /* TimeBasedEpochGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690B2DF22C205B5600AE3B45 /* TimeBasedEpochGenerator.swift */; }; 690FF05F2AE7E2D400A0B06B /* Data+Gzip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF05E2AE7E2D400A0B06B /* Data+Gzip.swift */; }; 690FF0AF2AEB9C1400A0B06B /* DateUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0AE2AEB9C1400A0B06B /* DateUtils.swift */; }; @@ -190,7 +189,7 @@ DA5063D22EF5918200C51DA0 /* PostHogCrashReportProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */; }; DA5063D62EF591CB00C51DA0 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = DA5063D52EF591CB00C51DA0 /* CrashReporter */; }; DA5064412EF5E40300C51DA0 /* ApplicationScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */; }; - DA5064432EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064422EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift */; }; + DA5064432EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift */; }; DA5064572EF6171900C51DA0 /* SwiftCrashTriggers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064562EF6171900C51DA0 /* SwiftCrashTriggers.swift */; }; DA53DE762D3E29A900C38DCA /* fixture_remote_config.json in Resources */ = {isa = PBXBuildFile; fileRef = DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */; }; DA53DE7E2D3E66AA00C38DCA /* PostHogRemoteConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */; }; @@ -363,6 +362,7 @@ DAB9F6C42D6C49BB00A988A1 /* ApplicationEventPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB9F6C32D6C49B300A988A1 /* ApplicationEventPublisher.swift */; }; DABDF9C32E02994000C7E498 /* PostHogDisplaySurvey.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABDF9C22E02994000C7E498 /* PostHogDisplaySurvey.swift */; }; DABF95052E8FEB6F0029CBBB /* PostHogSurveyEventsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABF95042E8FEB6F0029CBBB /* PostHogSurveyEventsTest.swift */; }; + DAC0A2BF2F2C419400BAA602 /* PostHogDebugImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */; }; DAC699D62CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699D52CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift */; }; DAC699EC2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */; }; DACB3ED22E0193B20061FC7D /* PostHogSurvey+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACB3ED12E0193B20061FC7D /* PostHogSurvey+Display.swift */; }; @@ -549,7 +549,6 @@ /* Begin PBXFileReference section */ 1F55581F2DFC47E8007643C0 /* PostHogStorageMergeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStorageMergeTest.swift; sourceTree = ""; }; - 34EA6EED2443DFD84061990F /* PostHogDebugImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PostHogDebugImageProvider.swift; path = "PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift"; sourceTree = ""; }; 3A0F108229C47940002C0084 /* UIViewExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExample.swift; sourceTree = ""; }; 3A0F108429C9ABB6002C0084 /* ReadWriteLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadWriteLock.swift; sourceTree = ""; }; 3A0F108829C9BD76002C0084 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; @@ -738,7 +737,7 @@ DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogIdentityTests.swift; sourceTree = ""; }; DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingUtils.swift; sourceTree = ""; }; DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReportProcessor.swift; sourceTree = ""; }; - DA5064422EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReporterIntegration.swift; sourceTree = ""; }; + DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingAutoCaptureIntegration.swift; sourceTree = ""; }; DA5064562EF6171900C51DA0 /* SwiftCrashTriggers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftCrashTriggers.swift; sourceTree = ""; }; DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_remote_config.json; sourceTree = ""; }; DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfigTest.swift; sourceTree = ""; }; @@ -1085,7 +1084,6 @@ 3AC745B6296D6FE60025C109 /* Products */, 69261D152AD92D6C00232EC7 /* Frameworks */, DAE8AEC02D9D0E7700A1FE3A /* Recovered References */, - 34EA6EED2443DFD84061990F /* PostHogDebugImageProvider.swift */, ); sourceTree = ""; }; @@ -1115,7 +1113,7 @@ DA26419B2CC0499300CB427B /* Autocapture */, 69EE82B82BA9C4DA00EB9542 /* Replay */, DA30AE872D40173D00465A64 /* Surveys */, - DA8C9B972EC633FA00C6EADB /* Error Tracking */, + DA8C9B972EC633FA00C6EADB /* ErrorTracking */, 69BA38E62B893F2200AA69D6 /* Resources */, 69779BED2AE6B29E00D7A48E /* Models */, 3AA4C09B2988315D006C4731 /* Utils */, @@ -1444,7 +1442,7 @@ path = "App Life Cycle"; sourceTree = ""; }; - DA8C9B972EC633FA00C6EADB /* Error Tracking */ = { + DA8C9B972EC633FA00C6EADB /* ErrorTracking */ = { isa = PBXGroup; children = ( DA3BB4E72ED9929C0097A97A /* Models */, @@ -1452,9 +1450,9 @@ DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */, DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */, DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */, - DA5064422EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift */, + DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift */, ); - path = "Error Tracking"; + path = ErrorTracking; sourceTree = ""; }; DA8D37252CBEAC02005EBD27 /* Products */ = { @@ -2230,6 +2228,7 @@ DA9CE3372D3108AD00DFE652 /* lossless_sse2.c in Sources */, DA9CE3382D3108AD00DFE652 /* enc.c in Sources */, DA9CE3392D3108AD00DFE652 /* yuv.c in Sources */, + DAC0A2BF2F2C419400BAA602 /* PostHogDebugImageProvider.swift in Sources */, DA9CE33A2D3108AD00DFE652 /* bit_reader_utils.c in Sources */, DA9CE33B2D3108AD00DFE652 /* lossless_neon.c in Sources */, DAFF026D2D7B2F9200BD5B1D /* SurveyDisplayController.swift in Sources */, @@ -2289,7 +2288,7 @@ DA9CE3652D3108AD00DFE652 /* alpha_processing_neon.c in Sources */, DA0F989B2D94081A009AB6F5 /* ApplicationViewLayoutPublisher.swift in Sources */, DA9CE3662D3108AD00DFE652 /* sharpyuv_gamma.c in Sources */, - DA5064432EF5E58D00C51DA0 /* PostHogCrashReporterIntegration.swift in Sources */, + DA5064432EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift in Sources */, DA37933C2DBA571D005C6AA3 /* PostHogSurveysConfig.swift in Sources */, DA9CE3672D3108AD00DFE652 /* picture_tools_enc.c in Sources */, DA9CE3682D3108AD00DFE652 /* sharpyuv_cpu.c in Sources */, @@ -2377,7 +2376,6 @@ 69261D1D2AD967CD00232EC7 /* PostHogFileBackedQueue.swift in Sources */, 3AE3FB432992985A00AFFC18 /* Reachability.swift in Sources */, 69F518122BAC783300F52C14 /* CGColor+Util.swift in Sources */, - 628564DE224D783306683EEE /* PostHogDebugImageProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift b/PostHog/ErrorTracking/Models/PostHogBinaryImageInfo.swift similarity index 100% rename from PostHog/Error Tracking/Models/PostHogBinaryImageInfo.swift rename to PostHog/ErrorTracking/Models/PostHogBinaryImageInfo.swift diff --git a/PostHog/Error Tracking/Models/PostHogStackFrame.swift b/PostHog/ErrorTracking/Models/PostHogStackFrame.swift similarity index 100% rename from PostHog/Error Tracking/Models/PostHogStackFrame.swift rename to PostHog/ErrorTracking/Models/PostHogStackFrame.swift diff --git a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift similarity index 99% rename from PostHog/Error Tracking/PostHogCrashReportProcessor.swift rename to PostHog/ErrorTracking/PostHogCrashReportProcessor.swift index 62cb414f7..12ed25fa6 100644 --- a/PostHog/Error Tracking/PostHogCrashReportProcessor.swift +++ b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift @@ -247,7 +247,7 @@ import Foundation // MARK: - Helpers /// Format string for zero-padded 64-bit hex addresses (e.g., "0x00007fff12345678") - static let hexAddressPaddedFormat = "0x%016llx" + private static let hexAddressPaddedFormat = "0x%016llx" private static let machExceptionNames: [UInt64: String] = [ 1: "EXC_BAD_ACCESS", diff --git a/PostHog/Error Tracking/PostHogErrorTrackingAutoCaptureIntegration.swift b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift similarity index 100% rename from PostHog/Error Tracking/PostHogErrorTrackingAutoCaptureIntegration.swift rename to PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift diff --git a/PostHog/Error Tracking/PostHogErrorTrackingConfig.swift b/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift similarity index 100% rename from PostHog/Error Tracking/PostHogErrorTrackingConfig.swift rename to PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift diff --git a/PostHog/Error Tracking/PostHogExceptionProcessor.swift b/PostHog/ErrorTracking/PostHogExceptionProcessor.swift similarity index 100% rename from PostHog/Error Tracking/PostHogExceptionProcessor.swift rename to PostHog/ErrorTracking/PostHogExceptionProcessor.swift diff --git a/PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift b/PostHog/ErrorTracking/Utils/PostHogDebugImageProvider.swift similarity index 100% rename from PostHog/Error Tracking/Utils/PostHogDebugImageProvider.swift rename to PostHog/ErrorTracking/Utils/PostHogDebugImageProvider.swift diff --git a/PostHog/Error Tracking/Utils/PostHogErrorTrackingUtils.swift b/PostHog/ErrorTracking/Utils/PostHogErrorTrackingUtils.swift similarity index 100% rename from PostHog/Error Tracking/Utils/PostHogErrorTrackingUtils.swift rename to PostHog/ErrorTracking/Utils/PostHogErrorTrackingUtils.swift diff --git a/PostHog/Error Tracking/Utils/PostHogStackTraceProcessor.swift b/PostHog/ErrorTracking/Utils/PostHogStackTraceProcessor.swift similarity index 100% rename from PostHog/Error Tracking/Utils/PostHogStackTraceProcessor.swift rename to PostHog/ErrorTracking/Utils/PostHogStackTraceProcessor.swift From 6c781069bb0c4465264a33399b1b5eaf76b2ef67 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 30 Jan 2026 03:37:41 +0200 Subject: [PATCH 39/45] fix: --- PostHog/ErrorTracking/PostHogCrashReportProcessor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift index 12ed25fa6..bbf22db81 100644 --- a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift +++ b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift @@ -266,7 +266,7 @@ import Foundation ] private static func machExceptionName(_ type: UInt64) -> String { - machExceptionNames[type] ?? "EXC_UNKNOWN(\(type))" + machExceptionNames[type] ?? "EXC_UNKNOWN_\(type)" } // Exception codes for EXC_BREAKPOINT (from mach/arm/exception.h) From a993c536ced3499e1feb63435198d49ef2ad4cbd Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 30 Jan 2026 09:43:17 +0200 Subject: [PATCH 40/45] fix: mark autoCapture unavailable on watchOS and visionOS --- PostHog.xcodeproj/project.pbxproj | 2 +- .../PostHogErrorTrackingAutoCaptureIntegration.swift | 2 +- PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift | 10 ++++++++-- PostHog/PostHogConfig.swift | 8 +++++--- PostHogExample/AppDelegate.swift | 4 ++++ 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index eb91652fc..22e5c1844 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -187,7 +187,7 @@ DA4FFBB52DAD5B01006BAEEA /* PostHogIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */; }; DA5063C92EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */; }; DA5063D22EF5918200C51DA0 /* PostHogCrashReportProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */; }; - DA5063D62EF591CB00C51DA0 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = DA5063D52EF591CB00C51DA0 /* CrashReporter */; }; + DA5063D62EF591CB00C51DA0 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, macos, tvos, ); productRef = DA5063D52EF591CB00C51DA0 /* CrashReporter */; }; DA5064412EF5E40300C51DA0 /* ApplicationScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */; }; DA5064432EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift */; }; DA5064572EF6171900C51DA0 /* SwiftCrashTriggers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064562EF6171900C51DA0 /* SwiftCrashTriggers.swift */; }; diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift index 2141da523..2408d59fe 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift @@ -169,7 +169,7 @@ import Foundation var requiresSwizzling: Bool { false } func install(_: PostHogSDK) throws { - hedgeLog("Crash reporting is not available on watchOS") + hedgeLog("Crash reporting is only available on iOS, macOS and tvOS") } func uninstall(_: PostHogSDK) { /* no-op */ } diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift b/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift index 8f50309b3..414326505 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift @@ -23,12 +23,18 @@ import Foundation /// /// Crashes are persisted to disk and sent as `$exception` events with level "fatal" **on the next app launch** /// - /// - Note: Crash reporting is not available on watchOS (this will be a no-op). /// - Note: Crash reporting is automatically disabled when a debugger is attached, /// as the debugger intercepts signals before the crash handler can process them. /// /// Default: false - @objc public var autoCapture: Bool = false + private var _autoCapture: Bool = false + + @available(watchOS, unavailable, message: "Crash autocapture is not available on watchOS") + @available(visionOS, unavailable, message: "Crash autocapture is not available on visionOS") + @objc public var autoCapture: Bool { + get { _autoCapture } + set { _autoCapture = newValue } + } // MARK: - In-App Detection Configuration diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index d5bd0cdd4..3097f8165 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -215,9 +215,11 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? func getIntegrations() -> [PostHogIntegration] { var integrations: [PostHogIntegration] = [] - if errorTrackingConfig.autoCapture { - integrations.append(PostHogErrorTrackingAutoCaptureIntegration()) - } + #if os(iOS) || os(macOS) || os(tvOS) + if errorTrackingConfig.autoCapture { + integrations.append(PostHogErrorTrackingAutoCaptureIntegration()) + } + #endif if captureScreenViews { integrations.append(PostHogScreenViewIntegration()) diff --git a/PostHogExample/AppDelegate.swift b/PostHogExample/AppDelegate.swift index 64dc8842b..0d29ca577 100644 --- a/PostHogExample/AppDelegate.swift +++ b/PostHogExample/AppDelegate.swift @@ -24,6 +24,10 @@ class AppDelegate: NSObject, UIApplicationDelegate { config.flushAt = 1 config.sendFeatureFlagEvent = false + #if os(iOS) || os(macOS) || os(tvOS) + config.errorTrackingConfig.autoCapture = false + #endif + #if os(iOS) config.sessionReplay = false config.sessionReplayConfig.screenshotMode = true From 786e3d147b2fecb00689ac9699c4f049d466129a Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 30 Jan 2026 10:14:34 +0200 Subject: [PATCH 41/45] fix: tvOS mach unavailable --- .../PostHogErrorTrackingAutoCaptureIntegration.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift index 2408d59fe..3dc5de6d0 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift @@ -71,8 +71,15 @@ import Foundation private func setupCrashReporter() -> PLCrashReporter? { // Configure PLCrashReporter + // Note: Mach exception handling is not available on tvOS, so we fall back to BSD signal handlers + #if os(tvOS) + let signalHandlerType: PLCrashReporterSignalHandlerType = .BSD + #else + let signalHandlerType: PLCrashReporterSignalHandlerType = .mach + #endif + let config = PLCrashReporterConfig( - signalHandlerType: .mach, + signalHandlerType: signalHandlerType, symbolicationStrategy: [], // No local symbolication, we'll be doing server-side shouldRegisterUncaughtExceptionHandler: true ) From a0bcd80402ed4abd6f29e24616dd0740964bfbf3 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 30 Jan 2026 11:57:52 +0200 Subject: [PATCH 42/45] fix: project --- PostHog.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 22e5c1844..f932bf4c8 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -1108,8 +1108,8 @@ DAB06F9C2D09A744005B1C9B /* PostHog.modulemap */, 3AC745B8296D6FE60025C109 /* PostHog.h */, DA1D29612D115E13003A31DA /* DI.swift */, - DA578E912D6768A100B3A56C /* Screen Views */, - DA703C132D6615300069097B /* App Life Cycle */, + DA578E912D6768A100B3A56C /* ScreenViews */, + DA703C132D6615300069097B /* AppLifeCycle */, DA26419B2CC0499300CB427B /* Autocapture */, 69EE82B82BA9C4DA00EB9542 /* Replay */, DA30AE872D40173D00465A64 /* Surveys */, @@ -1411,13 +1411,13 @@ path = Models; sourceTree = ""; }; - DA578E912D6768A100B3A56C /* Screen Views */ = { + DA578E912D6768A100B3A56C /* ScreenViews */ = { isa = PBXGroup; children = ( DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */, DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */, ); - path = "Screen Views"; + path = ScreenViews; sourceTree = ""; }; DA690C7A2DA54DDD0045FF4E /* Surveys */ = { @@ -1433,13 +1433,13 @@ path = Surveys; sourceTree = ""; }; - DA703C132D6615300069097B /* App Life Cycle */ = { + DA703C132D6615300069097B /* AppLifeCycle */ = { isa = PBXGroup; children = ( DA703C1B2D6616F20069097B /* PostHogAppLifeCycleIntegration.swift */, DA1D29582D10B7A6003A31DA /* ApplicationLifecyclePublisher.swift */, ); - path = "App Life Cycle"; + path = AppLifeCycle; sourceTree = ""; }; DA8C9B972EC633FA00C6EADB /* ErrorTracking */ = { From 8df74ba8040cc62e8d2ef0d8051329bc8749d8c2 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 30 Jan 2026 13:52:34 +0200 Subject: [PATCH 43/45] fix: temp disable swiftlint rule --- PostHog/PostHogSDK.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 1f2d14baa..ec2cf0e3c 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -21,6 +21,7 @@ let retryDelay = 5.0 let maxRetryDelay = 30.0 // renamed to PostHogSDK due to https://github.com/apple/swift/issues/56573 +// swiftlint:disable:next type_body_length <- Should be removed once PostHogSDK is refactored @objc public class PostHogSDK: NSObject { private(set) var config: PostHogConfig From ab5dcaf456fbd6eb6477f771b68e3ec95de6795a Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 30 Jan 2026 16:26:02 +0200 Subject: [PATCH 44/45] fix: ci --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82d8f7f52..5c67c82d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ permissions: jobs: build: - runs-on: macos-14-xlarge + runs-on: macos-15-xlarge steps: - uses: actions/checkout@v6 - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 From 5f9f049c8a989cacb6697a905ed9bdeb366670a1 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sat, 31 Jan 2026 12:58:15 +0200 Subject: [PATCH 45/45] fix: build --- .github/workflows/build-examples.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 0e4f59852..5040a95cd 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -12,7 +12,7 @@ permissions: jobs: build: - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v6 - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f66e4bf4a..fbd5c726a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ permissions: jobs: test: - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v6 - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0