Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/minimal-flag-called-events.md
Original file line number Diff line number Diff line change
@@ -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/<key>` enumeration are stripped. Experiment-linked flags, ungated projects, and any response missing the signals keep sending the full event.
29 changes: 27 additions & 2 deletions PostHog/PostHogRemoteConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
}
}
Comment on lines +82 to +86

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendMinimalFlagCalledEvents re-reads disk on every access instead of caching in memory

consider performance

Why we think it's a valid issue
  • Checked: getCachedValue (PostHogRemoteConfig.swift:945-951) vs getCachedDictionary (743-751). Confirmed: getCachedValue takes a non-writable KeyPath and returns self[keyPath: cache] ?? load(key) with no write-back, while getCachedDictionary takes a ReferenceWritableKeyPath and memoizes (if self[keyPath: cache] == nil { self[keyPath: cache] = storage.getDictionary(...) }). The inconsistency the finding describes is real, and sendMinimalFlagCalledEvents/lastRequestId/lastEvaluatedAt all route through the non-memoizing helper.
  • Checked: the actual call frequency in reportFeatureFlagCalled (PostHogSDK.swift:2210-2283). The disk-reading block is gated by shouldCapture, which is deduped per (flagKey, flagValue) via flagCallReported (2216-2230). So these getters fire once per distinct flag value per session, not on every isFeatureEnabled/getFeatureFlag call β€” the cost is O(distinct flags), not O(flag evaluations).
  • Found: the read is a single serialized Bool, and getData (PostHogStorage.swift:279) does FileManager.fileExists first and returns nil without a full read when the file is absent β€” which is the common case, since the gate is off by default during this staged rollout and setCachedMinimalFlagCalledEvents(nil) removes the file. So the per-call cost is typically a fileExists syscall on a tiny file under featureFlagsLock.
  • Found: the bulk of the described cost is pre-existing. lastRequestId (67-71) and lastEvaluatedAt (73-77) already perform two such disk reads per capture through the same helper; this PR adds a third (sendMinimalFlagCalledEvents) plus one tiny write per /flags response (line 448, once per response β€” negligible).
  • Impact: a genuine, confirmed inefficiency with a clean, low-risk fix (make getCachedValue memoize like getCachedDictionary). But the magnitude β€” a tiny, OS-cached, deduped-per-flag file access, most of which predates this PR β€” does not rise to a performance defect that 'bites at real scale.' It is real (so not noise/dismiss) but minor.
  • Priority: lowering to consider. The finding overstates the PR-attributable cost by folding in the pre-existing lastRequestId/lastEvaluatedAt reads and framing it as per-flag-check when it is deduped per distinct flag; the delta this PR introduces is one extra tiny read per unique flag, not a launch-time bottleneck.
Issue description

sendMinimalFlagCalledEvents (lines 82-86) is built on top of getCachedValue (lines 945-951), which only reads self[keyPath: cache] ?? load(key) and never writes the loaded value back into the in-memory property. Compare this to getCachedDictionary (used for feature flags), which explicitly memoizes: if self[keyPath: cache] == nil { self[keyPath: cache] = storage.getDictionary(...) }. Because getCachedValue's cache parameter is a plain KeyPath (not a ReferenceWritableKeyPath) and the function body never assigns to it, the in-memory minimalFlagCalledEvents var stays nil until the next successful /flags response calls setCachedMinimalFlagCalledEvents. Until then, every single call to sendMinimalFlagCalledEvents re-executes storage.getBool(forKey:) -> getJson -> getData -> Data(contentsOf:) (a synchronous file read) + JSONSerialization.jsonObject, all while holding featureFlagsLock. This is exactly the "resume minimization from cache after a cold start" scenario the PR description calls out as a supported path: on a cold start, before the first /flags response of the session lands, every unique flag reported via reportFeatureFlagCalled (each doing its own featureFlagsLock.withLock acquisition, synchronously on whatever thread called isFeatureEnabled/getFeatureFlag β€” no dispatch hop exists in this call chain) will hit disk again for this same value. An app checking N distinct flags during launch (a common pattern) does N redundant disk reads + N lock acquisitions for a value that never changes in that window, and each acquisition briefly blocks any other thread (including the main thread) trying to read/write feature-flag state under the same coarse lock. The write side compounds this too: loadFeatureFlags now performs a 6th synchronous disk write under featureFlagsLock (via setCachedMinimalFlagCalledEvents) alongside the existing flags/payloads/requestId/evaluatedAt writes, incrementally lengthening how long that lock is held while processing a /flags response.

Suggested fix

Make getCachedValue actually memoize, mirroring getCachedDictionary: change the cache parameter to ReferenceWritableKeyPath<PostHogRemoteConfig, T?> and assign self[keyPath: cache] = load(key) when self[keyPath: cache] is nil before returning it. This fixes the repeated disk reads for sendMinimalFlagCalledEvents as well as the pre-existing lastRequestId/lastEvaluatedAt getters that share the same helper.

Prompt to fix with AI (copy-paste)
## Context
@PostHog/PostHogRemoteConfig.swift#L82-86
@PostHog/PostHogRemoteConfig.swift#L945-951

<issue_description>
`sendMinimalFlagCalledEvents` (lines 82-86) is built on top of `getCachedValue` (lines 945-951), which only reads `self[keyPath: cache] ?? load(key)` and never writes the loaded value back into the in-memory property. Compare this to `getCachedDictionary` (used for feature flags), which explicitly memoizes: `if self[keyPath: cache] == nil { self[keyPath: cache] = storage.getDictionary(...) }`. Because `getCachedValue`'s `cache` parameter is a plain `KeyPath` (not a `ReferenceWritableKeyPath`) and the function body never assigns to it, the in-memory `minimalFlagCalledEvents` var stays `nil` until the next successful `/flags` response calls `setCachedMinimalFlagCalledEvents`. Until then, every single call to `sendMinimalFlagCalledEvents` re-executes `storage.getBool(forKey:)` -> `getJson` -> `getData` -> `Data(contentsOf:)` (a synchronous file read) + `JSONSerialization.jsonObject`, all while holding `featureFlagsLock`. This is exactly the "resume minimization from cache after a cold start" scenario the PR description calls out as a supported path: on a cold start, before the first `/flags` response of the session lands, every unique flag reported via `reportFeatureFlagCalled` (each doing its own `featureFlagsLock.withLock` acquisition, synchronously on whatever thread called `isFeatureEnabled`/`getFeatureFlag` β€” no dispatch hop exists in this call chain) will hit disk again for this same value. An app checking N distinct flags during launch (a common pattern) does N redundant disk reads + N lock acquisitions for a value that never changes in that window, and each acquisition briefly blocks any other thread (including the main thread) trying to read/write feature-flag state under the same coarse lock. The write side compounds this too: `loadFeatureFlags` now performs a 6th synchronous disk write under `featureFlagsLock` (via `setCachedMinimalFlagCalledEvents`) alongside the existing flags/payloads/requestId/evaluatedAt writes, incrementally lengthening how long that lock is held while processing a `/flags` response.
</issue_description>

<issue_validation>
- **Checked:** `getCachedValue` (PostHogRemoteConfig.swift:945-951) vs `getCachedDictionary` (743-751). Confirmed: `getCachedValue` takes a non-writable `KeyPath` and returns `self[keyPath: cache] ?? load(key)` with no write-back, while `getCachedDictionary` takes a `ReferenceWritableKeyPath` and memoizes (`if self[keyPath: cache] == nil { self[keyPath: cache] = storage.getDictionary(...) }`). The inconsistency the finding describes is real, and `sendMinimalFlagCalledEvents`/`lastRequestId`/`lastEvaluatedAt` all route through the non-memoizing helper.
- **Checked:** the actual call frequency in `reportFeatureFlagCalled` (PostHogSDK.swift:2210-2283). The disk-reading block is gated by `shouldCapture`, which is deduped per `(flagKey, flagValue)` via `flagCallReported` (2216-2230). So these getters fire **once per distinct flag value per session**, not on every `isFeatureEnabled`/`getFeatureFlag` call β€” the cost is O(distinct flags), not O(flag evaluations).
- **Found:** the read is a single serialized `Bool`, and `getData` (PostHogStorage.swift:279) does `FileManager.fileExists` first and returns `nil` without a full read when the file is absent β€” which is the common case, since the gate is off by default during this staged rollout and `setCachedMinimalFlagCalledEvents(nil)` removes the file. So the per-call cost is typically a `fileExists` syscall on a tiny file under `featureFlagsLock`.
- **Found:** the bulk of the described cost is **pre-existing**. `lastRequestId` (67-71) and `lastEvaluatedAt` (73-77) already perform two such disk reads per capture through the same helper; this PR adds a third (`sendMinimalFlagCalledEvents`) plus one tiny write per `/flags` response (line 448, once per response β€” negligible).
- **Impact:** a genuine, confirmed inefficiency with a clean, low-risk fix (make `getCachedValue` memoize like `getCachedDictionary`). But the magnitude β€” a tiny, OS-cached, deduped-per-flag file access, most of which predates this PR β€” does not rise to a performance defect that 'bites at real scale.' It is real (so not noise/dismiss) but minor.
- **Priority:** lowering to `consider`. The finding overstates the PR-attributable cost by folding in the pre-existing `lastRequestId`/`lastEvaluatedAt` reads and framing it as per-flag-check when it is deduped per distinct flag; the delta this PR introduces is one extra tiny read per unique flag, not a launch-time bottleneck.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Make `getCachedValue` actually memoize, mirroring `getCachedDictionary`: change the `cache` parameter to `ReferenceWritableKeyPath<PostHogRemoteConfig, T?>` and assign `self[keyPath: cache] = load(key)` when `self[keyPath: cache]` is nil before returning it. This fixes the repeated disk reads for `sendMinimalFlagCalledEvents` as well as the pre-existing `lastRequestId`/`lastEvaluatedAt` getters that share the same helper.
</potential_solution>


init(_ config: PostHogConfig,
_ storage: PostHogStorage,
_ api: PostHogApi,
Expand Down Expand Up @@ -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() ?? [:]
Expand Down Expand Up @@ -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<T>(
_ cache: KeyPath<PostHogRemoteConfig, T?>,
_ cache: ReferenceWritableKeyPath<PostHogRemoteConfig, T?>,
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<T>(
Expand Down Expand Up @@ -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.
Expand Down
60 changes: 56 additions & 4 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = nil
) {
if !isEnabled() {
return
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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/<key>` enumeration,
/// bootstrap enrichment β€” is stripped. Kept in sync with the cross-SDK minimal
/// `$feature_flag_called` contract.
private static let minimalFeatureFlagCalledProperties: Set<String> = [
"$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
Expand All @@ -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,
Expand All @@ -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
}
}
}
Expand All @@ -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
)
}
}

Expand Down
2 changes: 2 additions & 0 deletions PostHog/PostHogStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions PostHogTests/PostHogRemoteConfigTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading
Loading