From bf0f73d452c8ff0ea5fd124fd36df9acd7424901 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 16 Jul 2026 10:28:02 -0700 Subject: [PATCH 1/4] feat(flags): send minimal $feature_flag_called events when server-gated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit a minimal $feature_flag_called event when the /flags v2 response carries top-level minimalFlagCalledEvents == true and the evaluated flag's has_experiment is explicitly false. Minimal events keep only a strict allowlist of flag-evaluation and linkage properties; everything else (context envelope, super properties, $active_feature_flags, the $feature/ enumeration, bootstrap enrichment) is stripped. Any missing signal — field absent, cached state without it, unknown has_experiment — falls back to the full event, and experiment-linked flags always send the full envelope. The gate is persisted alongside the cached flags so it survives app restarts. Generated-By: PostHog Code Task-Id: ffe402fd-d75c-4043-8e5d-d2fe513cac6f --- .changeset/minimal-flag-called-events.md | 5 + PostHog/PostHogRemoteConfig.swift | 22 +++ PostHog/PostHogSDK.swift | 51 ++++++- PostHog/PostHogStorage.swift | 2 + PostHogTests/PostHogRemoteConfigTest.swift | 77 ++++++++++ PostHogTests/PostHogSDKTest.swift | 140 ++++++++++++++++++ .../TestUtils/MockPostHogServer.swift | 5 + 7 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 .changeset/minimal-flag-called-events.md diff --git a/.changeset/minimal-flag-called-events.md b/.changeset/minimal-flag-called-events.md new file mode 100644 index 0000000000..6f039347ea --- /dev/null +++ b/.changeset/minimal-flag-called-events.md @@ -0,0 +1,5 @@ +--- +"posthog-ios": minor +--- + +Send minimal `$feature_flag_called` events when the server opts the project in (top-level `minimalFlagCalledEvents` in the flags response) and the evaluated flag has no experiment. Minimal events keep only a strict allowlist of flag-evaluation and linkage properties; the device/OS context envelope, super properties, `$active_feature_flags`, and the `$feature/` enumeration are stripped. Experiment-linked flags, ungated projects, and any response missing the signals keep sending the full event. diff --git a/PostHog/PostHogRemoteConfig.swift b/PostHog/PostHogRemoteConfig.swift index 1de3ca34bf..bd7d9df863 100644 --- a/PostHog/PostHogRemoteConfig.swift +++ b/PostHog/PostHogRemoteConfig.swift @@ -39,6 +39,7 @@ class PostHogRemoteConfig { private var featureFlagPayloads: [String: Any]? private var requestId: String? private var evaluatedAt: Int? + private var minimalFlagCalledEvents: Bool? /// Copies of `config.bootstrap`, retained for `$feature_flag_called` enrichment and cleared by /// `clear()` (on `reset()`) so bootstrap never re-applies to a different user. Under `featureFlagsLock`. @@ -75,6 +76,15 @@ class PostHogRemoteConfig { } } + /// Whether the server gated this project into minimal `$feature_flag_called` events + /// (top-level `minimalFlagCalledEvents` of the v2 `/flags` response). Absent from the + /// response or cache means `false`, so the SDK fails safe to full events. + var sendMinimalFlagCalledEvents: Bool { + featureFlagsLock.withLock { + getCachedValue(\.minimalFlagCalledEvents, key: .minimalFlagCalledEvents) { storage.getBool(forKey: $0) } ?? false + } + } + init(_ config: PostHogConfig, _ storage: PostHogStorage, _ api: PostHogApi, @@ -433,6 +443,10 @@ class PostHogRemoteConfig { self.setCachedEvaluatedAt(evaluatedAt) } + // Persist the minimal $feature_flag_called gate alongside the cached flags so it + // survives restarts. Set unconditionally: an absent field means the gate is off. + self.setCachedMinimalFlagCalledEvents(data["minimalFlagCalledEvents"] as? Bool) + if errorsWhileComputingFlags { let cachedFlags = self.getCachedFlags() ?? [:] let cachedFeatureFlags = self.getCachedFeatureFlags() ?? [:] @@ -921,6 +935,13 @@ class PostHogRemoteConfig { } } + // To be called after acquiring `featureFlagsLock` + private func setCachedMinimalFlagCalledEvents(_ value: Bool?) { + setCachedValue(value, cache: \.minimalFlagCalledEvents, key: .minimalFlagCalledEvents) { key, value in + storage.setBool(forKey: key, contents: value) + } + } + private func getCachedValue( _ cache: KeyPath, key: PostHogStorage.StorageKey, @@ -989,6 +1010,7 @@ class PostHogRemoteConfig { setCachedFeatureFlagPayload([:]) setCachedRequestId(nil) // requestId no longer valid setCachedEvaluatedAt(nil) // evaluatedAt no longer valid + setCachedMinimalFlagCalledEvents(nil) // gate travels with the cached flags; re-arms on the next /flags } /// Clears all cached feature flags, remote config state, and user-specific properties. diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 25d20d645e..492d8a6e00 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1274,7 +1274,8 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: [String: Any]? = nil, groups: [String: String]? = nil, timestamp: Date? = nil, - skipBuildProperties: Bool = false + skipBuildProperties: Bool = false, + propertyAllowlist: Set? = nil ) { if !isEnabled() { return @@ -1329,6 +1330,14 @@ let maxRetryDelay = 30.0 ) } + // Filtering after the full build stays robust as new context properties are added later: + // anything not explicitly allowlisted is stripped. beforeSend hooks and the legacy + // propertiesSanitizer run later (in buildEvent) and may re-add keys — an accepted + // escape hatch, codified in the minimal-event contract. + if let propertyAllowlist { + finalProperties = finalProperties.filter { propertyAllowlist.contains($0.key) } + } + // Attach the session-scoped step buffer to a `$exception` unless the caller provided their own. // The buffer is left intact; recording is synchronous, so a step added just before this capture // on the same thread is already present. @@ -2171,6 +2180,29 @@ let maxRetryDelay = 30.0 } } + /// The strict property allowlist for minimal `$feature_flag_called` events. Everything else — + /// registered super properties, the device/OS context envelope, `$active_feature_flags`, the + /// `$feature/` enumeration, bootstrap enrichment — is stripped. Kept in sync with the + /// cross-SDK minimal `$feature_flag_called` contract. + private static let minimalFeatureFlagCalledProperties: Set = [ + "$feature_flag", + "$feature_flag_response", + "$feature_flag_has_experiment", + "$feature_flag_id", + "$feature_flag_version", + "$feature_flag_reason", + "$feature_flag_request_id", + "$feature_flag_evaluated_at", + "$feature_flag_error", + "$groups", + "$process_person_profile", + "$session_id", + "$window_id", + "$lib", + "$lib_version", + "$device_id", + ] + private func reportFeatureFlagCalled(flagKey: String, flagValue: Any?) { if remoteConfig == nil { return @@ -2197,6 +2229,8 @@ let maxRetryDelay = 30.0 let requestId = remoteConfig?.lastRequestId ?? "" let evaluatedAt = remoteConfig?.lastEvaluatedAt let details = remoteConfig?.getFeatureFlagDetails(flagKey) + // Unknown until the flags response explicitly reports it; any missing signal → full event. + var hasExperiment: Bool? var properties: [String: Any] = [ "$feature_flag": flagKey, @@ -2216,8 +2250,9 @@ let maxRetryDelay = 30.0 if let metadata = details["metadata"] as? [String: Any] { properties["$feature_flag_id"] = metadata["id"] ?? NSNull() properties["$feature_flag_version"] = metadata["version"] ?? NSNull() - if let hasExperiment = metadata["has_experiment"] as? Bool { - properties["$feature_flag_has_experiment"] = hasExperiment + if let flagHasExperiment = metadata["has_experiment"] as? Bool { + properties["$feature_flag_has_experiment"] = flagHasExperiment + hasExperiment = flagHasExperiment } } } @@ -2232,7 +2267,15 @@ let maxRetryDelay = 30.0 properties["$used_bootstrap_value"] = bootstrapMetadata.usedBootstrapValue } - capture("$feature_flag_called", properties: properties) + // Emit the minimal shape only when the server gate is on and the flag verifiably has no + // experiment. Experiment-linked flags keep the full envelope for exposure analysis. + let sendMinimalEvent = remoteConfig?.sendMinimalFlagCalledEvents == true && hasExperiment == false + + captureInternal( + "$feature_flag_called", + properties: properties, + propertyAllowlist: sendMinimalEvent ? PostHogSDK.minimalFeatureFlagCalledProperties : nil + ) } } diff --git a/PostHog/PostHogStorage.swift b/PostHog/PostHogStorage.swift index e69a7f0994..19ff875f70 100644 --- a/PostHog/PostHogStorage.swift +++ b/PostHog/PostHogStorage.swift @@ -248,6 +248,7 @@ class PostHogStorage { case lastSeenSurveyDate = "posthog.lastSeenSurveyDate" case requestId = "posthog.requestId" case evaluatedAt = "posthog.evaluatedAt" + case minimalFlagCalledEvents = "posthog.minimalFlagCalledEvents" case personPropertiesForFlags = "posthog.personPropertiesForFlags" case groupPropertiesForFlags = "posthog.groupPropertiesForFlags" case errorTracking = "posthog.errorTracking" @@ -414,6 +415,7 @@ class PostHogStorage { deleteSafely(url(forKey: .surveySeen)) deleteSafely(url(forKey: .lastSeenSurveyDate)) deleteSafely(url(forKey: .requestId)) + deleteSafely(url(forKey: .minimalFlagCalledEvents)) deleteSafely(url(forKey: .personPropertiesForFlags)) deleteSafely(url(forKey: .groupPropertiesForFlags)) // legacy slices, no longer written (config now lives in .remoteConfig); drop stragglers from older SDKs diff --git a/PostHogTests/PostHogRemoteConfigTest.swift b/PostHogTests/PostHogRemoteConfigTest.swift index 6fab5d8281..9eec3f20b6 100644 --- a/PostHogTests/PostHogRemoteConfigTest.swift +++ b/PostHogTests/PostHogRemoteConfigTest.swift @@ -1078,4 +1078,81 @@ enum PostHogRemoteConfigTest { #expect(sut.getRemoteConfig()?["capturePerformance"] != nil) } } + + @Suite("Test minimal flag called events gate") + class TestMinimalFlagCalledEventsGate: BaseTestClass { + private func makeIsolatedConfig() -> PostHogConfig { + let config = PostHogConfig(projectToken: "\(testProjectToken)-\(UUID().uuidString)", host: "http://localhost:9001") + config.disableRemoteConfigForTesting = true + return config + } + + @Test("gate is off by default and stays off when the response omits the field") + func gateOffWhenFieldAbsent() async { + let config = makeIsolatedConfig() + let storage = PostHogStorage(config) + defer { storage.reset() } + + let sut = getSut(storage: storage, config: config) + + #expect(sut.sendMinimalFlagCalledEvents == false) + + await loadFeatureFlags(sut) + + #expect(sut.sendMinimalFlagCalledEvents == false) + } + + @Test("gate persists alongside cached flags across a simulated restart") + func gatePersistsAcrossRestart() async { + server.minimalFlagCalledEvents = true + let config = makeIsolatedConfig() + let storage = PostHogStorage(config) + defer { storage.reset() } + + let sut = getSut(storage: storage, config: config) + await loadFeatureFlags(sut) + + #expect(sut.sendMinimalFlagCalledEvents == true) + + // a fresh instance on the same storage re-reads the persisted gate + let restarted = getSut(storage: storage, config: config) + + #expect(restarted.sendMinimalFlagCalledEvents == true) + } + + @Test("gate turns off when a later response no longer carries it") + func gateClearsWhenFieldDisappears() async { + server.minimalFlagCalledEvents = true + let config = makeIsolatedConfig() + let storage = PostHogStorage(config) + defer { storage.reset() } + + let sut = getSut(storage: storage, config: config) + await loadFeatureFlags(sut) + + #expect(sut.sendMinimalFlagCalledEvents == true) + + server.minimalFlagCalledEvents = false + await loadFeatureFlags(sut) + + #expect(sut.sendMinimalFlagCalledEvents == false) + } + + @Test("clear() drops the persisted gate") + func clearDropsGate() async { + server.minimalFlagCalledEvents = true + let config = makeIsolatedConfig() + let storage = PostHogStorage(config) + defer { storage.reset() } + + let sut = getSut(storage: storage, config: config) + await loadFeatureFlags(sut) + + #expect(sut.sendMinimalFlagCalledEvents == true) + + sut.clear() + + #expect(sut.sendMinimalFlagCalledEvents == false) + } + } } diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 1d21000002..c45a307404 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -515,6 +515,146 @@ class PostHogSDKTest: QuickSpec { sut.close() } + it("sends minimal feature flag event when gated and flag has no experiment") { + server.minimalFlagCalledEvents = true + let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) + + waitForFeatureFlagsLoaded(server, sut) + expect(sut.isFeatureEnabled("string-value")) == true + + let events = getBatchedEvents(server) + + expect(events.count) == 1 + + let event = events.first! + expect(event.event) == "$feature_flag_called" + // Strict allowlist: everything else (context envelope, super properties, + // $active_feature_flags, $feature/, $is_identified) is stripped. + expect(Set(event.properties.keys)) == Set([ + "$feature_flag", + "$feature_flag_response", + "$feature_flag_has_experiment", + "$feature_flag_id", + "$feature_flag_version", + "$feature_flag_reason", + "$feature_flag_request_id", + "$feature_flag_evaluated_at", + "$process_person_profile", + "$session_id", + "$lib", + "$lib_version", + ]) + expect(event.properties["$feature_flag"] as? String) == "string-value" + expect(event.properties["$feature_flag_response"] as? String) == "test" + expect(event.properties["$feature_flag_has_experiment"] as? Bool) == false + + sut.reset() + sut.close() + } + + it("keeps $groups on minimal feature flag events") { + server.minimalFlagCalledEvents = true + // flushAt 2 so the $groupidentify and $feature_flag_called events share one batch + let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true, flushAt: 2) + + waitForFeatureFlagsLoaded(server, sut) + + sut.group(type: "some-type", key: "some-key") + + expect(sut.isFeatureEnabled("string-value")) == true + + let events = getBatchedEvents(server) + + expect(events.count) == 2 + + let event = events.last! + expect(event.event) == "$feature_flag_called" + // $groups is correctness-required (ingestion dedup key + personful routing for group + // flags), so it must survive minimization when groups are registered. + expect(Set(event.properties.keys)) == Set([ + "$feature_flag", + "$feature_flag_response", + "$feature_flag_has_experiment", + "$feature_flag_id", + "$feature_flag_version", + "$feature_flag_reason", + "$feature_flag_request_id", + "$feature_flag_evaluated_at", + "$groups", + "$process_person_profile", + "$session_id", + "$lib", + "$lib_version", + ]) + let groups = event.properties["$groups"] as? [String: String] + expect(groups?["some-type"]) == "some-key" + + sut.reset() + sut.close() + } + + it("sends full feature flag event when gated but flag has an experiment") { + server.minimalFlagCalledEvents = true + let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) + + waitForFeatureFlagsLoaded(server, sut) + expect(sut.isFeatureEnabled("bool-value")) == true + + let events = getBatchedEvents(server) + + expect(events.count) == 1 + + let event = events.first! + expect(event.event) == "$feature_flag_called" + expect(event.properties["$feature_flag_has_experiment"] as? Bool) == true + expect(event.properties["$feature/bool-value"] as? Bool) == true + expect(event.properties["$active_feature_flags"]).toNot(beNil()) + expect(event.properties["$is_identified"]).toNot(beNil()) + + sut.reset() + sut.close() + } + + it("sends full feature flag event when gated but has_experiment is unknown") { + server.minimalFlagCalledEvents = true + let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) + + waitForFeatureFlagsLoaded(server, sut) + expect(sut.isFeatureEnabled("number-value")) == true + + let events = getBatchedEvents(server) + + expect(events.count) == 1 + + let event = events.first! + expect(event.event) == "$feature_flag_called" + expect(event.properties["$feature_flag_has_experiment"]).to(beNil()) + expect(event.properties["$active_feature_flags"]).toNot(beNil()) + + sut.reset() + sut.close() + } + + it("sends full feature flag event when the server does not gate minimal events") { + let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) + + waitForFeatureFlagsLoaded(server, sut) + expect(sut.isFeatureEnabled("string-value")) == true + + let events = getBatchedEvents(server) + + expect(events.count) == 1 + + let event = events.first! + expect(event.event) == "$feature_flag_called" + expect(event.properties["$feature_flag_has_experiment"] as? Bool) == false + expect(event.properties["$feature/string-value"] as? String) == "test" + expect(event.properties["$active_feature_flags"]).toNot(beNil()) + + sut.reset() + sut.close() + } + it("send feature flag event for getFeatureFlag when enabled") { let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index cade0f8d30..616ffbb0aa 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -84,6 +84,7 @@ class MockPostHogServer { var flagsSkipReplayVariantName = false var replayVariantValue: Any = true var quotaLimitFeatureFlags: Bool = false + var minimalFlagCalledEvents: Bool = false var remoteConfigSurveys: String? var hasFeatureFlags: Bool? = true var featureFlags: [String: Any]? @@ -296,6 +297,10 @@ class MockPostHogServer { ] } + if self.minimalFlagCalledEvents { + obj["minimalFlagCalledEvents"] = true + } + if self.returnReplay { var sessionRecording: [String: Any] = [ "endpoint": "/newS/", From e278f4dfa786f9ba7706c53b2ec7c6f4dab85f36 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Fri, 17 Jul 2026 21:23:24 -0700 Subject: [PATCH 2/4] Clarify unreachable allowlist entries kept for cross-SDK parity $device_id, $window_id, and $feature_flag_error are currently unreachable on this SDK's minimal $feature_flag_called events; comment why they're kept anyway (cross-SDK contract parity) rather than reading as dead weight. --- PostHog/PostHogSDK.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 492d8a6e00..7870e29432 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2193,13 +2193,17 @@ let maxRetryDelay = 30.0 "$feature_flag_reason", "$feature_flag_request_id", "$feature_flag_evaluated_at", - "$feature_flag_error", "$groups", "$process_person_profile", "$session_id", - "$window_id", "$lib", "$lib_version", + // Forward-looking cross-SDK contract entries: not produced by buildProperties for + // $feature_flag_called on iOS today ($device_id is added later by PostHogApi on the + // /flags request only; $window_id is snapshot-only; $feature_flag_error isn't emitted + // by this SDK yet). Kept so the allowlist matches the shared contract as those signals land. + "$feature_flag_error", + "$window_id", "$device_id", ] From 19f677dfc1ca2d94ee4e1d0f63ea508e83aad53e Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 21 Jul 2026 14:35:33 -0700 Subject: [PATCH 3/4] Memoize getCachedValue to avoid repeated disk reads --- PostHog/PostHogRemoteConfig.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/PostHog/PostHogRemoteConfig.swift b/PostHog/PostHogRemoteConfig.swift index bd7d9df863..3f05154603 100644 --- a/PostHog/PostHogRemoteConfig.swift +++ b/PostHog/PostHogRemoteConfig.swift @@ -943,11 +943,14 @@ class PostHogRemoteConfig { } private func getCachedValue( - _ cache: KeyPath, + _ cache: ReferenceWritableKeyPath, key: PostHogStorage.StorageKey, load: (PostHogStorage.StorageKey) -> T? ) -> T? { - self[keyPath: cache] ?? load(key) + if self[keyPath: cache] == nil { + self[keyPath: cache] = load(key) + } + return self[keyPath: cache] } private func setCachedValue( From 54a532820002bbd35675daf71a829b847e51ba84 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 21 Jul 2026 14:46:29 -0700 Subject: [PATCH 4/4] Keep OS and app version on minimal feature_flag_called events Add $os_name, $os_version, and $app_version to the minimal $feature_flag_called allowlist so OS- and app-version-segmented insights keep working once the server gate flips on, matching the debug-location analogs python and browser JS already keep. --- .changeset/minimal-flag-called-events.md | 2 +- PostHog/PostHogSDK.swift | 11 ++++++++--- PostHogTests/PostHogSDKTest.swift | 11 +++++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.changeset/minimal-flag-called-events.md b/.changeset/minimal-flag-called-events.md index 6f039347ea..a6965accd2 100644 --- a/.changeset/minimal-flag-called-events.md +++ b/.changeset/minimal-flag-called-events.md @@ -2,4 +2,4 @@ "posthog-ios": minor --- -Send minimal `$feature_flag_called` events when the server opts the project in (top-level `minimalFlagCalledEvents` in the flags response) and the evaluated flag has no experiment. Minimal events keep only a strict allowlist of flag-evaluation and linkage properties; the device/OS context envelope, super properties, `$active_feature_flags`, and the `$feature/` enumeration are stripped. Experiment-linked flags, ungated projects, and any response missing the signals keep sending the full event. +Send minimal `$feature_flag_called` events when the server opts the project in (top-level `minimalFlagCalledEvents` in the flags response) and the evaluated flag has no experiment. Minimal events keep only a strict allowlist of flag-evaluation and linkage properties plus `$os_name`, `$os_version`, and `$app_version` for OS- and version-segmented insights; the rest of the device/OS context envelope, super properties, `$active_feature_flags`, and the `$feature/` enumeration are stripped. Experiment-linked flags, ungated projects, and any response missing the signals keep sending the full event. diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 7870e29432..9180edf376 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2181,9 +2181,9 @@ let maxRetryDelay = 30.0 } /// The strict property allowlist for minimal `$feature_flag_called` events. Everything else — - /// registered super properties, the device/OS context envelope, `$active_feature_flags`, the - /// `$feature/` enumeration, bootstrap enrichment — is stripped. Kept in sync with the - /// cross-SDK minimal `$feature_flag_called` contract. + /// registered super properties, `$active_feature_flags`, the `$feature/` enumeration, + /// bootstrap enrichment — is stripped. Kept in sync with the cross-SDK minimal + /// `$feature_flag_called` contract. private static let minimalFeatureFlagCalledProperties: Set = [ "$feature_flag", "$feature_flag_response", @@ -2198,6 +2198,11 @@ let maxRetryDelay = 30.0 "$session_id", "$lib", "$lib_version", + // Mobile's debug/breakdown analog to python's $os/$os_version/$python_runtime and browser + // JS's $current_url/$pathname: kept so OS- and app-version-segmented insights still work. + "$os_name", + "$os_version", + "$app_version", // Forward-looking cross-SDK contract entries: not produced by buildProperties for // $feature_flag_called on iOS today ($device_id is added later by PostHogApi on the // /flags request only; $window_id is snapshot-only; $feature_flag_error isn't emitted diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index c45a307404..d3fab0d5c1 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -528,8 +528,9 @@ class PostHogSDKTest: QuickSpec { let event = events.first! expect(event.event) == "$feature_flag_called" - // Strict allowlist: everything else (context envelope, super properties, - // $active_feature_flags, $feature/, $is_identified) is stripped. + // Strict allowlist: everything else (super properties, $active_feature_flags, + // $feature/, $is_identified) is stripped; $os_name/$os_version/$app_version + // survive as mobile's OS- and app-version-breakdown analog. expect(Set(event.properties.keys)) == Set([ "$feature_flag", "$feature_flag_response", @@ -543,6 +544,9 @@ class PostHogSDKTest: QuickSpec { "$session_id", "$lib", "$lib_version", + "$os_name", + "$os_version", + "$app_version", ]) expect(event.properties["$feature_flag"] as? String) == "string-value" expect(event.properties["$feature_flag_response"] as? String) == "test" @@ -585,6 +589,9 @@ class PostHogSDKTest: QuickSpec { "$session_id", "$lib", "$lib_version", + "$os_name", + "$os_version", + "$app_version", ]) let groups = event.properties["$groups"] as? [String: String] expect(groups?["some-type"]) == "some-key"