Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6b75002
feat: automatically capture deep link events
jeremiahseun Feb 18, 2026
c6791be
docs: add TESTING_DEEP_LINKS.md
jeremiahseun Feb 18, 2026
239e1cb
docs: add TESTING_DEEP_LINKS.md
jeremiahseun Feb 18, 2026
32d183d
docs: add TESTING_DEEP_LINKS.md
jeremiahseun Feb 18, 2026
55732bc
Deep link integration, tests, and project config
jeremiahseun Feb 19, 2026
995939a
Merge pull request #1 from jeremiahseun/add-referrer-url-automatically
jeremiahseun Feb 19, 2026
65dc2c2
feat: add captureFeatureView and captureFeatureInteraction
jeremiahseun Feb 19, 2026
17dee20
To prevent lifecycle events from interfering with test assertions
jeremiahseun Feb 20, 2026
f524e25
Revert "Merge pull request #1 from jeremiahseun/add-referrer-url-auto…
jeremiahseun Feb 20, 2026
6cca921
feat: add captureFeatureView and captureFeatureInteraction
jeremiahseun Feb 19, 2026
06c84b7
To prevent lifecycle events from interfering with test assertions
jeremiahseun Feb 20, 2026
0003405
Merge branch 'feat/enriched-flag-analytics-5134481175872936769' of ht…
jeremiahseun Feb 20, 2026
1376079
Merge branch 'main' into feat/enriched-flag-analytic
jeremiahseun Feb 20, 2026
19fd08e
Support feature flag variants in captures
jeremiahseun Feb 20, 2026
5d50daf
Migrated test from Quick and Nimble
jeremiahseun Feb 20, 2026
2b1ad49
Merge branch 'main' into feat/enriched-flag-analytic
jeremiahseun Feb 25, 2026
4118a20
Require feature variant when capturing events
jeremiahseun Feb 25, 2026
05232f4
Merge branch 'main' into feat/enriched-flag-analytic
ioannisj Mar 11, 2026
b8e6461
chore: add changeset
ioannisj Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-experts-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ios": minor
---

feat: Add `captureFeatureView` and `captureFeatureInteraction` methods for tracking feature flag analytics
81 changes: 81 additions & 0 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,87 @@ let maxRetryDelay = 30.0
}
}

/// Captures a $feature_view event for the specified feature flag.
Comment thread
jeremiahseun marked this conversation as resolved.
Resolved
///
/// - 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
Expand Down
159 changes: 159 additions & 0 deletions PostHogTests/PostHogEnrichedAnalyticsTest.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading