Skip to content

feat(flags): minimize $feature_flag_called events for non-experiment flags - #724

Merged
haacked merged 4 commits into
mainfrom
haacked/minimal-flag-called-events
Jul 21, 2026
Merged

feat(flags): minimize $feature_flag_called events for non-experiment flags#724
haacked merged 4 commits into
mainfrom
haacked/minimal-flag-called-events

Conversation

@haacked

@haacked haacked commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

$feature_flag_called events 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-level minimalFlagCalledEvents in the v2 /flags response) and the flag's has_experiment is exactly false. Any missing signal (legacy response, cached/bootstrap state without the field, has_experiment unknown) sends the full legacy shape unchanged. Server-gated because rolling this out unconditionally could break existing insights; announcement/comms come before enablement.

iOS specifics:

  • The gate is persisted alongside the cached flags in storage (posthog.minimalFlagCalledEvents), so it survives app restarts; it is removed when a response omits the field and cleared on reset().
  • The allowlist is applied after buildProperties() builds the full property set, so the strip is structural — new context properties added later are stripped automatically. beforeSend hooks and the legacy propertiesSanitizer run after the filter and may re-add keys (accepted escape hatch, codified in the contract).
  • Experiment-linked flags always send the full envelope — experiment exposure analysis reads it.
  • sendMinimalFlagCalledEvents, lastRequestId, and lastEvaluatedAt all 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 -DTESTING type-checking and swiftformat --lint were 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 → $groups survives minimization and carries the group; gated + experiment → full event; gated + unknown has_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) lack has_experiment, so events stay full until the first fresh /flags response — fail-safe.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

Created with PostHog Code

@haacked haacked self-assigned this Jul 16, 2026
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-07-21 18:44:20 UTC
Duration: 203313ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 2734ms
Format Validation.Event Has Uuid 254ms
Format Validation.Event Has Lib Properties 279ms
Format Validation.Distinct Id Is String 387ms
Format Validation.Token Is Present 2728ms
Format Validation.Custom Properties Preserved 2642ms
Format Validation.Event Has Timestamp 2799ms
Retry Behavior.Retries On 503 11207ms
Retry Behavior.Does Not Retry On 400 4787ms
Retry Behavior.Does Not Retry On 401 4776ms
Retry Behavior.Respects Retry After Header 7702ms
Retry Behavior.Implements Backoff 21284ms
Retry Behavior.Retries On 500 9182ms
Retry Behavior.Retries On 502 5255ms
Retry Behavior.Retries On 504 9111ms
Retry Behavior.Max Retries Respected 15345ms
Deduplication.Generates Unique Uuids 3016ms
Deduplication.Preserves Uuid On Retry 9204ms
Deduplication.Preserves Uuid And Timestamp On Retry 10260ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7856ms
Deduplication.No Duplicate Events In Batch 2747ms
Deduplication.Different Events Have Different Uuids 2749ms
Compression.Sends Gzip When Enabled 2721ms
Batch Format.Uses Proper Batch Structure 325ms
Batch Format.Flush With No Events Sends Nothing 205ms
Batch Format.Multiple Events Batched Together 2725ms
Error Handling.Does Not Retry On 403 4775ms
Error Handling.Does Not Retry On 413 2368ms
Error Handling.Retries On 408 9319ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 2711ms
Request Payload.Flags Request Uses V2 Query Param 2733ms
Request Payload.Flags Request Hits Flags Path Not Decide 2729ms
Request Payload.Flags Request Omits Authorization Header 2668ms
Request Payload.Token In Flags Body Matches Init 2733ms
Request Payload.Groups Round Trip 2744ms
Request Payload.Groups Default To Empty Object 2745ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 2748ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 2762ms
Request Payload.Disable Geoip Omitted Defaults To False 2753ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 2702ms
Request Lifecycle.No Flags Request On Init Alone 64ms
Request Lifecycle.No Flags Request On Normal Capture 2702ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 5418ms
Request Lifecycle.Mock Response Value Is Returned To Caller 2801ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 2923ms

@haacked
haacked marked this pull request as ready for review July 18, 2026 04:38
@haacked
haacked requested a review from a team as a code owner July 18, 2026 04:38
@posthog

posthog Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 0 should fix, 1 consider.

Published 1 finding (view the review).

@posthog

posthog Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

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.

ReviewHog Report

Changes

Issues: 1 issue

Files (7)
  • .changeset/minimal-flag-called-events.md
  • PostHog/PostHogRemoteConfig.swift
  • PostHog/PostHogSDK.swift
  • PostHog/PostHogStorage.swift
  • PostHogTests/PostHogRemoteConfigTest.swift
  • PostHogTests/PostHogSDKTest.swift
  • PostHogTests/TestUtils/MockPostHogServer.swift

Comment on lines +82 to +86
var sendMinimalFlagCalledEvents: Bool {
featureFlagsLock.withLock {
getCachedValue(\.minimalFlagCalledEvents, key: .minimalFlagCalledEvents) { storage.getBool(forKey: $0) } ?? false
}
}

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>

@turnipdabeets turnipdabeets left a comment

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.

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.

haacked added 2 commits July 21, 2026 10:36
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.
@haacked
haacked force-pushed the haacked/minimal-flag-called-events branch from 5d32bb9 to e278f4d Compare July 21, 2026 18:31
haacked added 2 commits July 21, 2026 14:35
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.
@haacked

haacked commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @turnipdabeets. I addressed both of your suggestions.

@haacked
haacked enabled auto-merge (squash) July 21, 2026 21:50
@haacked
haacked merged commit 262c9b2 into main Jul 21, 2026
42 of 43 checks passed
@haacked
haacked deleted the haacked/minimal-flag-called-events branch July 21, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants