feat(flags): minimize $feature_flag_called events for non-experiment flags - #724
Conversation
posthog-ios Compliance ReportDate: 2026-07-21 18:44:20 UTC ✅ All Tests Passed!45/45 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 16/16 tests passed View Details
|
🦔 ReviewHog reviewed this pull requestFound 0 must fix, 0 should fix, 1 consider. Published 1 finding (view the review). |
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
There was a problem hiding this comment.
ReviewHog Report
Changes
Issues: 1 issue
Files (7)
.changeset/minimal-flag-called-events.mdPostHog/PostHogRemoteConfig.swiftPostHog/PostHogSDK.swiftPostHog/PostHogStorage.swiftPostHogTests/PostHogRemoteConfigTest.swiftPostHogTests/PostHogSDKTest.swiftPostHogTests/TestUtils/MockPostHogServer.swift
| var sendMinimalFlagCalledEvents: Bool { | ||
| featureFlagsLock.withLock { | ||
| getCachedValue(\.minimalFlagCalledEvents, key: .minimalFlagCalledEvents) { storage.getBool(forKey: $0) } ?? false | ||
| } | ||
| } |
There was a problem hiding this comment.
sendMinimalFlagCalledEvents re-reads disk on every access instead of caching in memory
Why we think it's a valid issue
- Checked:
getCachedValue(PostHogRemoteConfig.swift:945-951) vsgetCachedDictionary(743-751). Confirmed:getCachedValuetakes a non-writableKeyPathand returnsself[keyPath: cache] ?? load(key)with no write-back, whilegetCachedDictionarytakes aReferenceWritableKeyPathand memoizes (if self[keyPath: cache] == nil { self[keyPath: cache] = storage.getDictionary(...) }). The inconsistency the finding describes is real, andsendMinimalFlagCalledEvents/lastRequestId/lastEvaluatedAtall route through the non-memoizing helper. - Checked: the actual call frequency in
reportFeatureFlagCalled(PostHogSDK.swift:2210-2283). The disk-reading block is gated byshouldCapture, which is deduped per(flagKey, flagValue)viaflagCallReported(2216-2230). So these getters fire once per distinct flag value per session, not on everyisFeatureEnabled/getFeatureFlagcall — the cost is O(distinct flags), not O(flag evaluations). - Found: the read is a single serialized
Bool, andgetData(PostHogStorage.swift:279) doesFileManager.fileExistsfirst and returnsnilwithout a full read when the file is absent — which is the common case, since the gate is off by default during this staged rollout andsetCachedMinimalFlagCalledEvents(nil)removes the file. So the per-call cost is typically afileExistssyscall on a tiny file underfeatureFlagsLock. - Found: the bulk of the described cost is pre-existing.
lastRequestId(67-71) andlastEvaluatedAt(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/flagsresponse (line 448, once per response — negligible). - Impact: a genuine, confirmed inefficiency with a clean, low-risk fix (make
getCachedValuememoize likegetCachedDictionary). 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-existinglastRequestId/lastEvaluatedAtreads 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>
turnipdabeets
left a comment
There was a problem hiding this comment.
LGTM
Two non-blocking items:
Before the server gate ever turns on, since each SDK carved out a different debug exception: python keeps $os/$os_version/$python_runtime for platform breakdowns, browser js keeps $current_url/$pathname as debug location, but iOS (and flutter) keep neither analog — $os_name/$os_version/$app_version/$screen_name are all stripped. Once the gate goes live, insights that break down $feature_flag_called by OS or app version will silently lose mobile rows. Fine to ship as is, but I think we should either add the mobile analogs to the shared list or explicitly note the mobile divergence in the contract before enablement — worth settling once since it affects flutter too.
Also ReviewHog's memoization point is valid but minor (the read is deduped per flag and the pattern predates this PR via lastRequestId/lastEvaluatedAt). The suggested ReferenceWritableKeyPath fix is a nice 3-liner that fixes all three getters.
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/<key> 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
$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.
5d32bb9 to
e278f4d
Compare
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.
|
Thanks @turnipdabeets. I addressed both of your suggestions. |
💡 Motivation and Context
$feature_flag_calledevents carry the full enriched properties (context envelope, super properties,$active_feature_flags, the$feature/<key>enumeration) on every flag call, even for flags not linked to an experiment. This PR trims those events to a strict allowlist iff the server-controlled gate is on (top-levelminimalFlagCalledEventsin the v2/flagsresponse) and the flag'shas_experimentis exactlyfalse. Any missing signal (legacy response, cached/bootstrap state without the field,has_experimentunknown) sends the full legacy shape unchanged. Server-gated because rolling this out unconditionally could break existing insights; announcement/comms come before enablement.iOS specifics:
posthog.minimalFlagCalledEvents), so it survives app restarts; it is removed when a response omits the field and cleared onreset().buildProperties()builds the full property set, so the strip is structural — new context properties added later are stripped automatically.beforeSendhooks and the legacypropertiesSanitizerrun after the filter and may re-add keys (accepted escape hatch, codified in the contract).sendMinimalFlagCalledEvents,lastRequestId, andlastEvaluatedAtall memoize their value in memory after the first disk read, so a flag check only touches storage once per process instead of on every call.Part of a cross-SDK rollout; reference implementation and fuller context: PostHog/posthog-python#748
💚 How did you test it?
New test suites were authored but could not be executed in the authoring environment (no Xcode available — only
swift build -Xswiftc -DTESTINGtype-checking andswiftformat --lintwere run), so CI is the validation gate for this PR:PostHogSDKTest(Quick/Nimble): gated + no experiment → event's key set equals the exact expected minimal set; gated + registered group →$groupssurvives minimization and carries the group; gated + experiment → full event; gated + unknownhas_experiment→ full event; ungated → full event.PostHogRemoteConfigTest(swift-testing): gate defaults to false when absent; persists across a simulated restart (fresh instance re-reads it from storage); clears when a later response omits the field;clear()drops it.Behavioral note: the v4 flag details (including
metadata.has_experiment) persist with the cached flags, so minimization resumes from cache after a cold start once a gated response has been seen. Caches written before this change (or responses without flag details) lackhas_experiment, so events stay full until the first fresh/flagsresponse — fail-safe.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset fileCreated with PostHog Code