From 6b7500225bce99c5de63b01b4b543cf150e6b4f9 Mon Sep 17 00:00:00 2001 From: jeremiahseun <53568423+jeremiahseun@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:30:52 +0000 Subject: [PATCH 01/14] feat: automatically capture deep link events - Added `captureDeepLinks` option to `PostHogConfig` (default: true). - Implemented `PostHogDeepLinkIntegration` to automatically track deep link opens and referrer information. - Swizzles `UIApplicationDelegate` and `UISceneDelegate` methods to capture `Deep Link Opened` events. - Captures `$referrer` and `$referring_domain` properties when available. --- .../PostHogDeepLinkIntegration.swift | 318 ++++++++++++++++++ PostHog/PostHogConfig.swift | 8 + PostHog/PostHogSwizzler.swift | 4 + 3 files changed, 330 insertions(+) create mode 100644 PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift diff --git a/PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift b/PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift new file mode 100644 index 0000000000..493528d681 --- /dev/null +++ b/PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift @@ -0,0 +1,318 @@ +// +// PostHogDeepLinkIntegration.swift +// PostHog +// +// Created by PostHog on 27.02.25. +// + +import Foundation + +#if os(iOS) || os(tvOS) + import UIKit +#endif + +class PostHogDeepLinkIntegration: PostHogIntegration { + var requiresSwizzling: Bool { true } + + private static var integrationInstalledLock = NSLock() + private static var integrationInstalled = false + private static var swizzledClasses = Set() + + private weak var postHog: PostHogSDK? + + // Store a reference to the active SDK instance for the static tracking methods + // We only support one active SDK instance for deep link tracking at a time because + // deep links are global app events. + private static weak var currentInstance: PostHogSDK? + + func install(_ postHog: PostHogSDK) throws { + try PostHogDeepLinkIntegration.integrationInstalledLock.withLock { + if PostHogDeepLinkIntegration.integrationInstalled { + throw InternalPostHogError(description: "Deep link integration already installed to another PostHogSDK instance.") + } + PostHogDeepLinkIntegration.integrationInstalled = true + } + + self.postHog = postHog + PostHogDeepLinkIntegration.currentInstance = postHog + + start() + } + + func uninstall(_ postHog: PostHogSDK) { + if self.postHog === postHog || self.postHog == nil { + stop() + self.postHog = nil + PostHogDeepLinkIntegration.currentInstance = nil + PostHogDeepLinkIntegration.integrationInstalledLock.withLock { + PostHogDeepLinkIntegration.integrationInstalled = false + } + } + } + + func start() { + #if os(iOS) || os(tvOS) + // Swizzle UIApplicationDelegate + if let delegate = UIApplication.shared.delegate { + swizzleUIApplicationDelegate(delegate) + } + + // Swizzle connected scenes + if #available(iOS 13.0, tvOS 13.0, *) { + for scene in UIApplication.shared.connectedScenes { + if let delegate = scene.delegate { + swizzleUISceneDelegate(delegate) + } + } + + // Observe new scenes connecting + NotificationCenter.default.addObserver(self, + selector: #selector(sceneWillConnect(_:)), + name: UIScene.willConnectNotification, + object: nil) + } + #endif + } + + func stop() { + NotificationCenter.default.removeObserver(self) + } + + #if os(iOS) || os(tvOS) + private func swizzleUIApplicationDelegate(_ delegate: UIApplicationDelegate) { + let delegateClass: AnyClass = type(of: delegate) + let className = String(describing: delegateClass) + + // Ensure we only swizzle each class once + var alreadySwizzled = false + PostHogDeepLinkIntegration.integrationInstalledLock.withLock { + if PostHogDeepLinkIntegration.swizzledClasses.contains(className) { + alreadySwizzled = true + } else { + PostHogDeepLinkIntegration.swizzledClasses.insert(className) + } + } + + if alreadySwizzled { return } + + // application(_:open:options:) + let openURLSelector = #selector(UIApplicationDelegate.application(_:open:options:)) + let swizzledOpenURLSelector = #selector(UIApplicationDelegate.ph_swizzled_application(_:open:options:)) + + // Add the swizzled implementation to the delegate class + let openURLMethod = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_swizzled_application(_:open:options:))) + if let method = openURLMethod { + addMethod(forClass: delegateClass, selector: swizzledOpenURLSelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + } + + // Check if the delegate implements the original method, if not add a fallback + if class_getInstanceMethod(delegateClass, openURLSelector) == nil { + let method = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_fallback_application(_:open:options:))) + if let method = method { + addMethod(forClass: delegateClass, selector: openURLSelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + } + } + + swizzle( + forClass: delegateClass, + original: openURLSelector, + new: swizzledOpenURLSelector + ) + + // application(_:continue:restorationHandler:) + let continueActivitySelector = #selector(UIApplicationDelegate.application(_:continue:restorationHandler:)) + let swizzledContinueActivitySelector = #selector(UIApplicationDelegate.ph_swizzled_application(_:continue:restorationHandler:)) + + // Add the swizzled implementation to the delegate class + let continueActivityMethod = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_swizzled_application(_:continue:restorationHandler:))) + if let method = continueActivityMethod { + addMethod(forClass: delegateClass, selector: swizzledContinueActivitySelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + } + + if class_getInstanceMethod(delegateClass, continueActivitySelector) == nil { + let method = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_fallback_application(_:continue:restorationHandler:))) + if let method = method { + addMethod(forClass: delegateClass, selector: continueActivitySelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + } + } + + swizzle( + forClass: delegateClass, + original: continueActivitySelector, + new: swizzledContinueActivitySelector + ) + } + + @available(iOS 13.0, tvOS 13.0, *) + private func swizzleUISceneDelegate(_ delegate: UISceneDelegate) { + let delegateClass: AnyClass = type(of: delegate) + let className = String(describing: delegateClass) + + // Ensure we only swizzle each class once + var alreadySwizzled = false + PostHogDeepLinkIntegration.integrationInstalledLock.withLock { + if PostHogDeepLinkIntegration.swizzledClasses.contains(className) { + alreadySwizzled = true + } else { + PostHogDeepLinkIntegration.swizzledClasses.insert(className) + } + } + + if alreadySwizzled { return } + + // scene(_:openURLContexts:) + let openURLContextsSelector = #selector(UISceneDelegate.scene(_:openURLContexts:)) + let swizzledOpenURLContextsSelector = #selector(UISceneDelegate.ph_swizzled_scene(_:openURLContexts:)) + + // Add the swizzled implementation to the delegate class + let openURLContextsMethod = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_swizzled_scene(_:openURLContexts:))) + if let method = openURLContextsMethod { + addMethod(forClass: delegateClass, selector: swizzledOpenURLContextsSelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + } + + if class_getInstanceMethod(delegateClass, openURLContextsSelector) == nil { + let method = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_fallback_scene(_:openURLContexts:))) + if let method = method { + addMethod(forClass: delegateClass, selector: openURLContextsSelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + } + } + + swizzle( + forClass: delegateClass, + original: openURLContextsSelector, + new: swizzledOpenURLContextsSelector + ) + + // scene(_:continue:) + let continueActivitySelector = #selector(UISceneDelegate.scene(_:continue:)) + let swizzledContinueActivitySelector = #selector(UISceneDelegate.ph_swizzled_scene(_:continue:)) + + // Add the swizzled implementation to the delegate class + let continueActivityMethod = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_swizzled_scene(_:continue:))) + if let method = continueActivityMethod { + addMethod(forClass: delegateClass, selector: swizzledContinueActivitySelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + } + + if class_getInstanceMethod(delegateClass, continueActivitySelector) == nil { + let method = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_fallback_scene(_:continue:))) + if let method = method { + addMethod(forClass: delegateClass, selector: continueActivitySelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + } + } + + swizzle( + forClass: delegateClass, + original: continueActivitySelector, + new: swizzledContinueActivitySelector + ) + } + + @objc private func sceneWillConnect(_ notification: Notification) { + if #available(iOS 13.0, tvOS 13.0, *) { + guard let scene = notification.object as? UIScene, + let delegate = scene.delegate else { return } + swizzleUISceneDelegate(delegate) + } + } + + // MARK: - Swizzled Implementations (to be added to delegate classes) + + @objc func ph_swizzled_application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + PostHogDeepLinkIntegration.trackDeepLink(url: url, options: options) + // Call the original implementation (which is now swizzled to this method, but the selector points to the original implementation due to the exchange) + // However, since we are adding this method to the delegate class, 'self' will be the delegate instance. + // The original selector now points to this implementation. + // The swizzled selector points to the original implementation. + // So we need to call the method corresponding to the swizzled selector on self. + + // Wait, standard swizzling: + // original -> originalImp + // swizzled -> swizzledImp + // exchange: + // original -> swizzledImp + // swizzled -> originalImp + + // Inside swizzledImp (this function): + // calling self.swizzled() calls originalImp. + + return ph_swizzled_application(app, open: url, options: options) + } + + @objc func ph_swizzled_application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + PostHogDeepLinkIntegration.trackDeepLink(userActivity: userActivity) + return ph_swizzled_application(application, continue: userActivity, restorationHandler: restorationHandler) + } + + @available(iOS 13.0, tvOS 13.0, *) + @objc func ph_swizzled_scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for context in URLContexts { + PostHogDeepLinkIntegration.trackDeepLink(url: context.url, options: context.options) + } + ph_swizzled_scene(scene, openURLContexts: URLContexts) + } + + @available(iOS 13.0, tvOS 13.0, *) + @objc func ph_swizzled_scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + PostHogDeepLinkIntegration.trackDeepLink(userActivity: userActivity) + ph_swizzled_scene(scene, continue: userActivity) + } + + // MARK: - Fallback implementations for adding methods + + @objc func ph_fallback_application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + return false + } + + @objc func ph_fallback_application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + return false + } + + @available(iOS 13.0, tvOS 13.0, *) + @objc func ph_fallback_scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + } + + @available(iOS 13.0, tvOS 13.0, *) + @objc func ph_fallback_scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + } + #endif +} + +// MARK: - Tracking Logic + +extension PostHogDeepLinkIntegration { + #if os(iOS) || os(tvOS) + static func trackDeepLink(url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) { + let referrer = options[.sourceApplication] as? String + trackDeepLink(url: url, referrer: referrer) + } + + @available(iOS 13.0, tvOS 13.0, *) + static func trackDeepLink(url: URL, options: UIScene.OpenURLOptions) { + trackDeepLink(url: url, referrer: options.sourceApplication) + } + + static func trackDeepLink(userActivity: NSUserActivity) { + if userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL { + trackDeepLink(url: url, referrer: userActivity.referrerURL?.absoluteString) + } + } + #endif + + static func trackDeepLink(url: URL, referrer: String?) { + guard let postHog = PostHogDeepLinkIntegration.currentInstance, + postHog.config.captureDeepLinks else { return } + + var properties: [String: Any] = ["url": url.absoluteString] + + if let referrer = referrer { + properties["$referrer"] = referrer + + // Try to extract domain from referrer if it looks like a URL + if let referrerURL = URL(string: referrer), let host = referrerURL.host { + properties["$referring_domain"] = host + } + } + + postHog.capture("Deep Link Opened", properties: properties) + } +} diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 2b35a947f5..ed2182b732 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -65,6 +65,10 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Default: true @objc public var enableSwizzling: Bool = true + /// Enable capturing deep links + /// Default: true + @objc public var captureDeepLinks: Bool = true + #if os(iOS) || targetEnvironment(macCatalyst) /// Enable autocapture for iOS /// Default: false @@ -225,6 +229,10 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? integrations.append(PostHogAppLifeCycleIntegration()) } + if captureDeepLinks { + integrations.append(PostHogDeepLinkIntegration()) + } + #if os(iOS) if sessionReplay { integrations.append(PostHogReplayIntegration()) diff --git a/PostHog/PostHogSwizzler.swift b/PostHog/PostHogSwizzler.swift index 7aaf73c4d1..0803b23ba4 100644 --- a/PostHog/PostHogSwizzler.swift +++ b/PostHog/PostHogSwizzler.swift @@ -12,3 +12,7 @@ func swizzle(forClass: AnyClass, original: Selector, new: Selector) { guard let swizzledMethod = class_getInstanceMethod(forClass, new) else { return } method_exchangeImplementations(originalMethod, swizzledMethod) } + +func addMethod(forClass: AnyClass, selector: Selector, implementation: IMP, types: String) { + class_addMethod(forClass, selector, implementation, types) +} From c6791be20bb59c6ccc130ab47a2a5838f1398dd0 Mon Sep 17 00:00:00 2001 From: jeremiahseun <53568423+jeremiahseun@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:57:16 +0000 Subject: [PATCH 02/14] docs: add TESTING_DEEP_LINKS.md Added a guide on how to test the automatic deep link capture feature. From 239e1cb0a4fa3dcefe1027ab2e6d53537866cf81 Mon Sep 17 00:00:00 2001 From: jeremiahseun <53568423+jeremiahseun@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:59:55 +0000 Subject: [PATCH 03/14] docs: add TESTING_DEEP_LINKS.md Added a guide on how to test the automatic deep link capture feature. From 32d183d3457cee300d1bdead7b766fe96dae27d2 Mon Sep 17 00:00:00 2001 From: jeremiahseun <53568423+jeremiahseun@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:01:17 +0000 Subject: [PATCH 04/14] docs: add TESTING_DEEP_LINKS.md Added a guide on how to test the automatic deep link capture feature. --- TESTING_DEEP_LINKS.md | 89 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 TESTING_DEEP_LINKS.md diff --git a/TESTING_DEEP_LINKS.md b/TESTING_DEEP_LINKS.md new file mode 100644 index 0000000000..0366be3acd --- /dev/null +++ b/TESTING_DEEP_LINKS.md @@ -0,0 +1,89 @@ +# Testing Deep Link Capture + +This document outlines how to verify that the automatic deep link capture feature works as expected. + +## 1. Unit Testing + +Since the feature relies on swizzling `UIApplicationDelegate` and `UISceneDelegate`, unit tests need to ensure that the integration installs correctly and that the tracking logic produces the correct events. + +### Example Test Case (Swift) + +You can add a test case to `PostHogTests` to verify the property extraction logic. + +```swift +import XCTest +@testable import PostHog + +class PostHogDeepLinkTests: XCTestCase { + + func testTrackDeepLinkWithReferrer() { + // Setup + let config = PostHogConfig(apiKey: "test_api_key") + config.captureDeepLinks = true + let postHog = PostHogSDK.with(config) + + // Mock URL and Referrer + let url = URL(string: "https://myapp.com/product/123?utm_source=newsletter")! + let referrer = "https://www.google.com" + + // Trigger tracking manually (since we can't easily mock the swizzling in a simple unit test without a host app) + // Note: internal access to PostHogDeepLinkIntegration might be needed or test via the public side effect + PostHogDeepLinkIntegration.trackDeepLink(url: url, referrer: referrer) + + // Verify (Mocking the Queue/Network would be required for a real assertion) + // Check logs or use a mock server to verify "Deep Link Opened" event was sent + // Properties should include: + // - url: "https://myapp.com/product/123?utm_source=newsletter" + // - $referrer: "https://www.google.com" + // - $referring_domain: "www.google.com" + } +} +``` + +## 2. Manual Testing (Simulator or Device) + +To verify the integration end-to-end: + +### Prerequisites +1. Use an iOS app that integrates the PostHog SDK. +2. Ensure `config.captureDeepLinks = true` (default). +3. Configure a Custom URL Scheme (e.g., `my-app://`) or Universal Links for your app. + +### Scenario A: Custom URL Scheme via Terminal (Simulator) +1. Build and run the app on the iOS Simulator. +2. Background the app. +3. Open Terminal and run: + ```bash + xcrun simctl openurl booted "my-app://path/to/content?foo=bar" + ``` +4. **Verification:** + - Watch the Xcode console logs (if debug logging is enabled via `config.debug = true`). + - You should see a log entry for capturing `Deep Link Opened`. + - Check the event in PostHog Activity: + - **Event:** `Deep Link Opened` + - **Property `url`:** `my-app://path/to/content?foo=bar` + - **Property `$referrer`:** (Might be nil or system defined depending on source) + +### Scenario B: Universal Link via Safari +1. Install the app on a device or simulator. +2. Open Safari. +3. Navigate to a URL that your app supports (e.g., `https://your-site.com/link`). +4. Tap "Open in [App Name]" banner or if it redirects automatically. +5. **Verification:** + - **Event:** `Deep Link Opened` + - **Property `url`:** `https://your-site.com/link` + - **Property `$referrer`:** URL of the page you were on in Safari. + - **Property `$referring_domain`:** Domain of the referrer. + +### Scenario C: App-to-App Referrer +1. Create a second test app or use Notes/Messages. +2. Paste your custom URL scheme link `my-app://test`. +3. Tap the link to open your app. +4. **Verification:** + - **Event:** `Deep Link Opened` + - **Property `$referrer`:** Bundle ID of the calling app (e.g., `com.apple.mobilenotes`). + +## 3. Verify Configuration Toggle +1. Set `config.captureDeepLinks = false` in your initialization code. +2. Repeat Scenario A. +3. **Verification:** Ensure **NO** `Deep Link Opened` event is captured. From 55732bceeeaa711d36624f34f4a7dbe0d0365f57 Mon Sep 17 00:00:00 2001 From: Jeremiah Oluwaseun Erinola Date: Thu, 19 Feb 2026 19:22:15 +0100 Subject: [PATCH 05/14] Deep link integration, tests, and project config Add and improve deep-link handling and testability: introduce buildDeepLinkProperties and refactor trackDeepLink, enhance swizzling (including scene(_:willConnectTo:options:) handling) and move swizzled/fallback implementations onto NSObject for injection. Add unit tests (PostHogDeepLinkIntegrationTests) and an example Info.plist for PostHogExample. Update Xcode project to include new files, set INFOPLIST_FILE, disable GENERATE_INFOPLIST_FILE, and add runpath entries. Add TESTING compiler define for debug in Package.swift. Also: fix SwiftUI target class name extraction in autocapture, adjust Session Replay test imports and NSNull handling, and trim manual testing steps from TESTING_DEEP_LINKS.md. --- Package.swift | 3 + PostHog.xcodeproj/project.pbxproj | 38 +++-- .../PostHogDeepLinkIntegration.swift | 134 ++++++++++++------ .../PostHogAutocaptureEventTracker.swift | 7 +- PostHogExample/Info.plist | 67 +++++++++ .../PostHogDeepLinkIntegrationTests.swift | 54 +++++++ .../PostHogSessionReplayPluginTest.swift | 10 +- TESTING_DEEP_LINKS.md | 49 ------- 8 files changed, 252 insertions(+), 110 deletions(-) create mode 100644 PostHogExample/Info.plist create mode 100644 PostHogTests/PostHogDeepLinkIntegrationTests.swift diff --git a/Package.swift b/Package.swift index 91d46cb4cb..61a4a94dc2 100644 --- a/Package.swift +++ b/Package.swift @@ -29,6 +29,9 @@ let package = Package( path: "PostHog", resources: [ .copy("Resources/PrivacyInfo.xcprivacy"), + ], + swiftSettings: [ + .define("TESTING", .when(configuration: .debug)) ] ), .target( diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 2fd45d3831..ca38f16c91 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -105,6 +105,7 @@ 69F5181A2BAC81FC00F52C14 /* UITextInputTraits+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */; }; 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */; }; CL000001CDE129010001FF01 /* PostHogFeatureFlagResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = CL000001CDE129010001FF00 /* PostHogFeatureFlagResult.swift */; }; + D9D9AC0C89F3174A30EE52DC /* PostHogDeepLinkIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83E6F62BC941AF59AF44595C /* PostHogDeepLinkIntegration.swift */; }; DA0B66A52D64EC00006F5FF3 /* ph_sharpyuv_csp.h in Headers */ = {isa = PBXBuildFile; fileRef = DA0B66982D64EC00006F5FF3 /* ph_sharpyuv_csp.h */; settings = {ATTRIBUTES = (Public, ); }; }; DA0B66A62D64EC00006F5FF3 /* ph_sharpyuv_dsp.h in Headers */ = {isa = PBXBuildFile; fileRef = DA0B66992D64EC00006F5FF3 /* ph_sharpyuv_dsp.h */; settings = {ATTRIBUTES = (Public, ); }; }; DA0B66A72D64EC00006F5FF3 /* ph_huffman_utils.h in Headers */ = {isa = PBXBuildFile; fileRef = DA0B668A2D64EC00006F5FF3 /* ph_huffman_utils.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -647,6 +648,7 @@ 69F518172BAC80A300F52C14 /* String+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Util.swift"; sourceTree = ""; }; 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextInputTraits+Util.swift"; sourceTree = ""; }; 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSwizzler.swift; sourceTree = ""; }; + 83E6F62BC941AF59AF44595C /* PostHogDeepLinkIntegration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostHogDeepLinkIntegration.swift; sourceTree = ""; }; CL000001CDE129010001FF00 /* PostHogFeatureFlagResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogFeatureFlagResult.swift; sourceTree = ""; }; DA0B66792D64EC00006F5FF3 /* ph_backward_references_enc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ph_backward_references_enc.h; sourceTree = ""; }; DA0B667A2D64EC00006F5FF3 /* ph_bit_reader_utils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ph_bit_reader_utils.h; sourceTree = ""; }; @@ -904,6 +906,7 @@ DAFEC4582E03F93A0064535E /* PostHogDisplaySurveyAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogDisplaySurveyAppearance.swift; sourceTree = ""; }; DAFF02652D7B1A9000BD5B1D /* SurveysRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveysRootView.swift; sourceTree = ""; }; DAFF026C2D7B2F8D00BD5B1D /* SurveyDisplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyDisplayController.swift; sourceTree = ""; }; + F2C7A6141B3B17BBBC836CAD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1001,6 +1004,7 @@ 3AA34CFB296D951A003398F4 /* ContentView.swift */, 3AA34CFD296D951B003398F4 /* Assets.xcassets */, 3AA34CFF296D951B003398F4 /* Preview Content */, + F2C7A6141B3B17BBBC836CAD /* Info.plist */, ); path = PostHogExample; sourceTree = ""; @@ -1388,6 +1392,7 @@ children = ( DA703C1B2D6616F20069097B /* PostHogAppLifeCycleIntegration.swift */, DA1D29582D10B7A6003A31DA /* ApplicationLifecyclePublisher.swift */, + 83E6F62BC941AF59AF44595C /* PostHogDeepLinkIntegration.swift */, ); path = "App Life Cycle"; sourceTree = ""; @@ -1747,8 +1752,6 @@ dependencies = ( ); name = PostHog; - packageProductDependencies = ( - ); productName = PostHog; productReference = 3AC745B5296D6FE60025C109 /* PostHog.framework */; productType = "com.apple.product-type.framework"; @@ -2303,6 +2306,7 @@ 69261D1D2AD967CD00232EC7 /* PostHogFileBackedQueue.swift in Sources */, 3AE3FB432992985A00AFFC18 /* Reachability.swift in Sources */, 69F518122BAC783300F52C14 /* CGColor+Util.swift in Sources */, + D9D9AC0C89F3174A30EE52DC /* PostHogDeepLinkIntegration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2480,15 +2484,17 @@ DEVELOPMENT_ASSET_PATHS = "\"PostHogExample/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PostHogExample/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = PostHogExample; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; - LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 4.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; @@ -2518,15 +2524,17 @@ DEVELOPMENT_ASSET_PATHS = "\"PostHogExample/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PostHogExample/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = PostHogExample; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; - LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 4.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; @@ -3275,15 +3283,17 @@ DEVELOPMENT_ASSET_PATHS = "\"PostHogExample/Preview Content\""; DEVELOPMENT_TEAM = PNC2XCH2XP; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PostHogExample/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = PostHogExample; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; - LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; diff --git a/PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift b/PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift index 493528d681..d6e27c32c4 100644 --- a/PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift +++ b/PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift @@ -2,7 +2,7 @@ // PostHogDeepLinkIntegration.swift // PostHog // -// Created by PostHog on 27.02.25. +// Created by Jeremiah Erinola on 18.02.26. // import Foundation @@ -11,7 +11,7 @@ import Foundation import UIKit #endif -class PostHogDeepLinkIntegration: PostHogIntegration { +class PostHogDeepLinkIntegration: NSObject, PostHogIntegration { var requiresSwizzling: Bool { true } private static var integrationInstalledLock = NSLock() @@ -97,19 +97,19 @@ class PostHogDeepLinkIntegration: PostHogIntegration { // application(_:open:options:) let openURLSelector = #selector(UIApplicationDelegate.application(_:open:options:)) - let swizzledOpenURLSelector = #selector(UIApplicationDelegate.ph_swizzled_application(_:open:options:)) + let swizzledOpenURLSelector = #selector(NSObject.ph_swizzled_application(_:open:options:)) // Add the swizzled implementation to the delegate class - let openURLMethod = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_swizzled_application(_:open:options:))) + let openURLMethod = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_swizzled_application(_:open:options:))) if let method = openURLMethod { - addMethod(forClass: delegateClass, selector: swizzledOpenURLSelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + addMethod(forClass: delegateClass, selector: swizzledOpenURLSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) } // Check if the delegate implements the original method, if not add a fallback if class_getInstanceMethod(delegateClass, openURLSelector) == nil { - let method = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_fallback_application(_:open:options:))) + let method = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_fallback_application(_:open:options:))) if let method = method { - addMethod(forClass: delegateClass, selector: openURLSelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + addMethod(forClass: delegateClass, selector: openURLSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) } } @@ -121,18 +121,18 @@ class PostHogDeepLinkIntegration: PostHogIntegration { // application(_:continue:restorationHandler:) let continueActivitySelector = #selector(UIApplicationDelegate.application(_:continue:restorationHandler:)) - let swizzledContinueActivitySelector = #selector(UIApplicationDelegate.ph_swizzled_application(_:continue:restorationHandler:)) + let swizzledContinueActivitySelector = #selector(NSObject.ph_swizzled_application(_:continue:restorationHandler:)) // Add the swizzled implementation to the delegate class - let continueActivityMethod = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_swizzled_application(_:continue:restorationHandler:))) + let continueActivityMethod = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_swizzled_application(_:continue:restorationHandler:))) if let method = continueActivityMethod { - addMethod(forClass: delegateClass, selector: swizzledContinueActivitySelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + addMethod(forClass: delegateClass, selector: swizzledContinueActivitySelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) } if class_getInstanceMethod(delegateClass, continueActivitySelector) == nil { - let method = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_fallback_application(_:continue:restorationHandler:))) + let method = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_fallback_application(_:continue:restorationHandler:))) if let method = method { - addMethod(forClass: delegateClass, selector: continueActivitySelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + addMethod(forClass: delegateClass, selector: continueActivitySelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) } } @@ -162,18 +162,18 @@ class PostHogDeepLinkIntegration: PostHogIntegration { // scene(_:openURLContexts:) let openURLContextsSelector = #selector(UISceneDelegate.scene(_:openURLContexts:)) - let swizzledOpenURLContextsSelector = #selector(UISceneDelegate.ph_swizzled_scene(_:openURLContexts:)) + let swizzledOpenURLContextsSelector = #selector(NSObject.ph_swizzled_scene(_:openURLContexts:)) // Add the swizzled implementation to the delegate class - let openURLContextsMethod = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_swizzled_scene(_:openURLContexts:))) + let openURLContextsMethod = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_swizzled_scene(_:openURLContexts:))) if let method = openURLContextsMethod { - addMethod(forClass: delegateClass, selector: swizzledOpenURLContextsSelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + addMethod(forClass: delegateClass, selector: swizzledOpenURLContextsSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) } if class_getInstanceMethod(delegateClass, openURLContextsSelector) == nil { - let method = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_fallback_scene(_:openURLContexts:))) + let method = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_fallback_scene(_:openURLContexts:))) if let method = method { - addMethod(forClass: delegateClass, selector: openURLContextsSelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + addMethod(forClass: delegateClass, selector: openURLContextsSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) } } @@ -185,18 +185,18 @@ class PostHogDeepLinkIntegration: PostHogIntegration { // scene(_:continue:) let continueActivitySelector = #selector(UISceneDelegate.scene(_:continue:)) - let swizzledContinueActivitySelector = #selector(UISceneDelegate.ph_swizzled_scene(_:continue:)) + let swizzledContinueActivitySelector = #selector(NSObject.ph_swizzled_scene(_:continue:)) // Add the swizzled implementation to the delegate class - let continueActivityMethod = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_swizzled_scene(_:continue:))) + let continueActivityMethod = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_swizzled_scene(_:continue:))) if let method = continueActivityMethod { - addMethod(forClass: delegateClass, selector: swizzledContinueActivitySelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + addMethod(forClass: delegateClass, selector: swizzledContinueActivitySelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) } if class_getInstanceMethod(delegateClass, continueActivitySelector) == nil { - let method = class_getInstanceMethod(PostHogDeepLinkIntegration.self, #selector(PostHogDeepLinkIntegration.ph_fallback_scene(_:continue:))) + let method = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_fallback_scene(_:continue:))) if let method = method { - addMethod(forClass: delegateClass, selector: continueActivitySelector, implementation: method_getImplementation(method), types: method_getTypeEncoding(method)!) + addMethod(forClass: delegateClass, selector: continueActivitySelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) } } @@ -205,6 +205,34 @@ class PostHogDeepLinkIntegration: PostHogIntegration { original: continueActivitySelector, new: swizzledContinueActivitySelector ) + + // scene(_:willConnectTo:options:) + // Only swizzle if the delegate implements it, to avoid breaking default Storyboard behavior + let willConnectSelector = #selector(UISceneDelegate.scene(_:willConnectTo:options:)) + let swizzledWillConnectSelector = #selector(NSObject.ph_swizzled_scene(_:willConnectTo:options:)) + + if class_getInstanceMethod(delegateClass, willConnectSelector) != nil { + let willConnectMethod = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_swizzled_scene(_:willConnectTo:options:))) + + if let method = willConnectMethod { + addMethod(forClass: delegateClass, selector: swizzledWillConnectSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) + } + + // Fallback is theoretically needed if we exchange, but here we ONLY swizzle if method exists. + // However, to be consistent and safe if logic changes: + if class_getInstanceMethod(delegateClass, willConnectSelector) == nil { + let method = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_fallback_scene(_:willConnectTo:options:))) + if let method = method { + addMethod(forClass: delegateClass, selector: willConnectSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) + } + } + + swizzle( + forClass: delegateClass, + original: willConnectSelector, + new: swizzledWillConnectSelector + ) + } } @objc private func sceneWillConnect(_ notification: Notification) { @@ -215,26 +243,15 @@ class PostHogDeepLinkIntegration: PostHogIntegration { } } + #endif +} + +extension NSObject { + #if os(iOS) || os(tvOS) // MARK: - Swizzled Implementations (to be added to delegate classes) @objc func ph_swizzled_application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { PostHogDeepLinkIntegration.trackDeepLink(url: url, options: options) - // Call the original implementation (which is now swizzled to this method, but the selector points to the original implementation due to the exchange) - // However, since we are adding this method to the delegate class, 'self' will be the delegate instance. - // The original selector now points to this implementation. - // The swizzled selector points to the original implementation. - // So we need to call the method corresponding to the swizzled selector on self. - - // Wait, standard swizzling: - // original -> originalImp - // swizzled -> swizzledImp - // exchange: - // original -> swizzledImp - // swizzled -> originalImp - - // Inside swizzledImp (this function): - // calling self.swizzled() calls originalImp. - return ph_swizzled_application(app, open: url, options: options) } @@ -257,6 +274,23 @@ class PostHogDeepLinkIntegration: PostHogIntegration { ph_swizzled_scene(scene, continue: userActivity) } + @available(iOS 13.0, tvOS 13.0, *) + @objc func ph_swizzled_scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + for context in connectionOptions.urlContexts { + PostHogDeepLinkIntegration.trackDeepLink(url: context.url, options: context.options) + } + for userActivity in connectionOptions.userActivities { + PostHogDeepLinkIntegration.trackDeepLink(userActivity: userActivity) + } + ph_forward_sceneWillConnect(scene, session, connectionOptions) + } + + @available(iOS 13.0, tvOS 13.0, *) + @inline(never) + private func ph_forward_sceneWillConnect(_ scene: UIScene, _ session: UISceneSession, _ options: UIScene.ConnectionOptions) { + ph_swizzled_scene(scene, willConnectTo: session, options: options) + } + // MARK: - Fallback implementations for adding methods @objc func ph_fallback_application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { @@ -274,6 +308,10 @@ class PostHogDeepLinkIntegration: PostHogIntegration { @available(iOS 13.0, tvOS 13.0, *) @objc func ph_fallback_scene(_ scene: UIScene, continue userActivity: NSUserActivity) { } + + @available(iOS 13.0, tvOS 13.0, *) + @objc func ph_fallback_scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + } #endif } @@ -298,10 +336,13 @@ extension PostHogDeepLinkIntegration { } #endif - static func trackDeepLink(url: URL, referrer: String?) { - guard let postHog = PostHogDeepLinkIntegration.currentInstance, - postHog.config.captureDeepLinks else { return } - + /// Builds the properties dictionary for a deep link event. + /// Extracted for unit testing of property extraction logic. + /// - Parameters: + /// - url: The deep link URL opened. + /// - referrer: The referrer string (may be a URL or other identifier). + /// - Returns: A dictionary of event properties including `url`, optional `$referrer` and `$referring_domain`. + static func buildDeepLinkProperties(url: URL, referrer: String?) -> [String: Any] { var properties: [String: Any] = ["url": url.absoluteString] if let referrer = referrer { @@ -313,6 +354,15 @@ extension PostHogDeepLinkIntegration { } } + return properties + } + + static func trackDeepLink(url: URL, referrer: String?) { + guard let postHog = PostHogDeepLinkIntegration.currentInstance, + postHog.config.captureDeepLinks else { return } + + let properties = buildDeepLinkProperties(url: url, referrer: referrer) postHog.capture("Deep Link Opened", properties: properties) } } + diff --git a/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift b/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift index bc38f0cea0..83411bd4fc 100644 --- a/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift +++ b/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift @@ -130,7 +130,11 @@ @objc func ph_swizzled_uiapplication_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool { defer { // Currently, the action methods pointing to a SwiftUI target are blocked. - let targetClass = String(describing: object_getClassName(target)) + let targetClass: String = { + guard let target else { return "(nil)" } + let cname = object_getClassName(target) + return String(cString: cname) + }() if targetClass.contains("SwiftUI") { hedgeLog("Action methods on SwiftUI targets are not yet supported.") } else if let control = sender as? UIControl, @@ -604,3 +608,4 @@ } #endif + diff --git a/PostHogExample/Info.plist b/PostHogExample/Info.plist new file mode 100644 index 0000000000..c8064a9bc8 --- /dev/null +++ b/PostHogExample/Info.plist @@ -0,0 +1,67 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + PostHogExample + CFBundleExecutable + PostHogExample + CFBundleIdentifier + com.posthog.PostHogExample + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + PostHogExample + CFBundlePackageType + APPL + CFBundleShortVersionString + 4.0 + CFBundleVersion + 10 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UILaunchScreen + + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~iphone + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.posthog.PostHogExample + CFBundleURLSchemes + + posthog + myapp + + + + + diff --git a/PostHogTests/PostHogDeepLinkIntegrationTests.swift b/PostHogTests/PostHogDeepLinkIntegrationTests.swift new file mode 100644 index 0000000000..9d7275159b --- /dev/null +++ b/PostHogTests/PostHogDeepLinkIntegrationTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import PostHog + +final class PostHogDeepLinkIntegrationTests: XCTestCase { + + func testBuildDeepLinkProperties_withValidReferrerURL() { + // Given + let url = URL(string: "myapp://product/123?utm_source=newsletter")! + let referrer = "https://www.google.com/path?q=search" + + // When + let props = PostHogDeepLinkIntegration.buildDeepLinkProperties(url: url, referrer: referrer) + + // Then + XCTAssertEqual(props["url"] as? String, url.absoluteString) + XCTAssertEqual(props["$referrer"] as? String, referrer) + XCTAssertEqual(props["$referring_domain"] as? String, "www.google.com") + } + + func testBuildDeepLinkProperties_withNonURLReferrerString() { + // Given + let url = URL(string: "myapp://home")! + let referrer = "com.example.sourceApp" // not a URL, should not produce $referring_domain + + // When + let props = PostHogDeepLinkIntegration.buildDeepLinkProperties(url: url, referrer: referrer) + + // Then + XCTAssertEqual(props["url"] as? String, url.absoluteString) + XCTAssertEqual(props["$referrer"] as? String, referrer) + XCTAssertNil(props["$referring_domain"], "$referring_domain should be absent for non-URL referrers") + } + + func testBuildDeepLinkProperties_withoutReferrer() { + // Given + let url = URL(string: "https://myapp.com/welcome")! + + // When + let props = PostHogDeepLinkIntegration.buildDeepLinkProperties(url: url, referrer: nil) + + // Then + XCTAssertEqual(props["url"] as? String, url.absoluteString) + XCTAssertNil(props["$referrer"]) + XCTAssertNil(props["$referring_domain"]) + } + + func testTrackDeepLink_userActivityBrowsingWeb_usesReferrerURL() { + // This test validates the helper behavior indirectly for the userActivity flow. + let url = URL(string: "https://myapp.com/article/42")! + let referrer = "https://news.ycombinator.com/item?id=42" + let props = PostHogDeepLinkIntegration.buildDeepLinkProperties(url: url, referrer: referrer) + XCTAssertEqual(props["$referring_domain"] as? String, "news.ycombinator.com") + } +} diff --git a/PostHogTests/PostHogSessionReplayPluginTest.swift b/PostHogTests/PostHogSessionReplayPluginTest.swift index 69b2fc3b6e..17f74749b7 100644 --- a/PostHogTests/PostHogSessionReplayPluginTest.swift +++ b/PostHogTests/PostHogSessionReplayPluginTest.swift @@ -1,6 +1,7 @@ #if os(iOS) @testable import PostHog import Testing + import Foundation @Suite("Session Replay Plugin Remote Config Tests") class PostHogSessionReplayPluginTests { @@ -105,21 +106,21 @@ let config: [String: Any?] = [ "capturePerformance": [ "network_timing": true, - "web_vitals_allowed_metrics": null, + "web_vitals_allowed_metrics": NSNull(), ], ] - #expect(PostHogSessionReplayNetworkPlugin.isEnabledRemotely(remoteConfig: config) == true) + #expect(PostHogSessionReplayNetworkPlugin.isEnabledRemotely(remoteConfig: config as [String : Any]) == true) } @Test("Network plugin disabled when capturePerformance object has NSNull web_vitals_allowed_metrics") - func networkPluginDisabledWhenNetworkTimingIsNull() { + func networkPluginDisabledWhenNetworkTimingIsNSNull() { let config: [String: Any?] = [ "capturePerformance": [ "network_timing": true, "web_vitals_allowed_metrics": NSNull(), ], ] - #expect(PostHogSessionReplayNetworkPlugin.isEnabledRemotely(remoteConfig: config) == true) + #expect(PostHogSessionReplayNetworkPlugin.isEnabledRemotely(remoteConfig: config as [String : Any]) == true) } @Test("Network plugin disabled when capturePerformance is false") @@ -131,3 +132,4 @@ } } #endif + diff --git a/TESTING_DEEP_LINKS.md b/TESTING_DEEP_LINKS.md index 0366be3acd..24674e33ef 100644 --- a/TESTING_DEEP_LINKS.md +++ b/TESTING_DEEP_LINKS.md @@ -38,52 +38,3 @@ class PostHogDeepLinkTests: XCTestCase { // - $referring_domain: "www.google.com" } } -``` - -## 2. Manual Testing (Simulator or Device) - -To verify the integration end-to-end: - -### Prerequisites -1. Use an iOS app that integrates the PostHog SDK. -2. Ensure `config.captureDeepLinks = true` (default). -3. Configure a Custom URL Scheme (e.g., `my-app://`) or Universal Links for your app. - -### Scenario A: Custom URL Scheme via Terminal (Simulator) -1. Build and run the app on the iOS Simulator. -2. Background the app. -3. Open Terminal and run: - ```bash - xcrun simctl openurl booted "my-app://path/to/content?foo=bar" - ``` -4. **Verification:** - - Watch the Xcode console logs (if debug logging is enabled via `config.debug = true`). - - You should see a log entry for capturing `Deep Link Opened`. - - Check the event in PostHog Activity: - - **Event:** `Deep Link Opened` - - **Property `url`:** `my-app://path/to/content?foo=bar` - - **Property `$referrer`:** (Might be nil or system defined depending on source) - -### Scenario B: Universal Link via Safari -1. Install the app on a device or simulator. -2. Open Safari. -3. Navigate to a URL that your app supports (e.g., `https://your-site.com/link`). -4. Tap "Open in [App Name]" banner or if it redirects automatically. -5. **Verification:** - - **Event:** `Deep Link Opened` - - **Property `url`:** `https://your-site.com/link` - - **Property `$referrer`:** URL of the page you were on in Safari. - - **Property `$referring_domain`:** Domain of the referrer. - -### Scenario C: App-to-App Referrer -1. Create a second test app or use Notes/Messages. -2. Paste your custom URL scheme link `my-app://test`. -3. Tap the link to open your app. -4. **Verification:** - - **Event:** `Deep Link Opened` - - **Property `$referrer`:** Bundle ID of the calling app (e.g., `com.apple.mobilenotes`). - -## 3. Verify Configuration Toggle -1. Set `config.captureDeepLinks = false` in your initialization code. -2. Repeat Scenario A. -3. **Verification:** Ensure **NO** `Deep Link Opened` event is captured. From 65dc2c2f0ad3848d9c1e889c4a67d1343962e8e3 Mon Sep 17 00:00:00 2001 From: jeremiahseun <53568423+jeremiahseun@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:35:31 +0000 Subject: [PATCH 06/14] feat: add captureFeatureView and captureFeatureInteraction --- PostHog/PostHogSDK.swift | 34 +++++ .../PostHogEnrichedAnalyticsTest.swift | 121 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 PostHogTests/PostHogEnrichedAnalyticsTest.swift diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index f2aff7b857..05dbfe1861 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1282,6 +1282,40 @@ let maxRetryDelay = 30.0 } } + /// Captures a $feature_view event for the specified feature flag. + /// + /// - Parameter featureFlag: The key of the feature flag being viewed. + @objc public func captureFeatureView(_ featureFlag: String) { + if !isEnabled() { + return + } + + if isOptOutState() { + return + } + + capture("$feature_view", properties: ["feature_flag": featureFlag]) + } + + /// Captures a $feature_interaction event for the specified feature flag. + /// + /// - Parameter featureFlag: The key of the feature flag being interacted with. + @objc public func captureFeatureInteraction(_ featureFlag: String) { + if !isEnabled() { + return + } + + if isOptOutState() { + return + } + + capture( + "$feature_interaction", + properties: ["feature_flag": featureFlag], + userProperties: ["$feature_interaction/\(featureFlag)": true] + ) + } + /// Returns the feature flag result containing the flag value, variant, and payload. /// /// This is the recommended method for retrieving feature flags as it provides diff --git a/PostHogTests/PostHogEnrichedAnalyticsTest.swift b/PostHogTests/PostHogEnrichedAnalyticsTest.swift new file mode 100644 index 0000000000..b43ac7b717 --- /dev/null +++ b/PostHogTests/PostHogEnrichedAnalyticsTest.swift @@ -0,0 +1,121 @@ +import Foundation +import Nimble +@testable import PostHog +import Quick + +class PostHogEnrichedAnalyticsTest: QuickSpec { + func getSut(optOut: Bool = false) -> PostHogSDK { + let config = PostHogConfig(apiKey: testAPIKey, host: "http://localhost:9001") + config.flushAt = 1 + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.optOut = optOut + + let storage = PostHogStorage(config) + storage.reset() + + return PostHogSDK.with(config) + } + + override func spec() { + var server: MockPostHogServer! + + func deleteDefaults() { + let userDefaults = UserDefaults.standard + userDefaults.removeObject(forKey: "PHGVersionKey") + userDefaults.removeObject(forKey: "PHGBuildKeyV2") + userDefaults.synchronize() + + deleteSafely(applicationSupportDirectoryURL()) + } + + beforeEach { + deleteDefaults() + server = MockPostHogServer(version: 4) + server.start() + } + afterEach { + server.stop() + server = nil + } + + it("captures $feature_view event") { + let sut = self.getSut() + + sut.captureFeatureView("test-flag") + + let events = getBatchedEvents(server) + expect(events.count) == 1 + + let event = events.first! + expect(event.event) == "$feature_view" + expect(event.properties["feature_flag"] as? String) == "test-flag" + + sut.reset() + sut.close() + } + + it("captures $feature_interaction event") { + let sut = self.getSut() + + sut.captureFeatureInteraction("test-flag") + + let events = getBatchedEvents(server) + expect(events.count) == 1 + + let event = events.first! + expect(event.event) == "$feature_interaction" + expect(event.properties["feature_flag"] as? String) == "test-flag" + + let setProps = event.properties["$set"] as? [String: Any] + expect(setProps?["$feature_interaction/test-flag"] as? Bool) == true + + sut.reset() + sut.close() + } + + it("does not capture feature view if opt out") { + let sut = self.getSut(optOut: true) + + sut.captureFeatureView("test-flag") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + expect(events.count) == 0 + + sut.reset() + sut.close() + } + + it("does not capture feature interaction if opt out") { + let sut = self.getSut(optOut: true) + + sut.captureFeatureInteraction("test-flag") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + expect(events.count) == 0 + + sut.reset() + sut.close() + } + + it("does not capture feature view if disabled") { + let sut = self.getSut() + sut.close() // Disable SDK + + sut.captureFeatureView("test-flag") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + expect(events.count) == 0 + } + + it("does not capture feature interaction if disabled") { + let sut = self.getSut() + sut.close() // Disable SDK + + sut.captureFeatureInteraction("test-flag") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + expect(events.count) == 0 + } + } +} From 17dee207c86e513733b02a80a57f0fa027681b2a Mon Sep 17 00:00:00 2001 From: Jeremiah Oluwaseun Erinola Date: Fri, 20 Feb 2026 10:44:24 +0100 Subject: [PATCH 07/14] To prevent lifecycle events from interfering with test assertions --- PostHogTests/PostHogEnrichedAnalyticsTest.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/PostHogTests/PostHogEnrichedAnalyticsTest.swift b/PostHogTests/PostHogEnrichedAnalyticsTest.swift index b43ac7b717..adbdec5451 100644 --- a/PostHogTests/PostHogEnrichedAnalyticsTest.swift +++ b/PostHogTests/PostHogEnrichedAnalyticsTest.swift @@ -7,6 +7,7 @@ class PostHogEnrichedAnalyticsTest: QuickSpec { func getSut(optOut: Bool = false) -> PostHogSDK { let config = PostHogConfig(apiKey: testAPIKey, host: "http://localhost:9001") config.flushAt = 1 + config.captureApplicationLifecycleEvents = false config.disableReachabilityForTesting = true config.disableQueueTimerForTesting = true config.optOut = optOut From f524e252f606d3959bb57601a4ef1c657f37e097 Mon Sep 17 00:00:00 2001 From: Jeremiah Oluwaseun Erinola Date: Fri, 20 Feb 2026 10:55:24 +0100 Subject: [PATCH 08/14] Revert "Merge pull request #1 from jeremiahseun/add-referrer-url-automatically" This reverts commit 995939aeb7053b9d2da0e8c33b5ce99a936aabe8, reversing changes made to 43af8063e1d6372c00803e049d6cc8a2ef254fcb. --- Package.swift | 3 - PostHog.xcodeproj/project.pbxproj | 38 +- .../PostHogDeepLinkIntegration.swift | 368 ------------------ .../PostHogAutocaptureEventTracker.swift | 7 +- PostHog/PostHogConfig.swift | 8 - PostHog/PostHogSwizzler.swift | 4 - PostHogExample/Info.plist | 67 ---- .../PostHogDeepLinkIntegrationTests.swift | 54 --- .../PostHogSessionReplayPluginTest.swift | 10 +- TESTING_DEEP_LINKS.md | 40 -- 10 files changed, 19 insertions(+), 580 deletions(-) delete mode 100644 PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift delete mode 100644 PostHogExample/Info.plist delete mode 100644 PostHogTests/PostHogDeepLinkIntegrationTests.swift delete mode 100644 TESTING_DEEP_LINKS.md diff --git a/Package.swift b/Package.swift index 61a4a94dc2..91d46cb4cb 100644 --- a/Package.swift +++ b/Package.swift @@ -29,9 +29,6 @@ let package = Package( path: "PostHog", resources: [ .copy("Resources/PrivacyInfo.xcprivacy"), - ], - swiftSettings: [ - .define("TESTING", .when(configuration: .debug)) ] ), .target( diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index ca38f16c91..2fd45d3831 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -105,7 +105,6 @@ 69F5181A2BAC81FC00F52C14 /* UITextInputTraits+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */; }; 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */; }; CL000001CDE129010001FF01 /* PostHogFeatureFlagResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = CL000001CDE129010001FF00 /* PostHogFeatureFlagResult.swift */; }; - D9D9AC0C89F3174A30EE52DC /* PostHogDeepLinkIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83E6F62BC941AF59AF44595C /* PostHogDeepLinkIntegration.swift */; }; DA0B66A52D64EC00006F5FF3 /* ph_sharpyuv_csp.h in Headers */ = {isa = PBXBuildFile; fileRef = DA0B66982D64EC00006F5FF3 /* ph_sharpyuv_csp.h */; settings = {ATTRIBUTES = (Public, ); }; }; DA0B66A62D64EC00006F5FF3 /* ph_sharpyuv_dsp.h in Headers */ = {isa = PBXBuildFile; fileRef = DA0B66992D64EC00006F5FF3 /* ph_sharpyuv_dsp.h */; settings = {ATTRIBUTES = (Public, ); }; }; DA0B66A72D64EC00006F5FF3 /* ph_huffman_utils.h in Headers */ = {isa = PBXBuildFile; fileRef = DA0B668A2D64EC00006F5FF3 /* ph_huffman_utils.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -648,7 +647,6 @@ 69F518172BAC80A300F52C14 /* String+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Util.swift"; sourceTree = ""; }; 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextInputTraits+Util.swift"; sourceTree = ""; }; 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSwizzler.swift; sourceTree = ""; }; - 83E6F62BC941AF59AF44595C /* PostHogDeepLinkIntegration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostHogDeepLinkIntegration.swift; sourceTree = ""; }; CL000001CDE129010001FF00 /* PostHogFeatureFlagResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogFeatureFlagResult.swift; sourceTree = ""; }; DA0B66792D64EC00006F5FF3 /* ph_backward_references_enc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ph_backward_references_enc.h; sourceTree = ""; }; DA0B667A2D64EC00006F5FF3 /* ph_bit_reader_utils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ph_bit_reader_utils.h; sourceTree = ""; }; @@ -906,7 +904,6 @@ DAFEC4582E03F93A0064535E /* PostHogDisplaySurveyAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogDisplaySurveyAppearance.swift; sourceTree = ""; }; DAFF02652D7B1A9000BD5B1D /* SurveysRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveysRootView.swift; sourceTree = ""; }; DAFF026C2D7B2F8D00BD5B1D /* SurveyDisplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyDisplayController.swift; sourceTree = ""; }; - F2C7A6141B3B17BBBC836CAD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1004,7 +1001,6 @@ 3AA34CFB296D951A003398F4 /* ContentView.swift */, 3AA34CFD296D951B003398F4 /* Assets.xcassets */, 3AA34CFF296D951B003398F4 /* Preview Content */, - F2C7A6141B3B17BBBC836CAD /* Info.plist */, ); path = PostHogExample; sourceTree = ""; @@ -1392,7 +1388,6 @@ children = ( DA703C1B2D6616F20069097B /* PostHogAppLifeCycleIntegration.swift */, DA1D29582D10B7A6003A31DA /* ApplicationLifecyclePublisher.swift */, - 83E6F62BC941AF59AF44595C /* PostHogDeepLinkIntegration.swift */, ); path = "App Life Cycle"; sourceTree = ""; @@ -1752,6 +1747,8 @@ dependencies = ( ); name = PostHog; + packageProductDependencies = ( + ); productName = PostHog; productReference = 3AC745B5296D6FE60025C109 /* PostHog.framework */; productType = "com.apple.product-type.framework"; @@ -2306,7 +2303,6 @@ 69261D1D2AD967CD00232EC7 /* PostHogFileBackedQueue.swift in Sources */, 3AE3FB432992985A00AFFC18 /* Reachability.swift in Sources */, 69F518122BAC783300F52C14 /* CGColor+Util.swift in Sources */, - D9D9AC0C89F3174A30EE52DC /* PostHogDeepLinkIntegration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2484,17 +2480,15 @@ DEVELOPMENT_ASSET_PATHS = "\"PostHogExample/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = PostHogExample/Info.plist; + GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = PostHogExample; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 4.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; @@ -2524,17 +2518,15 @@ DEVELOPMENT_ASSET_PATHS = "\"PostHogExample/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = PostHogExample/Info.plist; + GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = PostHogExample; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 4.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; @@ -3283,17 +3275,15 @@ DEVELOPMENT_ASSET_PATHS = "\"PostHogExample/Preview Content\""; DEVELOPMENT_TEAM = PNC2XCH2XP; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = PostHogExample/Info.plist; + GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = PostHogExample; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; diff --git a/PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift b/PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift deleted file mode 100644 index d6e27c32c4..0000000000 --- a/PostHog/App Life Cycle/PostHogDeepLinkIntegration.swift +++ /dev/null @@ -1,368 +0,0 @@ -// -// PostHogDeepLinkIntegration.swift -// PostHog -// -// Created by Jeremiah Erinola on 18.02.26. -// - -import Foundation - -#if os(iOS) || os(tvOS) - import UIKit -#endif - -class PostHogDeepLinkIntegration: NSObject, PostHogIntegration { - var requiresSwizzling: Bool { true } - - private static var integrationInstalledLock = NSLock() - private static var integrationInstalled = false - private static var swizzledClasses = Set() - - private weak var postHog: PostHogSDK? - - // Store a reference to the active SDK instance for the static tracking methods - // We only support one active SDK instance for deep link tracking at a time because - // deep links are global app events. - private static weak var currentInstance: PostHogSDK? - - func install(_ postHog: PostHogSDK) throws { - try PostHogDeepLinkIntegration.integrationInstalledLock.withLock { - if PostHogDeepLinkIntegration.integrationInstalled { - throw InternalPostHogError(description: "Deep link integration already installed to another PostHogSDK instance.") - } - PostHogDeepLinkIntegration.integrationInstalled = true - } - - self.postHog = postHog - PostHogDeepLinkIntegration.currentInstance = postHog - - start() - } - - func uninstall(_ postHog: PostHogSDK) { - if self.postHog === postHog || self.postHog == nil { - stop() - self.postHog = nil - PostHogDeepLinkIntegration.currentInstance = nil - PostHogDeepLinkIntegration.integrationInstalledLock.withLock { - PostHogDeepLinkIntegration.integrationInstalled = false - } - } - } - - func start() { - #if os(iOS) || os(tvOS) - // Swizzle UIApplicationDelegate - if let delegate = UIApplication.shared.delegate { - swizzleUIApplicationDelegate(delegate) - } - - // Swizzle connected scenes - if #available(iOS 13.0, tvOS 13.0, *) { - for scene in UIApplication.shared.connectedScenes { - if let delegate = scene.delegate { - swizzleUISceneDelegate(delegate) - } - } - - // Observe new scenes connecting - NotificationCenter.default.addObserver(self, - selector: #selector(sceneWillConnect(_:)), - name: UIScene.willConnectNotification, - object: nil) - } - #endif - } - - func stop() { - NotificationCenter.default.removeObserver(self) - } - - #if os(iOS) || os(tvOS) - private func swizzleUIApplicationDelegate(_ delegate: UIApplicationDelegate) { - let delegateClass: AnyClass = type(of: delegate) - let className = String(describing: delegateClass) - - // Ensure we only swizzle each class once - var alreadySwizzled = false - PostHogDeepLinkIntegration.integrationInstalledLock.withLock { - if PostHogDeepLinkIntegration.swizzledClasses.contains(className) { - alreadySwizzled = true - } else { - PostHogDeepLinkIntegration.swizzledClasses.insert(className) - } - } - - if alreadySwizzled { return } - - // application(_:open:options:) - let openURLSelector = #selector(UIApplicationDelegate.application(_:open:options:)) - let swizzledOpenURLSelector = #selector(NSObject.ph_swizzled_application(_:open:options:)) - - // Add the swizzled implementation to the delegate class - let openURLMethod = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_swizzled_application(_:open:options:))) - if let method = openURLMethod { - addMethod(forClass: delegateClass, selector: swizzledOpenURLSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) - } - - // Check if the delegate implements the original method, if not add a fallback - if class_getInstanceMethod(delegateClass, openURLSelector) == nil { - let method = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_fallback_application(_:open:options:))) - if let method = method { - addMethod(forClass: delegateClass, selector: openURLSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) - } - } - - swizzle( - forClass: delegateClass, - original: openURLSelector, - new: swizzledOpenURLSelector - ) - - // application(_:continue:restorationHandler:) - let continueActivitySelector = #selector(UIApplicationDelegate.application(_:continue:restorationHandler:)) - let swizzledContinueActivitySelector = #selector(NSObject.ph_swizzled_application(_:continue:restorationHandler:)) - - // Add the swizzled implementation to the delegate class - let continueActivityMethod = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_swizzled_application(_:continue:restorationHandler:))) - if let method = continueActivityMethod { - addMethod(forClass: delegateClass, selector: swizzledContinueActivitySelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) - } - - if class_getInstanceMethod(delegateClass, continueActivitySelector) == nil { - let method = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_fallback_application(_:continue:restorationHandler:))) - if let method = method { - addMethod(forClass: delegateClass, selector: continueActivitySelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) - } - } - - swizzle( - forClass: delegateClass, - original: continueActivitySelector, - new: swizzledContinueActivitySelector - ) - } - - @available(iOS 13.0, tvOS 13.0, *) - private func swizzleUISceneDelegate(_ delegate: UISceneDelegate) { - let delegateClass: AnyClass = type(of: delegate) - let className = String(describing: delegateClass) - - // Ensure we only swizzle each class once - var alreadySwizzled = false - PostHogDeepLinkIntegration.integrationInstalledLock.withLock { - if PostHogDeepLinkIntegration.swizzledClasses.contains(className) { - alreadySwizzled = true - } else { - PostHogDeepLinkIntegration.swizzledClasses.insert(className) - } - } - - if alreadySwizzled { return } - - // scene(_:openURLContexts:) - let openURLContextsSelector = #selector(UISceneDelegate.scene(_:openURLContexts:)) - let swizzledOpenURLContextsSelector = #selector(NSObject.ph_swizzled_scene(_:openURLContexts:)) - - // Add the swizzled implementation to the delegate class - let openURLContextsMethod = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_swizzled_scene(_:openURLContexts:))) - if let method = openURLContextsMethod { - addMethod(forClass: delegateClass, selector: swizzledOpenURLContextsSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) - } - - if class_getInstanceMethod(delegateClass, openURLContextsSelector) == nil { - let method = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_fallback_scene(_:openURLContexts:))) - if let method = method { - addMethod(forClass: delegateClass, selector: openURLContextsSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) - } - } - - swizzle( - forClass: delegateClass, - original: openURLContextsSelector, - new: swizzledOpenURLContextsSelector - ) - - // scene(_:continue:) - let continueActivitySelector = #selector(UISceneDelegate.scene(_:continue:)) - let swizzledContinueActivitySelector = #selector(NSObject.ph_swizzled_scene(_:continue:)) - - // Add the swizzled implementation to the delegate class - let continueActivityMethod = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_swizzled_scene(_:continue:))) - if let method = continueActivityMethod { - addMethod(forClass: delegateClass, selector: swizzledContinueActivitySelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) - } - - if class_getInstanceMethod(delegateClass, continueActivitySelector) == nil { - let method = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_fallback_scene(_:continue:))) - if let method = method { - addMethod(forClass: delegateClass, selector: continueActivitySelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) - } - } - - swizzle( - forClass: delegateClass, - original: continueActivitySelector, - new: swizzledContinueActivitySelector - ) - - // scene(_:willConnectTo:options:) - // Only swizzle if the delegate implements it, to avoid breaking default Storyboard behavior - let willConnectSelector = #selector(UISceneDelegate.scene(_:willConnectTo:options:)) - let swizzledWillConnectSelector = #selector(NSObject.ph_swizzled_scene(_:willConnectTo:options:)) - - if class_getInstanceMethod(delegateClass, willConnectSelector) != nil { - let willConnectMethod = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_swizzled_scene(_:willConnectTo:options:))) - - if let method = willConnectMethod { - addMethod(forClass: delegateClass, selector: swizzledWillConnectSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) - } - - // Fallback is theoretically needed if we exchange, but here we ONLY swizzle if method exists. - // However, to be consistent and safe if logic changes: - if class_getInstanceMethod(delegateClass, willConnectSelector) == nil { - let method = class_getInstanceMethod(NSObject.self, #selector(NSObject.ph_fallback_scene(_:willConnectTo:options:))) - if let method = method { - addMethod(forClass: delegateClass, selector: willConnectSelector, implementation: method_getImplementation(method), types: String(cString: method_getTypeEncoding(method)!)) - } - } - - swizzle( - forClass: delegateClass, - original: willConnectSelector, - new: swizzledWillConnectSelector - ) - } - } - - @objc private func sceneWillConnect(_ notification: Notification) { - if #available(iOS 13.0, tvOS 13.0, *) { - guard let scene = notification.object as? UIScene, - let delegate = scene.delegate else { return } - swizzleUISceneDelegate(delegate) - } - } - - #endif -} - -extension NSObject { - #if os(iOS) || os(tvOS) - // MARK: - Swizzled Implementations (to be added to delegate classes) - - @objc func ph_swizzled_application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - PostHogDeepLinkIntegration.trackDeepLink(url: url, options: options) - return ph_swizzled_application(app, open: url, options: options) - } - - @objc func ph_swizzled_application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - PostHogDeepLinkIntegration.trackDeepLink(userActivity: userActivity) - return ph_swizzled_application(application, continue: userActivity, restorationHandler: restorationHandler) - } - - @available(iOS 13.0, tvOS 13.0, *) - @objc func ph_swizzled_scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - for context in URLContexts { - PostHogDeepLinkIntegration.trackDeepLink(url: context.url, options: context.options) - } - ph_swizzled_scene(scene, openURLContexts: URLContexts) - } - - @available(iOS 13.0, tvOS 13.0, *) - @objc func ph_swizzled_scene(_ scene: UIScene, continue userActivity: NSUserActivity) { - PostHogDeepLinkIntegration.trackDeepLink(userActivity: userActivity) - ph_swizzled_scene(scene, continue: userActivity) - } - - @available(iOS 13.0, tvOS 13.0, *) - @objc func ph_swizzled_scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - for context in connectionOptions.urlContexts { - PostHogDeepLinkIntegration.trackDeepLink(url: context.url, options: context.options) - } - for userActivity in connectionOptions.userActivities { - PostHogDeepLinkIntegration.trackDeepLink(userActivity: userActivity) - } - ph_forward_sceneWillConnect(scene, session, connectionOptions) - } - - @available(iOS 13.0, tvOS 13.0, *) - @inline(never) - private func ph_forward_sceneWillConnect(_ scene: UIScene, _ session: UISceneSession, _ options: UIScene.ConnectionOptions) { - ph_swizzled_scene(scene, willConnectTo: session, options: options) - } - - // MARK: - Fallback implementations for adding methods - - @objc func ph_fallback_application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - return false - } - - @objc func ph_fallback_application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - return false - } - - @available(iOS 13.0, tvOS 13.0, *) - @objc func ph_fallback_scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - } - - @available(iOS 13.0, tvOS 13.0, *) - @objc func ph_fallback_scene(_ scene: UIScene, continue userActivity: NSUserActivity) { - } - - @available(iOS 13.0, tvOS 13.0, *) - @objc func ph_fallback_scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - } - #endif -} - -// MARK: - Tracking Logic - -extension PostHogDeepLinkIntegration { - #if os(iOS) || os(tvOS) - static func trackDeepLink(url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) { - let referrer = options[.sourceApplication] as? String - trackDeepLink(url: url, referrer: referrer) - } - - @available(iOS 13.0, tvOS 13.0, *) - static func trackDeepLink(url: URL, options: UIScene.OpenURLOptions) { - trackDeepLink(url: url, referrer: options.sourceApplication) - } - - static func trackDeepLink(userActivity: NSUserActivity) { - if userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL { - trackDeepLink(url: url, referrer: userActivity.referrerURL?.absoluteString) - } - } - #endif - - /// Builds the properties dictionary for a deep link event. - /// Extracted for unit testing of property extraction logic. - /// - Parameters: - /// - url: The deep link URL opened. - /// - referrer: The referrer string (may be a URL or other identifier). - /// - Returns: A dictionary of event properties including `url`, optional `$referrer` and `$referring_domain`. - static func buildDeepLinkProperties(url: URL, referrer: String?) -> [String: Any] { - var properties: [String: Any] = ["url": url.absoluteString] - - if let referrer = referrer { - properties["$referrer"] = referrer - - // Try to extract domain from referrer if it looks like a URL - if let referrerURL = URL(string: referrer), let host = referrerURL.host { - properties["$referring_domain"] = host - } - } - - return properties - } - - static func trackDeepLink(url: URL, referrer: String?) { - guard let postHog = PostHogDeepLinkIntegration.currentInstance, - postHog.config.captureDeepLinks else { return } - - let properties = buildDeepLinkProperties(url: url, referrer: referrer) - postHog.capture("Deep Link Opened", properties: properties) - } -} - diff --git a/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift b/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift index 83411bd4fc..bc38f0cea0 100644 --- a/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift +++ b/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift @@ -130,11 +130,7 @@ @objc func ph_swizzled_uiapplication_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool { defer { // Currently, the action methods pointing to a SwiftUI target are blocked. - let targetClass: String = { - guard let target else { return "(nil)" } - let cname = object_getClassName(target) - return String(cString: cname) - }() + let targetClass = String(describing: object_getClassName(target)) if targetClass.contains("SwiftUI") { hedgeLog("Action methods on SwiftUI targets are not yet supported.") } else if let control = sender as? UIControl, @@ -608,4 +604,3 @@ } #endif - diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index ed2182b732..2b35a947f5 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -65,10 +65,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Default: true @objc public var enableSwizzling: Bool = true - /// Enable capturing deep links - /// Default: true - @objc public var captureDeepLinks: Bool = true - #if os(iOS) || targetEnvironment(macCatalyst) /// Enable autocapture for iOS /// Default: false @@ -229,10 +225,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? integrations.append(PostHogAppLifeCycleIntegration()) } - if captureDeepLinks { - integrations.append(PostHogDeepLinkIntegration()) - } - #if os(iOS) if sessionReplay { integrations.append(PostHogReplayIntegration()) diff --git a/PostHog/PostHogSwizzler.swift b/PostHog/PostHogSwizzler.swift index 0803b23ba4..7aaf73c4d1 100644 --- a/PostHog/PostHogSwizzler.swift +++ b/PostHog/PostHogSwizzler.swift @@ -12,7 +12,3 @@ func swizzle(forClass: AnyClass, original: Selector, new: Selector) { guard let swizzledMethod = class_getInstanceMethod(forClass, new) else { return } method_exchangeImplementations(originalMethod, swizzledMethod) } - -func addMethod(forClass: AnyClass, selector: Selector, implementation: IMP, types: String) { - class_addMethod(forClass, selector, implementation, types) -} diff --git a/PostHogExample/Info.plist b/PostHogExample/Info.plist deleted file mode 100644 index c8064a9bc8..0000000000 --- a/PostHogExample/Info.plist +++ /dev/null @@ -1,67 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - PostHogExample - CFBundleExecutable - PostHogExample - CFBundleIdentifier - com.posthog.PostHogExample - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - PostHogExample - CFBundlePackageType - APPL - CFBundleShortVersionString - 4.0 - CFBundleVersion - 10 - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - - UIApplicationSupportsIndirectInputEvents - - UILaunchScreen - - UILaunchScreen - - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~iphone - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLName - com.posthog.PostHogExample - CFBundleURLSchemes - - posthog - myapp - - - - - diff --git a/PostHogTests/PostHogDeepLinkIntegrationTests.swift b/PostHogTests/PostHogDeepLinkIntegrationTests.swift deleted file mode 100644 index 9d7275159b..0000000000 --- a/PostHogTests/PostHogDeepLinkIntegrationTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -import XCTest -@testable import PostHog - -final class PostHogDeepLinkIntegrationTests: XCTestCase { - - func testBuildDeepLinkProperties_withValidReferrerURL() { - // Given - let url = URL(string: "myapp://product/123?utm_source=newsletter")! - let referrer = "https://www.google.com/path?q=search" - - // When - let props = PostHogDeepLinkIntegration.buildDeepLinkProperties(url: url, referrer: referrer) - - // Then - XCTAssertEqual(props["url"] as? String, url.absoluteString) - XCTAssertEqual(props["$referrer"] as? String, referrer) - XCTAssertEqual(props["$referring_domain"] as? String, "www.google.com") - } - - func testBuildDeepLinkProperties_withNonURLReferrerString() { - // Given - let url = URL(string: "myapp://home")! - let referrer = "com.example.sourceApp" // not a URL, should not produce $referring_domain - - // When - let props = PostHogDeepLinkIntegration.buildDeepLinkProperties(url: url, referrer: referrer) - - // Then - XCTAssertEqual(props["url"] as? String, url.absoluteString) - XCTAssertEqual(props["$referrer"] as? String, referrer) - XCTAssertNil(props["$referring_domain"], "$referring_domain should be absent for non-URL referrers") - } - - func testBuildDeepLinkProperties_withoutReferrer() { - // Given - let url = URL(string: "https://myapp.com/welcome")! - - // When - let props = PostHogDeepLinkIntegration.buildDeepLinkProperties(url: url, referrer: nil) - - // Then - XCTAssertEqual(props["url"] as? String, url.absoluteString) - XCTAssertNil(props["$referrer"]) - XCTAssertNil(props["$referring_domain"]) - } - - func testTrackDeepLink_userActivityBrowsingWeb_usesReferrerURL() { - // This test validates the helper behavior indirectly for the userActivity flow. - let url = URL(string: "https://myapp.com/article/42")! - let referrer = "https://news.ycombinator.com/item?id=42" - let props = PostHogDeepLinkIntegration.buildDeepLinkProperties(url: url, referrer: referrer) - XCTAssertEqual(props["$referring_domain"] as? String, "news.ycombinator.com") - } -} diff --git a/PostHogTests/PostHogSessionReplayPluginTest.swift b/PostHogTests/PostHogSessionReplayPluginTest.swift index 17f74749b7..69b2fc3b6e 100644 --- a/PostHogTests/PostHogSessionReplayPluginTest.swift +++ b/PostHogTests/PostHogSessionReplayPluginTest.swift @@ -1,7 +1,6 @@ #if os(iOS) @testable import PostHog import Testing - import Foundation @Suite("Session Replay Plugin Remote Config Tests") class PostHogSessionReplayPluginTests { @@ -106,21 +105,21 @@ let config: [String: Any?] = [ "capturePerformance": [ "network_timing": true, - "web_vitals_allowed_metrics": NSNull(), + "web_vitals_allowed_metrics": null, ], ] - #expect(PostHogSessionReplayNetworkPlugin.isEnabledRemotely(remoteConfig: config as [String : Any]) == true) + #expect(PostHogSessionReplayNetworkPlugin.isEnabledRemotely(remoteConfig: config) == true) } @Test("Network plugin disabled when capturePerformance object has NSNull web_vitals_allowed_metrics") - func networkPluginDisabledWhenNetworkTimingIsNSNull() { + func networkPluginDisabledWhenNetworkTimingIsNull() { let config: [String: Any?] = [ "capturePerformance": [ "network_timing": true, "web_vitals_allowed_metrics": NSNull(), ], ] - #expect(PostHogSessionReplayNetworkPlugin.isEnabledRemotely(remoteConfig: config as [String : Any]) == true) + #expect(PostHogSessionReplayNetworkPlugin.isEnabledRemotely(remoteConfig: config) == true) } @Test("Network plugin disabled when capturePerformance is false") @@ -132,4 +131,3 @@ } } #endif - diff --git a/TESTING_DEEP_LINKS.md b/TESTING_DEEP_LINKS.md deleted file mode 100644 index 24674e33ef..0000000000 --- a/TESTING_DEEP_LINKS.md +++ /dev/null @@ -1,40 +0,0 @@ -# Testing Deep Link Capture - -This document outlines how to verify that the automatic deep link capture feature works as expected. - -## 1. Unit Testing - -Since the feature relies on swizzling `UIApplicationDelegate` and `UISceneDelegate`, unit tests need to ensure that the integration installs correctly and that the tracking logic produces the correct events. - -### Example Test Case (Swift) - -You can add a test case to `PostHogTests` to verify the property extraction logic. - -```swift -import XCTest -@testable import PostHog - -class PostHogDeepLinkTests: XCTestCase { - - func testTrackDeepLinkWithReferrer() { - // Setup - let config = PostHogConfig(apiKey: "test_api_key") - config.captureDeepLinks = true - let postHog = PostHogSDK.with(config) - - // Mock URL and Referrer - let url = URL(string: "https://myapp.com/product/123?utm_source=newsletter")! - let referrer = "https://www.google.com" - - // Trigger tracking manually (since we can't easily mock the swizzling in a simple unit test without a host app) - // Note: internal access to PostHogDeepLinkIntegration might be needed or test via the public side effect - PostHogDeepLinkIntegration.trackDeepLink(url: url, referrer: referrer) - - // Verify (Mocking the Queue/Network would be required for a real assertion) - // Check logs or use a mock server to verify "Deep Link Opened" event was sent - // Properties should include: - // - url: "https://myapp.com/product/123?utm_source=newsletter" - // - $referrer: "https://www.google.com" - // - $referring_domain: "www.google.com" - } -} From 6cca921cad16272e85fea9207c2e9cab68453ca9 Mon Sep 17 00:00:00 2001 From: jeremiahseun <53568423+jeremiahseun@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:35:31 +0000 Subject: [PATCH 09/14] feat: add captureFeatureView and captureFeatureInteraction --- PostHog/PostHogSDK.swift | 34 +++++ .../PostHogEnrichedAnalyticsTest.swift | 121 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 PostHogTests/PostHogEnrichedAnalyticsTest.swift diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index f2aff7b857..05dbfe1861 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1282,6 +1282,40 @@ let maxRetryDelay = 30.0 } } + /// Captures a $feature_view event for the specified feature flag. + /// + /// - Parameter featureFlag: The key of the feature flag being viewed. + @objc public func captureFeatureView(_ featureFlag: String) { + if !isEnabled() { + return + } + + if isOptOutState() { + return + } + + capture("$feature_view", properties: ["feature_flag": featureFlag]) + } + + /// Captures a $feature_interaction event for the specified feature flag. + /// + /// - Parameter featureFlag: The key of the feature flag being interacted with. + @objc public func captureFeatureInteraction(_ featureFlag: String) { + if !isEnabled() { + return + } + + if isOptOutState() { + return + } + + capture( + "$feature_interaction", + properties: ["feature_flag": featureFlag], + userProperties: ["$feature_interaction/\(featureFlag)": true] + ) + } + /// Returns the feature flag result containing the flag value, variant, and payload. /// /// This is the recommended method for retrieving feature flags as it provides diff --git a/PostHogTests/PostHogEnrichedAnalyticsTest.swift b/PostHogTests/PostHogEnrichedAnalyticsTest.swift new file mode 100644 index 0000000000..b43ac7b717 --- /dev/null +++ b/PostHogTests/PostHogEnrichedAnalyticsTest.swift @@ -0,0 +1,121 @@ +import Foundation +import Nimble +@testable import PostHog +import Quick + +class PostHogEnrichedAnalyticsTest: QuickSpec { + func getSut(optOut: Bool = false) -> PostHogSDK { + let config = PostHogConfig(apiKey: testAPIKey, host: "http://localhost:9001") + config.flushAt = 1 + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.optOut = optOut + + let storage = PostHogStorage(config) + storage.reset() + + return PostHogSDK.with(config) + } + + override func spec() { + var server: MockPostHogServer! + + func deleteDefaults() { + let userDefaults = UserDefaults.standard + userDefaults.removeObject(forKey: "PHGVersionKey") + userDefaults.removeObject(forKey: "PHGBuildKeyV2") + userDefaults.synchronize() + + deleteSafely(applicationSupportDirectoryURL()) + } + + beforeEach { + deleteDefaults() + server = MockPostHogServer(version: 4) + server.start() + } + afterEach { + server.stop() + server = nil + } + + it("captures $feature_view event") { + let sut = self.getSut() + + sut.captureFeatureView("test-flag") + + let events = getBatchedEvents(server) + expect(events.count) == 1 + + let event = events.first! + expect(event.event) == "$feature_view" + expect(event.properties["feature_flag"] as? String) == "test-flag" + + sut.reset() + sut.close() + } + + it("captures $feature_interaction event") { + let sut = self.getSut() + + sut.captureFeatureInteraction("test-flag") + + let events = getBatchedEvents(server) + expect(events.count) == 1 + + let event = events.first! + expect(event.event) == "$feature_interaction" + expect(event.properties["feature_flag"] as? String) == "test-flag" + + let setProps = event.properties["$set"] as? [String: Any] + expect(setProps?["$feature_interaction/test-flag"] as? Bool) == true + + sut.reset() + sut.close() + } + + it("does not capture feature view if opt out") { + let sut = self.getSut(optOut: true) + + sut.captureFeatureView("test-flag") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + expect(events.count) == 0 + + sut.reset() + sut.close() + } + + it("does not capture feature interaction if opt out") { + let sut = self.getSut(optOut: true) + + sut.captureFeatureInteraction("test-flag") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + expect(events.count) == 0 + + sut.reset() + sut.close() + } + + it("does not capture feature view if disabled") { + let sut = self.getSut() + sut.close() // Disable SDK + + sut.captureFeatureView("test-flag") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + expect(events.count) == 0 + } + + it("does not capture feature interaction if disabled") { + let sut = self.getSut() + sut.close() // Disable SDK + + sut.captureFeatureInteraction("test-flag") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + expect(events.count) == 0 + } + } +} From 06c84b7f824230d343e92f6937d17b2223014db2 Mon Sep 17 00:00:00 2001 From: Jeremiah Oluwaseun Erinola Date: Fri, 20 Feb 2026 10:44:24 +0100 Subject: [PATCH 10/14] To prevent lifecycle events from interfering with test assertions --- PostHogTests/PostHogEnrichedAnalyticsTest.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/PostHogTests/PostHogEnrichedAnalyticsTest.swift b/PostHogTests/PostHogEnrichedAnalyticsTest.swift index b43ac7b717..adbdec5451 100644 --- a/PostHogTests/PostHogEnrichedAnalyticsTest.swift +++ b/PostHogTests/PostHogEnrichedAnalyticsTest.swift @@ -7,6 +7,7 @@ class PostHogEnrichedAnalyticsTest: QuickSpec { func getSut(optOut: Bool = false) -> PostHogSDK { let config = PostHogConfig(apiKey: testAPIKey, host: "http://localhost:9001") config.flushAt = 1 + config.captureApplicationLifecycleEvents = false config.disableReachabilityForTesting = true config.disableQueueTimerForTesting = true config.optOut = optOut From 19fd08e80ede2dc52032a97b22820c7f6b039380 Mon Sep 17 00:00:00 2001 From: Jeremiah Oluwaseun Erinola Date: Fri, 20 Feb 2026 12:33:59 +0100 Subject: [PATCH 11/14] Support feature flag variants in captures Add optional flagVariant parameter to captureFeatureView and captureFeatureInteraction. Build event properties to include feature_flag and, when available as a string, feature_flag_variant. User properties now include a "$feature_view/" or "$feature_interaction/" entry set to the resolved variant (falls back to getFeatureFlag(... ) or true). Update tests to call the new signatures and assert the user property is set. --- PostHog/PostHogSDK.swift | 48 ++++++++++++++++--- .../PostHogEnrichedAnalyticsTest.swift | 15 +++--- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 05dbfe1861..c03a6b7464 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1284,8 +1284,9 @@ let maxRetryDelay = 30.0 /// Captures a $feature_view event for the specified feature flag. /// - /// - Parameter featureFlag: The key of the feature flag being viewed. - @objc public func captureFeatureView(_ featureFlag: String) { + /// - Parameter flag: The key of the feature flag being viewed. + /// - Parameter flagVariant: The variant of the feature flag being viewed. + @objc public func captureFeatureView(flag: String, flagVariant: String?) { if !isEnabled() { return } @@ -1294,13 +1295,32 @@ let maxRetryDelay = 30.0 return } - capture("$feature_view", properties: ["feature_flag": featureFlag]) + var props: [String: Any] = [ + "feature_flag": flag, + ] + + let variant: Any = flagVariant ?? getFeatureFlag(flag, sendEvent: false) ?? true + if let variantStr = variant as? String { + props["feature_flag_variant"] = variantStr + } + + let userProps: [String: Any] = [ + "$feature_view/\(flag)": variant, + ] + + capture( + "$feature_view", + properties: props, + userProperties: userProps + ) } /// Captures a $feature_interaction event for the specified feature flag. /// - /// - Parameter featureFlag: The key of the feature flag being interacted with. - @objc public func captureFeatureInteraction(_ featureFlag: String) { + /// - Parameter flag: The key of the feature flag being interacted with. + /// - Parameter flagVariant: The variant of the feature flag being interacted with. + @objc public func captureFeatureInteraction(flag: String, + flagVariant: String?) { if !isEnabled() { return } @@ -1309,10 +1329,23 @@ let maxRetryDelay = 30.0 return } + var props: [String: Any] = [ + "feature_flag": flag, + ] + + let variant: Any = flagVariant ?? getFeatureFlag(flag, sendEvent: false) ?? true + if let variantStr = variant as? String { + props["feature_flag_variant"] = variantStr + } + + let userProps: [String: Any] = [ + "$feature_interaction/\(flag)": variant, + ] + capture( "$feature_interaction", - properties: ["feature_flag": featureFlag], - userProperties: ["$feature_interaction/\(featureFlag)": true] + properties: props, + userProperties: userProps ) } @@ -1758,3 +1791,4 @@ let maxRetryDelay = 30.0 #endif // swiftlint:enable file_length cyclomatic_complexity + diff --git a/PostHogTests/PostHogEnrichedAnalyticsTest.swift b/PostHogTests/PostHogEnrichedAnalyticsTest.swift index adbdec5451..e9996e28e0 100644 --- a/PostHogTests/PostHogEnrichedAnalyticsTest.swift +++ b/PostHogTests/PostHogEnrichedAnalyticsTest.swift @@ -43,7 +43,7 @@ class PostHogEnrichedAnalyticsTest: QuickSpec { it("captures $feature_view event") { let sut = self.getSut() - sut.captureFeatureView("test-flag") + sut.captureFeatureView(flag: "test-flag", flagVariant: nil) let events = getBatchedEvents(server) expect(events.count) == 1 @@ -51,6 +51,8 @@ class PostHogEnrichedAnalyticsTest: QuickSpec { let event = events.first! expect(event.event) == "$feature_view" expect(event.properties["feature_flag"] as? String) == "test-flag" + let setProps = event.properties["$set"] as? [String: Any] + expect(setProps?["$feature_view/test-flag"] as? Bool) == true sut.reset() sut.close() @@ -59,7 +61,7 @@ class PostHogEnrichedAnalyticsTest: QuickSpec { it("captures $feature_interaction event") { let sut = self.getSut() - sut.captureFeatureInteraction("test-flag") + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) let events = getBatchedEvents(server) expect(events.count) == 1 @@ -78,7 +80,7 @@ class PostHogEnrichedAnalyticsTest: QuickSpec { it("does not capture feature view if opt out") { let sut = self.getSut(optOut: true) - sut.captureFeatureView("test-flag") + sut.captureFeatureView(flag: "test-flag", flagVariant: nil) let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) expect(events.count) == 0 @@ -90,7 +92,7 @@ class PostHogEnrichedAnalyticsTest: QuickSpec { it("does not capture feature interaction if opt out") { let sut = self.getSut(optOut: true) - sut.captureFeatureInteraction("test-flag") + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) expect(events.count) == 0 @@ -103,7 +105,7 @@ class PostHogEnrichedAnalyticsTest: QuickSpec { let sut = self.getSut() sut.close() // Disable SDK - sut.captureFeatureView("test-flag") + sut.captureFeatureView(flag: "test-flag", flagVariant: nil) let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) expect(events.count) == 0 @@ -113,10 +115,11 @@ class PostHogEnrichedAnalyticsTest: QuickSpec { let sut = self.getSut() sut.close() // Disable SDK - sut.captureFeatureInteraction("test-flag") + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) expect(events.count) == 0 } } } + From 5d50dafb3218ec9ab6929d9a21406dc73596c776 Mon Sep 17 00:00:00 2001 From: Jeremiah Oluwaseun Erinola Date: Fri, 20 Feb 2026 14:43:20 +0100 Subject: [PATCH 12/14] Migrated test from Quick and Nimble --- .../PostHogEnrichedAnalyticsTest.swift | 167 +++++++++--------- 1 file changed, 85 insertions(+), 82 deletions(-) diff --git a/PostHogTests/PostHogEnrichedAnalyticsTest.swift b/PostHogTests/PostHogEnrichedAnalyticsTest.swift index e9996e28e0..e6e7f34a94 100644 --- a/PostHogTests/PostHogEnrichedAnalyticsTest.swift +++ b/PostHogTests/PostHogEnrichedAnalyticsTest.swift @@ -1,9 +1,30 @@ import Foundation -import Nimble @testable import PostHog -import Quick +import Testing + +@Suite("PostHogEnrichedAnalytics Tests", .serialized) +class PostHogEnrichedAnalyticsTest { + let server: MockPostHogServer + + init() { + Self.deleteDefaults() + server = MockPostHogServer(version: 4) + server.start() + } + + deinit { + server.stop() + } + + private static func deleteDefaults() { + let userDefaults = UserDefaults.standard + userDefaults.removeObject(forKey: "PHGVersionKey") + userDefaults.removeObject(forKey: "PHGBuildKeyV2") + userDefaults.synchronize() + + deleteSafely(applicationSupportDirectoryURL()) + } -class PostHogEnrichedAnalyticsTest: QuickSpec { func getSut(optOut: Bool = false) -> PostHogSDK { let config = PostHogConfig(apiKey: testAPIKey, host: "http://localhost:9001") config.flushAt = 1 @@ -18,108 +39,90 @@ class PostHogEnrichedAnalyticsTest: QuickSpec { return PostHogSDK.with(config) } - override func spec() { - var server: MockPostHogServer! - - func deleteDefaults() { - let userDefaults = UserDefaults.standard - userDefaults.removeObject(forKey: "PHGVersionKey") - userDefaults.removeObject(forKey: "PHGBuildKeyV2") - userDefaults.synchronize() + @Test("captures $feature_view event") + func capturesFeatureViewEvent() { + let sut = getSut() - deleteSafely(applicationSupportDirectoryURL()) - } + sut.captureFeatureView(flag: "test-flag", flagVariant: nil) - beforeEach { - deleteDefaults() - server = MockPostHogServer(version: 4) - server.start() - } - afterEach { - server.stop() - server = nil - } + let events = getBatchedEvents(server) + #expect(events.count == 1) - it("captures $feature_view event") { - let sut = self.getSut() + let event = events.first! + #expect(event.event == "$feature_view") + #expect(event.properties["feature_flag"] as? String == "test-flag") + let setProps = event.properties["$set"] as? [String: Any] + #expect(setProps?["$feature_view/test-flag"] as? Bool == true) - sut.captureFeatureView(flag: "test-flag", flagVariant: nil) - - let events = getBatchedEvents(server) - expect(events.count) == 1 - - let event = events.first! - expect(event.event) == "$feature_view" - expect(event.properties["feature_flag"] as? String) == "test-flag" - let setProps = event.properties["$set"] as? [String: Any] - expect(setProps?["$feature_view/test-flag"] as? Bool) == true - - sut.reset() - sut.close() - } + sut.reset() + sut.close() + } - it("captures $feature_interaction event") { - let sut = self.getSut() + @Test("captures $feature_interaction event") + func capturesFeatureInteractionEvent() { + let sut = getSut() - sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) - let events = getBatchedEvents(server) - expect(events.count) == 1 + let events = getBatchedEvents(server) + #expect(events.count == 1) - let event = events.first! - expect(event.event) == "$feature_interaction" - expect(event.properties["feature_flag"] as? String) == "test-flag" + let event = events.first! + #expect(event.event == "$feature_interaction") + #expect(event.properties["feature_flag"] as? String == "test-flag") - let setProps = event.properties["$set"] as? [String: Any] - expect(setProps?["$feature_interaction/test-flag"] as? Bool) == true + let setProps = event.properties["$set"] as? [String: Any] + #expect(setProps?["$feature_interaction/test-flag"] as? Bool == true) - sut.reset() - sut.close() - } + sut.reset() + sut.close() + } - it("does not capture feature view if opt out") { - let sut = self.getSut(optOut: true) + @Test("does not capture feature view if opt out") + func doesNotCaptureFeatureViewIfOptOut() { + let sut = getSut(optOut: true) - sut.captureFeatureView(flag: "test-flag", flagVariant: nil) + sut.captureFeatureView(flag: "test-flag", flagVariant: nil) - let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) - expect(events.count) == 0 + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + #expect(events.count == 0) - sut.reset() - sut.close() - } + sut.reset() + sut.close() + } - it("does not capture feature interaction if opt out") { - let sut = self.getSut(optOut: true) + @Test("does not capture feature interaction if opt out") + func doesNotCaptureFeatureInteractionIfOptOut() { + let sut = getSut(optOut: true) - sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) - let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) - expect(events.count) == 0 + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + #expect(events.count == 0) - sut.reset() - sut.close() - } + sut.reset() + sut.close() + } - it("does not capture feature view if disabled") { - let sut = self.getSut() - sut.close() // Disable SDK + @Test("does not capture feature view if disabled") + func doesNotCaptureFeatureViewIfDisabled() { + let sut = getSut() + sut.close() // Disable SDK - sut.captureFeatureView(flag: "test-flag", flagVariant: nil) + sut.captureFeatureView(flag: "test-flag", flagVariant: nil) - let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) - expect(events.count) == 0 - } + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + #expect(events.count == 0) + } - it("does not capture feature interaction if disabled") { - let sut = self.getSut() - sut.close() // Disable SDK + @Test("does not capture feature interaction if disabled") + func doesNotCaptureFeatureInteractionIfDisabled() { + let sut = getSut() + sut.close() // Disable SDK - sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) - let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) - expect(events.count) == 0 - } + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + #expect(events.count == 0) } } - From 4118a20200c2b6c64aa2b28fc2e8eed760db5164 Mon Sep 17 00:00:00 2001 From: Jeremiah Oluwaseun Erinola Date: Wed, 25 Feb 2026 13:05:50 +0100 Subject: [PATCH 13/14] Require feature variant when capturing events Do not record feature view/interaction events when no variant is available. In PostHogSDK.swift the code now prefers an explicitly passed variant and falls back to a flag lookup, but will early-return (with a log) if neither provides a value instead of defaulting to true. Tests in PostHogEnrichedAnalyticsTest.swift were updated to pass an explicit "control" variant where needed, to expect feature_flag_variant as a string, and to add cases asserting no events are captured when no variant is available. This prevents emitting meaningless feature events and aligns the $set values to the variant string. --- PostHog/PostHogSDK.swift | 17 ++++-- .../PostHogEnrichedAnalyticsTest.swift | 53 +++++++++++++++---- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index c03a6b7464..41f5743029 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1295,11 +1295,17 @@ let maxRetryDelay = 30.0 return } + // Get the variant value — prefer the explicitly passed variant, then fall back to a flag lookup. + // If neither is available, there is no meaningful variant to record, so we skip the event. + guard let variant: Any = flagVariant ?? getFeatureFlag(flag, sendEvent: false) else { + hedgeLog("captureFeatureView called for flag '\(flag)' but no variant value is available. Event will not be captured.") + return + } + var props: [String: Any] = [ "feature_flag": flag, ] - let variant: Any = flagVariant ?? getFeatureFlag(flag, sendEvent: false) ?? true if let variantStr = variant as? String { props["feature_flag_variant"] = variantStr } @@ -1329,11 +1335,17 @@ let maxRetryDelay = 30.0 return } + // Get the variant value — prefer the explicitly passed variant, then fall back to a flag lookup. + // If neither is available, there is no meaningful variant to record, so we skip the event. + guard let variant: Any = flagVariant ?? getFeatureFlag(flag, sendEvent: false) else { + hedgeLog("captureFeatureInteraction called for flag '\(flag)' but no variant value is available. Event will not be captured.") + return + } + var props: [String: Any] = [ "feature_flag": flag, ] - let variant: Any = flagVariant ?? getFeatureFlag(flag, sendEvent: false) ?? true if let variantStr = variant as? String { props["feature_flag_variant"] = variantStr } @@ -1791,4 +1803,3 @@ let maxRetryDelay = 30.0 #endif // swiftlint:enable file_length cyclomatic_complexity - diff --git a/PostHogTests/PostHogEnrichedAnalyticsTest.swift b/PostHogTests/PostHogEnrichedAnalyticsTest.swift index e6e7f34a94..91101d0c03 100644 --- a/PostHogTests/PostHogEnrichedAnalyticsTest.swift +++ b/PostHogTests/PostHogEnrichedAnalyticsTest.swift @@ -39,11 +39,12 @@ class PostHogEnrichedAnalyticsTest { return PostHogSDK.with(config) } - @Test("captures $feature_view event") + @Test("captures $feature_view event with explicit variant") func capturesFeatureViewEvent() { let sut = getSut() - sut.captureFeatureView(flag: "test-flag", flagVariant: nil) + // An explicit flagVariant must be supplied (or a flag must be loaded) so the guard passes. + sut.captureFeatureView(flag: "test-flag", flagVariant: "control") let events = getBatchedEvents(server) #expect(events.count == 1) @@ -51,18 +52,20 @@ class PostHogEnrichedAnalyticsTest { let event = events.first! #expect(event.event == "$feature_view") #expect(event.properties["feature_flag"] as? String == "test-flag") + #expect(event.properties["feature_flag_variant"] as? String == "control") let setProps = event.properties["$set"] as? [String: Any] - #expect(setProps?["$feature_view/test-flag"] as? Bool == true) + #expect(setProps?["$feature_view/test-flag"] as? String == "control") sut.reset() sut.close() } - @Test("captures $feature_interaction event") + @Test("captures $feature_interaction event with explicit variant") func capturesFeatureInteractionEvent() { let sut = getSut() - sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) + // An explicit flagVariant must be supplied (or a flag must be loaded) so the guard passes. + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: "control") let events = getBatchedEvents(server) #expect(events.count == 1) @@ -70,9 +73,37 @@ class PostHogEnrichedAnalyticsTest { let event = events.first! #expect(event.event == "$feature_interaction") #expect(event.properties["feature_flag"] as? String == "test-flag") - + #expect(event.properties["feature_flag_variant"] as? String == "control") let setProps = event.properties["$set"] as? [String: Any] - #expect(setProps?["$feature_interaction/test-flag"] as? Bool == true) + #expect(setProps?["$feature_interaction/test-flag"] as? String == "control") + + sut.reset() + sut.close() + } + + @Test("does not capture $feature_view event when no variant is available") + func doesNotCaptureFeatureViewWhenNoVariant() { + let sut = getSut() + + // No flagVariant is passed and no flags are loaded, so the guard should fire and return early. + sut.captureFeatureView(flag: "unknown-flag", flagVariant: nil) + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + #expect(events.count == 0) + + sut.reset() + sut.close() + } + + @Test("does not capture $feature_interaction event when no variant is available") + func doesNotCaptureFeatureInteractionWhenNoVariant() { + let sut = getSut() + + // No flagVariant is passed and no flags are loaded, so the guard should fire and return early. + sut.captureFeatureInteraction(flag: "unknown-flag", flagVariant: nil) + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + #expect(events.count == 0) sut.reset() sut.close() @@ -82,7 +113,7 @@ class PostHogEnrichedAnalyticsTest { func doesNotCaptureFeatureViewIfOptOut() { let sut = getSut(optOut: true) - sut.captureFeatureView(flag: "test-flag", flagVariant: nil) + sut.captureFeatureView(flag: "test-flag", flagVariant: "control") let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) #expect(events.count == 0) @@ -95,7 +126,7 @@ class PostHogEnrichedAnalyticsTest { func doesNotCaptureFeatureInteractionIfOptOut() { let sut = getSut(optOut: true) - sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: "control") let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) #expect(events.count == 0) @@ -109,7 +140,7 @@ class PostHogEnrichedAnalyticsTest { let sut = getSut() sut.close() // Disable SDK - sut.captureFeatureView(flag: "test-flag", flagVariant: nil) + sut.captureFeatureView(flag: "test-flag", flagVariant: "control") let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) #expect(events.count == 0) @@ -120,7 +151,7 @@ class PostHogEnrichedAnalyticsTest { let sut = getSut() sut.close() // Disable SDK - sut.captureFeatureInteraction(flag: "test-flag", flagVariant: nil) + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: "control") let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) #expect(events.count == 0) From b8e646179ca32864da75107a6fba5a52111bf2e6 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 11 Mar 2026 12:53:52 +0200 Subject: [PATCH 14/14] chore: add changeset --- .changeset/fuzzy-experts-teach.md | 5 +++++ PostHog/PostHogSDK.swift | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/fuzzy-experts-teach.md diff --git a/.changeset/fuzzy-experts-teach.md b/.changeset/fuzzy-experts-teach.md new file mode 100644 index 0000000000..fc83f13084 --- /dev/null +++ b/.changeset/fuzzy-experts-teach.md @@ -0,0 +1,5 @@ +--- +"posthog-ios": minor +--- + +feat: Add `captureFeatureView` and `captureFeatureInteraction` methods for tracking feature flag analytics diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 775edce263..f169637aa7 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1402,8 +1402,10 @@ let maxRetryDelay = 30.0 /// /// - Parameter flag: The key of the feature flag being interacted with. /// - Parameter flagVariant: The variant of the feature flag being interacted with. - @objc public func captureFeatureInteraction(flag: String, - flagVariant: String?) { + @objc public func captureFeatureInteraction( + flag: String, + flagVariant: String? + ) { if !isEnabled() { return }