feat(flags): minimize $feature_flag_called events for non-experiment flags - #4172
Merged
Conversation
When the v2 /flags response reports minimalFlagCalledEvents: true (or, for posthog-node local evaluation, the flag-definitions payload reports minimal_flag_called_events: true) and the evaluated flag is not linked to an experiment (has_experiment === false), $feature_flag_called events are rebuilt from a strict cross-SDK allowlist of flag-evaluation, processing-control, and SDK-identity properties. Any missing signal (no gate, bootstrapped or locally injected flags, unknown has_experiment) falls back to the full event. The gate is persisted alongside cached flags and is server-controlled; before_send runs after the filter and may re-add stripped properties. Generated-By: PostHog Code Task-Id: ffe402fd-d75c-4043-8e5d-d2fe513cac6f
Contributor
posthog-node Compliance ReportDate: 2026-07-20 17:47:44 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
Contributor
Contributor
posthog-js Compliance ReportDate: 2026-07-20 17:53:06 UTC ✅ All Tests Passed!26/26 tests passed Capture Tests✅ 26/26 tests passed View Details
|
Contributor
|
Size Change: +17.3 kB (+0.1%) Total Size: 17.6 MB
ℹ️ View Unchanged
|
haacked
marked this pull request as ready for review
July 18, 2026 00:51
Avoid a per-call array allocation in minimizeFlagCalledEventProperties. Rename isMinimalFlagCalledEvent to shouldSendMinimalFlagCalledEvent in the browser package to match node's naming, and fold node's event-name check into _shouldSendMinimalFlagCalledEvent instead of splitting it across the method and its caller. Add tests for before_send re-adding a stripped property, local-eval gate flip-off on reload, and a truthy non-boolean gate value falling back to the full event.
dustinbyrne
approved these changes
Jul 20, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Problem
$feature_flag_calledevents carry the full enriched properties dict (super properties, context tags, custom event properties, system context) on every flag call, even for the vast majority of flags that aren't linked to an experiment. This PR minimizes those events for non-experiment flags when the server enables it, cutting event size and ingestion cost without losing anything needed for exposure analysis.The event is trimmed to a strict allowlist iff the server-controlled gate is on (
minimalFlagCalledEventsin the v2/flagsresponse, orminimal_flag_called_eventsin the local-evaluation payload) and the evaluated flag'shas_experimentis exactlyfalse. Any missing signal sends the full legacy shape unchanged. The reason for the server-gate is we can't just roll this out to everyone — it could break existing insights; we'll announce first and give people time to adjust. The main goal is to establish$feature_flag_calledas a special system event.Part of a cross-SDK rollout; reference implementation and fuller context: PostHog/posthog-python#748
Changes
@posthog/core— the shared pieces:MINIMAL_FLAG_CALLED_EVENT_PROPERTIES: the canonical allowlist (flag identity, evaluation debug scalars, processing controls like$groups/$process_person_profile/$geoip_disable, debug location, and SDK identity incl.$is_server). It's the union across client and server SDKs; entries are inert where an SDK never sets them.minimizeFlagCalledEventProperties(): builds the minimal property set from the allowlist rather than deleting keys, so anything not explicitly allowlisted is structurally excluded.PostHogCoreStateless.buildPayloadapplies the filter for gated non-experiment$feature_flag_calledevents, behind aprotected isMinimalFlagCalledEventsEnabled()hook (base:false, fail-safe).PostHogCorepersists the gate alongside the cached flag details (minimalFlagCalledEventson the stored flags format), overwriting it on every successful flags response so an absent field flips it off. Failed/quota-limited requests keep the stored gate, matching how they keep the cached flags.posthog-js (browser) — the biggest payload win:
parseFlagsResponseand persisted as a new$minimal_flag_called_eventspersistence key, registered withexposure: 'hidden'in the persistence key policy so it can never leak into event properties. Legacy v1 responses and locally injected flags always storefalse; bootstrap flags carry no gate, so they send full events until the first real/flagsresponse.capture()aftercalculateEventPropertieshas merged persistence/context/super properties and after the last SDK-added property (the timestamp-override props), so the strip is structural.before_sendruns after the filter and may deliberately re-add stripped properties. Transport-level keys the browser carries insideproperties(token,distinct_id,$cookieless_mode) are preserved.$set/$set_oncehandling; the$set_oncecalculation is skipped (not discarded) so the initial person props aren't marked as sent and still go out with the next full event.posthog-node — dual-source gate:
/flagsresponse (getFeatureFlagResult,evaluateFlagsfallback) and the local-evaluation poller, which reports the top-levelminimal_flag_called_eventsfield of the definitions payload via a newonMinimalFlagCalledEventscallback whenever definitions are (re)loaded — from the API, from aFlagDefinitionCacheProvider(the field round-trips throughFlagDefinitionCacheData), or on quota reset._prepareEventMessagebeforebefore_send, so hooks can re-add properties; everything the SDK adds afterwards ($groups,$lib/$lib_version/$is_server,$geoip_disable) is itself allowlisted.$is_serveris kept on minimal events;locally_evaluatedis kept,$feature_flag_definitions_loaded_atis stripped.posthog-react-native — no changes needed beyond
@posthog/core: RN has no$feature_flag_calledcapture path of its own, so it inherits the gate persistence and minimization fromPostHogCore(as does posthog-js-lite). Only the generated API reference changed.Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset fileTesting
Every package follows the same matrix from the cross-SDK contract: gated + no experiment → exactly the allowlist; gated + experiment → full; ungated / field absent /
has_experimentabsent / bootstrap-only → full; gate persisted across restart; gate flips off when a later response omits the field.@posthog/core: 7 new tests inposthog.featureflags.spec.ts— exact key-set assertion with registered super properties proving the strip, flip-off on a later response, and gate survival across a simulated restart on shared storage. Full suite: 44 suites, 776 passed.$groupsfromposthog.group()and the transport keys, no top-level$set_once, explicit-timestamp capture stays allowlisted (no$event_time_override_*), bootstrap-only → full, and a reload backed by the same persistence still sending minimal events. Full suite: 119 suites, 4919 passed.feature-flags.flags.spec.ts(remote exact key set with$groups,$is_server: trueand$geoip_disable, local-eval exact key set, gate read from cached definitions via a cache provider, remote flip-off sequence) and 1 inevaluate-flags.spec.ts(minimal vs experiment-full through the snapshot path). Full suite: 28 suites, 828 passed.Lint clean in all touched packages; public API references regenerated.
Created with PostHog Code