diff --git a/.changeset/minimal-flag-called-events.md b/.changeset/minimal-flag-called-events.md new file mode 100644 index 0000000000..a6965accd2 --- /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 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/PostHogRemoteConfig.swift b/PostHog/PostHogRemoteConfig.swift index 1de3ca34bf..3f05154603 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,12 +935,22 @@ 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, + _ 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( @@ -989,6 +1013,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..9180edf376 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,38 @@ let maxRetryDelay = 30.0 } } + /// The strict property allowlist for minimal `$feature_flag_called` events. Everything else — + /// 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", + "$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", + // 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 + // by this SDK yet). Kept so the allowlist matches the shared contract as those signals land. + "$feature_flag_error", + "$window_id", + "$device_id", + ] + private func reportFeatureFlagCalled(flagKey: String, flagValue: Any?) { if remoteConfig == nil { return @@ -2197,6 +2238,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 +2259,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 +2276,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..d3fab0d5c1 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -515,6 +515,153 @@ 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 (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", + "$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", + "$os_name", + "$os_version", + "$app_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", + "$os_name", + "$os_version", + "$app_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/",