From 28a3d2b4efe0392bee6d35f72e7563b81974fc38 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 13 Nov 2025 20:56:37 +0200 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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 b9c206a167c7df9394aa5de19980efd3d3d5329a Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 9 Dec 2025 00:33:54 +0200 Subject: [PATCH 18/19] feat: add dsym upload script --- PostHog.podspec | 3 ++ build-tools/upload-symbols | 84 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100755 build-tools/upload-symbols diff --git a/PostHog.podspec b/PostHog.podspec index 2380b6221..d203b7cb6 100644 --- a/PostHog.podspec +++ b/PostHog.podspec @@ -30,4 +30,7 @@ Pod::Spec.new do |s| 'vendor/libwebp/**/*.{h,c}' ] s.resource_bundles = { "PostHog" => "PostHog/Resources/PrivacyInfo.xcprivacy" } + + # Include the upload script for dSYM uploads + s.preserve_paths = 'build-tools/upload-symbols' end diff --git a/build-tools/upload-symbols b/build-tools/upload-symbols new file mode 100755 index 000000000..8856daaa2 --- /dev/null +++ b/build-tools/upload-symbols @@ -0,0 +1,84 @@ +#!/bin/bash +# +# PostHog Debug Symbols Upload Script +# https://posthog.com/docs/error-tracking/upload-source-maps/ios +# +# Xcode Build Phase Setup: +# SPM: "${BUILD_DIR%/Build/*}/SourcePackages/checkouts/posthog-ios/build-tools/upload-symbols" +# CocoaPods: "${PODS_ROOT}/PostHog/build-tools/upload-symbols" +# +# Input Files (required for script sandboxing): +# ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME} +# ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME} +# ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist +# $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH) +# +# Build Settings: +# DEBUG_INFORMATION_FORMAT = DWARF with dSYM File (script will skip DWARF-only builds) +# +# Environment Variables (optional): +# POSTHOG_CLI_INSTALL_DIR - Custom directory containing posthog-cli binary +# + +# Validate environment +if [ -z "${DWARF_DSYM_FOLDER_PATH}" ]; then + echo "warning: DWARF_DSYM_FOLDER_PATH not set" + exit 0 +fi + +if [ ! -d "${DWARF_DSYM_FOLDER_PATH}" ]; then + echo "warning: dSYM folder not found: ${DWARF_DSYM_FOLDER_PATH}" + exit 0 +fi + +# Check if folder contains any dSYM bundles +if [ -z "$(find "${DWARF_DSYM_FOLDER_PATH}" -name '*.dSYM' -type d 2>/dev/null)" ]; then + echo "info: No dSYM bundles found in ${DWARF_DSYM_FOLDER_PATH}" + exit 0 +fi + +# Find posthog-cli (Xcode doesn't load shell profiles) +# Priority: env var override > well-known location > npm global > PATH fallback +if [ -n "$POSTHOG_CLI_INSTALL_DIR" ]; then + PH_CLI_PATH="$POSTHOG_CLI_INSTALL_DIR/posthog-cli" +elif [ -n "$CARGO_DIST_FORCE_INSTALL_DIR" ]; then + PH_CLI_PATH="$CARGO_DIST_FORCE_INSTALL_DIR/posthog-cli" +elif [ -f "$HOME/.posthog/posthog-cli" ]; then + PH_CLI_PATH="$HOME/.posthog/posthog-cli" +else + # Check npm global install + NPM_GLOBAL_PREFIX=$(npm prefix -g 2>/dev/null) + if [ -n "$NPM_GLOBAL_PREFIX" ] && [ -f "$NPM_GLOBAL_PREFIX/bin/posthog-cli" ]; then + PH_CLI_PATH="$NPM_GLOBAL_PREFIX/bin/posthog-cli" + else + # Fallback: add common paths and search + export PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.posthog:$PATH" + PH_CLI_PATH=$(command -v posthog-cli 2>/dev/null) + fi +fi + +if [ -z "$PH_CLI_PATH" ] || [ ! -x "$PH_CLI_PATH" ]; then + echo "error: posthog-cli not found, install with: npm install -g @posthog/cli" + exit 1 +fi + +# Build CLI arguments +CLI_ARGS="--directory $DWARF_DSYM_FOLDER_PATH" + +# Pass main target dSYM name for accurate version extraction +if [ -n "${DWARF_DSYM_FILE_NAME}" ]; then + CLI_ARGS="$CLI_ARGS --main-dsym $DWARF_DSYM_FILE_NAME" +fi + +# Pass version info from Xcode build settings (overrides plist extraction) +if [ -n "${PRODUCT_BUNDLE_IDENTIFIER}" ]; then + CLI_ARGS="$CLI_ARGS --project $PRODUCT_BUNDLE_IDENTIFIER" +fi +if [ -n "${MARKETING_VERSION}" ]; then + CLI_ARGS="$CLI_ARGS --version $MARKETING_VERSION" +fi +if [ -n "${CURRENT_PROJECT_VERSION}" ]; then + CLI_ARGS="$CLI_ARGS --build $CURRENT_PROJECT_VERSION" +fi + +$PH_CLI_PATH exp dsym upload $CLI_ARGS || exit 1 \ No newline at end of file From 0480824200fc1466ddff32e82c1a6f7ba92dc785 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 30 Jan 2026 00:17:10 +0200 Subject: [PATCH 19/19] fix: posthog-cli search locations --- build-tools/upload-symbols | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/build-tools/upload-symbols b/build-tools/upload-symbols index 8856daaa2..f46664f95 100755 --- a/build-tools/upload-symbols +++ b/build-tools/upload-symbols @@ -39,22 +39,24 @@ fi # Find posthog-cli (Xcode doesn't load shell profiles) # Priority: env var override > well-known location > npm global > PATH fallback -if [ -n "$POSTHOG_CLI_INSTALL_DIR" ]; then - PH_CLI_PATH="$POSTHOG_CLI_INSTALL_DIR/posthog-cli" -elif [ -n "$CARGO_DIST_FORCE_INSTALL_DIR" ]; then - PH_CLI_PATH="$CARGO_DIST_FORCE_INSTALL_DIR/posthog-cli" -elif [ -f "$HOME/.posthog/posthog-cli" ]; then - PH_CLI_PATH="$HOME/.posthog/posthog-cli" +if [ -f "$HOME/.posthog/posthog-cli" ]; then + PH_CLI_PATH="$HOME/.posthog/posthog-cli" else - # Check npm global install - NPM_GLOBAL_PREFIX=$(npm prefix -g 2>/dev/null) - if [ -n "$NPM_GLOBAL_PREFIX" ] && [ -f "$NPM_GLOBAL_PREFIX/bin/posthog-cli" ]; then - PH_CLI_PATH="$NPM_GLOBAL_PREFIX/bin/posthog-cli" + # Check if installed via npm -g @posthog/cli + NPM_GLOBAL_PREFIX=$(npm prefix -g 2>/dev/null) + if [ -n "$NPM_GLOBAL_PREFIX" ] && [ -f "$NPM_GLOBAL_PREFIX/bin/posthog-cli" ]; then + PH_CLI_PATH="$NPM_GLOBAL_PREFIX/bin/posthog-cli" + else + # Check if installed as local dependency + NPM_LOCAL_ROOT=$(npm root 2>/dev/null) + if [ -n "$NPM_LOCAL_ROOT" ] && [ -f "$NPM_LOCAL_ROOT/.bin/posthog-cli" ]; then + PH_CLI_PATH="$NPM_LOCAL_ROOT/.bin/posthog-cli" else - # Fallback: add common paths and search - export PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.posthog:$PATH" - PH_CLI_PATH=$(command -v posthog-cli 2>/dev/null) + # Fallback to searching common locations + export PATH="/usr/local/bin:/opt/homebrew/bin:$HOME/.cargo/bin:$HOME/.local/bin:$HOME/.posthog:$PATH" + PH_CLI_PATH=$(command -v posthog-cli 2>/dev/null) fi + fi fi if [ -z "$PH_CLI_PATH" ] || [ ! -x "$PH_CLI_PATH" ]; then