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 e0624e14a8..f169637aa7 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1359,6 +1359,87 @@ let maxRetryDelay = 30.0 } } + /// Captures a $feature_view event for the specified feature flag. + /// + /// - 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 + } + + if isOptOutState() { + 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, + ] + + 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 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 + } + + if isOptOutState() { + 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, + ] + + if let variantStr = variant as? String { + props["feature_flag_variant"] = variantStr + } + + let userProps: [String: Any] = [ + "$feature_interaction/\(flag)": variant, + ] + + capture( + "$feature_interaction", + properties: props, + userProperties: userProps + ) + } + /// 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..91101d0c03 --- /dev/null +++ b/PostHogTests/PostHogEnrichedAnalyticsTest.swift @@ -0,0 +1,159 @@ +import Foundation +@testable import PostHog +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()) + } + + 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 + + let storage = PostHogStorage(config) + storage.reset() + + return PostHogSDK.with(config) + } + + @Test("captures $feature_view event with explicit variant") + func capturesFeatureViewEvent() { + let sut = getSut() + + // 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) + + 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? String == "control") + + sut.reset() + sut.close() + } + + @Test("captures $feature_interaction event with explicit variant") + func capturesFeatureInteractionEvent() { + let sut = getSut() + + // 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) + + 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? 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() + } + + @Test("does not capture feature view if opt out") + func doesNotCaptureFeatureViewIfOptOut() { + let sut = getSut(optOut: true) + + sut.captureFeatureView(flag: "test-flag", flagVariant: "control") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + #expect(events.count == 0) + + sut.reset() + sut.close() + } + + @Test("does not capture feature interaction if opt out") + func doesNotCaptureFeatureInteractionIfOptOut() { + let sut = getSut(optOut: true) + + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: "control") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + #expect(events.count == 0) + + sut.reset() + sut.close() + } + + @Test("does not capture feature view if disabled") + func doesNotCaptureFeatureViewIfDisabled() { + let sut = getSut() + sut.close() // Disable SDK + + sut.captureFeatureView(flag: "test-flag", flagVariant: "control") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + #expect(events.count == 0) + } + + @Test("does not capture feature interaction if disabled") + func doesNotCaptureFeatureInteractionIfDisabled() { + let sut = getSut() + sut.close() // Disable SDK + + sut.captureFeatureInteraction(flag: "test-flag", flagVariant: "control") + + let events = getBatchedEvents(server, timeout: 1.0, failIfNotCompleted: false) + #expect(events.count == 0) + } +}