-
Notifications
You must be signed in to change notification settings - Fork 99
feat(flags): minimize $feature_flag_called events for non-experiment flags #724
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
bf0f73d
feat(flags): send minimal $feature_flag_called events when server-gated
haacked e278f4d
Clarify unreachable allowlist entries kept for cross-SDK parity
haacked 19f677d
Memoize getCachedValue to avoid repeated disk reads
haacked 54a5328
Keep OS and app version on minimal feature_flag_called events
haacked File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
Why we think it's a valid issue
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.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).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.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).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.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 ofgetCachedValue(lines 945-951), which only readsself[keyPath: cache] ?? load(key)and never writes the loaded value back into the in-memory property. Compare this togetCachedDictionary(used for feature flags), which explicitly memoizes:if self[keyPath: cache] == nil { self[keyPath: cache] = storage.getDictionary(...) }. BecausegetCachedValue'scacheparameter is a plainKeyPath(not aReferenceWritableKeyPath) and the function body never assigns to it, the in-memoryminimalFlagCalledEventsvar staysniluntil the next successful/flagsresponse callssetCachedMinimalFlagCalledEvents. Until then, every single call tosendMinimalFlagCalledEventsre-executesstorage.getBool(forKey:)->getJson->getData->Data(contentsOf:)(a synchronous file read) +JSONSerialization.jsonObject, all while holdingfeatureFlagsLock. 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/flagsresponse of the session lands, every unique flag reported viareportFeatureFlagCalled(each doing its ownfeatureFlagsLock.withLockacquisition, synchronously on whatever thread calledisFeatureEnabled/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:loadFeatureFlagsnow performs a 6th synchronous disk write underfeatureFlagsLock(viasetCachedMinimalFlagCalledEvents) alongside the existing flags/payloads/requestId/evaluatedAt writes, incrementally lengthening how long that lock is held while processing a/flagsresponse.Suggested fix
Make
getCachedValueactually memoize, mirroringgetCachedDictionary: change thecacheparameter toReferenceWritableKeyPath<PostHogRemoteConfig, T?>and assignself[keyPath: cache] = load(key)whenself[keyPath: cache]is nil before returning it. This fixes the repeated disk reads forsendMinimalFlagCalledEventsas well as the pre-existinglastRequestId/lastEvaluatedAtgetters that share the same helper.Prompt to fix with AI (copy-paste)