diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 0e4f59852..5040a95cd 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -12,7 +12,7 @@ permissions: jobs: build: - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v6 - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82d8f7f52..5c67c82d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ permissions: jobs: build: - runs-on: macos-14-xlarge + runs-on: macos-15-xlarge steps: - uses: actions/checkout@v6 - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f66e4bf4a..fbd5c726a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ permissions: jobs: test: - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v6 - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 diff --git a/Package.resolved b/Package.resolved index 113b208ec..a99cc4395 100644 --- a/Package.resolved +++ b/Package.resolved @@ -37,6 +37,15 @@ "version": "9.1.0" } }, + { + "package": "PLCrashReporter", + "repositoryURL": "https://github.com/microsoft/plcrashreporter.git", + "state": { + "branch": null, + "revision": "0254f941c646b1ed17b243654723d0f071e990d0", + "version": "1.12.2" + } + }, { "package": "Quick", "repositoryURL": "https://github.com/Quick/Quick.git", diff --git a/Package.swift b/Package.swift index 91d46cb4c..b21bfe7fb 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. + .package(url: "https://github.com/microsoft/plcrashreporter.git", from: "1.8.0"), .package(url: "https://github.com/Quick/Quick.git", from: "6.0.0"), .package(url: "https://github.com/Quick/Nimble.git", from: "12.0.0"), .package(url: "https://github.com/AliSoftware/OHHTTPStubs.git", from: "9.0.0"), @@ -25,7 +26,10 @@ let package = Package( // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "PostHog", - dependencies: ["phlibwebp"], + dependencies: [ + "phlibwebp", + .product(name: "CrashReporter", package: "plcrashreporter", condition: .when(platforms: [.iOS, .macOS, .tvOS])), + ], path: "PostHog", resources: [ .copy("Resources/PrivacyInfo.xcprivacy"), diff --git a/PostHog.podspec b/PostHog.podspec index 5643c16fc..7c7c15df6 100644 --- a/PostHog.podspec +++ b/PostHog.podspec @@ -25,6 +25,12 @@ Pod::Spec.new do |s| s.frameworks = 'Foundation' + # PLCrashReporter dependency (not available on watchOS) + # Using ~> 1.8 for minimum compatibility with host apps + s.ios.dependency 'PLCrashReporter', '~> 1.8' + s.osx.dependency 'PLCrashReporter', '~> 1.8' + s.tvos.dependency 'PLCrashReporter', '~> 1.8' + s.source_files = [ 'PostHog/**/*.{swift,h,hpp,m,mm,c,cpp}', 'vendor/libwebp/**/*.{h,c}' diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 20c611117..f932bf4c8 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -176,7 +176,6 @@ DA3BB4AC2ED82EE80097A97A /* PostHogExceptionProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */; }; DA3BB4AF2ED88DE90097A97A /* ExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */; }; DA3BB4DF2ED992780097A97A /* PostHogBinaryImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DD2ED992780097A97A /* PostHogBinaryImageInfo.swift */; }; - DA3BB4E02ED992780097A97A /* PostHogDebugImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */; }; DA3BB5052EDF15320097A97A /* PostHogStackFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB5042EDF15320097A97A /* PostHogStackFrame.swift */; }; DA4AF61F2D1195D20053EA38 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA4AF6202D1195D20053EA38 /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -186,10 +185,15 @@ DA4AF62A2D119FCD0053EA38 /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DA4FFB152DA93C78006BAEEA /* PostHogSessionReplayTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4FFB142DA93C78006BAEEA /* PostHogSessionReplayTest.swift */; }; DA4FFBB52DAD5B01006BAEEA /* PostHogIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */; }; + DA5063C92EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */; }; + DA5063D22EF5918200C51DA0 /* PostHogCrashReportProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */; }; + DA5063D62EF591CB00C51DA0 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, macos, tvos, ); productRef = DA5063D52EF591CB00C51DA0 /* CrashReporter */; }; + DA5064412EF5E40300C51DA0 /* ApplicationScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */; }; + DA5064432EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift */; }; + DA5064572EF6171900C51DA0 /* SwiftCrashTriggers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064562EF6171900C51DA0 /* SwiftCrashTriggers.swift */; }; DA53DE762D3E29A900C38DCA /* fixture_remote_config.json in Resources */ = {isa = PBXBuildFile; fileRef = DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */; }; DA53DE7E2D3E66AA00C38DCA /* PostHogRemoteConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */; }; DA578E982D6768BC00B3A56C /* PostHogScreenViewIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */; }; - DA578E9A2D676AF100B3A56C /* ApplicationScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */; }; DA578E9C2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9B2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift */; }; DA578E9E2D6858BA00B3A56C /* PostHogScreenViewIntegrationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9D2D6858B200B3A56C /* PostHogScreenViewIntegrationTest.swift */; }; DA578EA02D6858CE00B3A56C /* MockScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9F2D6858C900B3A56C /* MockScreenViewPublisher.swift */; }; @@ -202,6 +206,11 @@ DA690C6C2DA54BD70045FF4E /* PostHogSurveyConditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA690C6B2DA54BD10045FF4E /* PostHogSurveyConditions.swift */; }; DA690C6E2DA54BEC0045FF4E /* PostHogSurveyQuestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA690C6D2DA54BE40045FF4E /* PostHogSurveyQuestion.swift */; }; DA690C752DA54C520045FF4E /* PostHogSurveyEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA690C742DA54C4B0045FF4E /* PostHogSurveyEnums.swift */; }; + DA697CB12EF935490019640F /* PostHogDebugImageProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA697CAB2EF935490019640F /* PostHogDebugImageProviderTest.swift */; }; + DA697CB22EF935490019640F /* PostHogErrorTrackingUtilsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA697CAD2EF935490019640F /* PostHogErrorTrackingUtilsTest.swift */; }; + DA697CB32EF935490019640F /* PostHogStackTraceProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA697CAF2EF935490019640F /* PostHogStackTraceProcessorTest.swift */; }; + DA697CB42EF935490019640F /* PostHogCrashReportProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA697CAA2EF935490019640F /* PostHogCrashReportProcessorTest.swift */; }; + DA697CB52EF935490019640F /* PostHogExceptionProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA697CAE2EF935490019640F /* PostHogExceptionProcessorTest.swift */; }; DA6B7C0B2D118C4E0024419F /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA6B7C0C2D118C4E0024419F /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DA6F24822D4A6CA100CA2777 /* PostHogApiTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6F24812D4A6CA100CA2777 /* PostHogApiTest.swift */; }; @@ -353,6 +362,7 @@ DAB9F6C42D6C49BB00A988A1 /* ApplicationEventPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB9F6C32D6C49B300A988A1 /* ApplicationEventPublisher.swift */; }; DABDF9C32E02994000C7E498 /* PostHogDisplaySurvey.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABDF9C22E02994000C7E498 /* PostHogDisplaySurvey.swift */; }; DABF95052E8FEB6F0029CBBB /* PostHogSurveyEventsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABF95042E8FEB6F0029CBBB /* PostHogSurveyEventsTest.swift */; }; + DAC0A2BF2F2C419400BAA602 /* PostHogDebugImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */; }; DAC699D62CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699D52CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift */; }; DAC699EC2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */; }; DACB3ED22E0193B20061FC7D /* PostHogSurvey+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACB3ED12E0193B20061FC7D /* PostHogSurvey+Display.swift */; }; @@ -725,6 +735,10 @@ DA3BB5042EDF15320097A97A /* PostHogStackFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStackFrame.swift; sourceTree = ""; }; DA4FFB142DA93C78006BAEEA /* PostHogSessionReplayTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayTest.swift; sourceTree = ""; }; DA4FFBB42DAD5AF9006BAEEA /* PostHogIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogIdentityTests.swift; sourceTree = ""; }; + DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingUtils.swift; sourceTree = ""; }; + DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReportProcessor.swift; sourceTree = ""; }; + DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingAutoCaptureIntegration.swift; sourceTree = ""; }; + DA5064562EF6171900C51DA0 /* SwiftCrashTriggers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftCrashTriggers.swift; sourceTree = ""; }; DA53DE702D3E299F00C38DCA /* fixture_remote_config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_remote_config.json; sourceTree = ""; }; DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfigTest.swift; sourceTree = ""; }; DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenViewIntegration.swift; sourceTree = ""; }; @@ -741,6 +755,11 @@ DA690C6B2DA54BD10045FF4E /* PostHogSurveyConditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveyConditions.swift; sourceTree = ""; }; DA690C6D2DA54BE40045FF4E /* PostHogSurveyQuestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveyQuestion.swift; sourceTree = ""; }; DA690C742DA54C4B0045FF4E /* PostHogSurveyEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveyEnums.swift; sourceTree = ""; }; + DA697CAA2EF935490019640F /* PostHogCrashReportProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCrashReportProcessorTest.swift; sourceTree = ""; }; + DA697CAB2EF935490019640F /* PostHogDebugImageProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogDebugImageProviderTest.swift; sourceTree = ""; }; + DA697CAD2EF935490019640F /* PostHogErrorTrackingUtilsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingUtilsTest.swift; sourceTree = ""; }; + DA697CAE2EF935490019640F /* PostHogExceptionProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogExceptionProcessorTest.swift; sourceTree = ""; }; + DA697CAF2EF935490019640F /* PostHogStackTraceProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStackTraceProcessorTest.swift; sourceTree = ""; }; DA6F24812D4A6CA100CA2777 /* PostHogApiTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogApiTest.swift; sourceTree = ""; }; DA703C042D6606E50069097B /* PostHogIntegrationInstallationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogIntegrationInstallationTest.swift; sourceTree = ""; }; DA703C1B2D6616F20069097B /* PostHogAppLifeCycleIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAppLifeCycleIntegration.swift; sourceTree = ""; }; @@ -925,6 +944,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DA5063D62EF591CB00C51DA0 /* CrashReporter in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1010,6 +1030,7 @@ DA3BB4AD2ED88DE90097A97A /* ExceptionHandler.h */, DA3BB4AE2ED88DE90097A97A /* ExceptionHandler.m */, DA3BB4B02ED88DEC0097A97A /* PostHogExample-Bridging-Header.h */, + DA5064562EF6171900C51DA0 /* SwiftCrashTriggers.swift */, ); path = PostHogExample; sourceTree = ""; @@ -1155,6 +1176,11 @@ DAF95F602D077C1C001E82BB /* PostHogWebPTest.swift */, 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */, 69ED1AB52C90711D00FE7A91 /* PostHogSDKPersonProfilesTest.swift */, + DA697CAA2EF935490019640F /* PostHogCrashReportProcessorTest.swift */, + DA697CAB2EF935490019640F /* PostHogDebugImageProviderTest.swift */, + DA697CAD2EF935490019640F /* PostHogErrorTrackingUtilsTest.swift */, + DA697CAE2EF935490019640F /* PostHogExceptionProcessorTest.swift */, + DA697CAF2EF935490019640F /* PostHogStackTraceProcessorTest.swift */, ); path = PostHogTests; sourceTree = ""; @@ -1371,6 +1397,7 @@ children = ( DA3BB4A62ED82E410097A97A /* PostHogStackTraceProcessor.swift */, DA3BB4DE2ED992780097A97A /* PostHogDebugImageProvider.swift */, + DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */, ); path = Utils; sourceTree = ""; @@ -1422,6 +1449,8 @@ DA3BB49E2ED82E250097A97A /* Utils */, DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */, DA3BB4AB2ED82EE80097A97A /* PostHogExceptionProcessor.swift */, + DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */, + DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift */, ); path = ErrorTracking; sourceTree = ""; @@ -1782,6 +1811,7 @@ ); name = PostHog; packageProductDependencies = ( + DA5063D52EF591CB00C51DA0 /* CrashReporter */, ); productName = PostHog; productReference = 3AC745B5296D6FE60025C109 /* PostHog.framework */; @@ -1938,6 +1968,7 @@ 3A867B6E29C1DF73009D0852 /* XCRemoteSwiftPackageReference "Quick" */, 3A867B7129C1DFEF009D0852 /* XCRemoteSwiftPackageReference "Nimble" */, 3A580B3D29E481F200C5C6F3 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */, + DA5063D42EF591CB00C51DA0 /* XCRemoteSwiftPackageReference "plcrashreporter" */, ); productRefGroup = 3AC745B6296D6FE60025C109 /* Products */; projectDirPath = ""; @@ -2129,6 +2160,7 @@ DA3BB4AF2ED88DE90097A97A /* ExceptionHandler.m in Sources */, 3AE3FB2C2991320300AFFC18 /* Api.swift in Sources */, 3A0F108329C47940002C0084 /* UIViewExample.swift in Sources */, + DA5064572EF6171900C51DA0 /* SwiftCrashTriggers.swift in Sources */, 3AA34CFA296D951A003398F4 /* PostHogExampleApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2155,7 +2187,6 @@ DA9AE8D52D841994002F1B44 /* Survey+Util.swift in Sources */, 690FF0BF2AEFA97F00A0B06B /* FileUtils.swift in Sources */, DA3BB4DF2ED992780097A97A /* PostHogBinaryImageInfo.swift in Sources */, - DA3BB4E02ED992780097A97A /* PostHogDebugImageProvider.swift in Sources */, 69261D252AD9787A00232EC7 /* PostHogExtensions.swift in Sources */, DA1D295E2D10B7B2003A31DA /* ApplicationLifecyclePublisher.swift in Sources */, 3AE3FB4E2993D1D600AFFC18 /* PostHogStorageManager.swift in Sources */, @@ -2197,6 +2228,7 @@ DA9CE3372D3108AD00DFE652 /* lossless_sse2.c in Sources */, DA9CE3382D3108AD00DFE652 /* enc.c in Sources */, DA9CE3392D3108AD00DFE652 /* yuv.c in Sources */, + DAC0A2BF2F2C419400BAA602 /* PostHogDebugImageProvider.swift in Sources */, DA9CE33A2D3108AD00DFE652 /* bit_reader_utils.c in Sources */, DA9CE33B2D3108AD00DFE652 /* lossless_neon.c in Sources */, DAFF026D2D7B2F9200BD5B1D /* SurveyDisplayController.swift in Sources */, @@ -2256,11 +2288,13 @@ DA9CE3652D3108AD00DFE652 /* alpha_processing_neon.c in Sources */, DA0F989B2D94081A009AB6F5 /* ApplicationViewLayoutPublisher.swift in Sources */, DA9CE3662D3108AD00DFE652 /* sharpyuv_gamma.c in Sources */, + DA5064432EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift in Sources */, DA37933C2DBA571D005C6AA3 /* PostHogSurveysConfig.swift in Sources */, DA9CE3672D3108AD00DFE652 /* picture_tools_enc.c in Sources */, DA9CE3682D3108AD00DFE652 /* sharpyuv_cpu.c in Sources */, DA9CE3692D3108AD00DFE652 /* syntax_enc.c in Sources */, DA263D4F2D8075D7004C100D /* SwiftUI+Util.swift in Sources */, + DA5063C92EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift in Sources */, DA9CE36A2D3108AD00DFE652 /* enc_neon.c in Sources */, DACB3ED22E0193B20061FC7D /* PostHogSurvey+Display.swift in Sources */, DABDF9C32E02994000C7E498 /* PostHogDisplaySurvey.swift in Sources */, @@ -2271,6 +2305,7 @@ DA9CE36E2D3108AD00DFE652 /* dec_neon.c in Sources */, DA9CE36F2D3108AD00DFE652 /* predictor_enc.c in Sources */, DA9CE3702D3108AD00DFE652 /* muxedit.c in Sources */, + DA5063D22EF5918200C51DA0 /* PostHogCrashReportProcessor.swift in Sources */, DA9CE3712D3108AD00DFE652 /* cpu.c in Sources */, DA9CE3722D3108AD00DFE652 /* sharpyuv_dsp.c in Sources */, DA9CE3732D3108AD00DFE652 /* lossless_enc_sse41.c in Sources */, @@ -2332,12 +2367,10 @@ 3AE3FB472992AB0000AFFC18 /* Hedgelog.swift in Sources */, 69B7F60C2CF7703400A48BCC /* UIImage+Util.swift in Sources */, DA703C1C2D6616FD0069097B /* PostHogAppLifeCycleIntegration.swift in Sources */, + DA5064412EF5E40300C51DA0 /* ApplicationScreenViewPublisher.swift in Sources */, DAB565CA2D142F8F0088F720 /* PostHogNoMaskViewModifier.swift in Sources */, - DA578E9A2D676AF100B3A56C /* ApplicationScreenViewPublisher.swift in Sources */, 69261D132AD5685B00232EC7 /* PostHogRemoteConfig.swift in Sources */, DA9AE8F42D89AFD7002F1B44 /* BottomSection.swift in Sources */, - DA578E9A2D676AF100B3A56C /* ApplicationScreenViewPublisher.swift in Sources */, - 69261D132AD5685B00232EC7 /* PostHogRemoteConfig.swift in Sources */, 699C5FE62C20178E007DB818 /* UUIDUtils.swift in Sources */, 690B2DF32C205B5600AE3B45 /* TimeBasedEpochGenerator.swift in Sources */, 69261D1D2AD967CD00232EC7 /* PostHogFileBackedQueue.swift in Sources */, @@ -2385,6 +2418,11 @@ DA1D29602D10C810003A31DA /* PostHogSessionManagerTest.swift in Sources */, DA703C1E2D6634770069097B /* PostHogAppLifeCycleIntegrationTest.swift in Sources */, DA578EA02D6858CE00B3A56C /* MockScreenViewPublisher.swift in Sources */, + DA697CB12EF935490019640F /* PostHogDebugImageProviderTest.swift in Sources */, + DA697CB22EF935490019640F /* PostHogErrorTrackingUtilsTest.swift in Sources */, + DA697CB32EF935490019640F /* PostHogStackTraceProcessorTest.swift in Sources */, + DA697CB42EF935490019640F /* PostHogCrashReportProcessorTest.swift in Sources */, + DA697CB52EF935490019640F /* PostHogExceptionProcessorTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3605,6 +3643,14 @@ minimumVersion = 13.7.0; }; }; + DA5063D42EF591CB00C51DA0 /* XCRemoteSwiftPackageReference "plcrashreporter" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/microsoft/plcrashreporter.git"; + requirement = { + kind = exactVersion; + version = 1.8.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3628,6 +3674,11 @@ package = 3A580B3D29E481F200C5C6F3 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */; productName = OHHTTPStubs; }; + DA5063D52EF591CB00C51DA0 /* CrashReporter */ = { + isa = XCSwiftPackageProductDependency; + package = DA5063D42EF591CB00C51DA0 /* XCRemoteSwiftPackageReference "plcrashreporter" */; + productName = CrashReporter; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 3AC745AC296D6FE60025C109 /* Project object */; diff --git a/PostHog/ErrorTracking/Models/PostHogBinaryImageInfo.swift b/PostHog/ErrorTracking/Models/PostHogBinaryImageInfo.swift index 40bf66cb2..80e29d20a 100644 --- a/PostHog/ErrorTracking/Models/PostHogBinaryImageInfo.swift +++ b/PostHog/ErrorTracking/Models/PostHogBinaryImageInfo.swift @@ -22,7 +22,7 @@ struct PostHogBinaryImageInfo { let uuid: String? /// Virtual memory address from the Mach-O headers (preferred load address) - let vmAddress: UInt64 + let vmAddress: UInt64? /// Actual load address in memory (may differ from vmAddress due to ASLR) let address: UInt64 @@ -30,6 +30,18 @@ struct PostHogBinaryImageInfo { /// Size of the binary image in bytes let size: UInt64 + /// CPU architecture (e.g., "arm64", "x86_64") + let arch: String? + + init(name: String, uuid: String?, vmAddress: UInt64?, address: UInt64, size: UInt64, arch: String? = nil) { + self.name = name + self.uuid = uuid + self.vmAddress = vmAddress + self.address = address + self.size = size + self.arch = arch + } + var toDictionary: [String: Any] { var dict: [String: Any] = [ "type": "macho", @@ -42,7 +54,13 @@ struct PostHogBinaryImageInfo { dict["debug_id"] = uuid } - dict["image_vmaddr"] = String(format: PostHogStackFrame.hexAddressFormat, vmAddress) + if let vmAddress, vmAddress > 0 { + dict["image_vmaddr"] = String(format: PostHogStackFrame.hexAddressFormat, vmAddress) + } + + if let arch = arch { + dict["arch"] = arch + } return dict } diff --git a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift new file mode 100644 index 000000000..bbf22db81 --- /dev/null +++ b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift @@ -0,0 +1,401 @@ +// +// PostHogCrashReportProcessor.swift +// PostHog +// +// Created by Ioannis Josephides on 14/12/2025. +// + +import Foundation + +#if os(iOS) || os(macOS) || os(tvOS) + import CrashReporter + + enum PostHogCrashReportProcessor { + /// Process a PLCrashReport and convert it to PostHog $exception event properties + /// + /// - Parameters: + /// - report: The PLCrashReport to process + /// - config: Error tracking configuration for in-app detection + /// - Returns: Dictionary of exception-specific properties for the $exception event + static func processReport(_ report: PLCrashReport, config: PostHogErrorTrackingConfig) -> [String: Any] { + var properties: [String: Any] = [:] + + // Fatal crash + properties["$exception_level"] = "fatal" + + // Build stack frames once, reuse for both exception info and debug images + let stackFrames = buildStackFrames(from: report, config: config) + + // Build exception list + var exceptions: [[String: Any]] = [] + + if let exceptionInfo = buildExceptionInfo(from: report, stackFrames: stackFrames) { + exceptions.append(exceptionInfo) + } + + if !exceptions.isEmpty { + properties["$exception_list"] = exceptions + } + + // Build debug images for symbolication (only images referenced in stack trace) + let debugImages = buildDebugImages(from: report, stackFrames: stackFrames) + if !debugImages.isEmpty { + properties["$debug_images"] = debugImages + } + + // Add crash metadata + if let uuidRef = report.uuidRef { + properties["$crash_report_id"] = CFUUIDCreateString(nil, uuidRef) as String + } + + return properties + } + + /// Get the crash timestamp from the report + static func getCrashTimestamp(_ report: PLCrashReport) -> Date? { + report.systemInfo?.timestamp + } + + // MARK: - Exception Building + + private static func buildExceptionInfo(from report: PLCrashReport, stackFrames: [PostHogStackFrame]) -> [String: Any]? { + var exception: [String: Any] = [:] + + // Determine exception type and value based on crash type + // Priority: NSException (richest info) → Signal (more familiar) → Mach (lowest level) + if report.hasExceptionInfo, let nsExceptionInfo = report.exceptionInfo { + // NSException - has actual exception name and reason + // + // Limitation: Unfortunately we cannot walk the exception chain via NSUnderlyingErrorKey because + // PLCrashReportExceptionInfo only exposes name, reason, and stackFrames. The original userInfo dictionary is not serialized. + // The chain information is lost at crash time. + exception["type"] = nsExceptionInfo.exceptionName + exception["value"] = nsExceptionInfo.exceptionReason + + exception["mechanism"] = [ + "type": "nsexception", + "handled": false, + "synthetic": false, + ] + } else if let signalInfo = report.signalInfo { + // POSIX signal - more familiar to developers (SIGTRAP, SIGABRT, etc.) + // + // Limitation: Swift crashes (fatalError, preconditionFailure, force unwrap, etc.) appear as SIGTRAP. + // The actual error message is stored in the __crash_info Mach-O section of libswiftCore.dylib, + // which PLCrashReporter doesn't expose. Sentry/Bugsnag parse this section to get the message. + // See: https://github.com/getsentry/sentry-cocoa/pull/1596 + // https://github.com/bugsnag/bugsnag-cocoa/pull/948 + // Future enhancement: implement __crash_info parsing in PLCrashReporter for richer Swift crash messages. + exception["type"] = signalInfo.name + exception["value"] = signalMessage(signalInfo) + + let signalMeta: [String: Any?] = [ + "code": signalInfo.code, + "name": signalInfo.name, + ].compactMapValues { $0 } + + exception["mechanism"] = [ + "type": "signal", + "handled": false, + "synthetic": false, + "meta": ["signal": signalMeta].compactMapValues { $0 }, + ] + } else if let machException = report.machExceptionInfo { + // Mach exception - lowest level, fallback + exception["type"] = machExceptionName(machException.type) + exception["value"] = machExceptionMessage(machException) + + exception["mechanism"] = [ + "type": "mach_exception", + "handled": false, + "synthetic": false, + "meta": [ + "mach": [ + "exception": machException.type, + "code": machException.codes.first, + "subcode": machException.codes.count > 1 ? machException.codes[1] : nil, + ].compactMapValues { $0 }, + ].compactMapValues { $0 }, + ] + } else { + return nil + } + + // Add stack trace from frames + if !stackFrames.isEmpty { + exception["stacktrace"] = [ + "frames": stackFrames.map(\.toDictionary), + "type": "raw", + ] + } + + // Add thread ID of crashed thread + // Note: Uses PLCrashReporter's threadNumber (sequential index) rather than Mach thread ID, + // since the original process has terminated and pthread_mach_thread_np is not available. + if let crashedThread = findCrashedThread(in: report) { + exception["thread_id"] = crashedThread.threadNumber + } + + // cleanup nil values + exception = exception.compactMapValues { $0 } + + return exception + } + + // MARK: - Stack Frames + + /// Builds stack frames from the crashed thread. + + private static func buildStackFrames( + from report: PLCrashReport, + config: PostHogErrorTrackingConfig + ) -> [PostHogStackFrame] { + guard let crashedThread = findCrashedThread(in: report) else { + return [] + } + + var frames: [PostHogStackFrame] = [] + + for case let frame as PLCrashReportStackFrameInfo in crashedThread.stackFrames { + var module: String? + var package: String? + var imageAddress: UInt64? + var function: String? + var symbolAddress: UInt64? + + // Try to find the binary image for this frame + if let image = report.image(forAddress: frame.instructionPointer) { + imageAddress = image.imageBaseAddress + + if let imageName = image.imageName { + package = (imageName as NSString).lastPathComponent + module = package + } + } + + // Add symbol info if available + if let symbolInfo = frame.symbolInfo { + function = symbolInfo.symbolName + symbolAddress = symbolInfo.startAddress + } + + // Determine in-app status based on module name and config + let inApp = module.map { PostHogStackTraceProcessor.isInApp(module: $0, config: config) } ?? false + + let stackFrame = PostHogStackFrame( + instructionAddress: frame.instructionPointer, + module: module, + package: package, + imageAddress: imageAddress, + inApp: inApp, + function: function, + symbolAddress: symbolAddress + ) + frames.append(stackFrame) + } + + return frames + } + + private static func findCrashedThread(in report: PLCrashReport) -> PLCrashReportThreadInfo? { + for case let thread as PLCrashReportThreadInfo in report.threads where thread.crashed { + return thread + } + // Fallback to first thread if none marked as crashed + return report.threads.first as? PLCrashReportThreadInfo + } + + // MARK: - Debug Images + + /// Build debug images for symbolication, including only images referenced in the stack frames. + private static func buildDebugImages( + from report: PLCrashReport, + stackFrames: [PostHogStackFrame] + ) -> [[String: Any]] { + // Extract unique image addresses from stack frames + let referencedImageAddresses = Set(stackFrames.compactMap(\.imageAddress)) + guard !referencedImageAddresses.isEmpty else { return [] } + + var debugImages: [PostHogBinaryImageInfo] = [] + + for case let image as PLCrashReportBinaryImageInfo in report.images { + guard referencedImageAddresses.contains(image.imageBaseAddress), + let imageName = image.imageName + else { continue } + + let arch: String? + if let codeType = image.codeType { + arch = PostHogCPUArchitecture.archName(cpuType: codeType.type, cpuSubtype: codeType.subtype) + } else { + arch = nil + } + + let binaryImage = PostHogBinaryImageInfo( + name: imageName, + uuid: image.imageUUID?.formattedAsUUID, + vmAddress: nil, // PLCrashReport doesn't expose vmAddress + address: image.imageBaseAddress, + size: image.imageSize, + arch: arch + ) + debugImages.append(binaryImage) + } + + return debugImages.map(\.toDictionary) + } + + // MARK: - Helpers + + /// Format string for zero-padded 64-bit hex addresses (e.g., "0x00007fff12345678") + private static let hexAddressPaddedFormat = "0x%016llx" + + private static let machExceptionNames: [UInt64: String] = [ + 1: "EXC_BAD_ACCESS", + 2: "EXC_BAD_INSTRUCTION", + 3: "EXC_ARITHMETIC", + 4: "EXC_EMULATION", + 5: "EXC_SOFTWARE", + 6: "EXC_BREAKPOINT", + 7: "EXC_SYSCALL", + 8: "EXC_MACH_SYSCALL", + 9: "EXC_RPC_ALERT", + 10: "EXC_CRASH", + 11: "EXC_RESOURCE", + 12: "EXC_GUARD", + 13: "EXC_CORPSE_NOTIFY", + ] + + private static func machExceptionName(_ type: UInt64) -> String { + machExceptionNames[type] ?? "EXC_UNKNOWN_\(type)" + } + + // Exception codes for EXC_BREAKPOINT (from mach/arm/exception.h) + private static let breakpointCodeNames: [Int64: String] = [ + 1: "EXC_ARM_BREAKPOINT", + ] + + // Exception codes for EXC_BAD_INSTRUCTION (from mach/arm/exception.h) + private static let badInstructionCodeNames: [Int64: String] = [ + 1: "EXC_ARM_UNDEFINED", + 2: "EXC_ARM_SME_DISALLOWED", + ] + + // Exception codes for EXC_ARITHMETIC (from mach/arm/exception.h) + private static let arithmeticCodeNames: [Int64: String] = [ + 0: "EXC_ARM_FP_UNDEFINED", + 1: "EXC_ARM_FP_IO", + 2: "EXC_ARM_FP_DZ", + 3: "EXC_ARM_FP_OF", + 4: "EXC_ARM_FP_UF", + 5: "EXC_ARM_FP_IX", + 6: "EXC_ARM_FP_ID", + ] + + // Kernel return codes (used as first code for EXC_BAD_ACCESS) + // From mach/kern_return.h + private static let kernelReturnCodeNames: [Int64: String] = [ + 0: "KERN_SUCCESS", + 1: "KERN_INVALID_ADDRESS", + 2: "KERN_PROTECTION_FAILURE", + 3: "KERN_NO_SPACE", + 4: "KERN_INVALID_ARGUMENT", + 5: "KERN_FAILURE", + 6: "KERN_RESOURCE_SHORTAGE", + 7: "KERN_NOT_RECEIVER", + 8: "KERN_NO_ACCESS", + 9: "KERN_MEMORY_FAILURE", + 10: "KERN_MEMORY_ERROR", + 11: "KERN_ALREADY_IN_SET", + 12: "KERN_NOT_IN_SET", + 13: "KERN_NAME_EXISTS", + 14: "KERN_ABORTED", + 15: "KERN_INVALID_NAME", + 16: "KERN_INVALID_TASK", + 17: "KERN_INVALID_RIGHT", + 18: "KERN_INVALID_VALUE", + 19: "KERN_UREFS_OVERFLOW", + 20: "KERN_INVALID_CAPABILITY", + 21: "KERN_RIGHT_EXISTS", + 22: "KERN_INVALID_HOST", + 23: "KERN_MEMORY_PRESENT", + 24: "KERN_MEMORY_DATA_MOVED", + 25: "KERN_MEMORY_RESTART_COPY", + 26: "KERN_INVALID_PROCESSOR_SET", + 27: "KERN_POLICY_LIMIT", + 28: "KERN_INVALID_POLICY", + 29: "KERN_INVALID_OBJECT", + 30: "KERN_ALREADY_WAITING", + 31: "KERN_DEFAULT_SET", + 32: "KERN_EXCEPTION_PROTECTED", + 33: "KERN_INVALID_LEDGER", + 34: "KERN_INVALID_MEMORY_CONTROL", + 35: "KERN_INVALID_SECURITY", + 36: "KERN_NOT_DEPRESSED", + 37: "KERN_TERMINATED", + 38: "KERN_LOCK_SET_DESTROYED", + 39: "KERN_LOCK_UNSTABLE", + 40: "KERN_LOCK_OWNED", + 41: "KERN_LOCK_OWNED_SELF", + 42: "KERN_SEMAPHORE_DESTROYED", + 43: "KERN_RPC_SERVER_TERMINATED", + 44: "KERN_RPC_TERMINATE_ORPHAN", + 45: "KERN_RPC_CONTINUE_ORPHAN", + 46: "KERN_NOT_SUPPORTED", + 47: "KERN_NODE_DOWN", + 48: "KERN_NOT_WAITING", + 49: "KERN_OPERATION_TIMED_OUT", + 50: "KERN_CODESIGN_ERROR", + // ARM-specific codes for EXC_BAD_ACCESS (from mach/arm/exception.h) + 0x101: "EXC_ARM_DA_ALIGN", // 257 + 0x102: "EXC_ARM_DA_DEBUG", // 258 + 0x103: "EXC_ARM_SP_ALIGN", // 259 + 0x104: "EXC_ARM_SWP", // 260 + 0x105: "EXC_ARM_PAC_FAIL", // 261 + ] + + private static let exceptionCodeNameMappings: [Int64: [Int64: String]] = [ + 1: kernelReturnCodeNames, // EXC_BAD_ACCESS + 2: badInstructionCodeNames, // EXC_BAD_INSTRUCTION + 3: arithmeticCodeNames, // EXC_ARITHMETIC + 6: breakpointCodeNames, // EXC_BREAKPOINT + ] + + private static func machExceptionMessage(_ exception: PLCrashReportMachExceptionInfo) -> String { + let typeName = machExceptionName(exception.type) + + guard let codesArray = exception.codes as? [NSNumber], !codesArray.isEmpty else { + return typeName + } + + let code = codesArray[0].int64Value + let subcode = codesArray.count > 1 ? codesArray[1].int64Value : nil + + // Format code with name if available (exception-type-specific) + let codeStr: String + if let codeNames = exceptionCodeNameMappings[Int64(exception.type)], + let codeName = codeNames[code] + { + codeStr = "\(codeName) (\(code))" + } else { + codeStr = String(code) + } + + // Format subcode as hex address if present + if let subcode = subcode { + let subcodeHex = String(format: hexAddressPaddedFormat, UInt64(bitPattern: subcode)) + return "\(typeName), Code \(codeStr), Subcode \(subcodeHex)" + } else { + return "\(typeName), Code \(codeStr)" + } + } + + private static func signalMessage(_ signal: PLCrashReportSignalInfo) -> String? { + guard let name = signal.name, let code = signal.code else { + return nil + } + + let address = String(format: PostHogStackFrame.hexAddressFormat, signal.address) + return "\(name) (code \(code)) at address \(address)" + } + } +#endif diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift new file mode 100644 index 000000000..3dc5de6d0 --- /dev/null +++ b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift @@ -0,0 +1,186 @@ +// +// PostHogErrorTrackingAutoCaptureIntegration.swift +// PostHog +// +// Created by Ioannis Josephides on 14/12/2025. +// + +import Foundation + +#if os(iOS) || os(macOS) || os(tvOS) + import CrashReporter + + class PostHogErrorTrackingAutoCaptureIntegration: PostHogIntegration { + private static let integrationInstalledLock = NSLock() + private static var integrationInstalled = false + + var requiresSwizzling: Bool { false } + + private weak var postHog: PostHogSDK? + private var crashReporter: PLCrashReporter? + + func install(_ postHog: PostHogSDK) throws { + try PostHogErrorTrackingAutoCaptureIntegration.integrationInstalledLock.withLock { + if PostHogErrorTrackingAutoCaptureIntegration.integrationInstalled { + throw InternalPostHogError(description: "Crash report integration already installed to another PostHogSDK instance.") + } + PostHogErrorTrackingAutoCaptureIntegration.integrationInstalled = true + } + + self.postHog = postHog + if let crashReporter = setupCrashReporter() { + self.crashReporter = crashReporter + // Note: Order here matters, we need to process any pending crash report before enabling the crash reporter + processPendingCrashReportIfNeeded(reporter: crashReporter) + enableCrashReporter(reporter: crashReporter) + } + } + + func uninstall(_ postHog: PostHogSDK) { + if self.postHog === postHog || self.postHog == nil { + stop() + crashReporter = nil + self.postHog = nil + PostHogErrorTrackingAutoCaptureIntegration.integrationInstalledLock.withLock { + PostHogErrorTrackingAutoCaptureIntegration.integrationInstalled = false + } + } + } + + func start() { + // No-op for crash reporting. Always active once installed + } + + func stop() { + // No-op for crash reporting. Always active once installed + } + + func contextDidChange(_ context: [String: Any]) { + guard let crashReporter else { return } + + // Serialize context to JSON and set as customData + do { + let jsonData = try JSONSerialization.data(withJSONObject: context, options: []) + crashReporter.customData = jsonData + } catch { + hedgeLog("Failed to serialize crash context: \(error)") + } + } + + // MARK: - Private Methods + + private func setupCrashReporter() -> PLCrashReporter? { + // Configure PLCrashReporter + // Note: Mach exception handling is not available on tvOS, so we fall back to BSD signal handlers + #if os(tvOS) + let signalHandlerType: PLCrashReporterSignalHandlerType = .BSD + #else + let signalHandlerType: PLCrashReporterSignalHandlerType = .mach + #endif + + let config = PLCrashReporterConfig( + signalHandlerType: signalHandlerType, + symbolicationStrategy: [], // No local symbolication, we'll be doing server-side + shouldRegisterUncaughtExceptionHandler: true + ) + + guard let reporter = PLCrashReporter(configuration: config) else { + hedgeLog("Failed to create PLCrashReporter instance") + return nil + } + + return reporter + } + + private func processPendingCrashReportIfNeeded(reporter: PLCrashReporter) { + // Check for pending crash report FIRST (before enabling for new crashes) + if reporter.hasPendingCrashReport() { + hedgeLog("Found pending crash report, processing...") + processPendingCrashReport() + } + } + + private func enableCrashReporter(reporter: PLCrashReporter) { + // Check for debugger first. Crash handler won't work when debugging + if PostHogDebugUtils.isDebuggerAttached() { + hedgeLog("Crash handler is disabled because a debugger is attached. Crashes will be caught by the debugger instead.") + return + } + + // Enable crash reporter for this session + do { + try reporter.enableAndReturnError() + hedgeLog("PLCrashReporter enabled successfully") + } catch { + hedgeLog("Failed to enable PLCrashReporter: \(error)") + } + } + + private func processPendingCrashReport() { + guard let crashReporter, let postHog else { + return + } + + do { + let crashData = try crashReporter.loadPendingCrashReportDataAndReturnError() + let crashReport = try PLCrashReport(data: crashData) + + // Extract saved context from crash report's customData + var savedContext: [String: Any] = [:] + if let customData = crashReport.customData { + savedContext = (try? JSONSerialization.jsonObject(with: customData, options: [])) as? [String: Any] ?? [:] + } + + // Extract identity and event properties from saved context + let crashDistinctId = savedContext["distinct_id"] as? String + let crashEventProperties = savedContext["event_properties"] as? [String: Any] ?? [:] + + // Collect crash-specific event properties (stack traces, exceptions etc) + let exceptionProperties = PostHogCrashReportProcessor.processReport(crashReport, config: postHog.config.errorTrackingConfig) + + // Merge: crash-time event properties as base, exception properties on top + let finalProperties = crashEventProperties.merging(exceptionProperties) { _, new in new } + + // Collect crash timestamp + let crashTimestamp = PostHogCrashReportProcessor.getCrashTimestamp(crashReport) + + // Capture using internal method and bypass buildProperties + postHog.captureInternal( + "$exception", + distinctId: crashDistinctId, + properties: finalProperties, + timestamp: crashTimestamp, + skipBuildProperties: true + ) + + hedgeLog("Crash report processed and captured") + } catch { + // Best effort for now. + // We log and ignore and let the crash report be purged. + // - On a new crash, old report will be overwritten anyway + // - Keeping the report around could risk infinite retry loop until next crash if it's corrupt + // + // Note: This could fail because of a transient error though, in the future we could check the returned error + // and only purge if PLCrashReporterErrorCrashReportInvalid, then keep the report around for max X retries + hedgeLog("Failed to process crash report: \(error)") + } + + // Always purge the crash report after processing + crashReporter.purgePendingCrashReport() + } + } + +#else + // watchOS stub - crash reporting is not available + class PostHogErrorTrackingAutoCaptureIntegration: PostHogIntegration { + var requiresSwizzling: Bool { false } + + func install(_: PostHogSDK) throws { + hedgeLog("Crash reporting is only available on iOS, macOS and tvOS") + } + + func uninstall(_: PostHogSDK) { /* no-op */ } + func start() { /* no-op */ } + func stop() { /* no-op */ } + } +#endif diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift b/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift index e97bf1b2b..414326505 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift @@ -12,6 +12,30 @@ import Foundation /// This class controls how exceptions are captured and processed, /// including which stack trace frames are marked as "in-app" code. @objc public class PostHogErrorTrackingConfig: NSObject { + // MARK: - Crash Reporting + + /// Enable crash autocapture + /// + /// When enabled, the SDK will capture the following crash types: + /// - Mach exceptions (e.g., `EXC_BAD_ACCESS`, `EXC_CRASH`) + /// - POSIX signals (e.g., `SIGSEGV`, `SIGABRT`, `SIGBUS`) + /// - Uncaught `NSException`s + /// + /// Crashes are persisted to disk and sent as `$exception` events with level "fatal" **on the next app launch** + /// + /// - Note: Crash reporting is automatically disabled when a debugger is attached, + /// as the debugger intercepts signals before the crash handler can process them. + /// + /// Default: false + private var _autoCapture: Bool = false + + @available(watchOS, unavailable, message: "Crash autocapture is not available on watchOS") + @available(visionOS, unavailable, message: "Crash autocapture is not available on visionOS") + @objc public var autoCapture: Bool { + get { _autoCapture } + set { _autoCapture = newValue } + } + // MARK: - In-App Detection Configuration /// List of package/bundle identifiers to be considered in-app frames diff --git a/PostHog/ErrorTracking/PostHogExceptionProcessor.swift b/PostHog/ErrorTracking/PostHogExceptionProcessor.swift index 459625474..bb8f9a7c9 100644 --- a/PostHog/ErrorTracking/PostHogExceptionProcessor.swift +++ b/PostHog/ErrorTracking/PostHogExceptionProcessor.swift @@ -32,7 +32,7 @@ enum PostHogExceptionProcessor { ) -> [String: Any] { var properties: [String: Any] = [:] - properties["$exception_level"] = "error" // TODO: figure if error or fatal based on wrapper error type when + properties["$exception_level"] = "error" let exceptions = buildExceptionList( from: error, @@ -78,6 +78,43 @@ 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, + mechanismType: String = "generic", + 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": mechanismType, + "handled": true, + "synthetic": true, // always true for message exceptions - we capture current stack + ] + + if let stacktrace = buildStacktrace(config: config) { + exception["stacktrace"] = stacktrace + } + + let exceptions = [exception] + attachExceptionsAndDebugImages(exceptions, to: &properties) + + return properties + } + // MARK: - Internal Exception Building /// Build list of exceptions from NSException chain diff --git a/PostHog/ErrorTracking/Utils/PostHogDebugImageProvider.swift b/PostHog/ErrorTracking/Utils/PostHogDebugImageProvider.swift index e3922864a..4165e2af3 100644 --- a/PostHog/ErrorTracking/Utils/PostHogDebugImageProvider.swift +++ b/PostHog/ErrorTracking/Utils/PostHogDebugImageProvider.swift @@ -15,6 +15,8 @@ import MachO /// - Text segment address and size (for address range calculation) /// - Load addresses (for offset calculation) /// +/// Note: Now that we have a PLCrashReporter integration, we could generate a live crash report using their API +/// and extract the debug images from there, to be consistent with crash reporting. Will keep this for now enum PostHogDebugImageProvider { private static let segmentText = "__TEXT" diff --git a/PostHog/ErrorTracking/Utils/PostHogErrorTrackingUtils.swift b/PostHog/ErrorTracking/Utils/PostHogErrorTrackingUtils.swift new file mode 100644 index 000000000..d03498284 --- /dev/null +++ b/PostHog/ErrorTracking/Utils/PostHogErrorTrackingUtils.swift @@ -0,0 +1,77 @@ +// +// PostHogErrorTrackingUtils.swift +// PostHog +// +// Created by Ioannis Josephides on 16/12/2025. +// + +import Foundation + +// MARK: - UUID Formatting + +extension String { + /// Formats a UUID string to the standard hyphenated format + /// Input can be with or without hyphens, output is always: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + var formattedAsUUID: String { + let clean = replacingOccurrences(of: "-", with: "").uppercased() + guard clean.count == 32 else { return self } + + let idx = clean.startIndex + let part1 = clean[idx ..< clean.index(idx, offsetBy: 8)] + let part2 = clean[clean.index(idx, offsetBy: 8) ..< clean.index(idx, offsetBy: 12)] + let part3 = clean[clean.index(idx, offsetBy: 12) ..< clean.index(idx, offsetBy: 16)] + let part4 = clean[clean.index(idx, offsetBy: 16) ..< clean.index(idx, offsetBy: 20)] + let part5 = clean[clean.index(idx, offsetBy: 20) ..< clean.index(idx, offsetBy: 32)] + + return "\(part1)-\(part2)-\(part3)-\(part4)-\(part5)" + } +} + +// MARK: - CPU Architecture Helpers + +enum PostHogCPUArchitecture { + /// Convert CPU type and subtype to architecture string + /// + /// - Parameters: + /// - cpuType: Mach-O CPU type + /// - cpuSubtype: Mach-O CPU subtype + /// - Returns: Architecture string (e.g., "arm64", "x86_64") or nil if unknown + static func archName(cpuType: UInt64, cpuSubtype: UInt64) -> String? { + // CPU_TYPE_ARM64 = 0x0100000C (16777228) + // CPU_TYPE_X86_64 = 0x01000007 (16777223) + // CPU_TYPE_ARM = 12 + + switch cpuType { + case 0x0100_000C: // CPU_TYPE_ARM64 + return "arm64" + case 0x0100_0007: // CPU_TYPE_X86_64 + return "x86_64" + case 12: // CPU_TYPE_ARM + switch cpuSubtype { + case 9: return "armv7" + case 11: return "armv7s" + default: return "arm" + } + default: + return nil + } + } +} + +// MARK: - Debug Utilities + +enum PostHogDebugUtils { + /// Check if the current process is being traced by a debugger. + /// Based on https://gist.github.com/dermotos/fde82d3eb617f5085b22893166519d51 + static func isDebuggerAttached() -> Bool { + var info = kinfo_proc() + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + var size = MemoryLayout.stride + let junk = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) + guard junk == 0 else { + hedgeLog("Failed to check for debugger. sysctl failed with error code: \(junk)") + return false + } + return (info.kp_proc.p_flag & P_TRACED) != 0 + } +} diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index f8ec710d0..3097f8165 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -215,6 +215,12 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? func getIntegrations() -> [PostHogIntegration] { var integrations: [PostHogIntegration] = [] + #if os(iOS) || os(macOS) || os(tvOS) + if errorTrackingConfig.autoCapture { + integrations.append(PostHogErrorTrackingAutoCaptureIntegration()) + } + #endif + if captureScreenViews { integrations.append(PostHogScreenViewIntegration()) } diff --git a/PostHog/PostHogIntegration.swift b/PostHog/PostHogIntegration.swift index 85c4ae42d..703c80f6e 100644 --- a/PostHog/PostHogIntegration.swift +++ b/PostHog/PostHogIntegration.swift @@ -56,4 +56,21 @@ protocol PostHogIntegration { * while maintaining its installation status (e.g manual start/stop for session recording) */ func stop() + + /** + * Called when the event context changes (e.g., after identify, reset, group, register). + * + * Integrations can use this to react to context changes. For example, the crash reporting + * integration persists this context to disk for crash-time capture. + * + * - Parameter context: The current event context dictionary containing static context, + * dynamic context, identity info (distinct_id, groups), session_id, and registered properties. + */ + func contextDidChange(_ context: [String: Any]) +} + +extension PostHogIntegration { + func contextDidChange(_: [String: Any]) { + // Default empty implementation since most integrations won't need this + } } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index e442d2b7b..ec2cf0e3c 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -21,6 +21,7 @@ let retryDelay = 5.0 let maxRetryDelay = 30.0 // renamed to PostHogSDK due to https://github.com/apple/swift/issues/56573 +// swiftlint:disable:next type_body_length <- Should be removed once PostHogSDK is refactored @objc public class PostHogSDK: NSObject { private(set) var config: PostHogConfig @@ -49,6 +50,7 @@ let maxRetryDelay = 30.0 private static var apiKeys = Set() private var installedIntegrations: [PostHogIntegration] = [] let sessionManager = PostHogSessionManager() + private var sessionIdChangedToken: RegistrationToken? #if os(iOS) private weak var replayIntegration: PostHogReplayIntegration? @@ -73,6 +75,7 @@ let maxRetryDelay = 30.0 self.reachability?.stopNotifier() #endif + sessionIdChangedToken = nil uninstallIntegrations() } @@ -142,10 +145,17 @@ let maxRetryDelay = 30.0 // Create session manager instance for this PostHogSDK instance sessionManager.setup(config: config) sessionManager.startSession() + // Listen for session changes to update crash context + sessionIdChangedToken = sessionManager.onSessionIdChanged { [weak self] in + self?.notifyContextDidChange() + } if !config.optOut { // don't install integrations if in opt-out state installIntegrations() + + // Notify integrations of initial context (e.g., for crash reporting) + notifyContextDidChange() } DispatchQueue.main.async { @@ -364,6 +374,9 @@ let maxRetryDelay = 30.0 // reload flags as anon user remoteConfig?.reloadFeatureFlags() + + // Notify integrations of context change (e.g., for crash reporting) + notifyContextDidChange() } private func getGroups() -> [String: String] { @@ -397,6 +410,9 @@ let maxRetryDelay = 30.0 let mergedProps = props.merging(sanitizedProps!) { _, new in new } storage?.setDictionary(forKey: .registerProperties, contents: mergedProps) } + + // Notify integrations of context change (e.g., for crash reporting) + notifyContextDidChange() } @objc(unregisterProperties:) @@ -410,6 +426,9 @@ let maxRetryDelay = 30.0 props.removeValue(forKey: key) storage?.setDictionary(forKey: .registerProperties, contents: props) } + + // Notify integrations of context change (e.g., for crash reporting) + notifyContextDidChange() } @objc public func identify(_ distinctId: String) { @@ -484,6 +503,9 @@ let maxRetryDelay = 30.0 remoteConfig?.reloadFeatureFlags() + // Notify integrations of context change (e.g., for crash reporting) + notifyContextDidChange() + // we need to make sure the user props update is for the same user // otherwise they have to reset and identify again } else if !hasDifferentDistinctId, !(userProperties?.isEmpty ?? true) || !(userPropertiesSetOnce?.isEmpty ?? true) { @@ -749,6 +771,33 @@ let maxRetryDelay = 30.0 groups: [String: String]? = nil, timestamp: Date? = nil) { + captureInternal( + event, + distinctId: distinctId, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + groups: groups, + timestamp: timestamp, + skipBuildProperties: false + ) + } + + /// Internal capture method that handles all event capture logic. + /// + /// - Parameters: + /// - skipBuildProperties: When true, skips buildProperties call and uses properties as-is. + /// Used by crash reporting to capture events with pre-built crash-time context. + func captureInternal( + _ event: String, + distinctId: String? = nil, + properties: [String: Any]? = nil, + userProperties: [String: Any]? = nil, + userPropertiesSetOnce: [String: Any]? = nil, + groups: [String: String]? = nil, + timestamp: Date? = nil, + skipBuildProperties: Bool = false + ) { if !isEnabled() { return } @@ -767,23 +816,35 @@ let maxRetryDelay = 30.0 // if the user isn't identified but passed userProperties, userPropertiesSetOnce or groups, // we should still enable person processing since this is intentional - if userProperties?.isEmpty == false || userPropertiesSetOnce?.isEmpty == false || groups?.isEmpty == false { + let hasPersonData = userProperties?.isEmpty == false + || userPropertiesSetOnce?.isEmpty == false + || groups?.isEmpty == false + + if !skipBuildProperties, hasPersonData { requirePersonProcessing() } - let properties = buildProperties(distinctId: eventDistinctId, - properties: sanitizeDictionary(properties), - userProperties: sanitizeDictionary(userProperties), - userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce), - groups: groups, - appendSharedProps: !isSnapshotEvent, - timestamp: timestamp) + let finalProperties: [String: Any] + if skipBuildProperties { + // Use properties as-is (already built at crash time) + finalProperties = properties ?? [:] + } else { + finalProperties = buildProperties( + distinctId: eventDistinctId, + properties: sanitizeDictionary(properties), + userProperties: sanitizeDictionary(userProperties), + userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce), + groups: groups, + appendSharedProps: !isSnapshotEvent, + timestamp: timestamp + ) + } // Sanitize is now called in buildEvent let posthogEvent = buildEvent( event: event, distinctId: eventDistinctId, - properties: properties, + properties: finalProperties, timestamp: eventTimestamp ) @@ -805,7 +866,9 @@ let maxRetryDelay = 30.0 targetQueue?.add(posthogEvent) // Automatically set person properties for feature flags during capture event - setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce: userPropertiesSetOnce) + if !skipBuildProperties { + setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce: userPropertiesSetOnce) + } #if os(iOS) surveysIntegration?.onEvent(event: posthogEvent.event) @@ -1026,6 +1089,9 @@ let maxRetryDelay = 30.0 _ = groups([type: key]) groupIdentify(type: type, key: key, groupProperties: sanitizeDictionary(groupProperties)) + + // Notify integrations of context change (e.g., for crash reporting) + notifyContextDidChange() } // FEATURE FLAGS @@ -1668,6 +1734,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.") @@ -1737,6 +1836,38 @@ let maxRetryDelay = 30.0 surveysIntegration = nil #endif } + + /// Notifies all installed integrations that the event context has changed. + /// + /// This is called after operations that modify the context (identify, reset, group, register). + /// Integrations like crash reporting use this to persist context for crash-time capture. + private func notifyContextDidChange() { + guard isEnabled() else { return } + + let distinctId = getDistinctId() + + // Build complete event properties snapshot + let eventProperties = buildProperties( + distinctId: distinctId, + properties: nil, + userProperties: nil, + userPropertiesSetOnce: nil, + groups: nil, + appendSharedProps: true, + timestamp: nil + ) + + // Build crash context with identity info + event properties + // This structure allows crash reporting to reconstruct events with crash-time data + let context: [String: Any] = [ + "distinct_id": distinctId, + "event_properties": eventProperties, + ] + + for integration in installedIntegrations { + integration.contextDidChange(context) + } + } } #if TESTING diff --git a/PostHog/PostHogSessionManager.swift b/PostHog/PostHogSessionManager.swift index 07de4bc4d..f0a2da198 100644 --- a/PostHog/PostHogSessionManager.swift +++ b/PostHog/PostHogSessionManager.swift @@ -56,8 +56,33 @@ import Foundation private let sessionActivityThreshold: TimeInterval = 60 * 30 // 24 hours in seconds private let sessionMaxLengthThreshold: TimeInterval = 24 * 60 * 60 - // Called when session id is cleared or changes - var onSessionIdChanged: () -> Void = {} + // Callbacks when session id is cleared or changes + private var sessionIdChangedCallbacks: [UUID: () -> Void] = [:] + private let callbacksLock = NSLock() + + /// Register a callback for session ID changes + /// - Parameter callback: Closure to call when session ID changes + /// - Returns: A RegistrationToken that removes the callback when deallocated + func onSessionIdChanged(_ callback: @escaping () -> Void) -> RegistrationToken { + let id = UUID() + callbacksLock.withLock { + sessionIdChangedCallbacks[id] = callback + } + + return RegistrationToken { [weak self] in + guard let self else { return } + self.callbacksLock.withLock { + _ = self.sessionIdChangedCallbacks.removeValue(forKey: id) + } + } + } + + private func notifySessionIdChanged() { + let callbacks = callbacksLock.withLock { Array(sessionIdChangedCallbacks.values) } + for callback in callbacks { + callback() + } + } @objc public func setSessionId(_ sessionId: String) { setSessionIdInternal(sessionId, at: now(), reason: .customSessionId) @@ -214,7 +239,7 @@ import Foundation self.sessionActivityTimestamp = timestamp } - onSessionIdChanged() + notifySessionIdChanged() if let sessionId { hedgeLog("New session id created \(sessionId) (\(reason))") diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index f7fa69978..8fd02efca 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -33,6 +33,7 @@ private var applicationBackgroundedToken: RegistrationToken? private var applicationForegroundedToken: RegistrationToken? private var viewLayoutToken: RegistrationToken? + private var sessionIdChangedToken: RegistrationToken? private var installedPlugins: [PostHogSessionReplayPlugin] = [] /** @@ -156,7 +157,7 @@ isEnabled = true // reset views when session id changes (or is cleared) so we can re-send new metadata (or full snapshot in the future) - postHog.sessionManager.onSessionIdChanged = { [weak self] in + sessionIdChangedToken = postHog.sessionManager.onSessionIdChanged { [weak self] in self?.resetViews() } @@ -199,7 +200,7 @@ guard isEnabled else { return } isEnabled = false resetViews() - postHog?.sessionManager.onSessionIdChanged = {} + sessionIdChangedToken = nil // stop listening to `UIApplication.sendEvent` applicationEventToken = nil diff --git a/PostHogExample/AppDelegate.swift b/PostHogExample/AppDelegate.swift index dc9cd83db..0d29ca577 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,15 @@ class AppDelegate: NSObject, UIApplicationDelegate { // config.flushAt = 1 // config.flushIntervalSeconds = 30 config.debug = true + config.flushAt = 1 config.sendFeatureFlagEvent = false + #if os(iOS) || os(macOS) || os(tvOS) + config.errorTrackingConfig.autoCapture = false + #endif + #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 de233eb84..a52c3743d 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -120,7 +120,7 @@ struct ContentView: View { /// Creates a multi-level async call chain to test stack trace capture func captureAsyncError() async { do { - await try asyncLevel1() + try await asyncLevel1() } catch { PostHogSDK.shared.captureException(error, properties: [ "is_test": true, @@ -301,6 +301,29 @@ struct ContentView: View { } } + Section("Crash Triggers") { + // NSException - richest info with name + reason + Button("Uncaught NSException") { + ExceptionHandler.triggerUncaughtNSException() + } + // SIGTRAP - message not captured (see PostHogCrashReportProcessor for details) + Button("fatalError()") { + SwiftCrashTriggers.triggerFatalError() + } + // SIGTRAP - Swift runtime trap on nil unwrap + Button("Force unwrap nil") { + SwiftCrashTriggers.triggerForceUnwrapNil() + } + // EXC_BAD_ACCESS - null pointer dereference + Button("Null Pointer") { + ExceptionHandler.triggerNullPointerCrash() + } + // SIGABRT - explicit abort() call + Button("Abort") { + ExceptionHandler.triggerAbortCrash() + } + } + Section("Error tracking") { Button("Capture Swift Enum Error (with associated value)") { do { @@ -378,6 +401,13 @@ struct ContentView: View { } } + Button("Trigger with Message") { + PostHogSDK.shared.captureException("Unexpected state detected", properties: [ + "is_test": true, + "app_state": "some_state", + ]) + } + Button("Capture Async/Await Error") { Task { await captureAsyncError() diff --git a/PostHogExample/ExceptionHandler.h b/PostHogExample/ExceptionHandler.h index fb2520275..445a9c089 100644 --- a/PostHogExample/ExceptionHandler.h +++ b/PostHogExample/ExceptionHandler.h @@ -25,7 +25,7 @@ NS_ASSUME_NONNULL_BEGIN catch:(void(^)(NSException *exception))catchBlock finally:(nullable void(^)(void))finallyBlock; -/// Trigger a sample NSRangeException for testing purposes +/// Trigger a sample NSRangeException for testing purposes + (void)triggerSampleRangeException; /// Trigger a sample NSInvalidArgumentException for testing purposes @@ -38,6 +38,16 @@ NS_ASSUME_NONNULL_BEGIN /// This demonstrates how exceptions can be caught and rethrown with additional context + (void)triggerChainedException; +// MARK: - Crash Triggers for Testing + +/// Trigger an uncaught NSException ++ (void)triggerUncaughtNSException; + +/// Trigger a null pointer dereference (EXC_BAD_ACCESS) ++ (void)triggerNullPointerCrash; + +/// Trigger an abort signal (SIGABRT) ++ (void)triggerAbortCrash; @end diff --git a/PostHogExample/ExceptionHandler.m b/PostHogExample/ExceptionHandler.m index f96a46c81..371fb405a 100644 --- a/PostHogExample/ExceptionHandler.m +++ b/PostHogExample/ExceptionHandler.m @@ -116,5 +116,24 @@ + (void)establishNetworkConnection { }]; } +// MARK: - Crash Triggers for Testing + ++ (void)triggerUncaughtNSException { + @throw [NSException exceptionWithName:@"UncaughtTestException" + reason:@"This is an intentionally uncaught exception for crash testing" + userInfo:@{ + @"test_type": @"uncaught_exception", + @"timestamp": [NSDate date] + }]; +} + ++ (void)triggerNullPointerCrash { + int *nullPointer = NULL; + *nullPointer = 42; +} + ++ (void)triggerAbortCrash { + abort(); +} @end diff --git a/PostHogExample/SwiftCrashTriggers.swift b/PostHogExample/SwiftCrashTriggers.swift new file mode 100644 index 000000000..a99f23264 --- /dev/null +++ b/PostHogExample/SwiftCrashTriggers.swift @@ -0,0 +1,54 @@ +// +// SwiftCrashTriggers.swift +// PostHogExample +// +// Swift-native crash triggers for testing crash reporting +// + +import Foundation + +/// Swift crash triggers with nested call stacks for testing stack trace capture +enum SwiftCrashTriggers { + // MARK: - Public API + + static func triggerFatalError() { + OuterLayer.processFatalError() + } + + static func triggerForceUnwrapNil() { + OuterLayer.processForceUnwrapNil() + } + + // MARK: - Nested Layers for Deeper Stack Traces + + private enum OuterLayer { + static func processFatalError() { + MiddleLayer.handleFatalError() + } + + static func processForceUnwrapNil() { + MiddleLayer.handleForceUnwrapNil() + } + } + + private enum MiddleLayer { + static func handleFatalError() { + InnerLayer.executeFatalError() + } + + static func handleForceUnwrapNil() { + InnerLayer.executeForceUnwrapNil() + } + } + + private enum InnerLayer { + static func executeFatalError() { + fatalError("Intentional fatalError for crash testing") + } + + static func executeForceUnwrapNil() { + let nilValue: String? = nil + _ = nilValue! + } + } +} diff --git a/PostHogTests/PostHogCrashReportProcessorTest.swift b/PostHogTests/PostHogCrashReportProcessorTest.swift new file mode 100644 index 000000000..22e0c38b1 --- /dev/null +++ b/PostHogTests/PostHogCrashReportProcessorTest.swift @@ -0,0 +1,297 @@ +// +// PostHogCrashReportProcessorTest.swift +// PostHogTests +// +// Created by Ioannis Josephides on 22/12/2025. +// + +import Foundation +@testable import PostHog +import Testing + +#if os(iOS) || os(macOS) || os(tvOS) + import CrashReporter + + @Suite("PostHogCrashReportProcessor Tests") + struct PostHogCrashReportProcessorTest { + // MARK: - Live Report Tests + + @Suite("Process Live Report") + struct ProcessLiveReportTests { + let config = PostHogErrorTrackingConfig() + + @Test("processes live crash report") + func processesLiveCrashReport() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + #expect(properties["$exception_level"] as? String == "fatal") + #expect(properties["$exception_list"] != nil) + } + + @Test("live report contains exception list") + func liveReportContainsExceptionList() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList != nil) + #expect(exceptionList!.count > 0) + } + + @Test("live report exception has type and mechanism") + func liveReportExceptionHasTypeAndMechanism() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exception = exceptionList?.first + + #expect(exception?["type"] != nil) + + let mechanism = exception?["mechanism"] as? [String: Any] + #expect(mechanism != nil) + #expect(mechanism?["handled"] as? Bool == false) + #expect(mechanism?["synthetic"] as? Bool == false) + } + + @Test("live report contains stack trace") + func liveReportContainsStackTrace() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exception = exceptionList?.first + let stacktrace = exception?["stacktrace"] as? [String: Any] + + #expect(stacktrace != nil) + #expect(stacktrace?["type"] as? String == "raw") + + let frames = stacktrace?["frames"] as? [[String: Any]] + #expect(frames != nil) + #expect(frames!.count > 0) + } + + @Test("live report frames have instruction addresses") + func liveReportFramesHaveInstructionAddresses() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + + for frame in frames ?? [] { + let addr = frame["instruction_addr"] as? String + #expect(addr != nil) + #expect(addr?.hasPrefix("0x") == true) + } + } + + @Test("live report contains debug images") + func liveReportContainsDebugImages() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let debugImages = properties["$debug_images"] as? [[String: Any]] + #expect(debugImages != nil) + #expect(debugImages!.count > 0) + } + + @Test("debug images have required fields") + func debugImagesHaveRequiredFields() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let debugImages = properties["$debug_images"] as? [[String: Any]] + let image = debugImages?.first + + #expect(image?["type"] as? String == "macho") + #expect(image?["code_file"] as? String != nil) + #expect(image?["image_addr"] as? String != nil) + #expect(image?["image_size"] as? UInt64 != nil) + } + + @Test("live report has crash report ID") + func liveReportHasCrashReportId() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let crashReportId = properties["$crash_report_id"] as? String + #expect(crashReportId != nil) + #expect(crashReportId!.count > 0) + } + } + + // MARK: - Crash Timestamp Tests + + @Suite("Crash Timestamp") + struct CrashTimestampTests { + @Test("extracts crash timestamp from report") + func extractsCrashTimestamp() throws { + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let timestamp = PostHogCrashReportProcessor.getCrashTimestamp(report) + + #expect(timestamp != nil) + #expect(timestamp!.timeIntervalSinceNow < 60) + #expect(timestamp!.timeIntervalSinceNow > -60) + } + } + + // MARK: - In-App Detection Tests + + @Suite("In-App Detection") + struct InAppDetectionTests { + @Test("marks frames as in-app based on config") + func marksFramesAsInApp() throws { + let config = PostHogErrorTrackingConfig() + config.inAppIncludes = ["xctest"] + + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + + let inAppFrames = frames?.filter { $0["in_app"] as? Bool == true } + #expect(inAppFrames != nil) + } + + @Test("marks system frames as not in-app") + func marksSystemFramesAsNotInApp() throws { + let config = PostHogErrorTrackingConfig() + + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + + let systemFrames = frames?.filter { frame in + let module = frame["module"] as? String ?? "" + return module.hasPrefix("libsystem") || module == "Foundation" + } + + for frame in systemFrames ?? [] { + #expect(frame["in_app"] as? Bool == false) + } + } + } + + // MARK: - Thread ID Tests + + @Suite("Thread ID") + struct ThreadIDTests { + @Test("exception has thread ID") + func exceptionHasThreadId() throws { + let config = PostHogErrorTrackingConfig() + + let reporter = PLCrashReporter(configuration: PLCrashReporterConfig.defaultConfiguration()) + guard let reporter else { + Issue.record("Failed to create PLCrashReporter") + return + } + + let reportData = try reporter.generateLiveReportAndReturnError() + let report = try PLCrashReport(data: reportData) + + let properties = PostHogCrashReportProcessor.processReport(report, config: config) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exception = exceptionList?.first + + #expect(exception?["thread_id"] != nil) + } + } + } +#endif diff --git a/PostHogTests/PostHogDebugImageProviderTest.swift b/PostHogTests/PostHogDebugImageProviderTest.swift new file mode 100644 index 000000000..41a9785a1 --- /dev/null +++ b/PostHogTests/PostHogDebugImageProviderTest.swift @@ -0,0 +1,263 @@ +// +// PostHogDebugImageProviderTest.swift +// PostHogTests +// +// Created by Ioannis Josephides on 22/12/2025. +// + +import Foundation +@testable import PostHog +import Testing + +@Suite("PostHogDebugImageProvider Tests") +struct PostHogDebugImageProviderTest { + // MARK: - Get All Binary Images Tests + + @Suite("Get All Binary Images") + struct GetAllBinaryImagesTests { + @Test("returns non-empty list of binary images") + func returnsNonEmptyList() { + let images = PostHogDebugImageProvider.getAllBinaryImages() + + #expect(images.count > 0) + } + + @Test("includes main executable") + func includesMainExecutable() { + let images = PostHogDebugImageProvider.getAllBinaryImages() + + let hasExecutable = images.contains { image in + image.name.contains("xctest") || image.name.contains("PostHog") + } + + #expect(hasExecutable == true) + } + + @Test("images have valid addresses") + func imagesHaveValidAddresses() { + let images = PostHogDebugImageProvider.getAllBinaryImages() + + for image in images { + #expect(image.address > 0) + #expect(image.size > 0) + } + } + + @Test("images have UUIDs") + func imagesHaveUUIDs() { + let images = PostHogDebugImageProvider.getAllBinaryImages() + + let imagesWithUUID = images.filter { $0.uuid != nil } + #expect(imagesWithUUID.count > 0) + } + + @Test("UUIDs are in correct format") + func uuidsAreInCorrectFormat() { + let images = PostHogDebugImageProvider.getAllBinaryImages() + + for image in images where image.uuid != nil { + let uuid = image.uuid! + let uuidPattern = #"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$"# + let regex = try? NSRegularExpression(pattern: uuidPattern) + let range = NSRange(uuid.startIndex..., in: uuid) + #expect(regex?.firstMatch(in: uuid, range: range) != nil) + } + } + } + + // MARK: - Get Debug Images for Frames Tests + + @Suite("Get Debug Images for Frames") + struct GetDebugImagesForFramesTests { + @Test("returns debug images for valid frame addresses") + func returnsDebugImagesForValidAddresses() { + let allImages = PostHogDebugImageProvider.getAllBinaryImages() + guard let firstImage = allImages.first else { + Issue.record("No binary images found") + return + } + + let frames: [[String: Any]] = [ + ["image_addr": String(format: "0x%llx", firstImage.address)], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(for: frames) + + #expect(debugImages.count >= 1) + } + + @Test("returns empty array for invalid addresses") + func returnsEmptyForInvalidAddresses() { + let frames: [[String: Any]] = [ + ["image_addr": "0x0"], + ["image_addr": "0xdeadbeef"], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(for: frames) + + #expect(debugImages.count == 0) + } + + @Test("returns empty array for empty frames") + func returnsEmptyForEmptyFrames() { + let frames: [[String: Any]] = [] + + let debugImages = PostHogDebugImageProvider.getDebugImages(for: frames) + + #expect(debugImages.count == 0) + } + + @Test("deduplicates images by address") + func deduplicatesImagesByAddress() { + let allImages = PostHogDebugImageProvider.getAllBinaryImages() + guard let firstImage = allImages.first else { + Issue.record("No binary images found") + return + } + + let address = String(format: "0x%llx", firstImage.address) + let frames: [[String: Any]] = [ + ["image_addr": address], + ["image_addr": address], + ["image_addr": address], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(for: frames) + + #expect(debugImages.count == 1) + } + } + + // MARK: - Get Debug Images from Exceptions Tests + + @Suite("Get Debug Images from Exceptions") + struct GetDebugImagesFromExceptionsTests { + @Test("extracts debug images from exception list") + func extractsDebugImagesFromExceptionList() { + let allImages = PostHogDebugImageProvider.getAllBinaryImages() + guard let firstImage = allImages.first else { + Issue.record("No binary images found") + return + } + + let exceptions: [[String: Any]] = [ + [ + "type": "TestException", + "stacktrace": [ + "type": "raw", + "frames": [ + ["image_addr": String(format: "0x%llx", firstImage.address)], + ], + ] as [String: Any], + ], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + + #expect(debugImages.count == 1) + } + + @Test("handles exceptions without stacktrace") + func handlesExceptionsWithoutStacktrace() { + let exceptions: [[String: Any]] = [ + ["type": "TestException", "value": "No stacktrace"], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + + #expect(debugImages.count == 0) + } + + @Test("handles empty exception list") + func handlesEmptyExceptionList() { + let exceptions: [[String: Any]] = [] + + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + + #expect(debugImages.count == 0) + } + + @Test("collects images from multiple exceptions") + func collectsFromMultipleExceptions() { + let allImages = PostHogDebugImageProvider.getAllBinaryImages() + guard allImages.count >= 2 else { + Issue.record("Need at least 2 binary images for this test") + return + } + + let exceptions: [[String: Any]] = [ + [ + "type": "Exception1", + "stacktrace": [ + "frames": [ + ["image_addr": String(format: "0x%llx", allImages[0].address)], + ], + ] as [String: Any], + ], + [ + "type": "Exception2", + "stacktrace": [ + "frames": [ + ["image_addr": String(format: "0x%llx", allImages[1].address)], + ], + ] as [String: Any], + ], + ] + + let debugImages = PostHogDebugImageProvider.getDebugImages(fromExceptions: exceptions) + + #expect(debugImages.count >= 2) + } + } + + // MARK: - Binary Image Info Dictionary Tests + + @Suite("Binary Image Info Dictionary") + struct BinaryImageInfoDictionaryTests { + @Test("omits nil UUID from dictionary") + func omitsNilUUID() { + let imageInfo = PostHogBinaryImageInfo( + name: "/test.dylib", + uuid: nil, + vmAddress: 0x100_0000, + address: 0x100_0000, + size: 0x1000 + ) + + let dict = imageInfo.toDictionary + + #expect(dict["debug_id"] == nil) + } + + @Test("omits zero vmAddress from dictionary") + func omitsZeroVmAddress() { + let imageInfo = PostHogBinaryImageInfo( + name: "/test.dylib", + uuid: nil, + vmAddress: 0, + address: 0x100_0000, + size: 0x1000 + ) + + let dict = imageInfo.toDictionary + + #expect(dict["image_vmaddr"] == nil) + } + + @Test("omits nil arch from dictionary") + func omitsNilArch() { + let imageInfo = PostHogBinaryImageInfo( + name: "/test.dylib", + uuid: nil, + vmAddress: nil, + address: 0x100_0000, + size: 0x1000, + arch: nil + ) + + let dict = imageInfo.toDictionary + + #expect(dict["arch"] == nil) + } + } +} diff --git a/PostHogTests/PostHogErrorTrackingUtilsTest.swift b/PostHogTests/PostHogErrorTrackingUtilsTest.swift new file mode 100644 index 000000000..8ad522e33 --- /dev/null +++ b/PostHogTests/PostHogErrorTrackingUtilsTest.swift @@ -0,0 +1,98 @@ +// +// PostHogErrorTrackingUtilsTest.swift +// PostHogTests +// +// Created by Ioannis Josephides on 22/12/2025. +// + +import Foundation +@testable import PostHog +import Testing + +@Suite("PostHogErrorTrackingUtils Tests") +struct PostHogErrorTrackingUtilsTest { + // MARK: - UUID Formatting Tests + + @Suite("UUID Formatting") + struct UUIDFormattingTests { + @Test("formats UUID without hyphens") + func formatsUUIDWithoutHyphens() { + let input = "A1B2C3D4E5F67890ABCDEF1234567890" + let expected = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + + #expect(input.formattedAsUUID == expected) + } + + @Test("preserves already formatted UUID") + func preservesFormattedUUID() { + let input = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + let expected = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + + #expect(input.formattedAsUUID == expected) + } + + @Test("uppercases lowercase UUID") + func uppercasesLowercaseUUID() { + let input = "a1b2c3d4e5f67890abcdef1234567890" + let expected = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + + #expect(input.formattedAsUUID == expected) + } + + @Test("returns original for invalid length") + func returnsOriginalForInvalidLength() { + let input = "tooshort" + + #expect(input.formattedAsUUID == input) + } + + @Test("handles mixed case UUID") + func handlesMixedCaseUUID() { + let input = "a1B2c3D4-e5F6-7890-AbCd-Ef1234567890" + let expected = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + + #expect(input.formattedAsUUID == expected) + } + } + + // MARK: - CPU Architecture Tests + + @Suite("CPU Architecture") + struct CPUArchitectureTests { + @Test("returns arm64 for ARM64 CPU type") + func returnsArm64() { + let arch = PostHogCPUArchitecture.archName(cpuType: 0x0100_000C, cpuSubtype: 0) + #expect(arch == "arm64") + } + + @Test("returns x86_64 for x86_64 CPU type") + func returnsX86_64() { + let arch = PostHogCPUArchitecture.archName(cpuType: 0x0100_0007, cpuSubtype: 0) + #expect(arch == "x86_64") + } + + @Test("returns armv7 for ARM CPU type with subtype 9") + func returnsArmv7() { + let arch = PostHogCPUArchitecture.archName(cpuType: 12, cpuSubtype: 9) + #expect(arch == "armv7") + } + + @Test("returns armv7s for ARM CPU type with subtype 11") + func returnsArmv7s() { + let arch = PostHogCPUArchitecture.archName(cpuType: 12, cpuSubtype: 11) + #expect(arch == "armv7s") + } + + @Test("returns arm for ARM CPU type with unknown subtype") + func returnsArmForUnknownSubtype() { + let arch = PostHogCPUArchitecture.archName(cpuType: 12, cpuSubtype: 99) + #expect(arch == "arm") + } + + @Test("returns nil for unknown CPU type") + func returnsNilForUnknownType() { + let arch = PostHogCPUArchitecture.archName(cpuType: 999, cpuSubtype: 0) + #expect(arch == nil) + } + } +} diff --git a/PostHogTests/PostHogExceptionProcessorTest.swift b/PostHogTests/PostHogExceptionProcessorTest.swift new file mode 100644 index 000000000..c0f0cdbd0 --- /dev/null +++ b/PostHogTests/PostHogExceptionProcessorTest.swift @@ -0,0 +1,399 @@ +// +// PostHogExceptionProcessorTest.swift +// PostHogTests +// +// Created by Ioannis Josephides on 22/12/2025. +// + +import Foundation +@testable import PostHog +import Testing + +@Suite("PostHogExceptionProcessor Tests") +struct PostHogExceptionProcessorTest { + let config = PostHogErrorTrackingConfig() + + // MARK: - Error to Properties Tests + + @Suite("Error to Properties") + struct ErrorToPropertiesTests { + let config = PostHogErrorTrackingConfig() + + @Test("converts simple Swift error to properties") + func convertsSimpleSwiftError() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + #expect(properties["$exception_level"] as? String == "error") + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList != nil) + #expect(exceptionList?.count == 1) + + let exception = exceptionList?.first + #expect(exception?["type"] as? String == "TestSwiftError") + #expect(exception?["thread_id"] as? Int != nil) + + let mechanism = exception?["mechanism"] as? [String: Any] + #expect(mechanism?["type"] as? String == "generic") + #expect(mechanism?["handled"] as? Bool == true) + #expect(mechanism?["synthetic"] as? Bool == true) + + let stacktrace = exception?["stacktrace"] as? [String: Any] + #expect(stacktrace != nil) + #expect(stacktrace?["type"] as? String == "raw") + } + + @Test("converts NSError to properties with domain as type") + func convertsNSErrorWithDomain() { + let error = NSError( + domain: "com.posthog.TestDomain", + code: 500, + userInfo: [NSLocalizedDescriptionKey: "Test error message"] + ) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: false, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exception = exceptionList?.first + + #expect(exception?["type"] as? String == "com.posthog.TestDomain") + #expect((exception?["value"] as? String)?.contains("Test error message") == true) + #expect((exception?["value"] as? String)?.contains("500") == true) + + let mechanism = exception?["mechanism"] as? [String: Any] + #expect(mechanism?["handled"] as? Bool == false) + } + + @Test("walks error chain via NSUnderlyingErrorKey") + func walksErrorChain() { + let rootError = NSError( + domain: "RootDomain", + code: 100, + userInfo: [NSLocalizedDescriptionKey: "Root cause"] + ) + let wrapperError = NSError( + domain: "WrapperDomain", + code: 200, + userInfo: [ + NSLocalizedDescriptionKey: "Wrapper error", + NSUnderlyingErrorKey: rootError, + ] + ) + + let properties = PostHogExceptionProcessor.errorToProperties( + wrapperError, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList?.count == 2) + + #expect(exceptionList?[0]["type"] as? String == "WrapperDomain") + #expect(exceptionList?[1]["type"] as? String == "RootDomain") + } + + @Test("handles circular error references") + func handlesCircularReferences() { + let error1 = NSError(domain: "Domain1", code: 1, userInfo: nil) + let error2 = NSError(domain: "Domain2", code: 2, userInfo: [NSUnderlyingErrorKey: error1]) + + let properties = PostHogExceptionProcessor.errorToProperties( + error2, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList != nil) + #expect(exceptionList!.count <= 2) + } + + @Test("uses custom mechanism type") + func usesCustomMechanismType() { + let error = TestSwiftError.validationError(field: "email") + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + mechanismType: "custom_handler", + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let mechanism = exceptionList?.first?["mechanism"] as? [String: Any] + #expect(mechanism?["type"] as? String == "custom_handler") + } + + @Test("extracts module from error domain") + func extractsModuleFromDomain() { + let error = TestSwiftError.networkError(code: 500) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exception = exceptionList?.first + #expect(exception?["module"] as? String != nil) + } + } + + // MARK: - NSException to Properties Tests + + @Suite("NSException to Properties") + struct NSExceptionToPropertiesTests { + let config = PostHogErrorTrackingConfig() + + @Test("converts NSException to properties") + func convertsNSException() { + let exception = NSException( + name: NSExceptionName("TestException"), + reason: "Test exception reason", + userInfo: nil + ) + + let properties = PostHogExceptionProcessor.exceptionToProperties( + exception, + handled: true, + config: config + ) + + #expect(properties["$exception_level"] as? String == "error") + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList?.count == 1) + + let exc = exceptionList?.first + #expect(exc?["type"] as? String == "TestException") + #expect(exc?["value"] as? String == "Test exception reason") + } + + @Test("handles NSException without reason") + func handlesExceptionWithoutReason() { + let exception = NSException( + name: NSExceptionName("NoReasonException"), + reason: nil, + userInfo: nil + ) + + let properties = PostHogExceptionProcessor.exceptionToProperties( + exception, + handled: false, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let exc = exceptionList?.first + #expect(exc?["type"] as? String == "NoReasonException") + #expect(exc?["value"] == nil) + } + + @Test("marks exception as unhandled") + func marksUnhandled() { + let exception = NSException( + name: .genericException, + reason: "Unhandled", + userInfo: nil + ) + + let properties = PostHogExceptionProcessor.exceptionToProperties( + exception, + handled: false, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let mechanism = exceptionList?.first?["mechanism"] as? [String: Any] + #expect(mechanism?["handled"] as? Bool == false) + } + } + + // MARK: - Message to Properties Tests + + @Suite("Message to Properties") + struct MessageToPropertiesTests { + let config = PostHogErrorTrackingConfig() + + @Test("converts message string to properties") + func convertsMessageString() { + let message = "Something went wrong" + let properties = PostHogExceptionProcessor.messageToProperties( + message, + config: config + ) + + #expect(properties["$exception_level"] as? String == "error") + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + #expect(exceptionList?.count == 1) + + let exception = exceptionList?.first + #expect(exception?["type"] as? String == "Message") + #expect(exception?["value"] as? String == "Something went wrong") + } + + @Test("message exceptions are always synthetic and handled") + func messageExceptionsAreSyntheticAndHandled() { + let properties = PostHogExceptionProcessor.messageToProperties( + "Test message", + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let mechanism = exceptionList?.first?["mechanism"] as? [String: Any] + #expect(mechanism?["synthetic"] as? Bool == true) + #expect(mechanism?["handled"] as? Bool == true) + } + + @Test("message uses custom mechanism type") + func usesCustomMechanismType() { + let properties = PostHogExceptionProcessor.messageToProperties( + "Test", + mechanismType: "custom", + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let mechanism = exceptionList?.first?["mechanism"] as? [String: Any] + #expect(mechanism?["type"] as? String == "custom") + } + } + + // MARK: - Debug Images Tests + + @Suite("Debug Images") + struct DebugImagesTests { + let config = PostHogErrorTrackingConfig() + + @Test("attaches debug images to error properties") + func attachesDebugImages() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let debugImages = properties["$debug_images"] as? [[String: Any]] + #expect(debugImages != nil) + #expect(debugImages!.count > 0) + + let image = debugImages?.first + #expect(image?["type"] as? String == "macho") + #expect(image?["code_file"] as? String != nil) + #expect(image?["image_addr"] as? String != nil) + #expect(image?["image_size"] as? UInt64 != nil) + } + + @Test("debug images have valid UUID format") + func debugImagesHaveValidUUID() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let debugImages = properties["$debug_images"] as? [[String: Any]] + let imageWithUUID = debugImages?.first { $0["debug_id"] != nil } + + if let uuid = imageWithUUID?["debug_id"] as? String { + let uuidPattern = #"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$"# + let regex = try? NSRegularExpression(pattern: uuidPattern) + let range = NSRange(uuid.startIndex..., in: uuid) + #expect(regex?.firstMatch(in: uuid, range: range) != nil) + } + } + } + + // MARK: - Stack Trace Tests + + @Suite("Stack Trace") + struct StackTraceTests { + let config = PostHogErrorTrackingConfig() + + @Test("stack trace has raw type") + func stackTraceHasRawType() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + #expect(stacktrace?["type"] as? String == "raw") + } + + @Test("stack trace contains frames") + func stackTraceContainsFrames() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + + #expect(frames != nil) + #expect(frames!.count > 0) + } + + @Test("frames have required fields") + func framesHaveRequiredFields() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + let frame = frames?.first + + #expect(frame?["instruction_addr"] as? String != nil) + #expect(frame?["platform"] as? String == "apple") + #expect(frame?["in_app"] as? Bool != nil) + } + + @Test("frames have hex address format") + func framesHaveHexAddressFormat() { + let error = TestSwiftError.networkError(code: 404) + let properties = PostHogExceptionProcessor.errorToProperties( + error, + handled: true, + config: config + ) + + let exceptionList = properties["$exception_list"] as? [[String: Any]] + let stacktrace = exceptionList?.first?["stacktrace"] as? [String: Any] + let frames = stacktrace?["frames"] as? [[String: Any]] + let instructionAddr = frames?.first?["instruction_addr"] as? String + + #expect(instructionAddr?.hasPrefix("0x") == true) + } + } +} + +// MARK: - Test Helpers + +enum TestSwiftError: Error { + case networkError(code: Int) + case validationError(field: String) + case unknownError +} diff --git a/PostHogTests/PostHogStackTraceProcessorTest.swift b/PostHogTests/PostHogStackTraceProcessorTest.swift new file mode 100644 index 000000000..4031779ad --- /dev/null +++ b/PostHogTests/PostHogStackTraceProcessorTest.swift @@ -0,0 +1,240 @@ +// +// PostHogStackTraceProcessorTest.swift +// PostHogTests +// +// Created by Ioannis Josephides on 22/12/2025. +// + +import Foundation +@testable import PostHog +import Testing + +@Suite("PostHogStackTraceProcessor Tests") +struct PostHogStackTraceProcessorTest { + // MARK: - In-App Detection Tests + + @Suite("In-App Detection") + struct InAppDetectionTests { + @Test("marks module as in-app when in inAppIncludes") + func marksInAppWhenInIncludes() { + let config = PostHogErrorTrackingConfig() + config.inAppIncludes = ["MyApp", "SharedModule"] + + #expect(PostHogStackTraceProcessor.isInApp(module: "MyApp", config: config) == true) + #expect(PostHogStackTraceProcessor.isInApp(module: "MyAppExtension", config: config) == true) + #expect(PostHogStackTraceProcessor.isInApp(module: "SharedModule", config: config) == true) + } + + @Test("marks module as not in-app when in inAppExcludes") + func marksNotInAppWhenInExcludes() { + let config = PostHogErrorTrackingConfig() + config.inAppExcludes = ["Alamofire", "SDWebImage"] + + #expect(PostHogStackTraceProcessor.isInApp(module: "Alamofire", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "SDWebImage", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "SDWebImageSwiftUI", config: config) == false) + } + + @Test("inAppIncludes takes precedence over inAppExcludes") + func includesTakesPrecedenceOverExcludes() { + let config = PostHogErrorTrackingConfig() + config.inAppIncludes = ["MyModule"] + config.inAppExcludes = ["MyModule"] + + #expect(PostHogStackTraceProcessor.isInApp(module: "MyModule", config: config) == true) + } + + @Test("marks system frameworks as not in-app") + func marksSystemFrameworksAsNotInApp() { + let config = PostHogErrorTrackingConfig() + + #expect(PostHogStackTraceProcessor.isInApp(module: "Foundation", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "UIKit", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "CoreFoundation", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "SwiftUI", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "libsystem_kernel.dylib", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "libswiftCore.dylib", config: config) == false) + } + + @Test("uses inAppByDefault for unknown modules") + func usesInAppByDefault() { + let config = PostHogErrorTrackingConfig() + + config.inAppByDefault = true + #expect(PostHogStackTraceProcessor.isInApp(module: "UnknownModule", config: config) == true) + + config.inAppByDefault = false + #expect(PostHogStackTraceProcessor.isInApp(module: "UnknownModule", config: config) == false) + } + + @Test("uses prefix matching for includes") + func usesPrefixMatchingForIncludes() { + let config = PostHogErrorTrackingConfig() + config.inAppIncludes = ["com.posthog"] + config.inAppByDefault = false + + #expect(PostHogStackTraceProcessor.isInApp(module: "com.posthog.sdk", config: config) == true) + #expect(PostHogStackTraceProcessor.isInApp(module: "com.posthog", config: config) == true) + #expect(PostHogStackTraceProcessor.isInApp(module: "com.other", config: config) == false) + } + + @Test("uses prefix matching for excludes") + func usesPrefixMatchingForExcludes() { + let config = PostHogErrorTrackingConfig() + config.inAppExcludes = ["Firebase"] + + #expect(PostHogStackTraceProcessor.isInApp(module: "FirebaseCore", config: config) == false) + #expect(PostHogStackTraceProcessor.isInApp(module: "FirebaseAnalytics", config: config) == false) + } + } + + // MARK: - Stack Trace Capture Tests + + @Suite("Stack Trace Capture") + struct StackTraceCaptureTests { + @Test("captures current stack trace") + func capturesCurrentStackTrace() { + let config = PostHogErrorTrackingConfig() + let frames = PostHogStackTraceProcessor.captureCurrentStackTraceWithMetadata(config: config) + + #expect(frames.count > 0) + } + + @Test("captured frames have instruction addresses") + func framesHaveInstructionAddresses() { + let config = PostHogErrorTrackingConfig() + let frames = PostHogStackTraceProcessor.captureCurrentStackTraceWithMetadata(config: config) + + for frame in frames { + #expect(frame.instructionAddress > 0) + } + } + + @Test("captured frames have module info") + func framesHaveModuleInfo() { + let config = PostHogErrorTrackingConfig() + let frames = PostHogStackTraceProcessor.captureCurrentStackTraceWithMetadata(config: config) + + let framesWithModule = frames.filter { $0.module != nil } + #expect(framesWithModule.count > 0) + } + + @Test("strips PostHog frames from top of stack") + func stripsPostHogFrames() { + let config = PostHogErrorTrackingConfig() + let frames = PostHogStackTraceProcessor.captureCurrentStackTraceWithMetadata(config: config) + + let topFrame = frames.first + #expect(topFrame?.module != "PostHog") + } + } + + // MARK: - Symbolicate Addresses Tests + + @Suite("Symbolicate Addresses") + struct SymbolicateAddressesTests { + @Test("symbolicates array of addresses") + func symbolicatesAddresses() { + let config = PostHogErrorTrackingConfig() + let addresses = Thread.callStackReturnAddresses + + let frames = PostHogStackTraceProcessor.symbolicateAddresses( + addresses, + config: config, + stripTopPostHogFrames: false + ) + + #expect(frames.count > 0) + } + + @Test("respects stripTopPostHogFrames parameter") + func respectsStripParameter() { + let config = PostHogErrorTrackingConfig() + let addresses = Thread.callStackReturnAddresses + + let framesWithStrip = PostHogStackTraceProcessor.symbolicateAddresses( + addresses, + config: config, + stripTopPostHogFrames: true + ) + + let framesWithoutStrip = PostHogStackTraceProcessor.symbolicateAddresses( + addresses, + config: config, + stripTopPostHogFrames: false + ) + + #expect(framesWithStrip.count <= framesWithoutStrip.count) + } + } + + // MARK: - Frame Dictionary Tests + + @Suite("Frame Dictionary Conversion") + struct FrameDictionaryTests { + @Test("converts frame to dictionary with required fields") + func convertsToDictionaryWithRequiredFields() { + let frame = PostHogStackFrame( + instructionAddress: 0x100_0000, + module: "TestModule", + package: "/path/to/TestModule", + imageAddress: 0x100_0000, + inApp: true, + function: "testFunction", + symbolAddress: 0x100_0000 + ) + + let dict = frame.toDictionary + + #expect(dict["instruction_addr"] as? String == "0x1000000") + #expect(dict["platform"] as? String == "apple") + #expect(dict["in_app"] as? Bool == true) + #expect(dict["module"] as? String == "TestModule") + #expect(dict["package"] as? String == "/path/to/TestModule") + #expect(dict["function"] as? String == "testFunction") + } + + @Test("omits nil fields from dictionary") + func omitsNilFields() { + let frame = PostHogStackFrame( + instructionAddress: 0x100_0000, + module: nil, + package: nil, + imageAddress: nil, + inApp: false, + function: nil, + symbolAddress: nil + ) + + let dict = frame.toDictionary + + #expect(dict["instruction_addr"] != nil) + #expect(dict["platform"] != nil) + #expect(dict["in_app"] != nil) + #expect(dict["module"] == nil) + #expect(dict["package"] == nil) + #expect(dict["function"] == nil) + #expect(dict["image_addr"] == nil) + #expect(dict["symbol_addr"] == nil) + } + + @Test("formats addresses as hex strings") + func formatsAddressesAsHex() { + let frame = PostHogStackFrame( + instructionAddress: 0x7FFF_1234_5678, + module: "Test", + package: nil, + imageAddress: 0x7FFF_0000_0000, + inApp: true, + function: nil, + symbolAddress: 0x7FFF_1234_5000 + ) + + let dict = frame.toDictionary + + #expect((dict["instruction_addr"] as? String)?.hasPrefix("0x") == true) + #expect((dict["image_addr"] as? String)?.hasPrefix("0x") == true) + #expect((dict["symbol_addr"] as? String)?.hasPrefix("0x") == true) + } + } +}