From c4256a3696edfd7f6dd1d7abe7c905a847c2d5e0 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 16 Jul 2026 13:06:28 -0700 Subject: [PATCH 1/3] feat(flags): send minimal $feature_flag_called events when server-gated 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 --- .changeset/minimal-flag-called-events.md | 10 + .../posthog-js-references-latest.json | 5 + .../src/__tests__/featureflags.test.ts | 269 +++++++++++++++++ .../__tests__/persistence-key-policy.test.ts | 2 + .../src/__tests__/posthog-persistence.test.ts | 1 + packages/browser/src/constants.ts | 1 + .../browser/src/persistence-key-policy.ts | 4 + packages/browser/src/posthog-core.ts | 36 ++- packages/browser/src/posthog-featureflags.ts | 6 + packages/browser/src/types.ts | 5 + .../__tests__/posthog.featureflags.spec.ts | 176 +++++++++++ .../__tests__/posthog.featureflags.v1.spec.ts | 1 + packages/core/src/featureFlagUtils.ts | 62 ++++ packages/core/src/index.ts | 2 + packages/core/src/posthog-core-stateless.ts | 22 +- packages/core/src/posthog-core.ts | 10 + packages/core/src/types.ts | 7 +- .../posthog-node-references-latest.json | 18 ++ packages/node/src/__tests__/cache.spec.ts | 1 + .../node/src/__tests__/evaluate-flags.spec.ts | 45 +++ .../src/__tests__/feature-flags.flags.spec.ts | 274 ++++++++++++++++++ packages/node/src/client.ts | 33 ++- .../src/extensions/feature-flags/cache.ts | 6 + .../extensions/feature-flags/feature-flags.ts | 14 + ...osthog-react-native-references-latest.json | 13 + 25 files changed, 1013 insertions(+), 10 deletions(-) create mode 100644 .changeset/minimal-flag-called-events.md diff --git a/.changeset/minimal-flag-called-events.md b/.changeset/minimal-flag-called-events.md new file mode 100644 index 0000000000..7b1cfc3e4f --- /dev/null +++ b/.changeset/minimal-flag-called-events.md @@ -0,0 +1,10 @@ +--- +'@posthog/core': minor +'posthog-js': minor +'posthog-node': minor +'posthog-react-native': minor +--- + +send minimal `$feature_flag_called` events when the server enables it + +When the v2 `/flags` response carries `minimalFlagCalledEvents: true` (or, for posthog-node local evaluation, the flag-definitions payload carries `minimal_flag_called_events: true`) and the evaluated flag is not linked to an experiment (`$feature_flag_has_experiment === false`), `$feature_flag_called` events are rebuilt from a strict allowlist of flag-evaluation, processing-control, and SDK-identity properties. Super properties, `$set`/`$set_once`, the `$feature/` enumeration, `$active_feature_flags`, and the context envelope are stripped. Any missing signal (no gate on the response, bootstrapped or locally injected flags, `has_experiment` unknown) falls back to the full event, and experiment-linked flags always send the full envelope. The gate is stored alongside the cached flags (posthog-js persistence, posthog-node poller state) and is server-controlled, with no SDK-side configuration. `before_send` runs after the filter and may re-add stripped properties. diff --git a/packages/browser/references/posthog-js-references-latest.json b/packages/browser/references/posthog-js-references-latest.json index a0962f546a..ee7f91fd7d 100644 --- a/packages/browser/references/posthog-js-references-latest.json +++ b/packages/browser/references/posthog-js-references-latest.json @@ -2621,6 +2621,11 @@ "type": "Record", "name": "flags" }, + { + "description": "Server-controlled gate for minimal `$feature_flag_called` events. `true` only when the project opted in; omitted otherwise. Absence always means full events.", + "type": "boolean", + "name": "minimalFlagCalledEvents" + }, { "type": "string", "name": "requestId" diff --git a/packages/browser/src/__tests__/featureflags.test.ts b/packages/browser/src/__tests__/featureflags.test.ts index f04b327f92..479661bc9d 100644 --- a/packages/browser/src/__tests__/featureflags.test.ts +++ b/packages/browser/src/__tests__/featureflags.test.ts @@ -12,6 +12,7 @@ import { isUndefined } from '@posthog/core' import { PostHogConfig } from '../types' import { createMockPostHog, createPosthogInstance } from './helpers/posthog-instance' import { SimpleEventEmitter } from '../utils/simple-event-emitter' +import { uuidv7 } from '../uuidv7' jest.useFakeTimers() jest.spyOn(global, 'setTimeout') @@ -2918,6 +2919,46 @@ describe('featureflags', () => { ) }) }) + + describe('minimal flag called events gate persistence', () => { + const receiveFlags = (response: Record) => { + featureFlags.receivedFeatureFlags({ + featureFlags: { 'test-flag': true }, + featureFlagPayloads: {}, + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variant: undefined, + reason: undefined, + metadata: undefined, + }, + }, + ...response, + }) + } + + it('persists the gate from the flags response and never exposes it as an event property', () => { + receiveFlags({ minimalFlagCalledEvents: true }) + + expect(instance.persistence.props['$minimal_flag_called_events']).toBe(true) + expect(instance.persistence.properties()).not.toHaveProperty('$minimal_flag_called_events') + }) + + it('flips the gate off when a new flags response omits the field', () => { + receiveFlags({ minimalFlagCalledEvents: true }) + receiveFlags({}) + + expect(instance.persistence.props['$minimal_flag_called_events']).toBe(false) + }) + + it('flips the gate off on a legacy v1 array response', () => { + receiveFlags({ minimalFlagCalledEvents: true }) + featureFlags.receivedFeatureFlags({ featureFlags: ['test-flag'] } as any) + + expect(instance.persistence.props['$minimal_flag_called_events']).toBe(false) + }) + }) }) describe('parseFlagsResponse', () => { @@ -2944,6 +2985,7 @@ describe('parseFlagsResponse', () => { parseFlagsResponse(flagsResponse, persistence) expect(persistence.register).toHaveBeenCalledWith({ + $minimal_flag_called_events: false, $active_feature_flags: ['beta-feature', 'alpha-feature-2', 'multivariate-flag'], $enabled_feature_flags: { 'beta-feature': true, @@ -2979,6 +3021,7 @@ describe('parseFlagsResponse', () => { parseFlagsResponse(flagsResponse, persistence) expect(persistence.register).toHaveBeenCalledWith({ + $minimal_flag_called_events: false, $active_feature_flags: ['beta-feature', 'alpha-feature-2', 'multivariate-flag'], $enabled_feature_flags: { 'beta-feature': true, @@ -3069,6 +3112,7 @@ describe('parseFlagsResponse', () => { parseFlagsResponse(flagsResponse, persistence) expect(persistence.register).toHaveBeenCalledWith({ + $minimal_flag_called_events: false, $active_feature_flags: ['beta-feature', 'alpha-feature', 'multivariate-flag'], $enabled_feature_flags: { 'alpha-feature': true, @@ -3158,6 +3202,7 @@ describe('parseFlagsResponse', () => { parseFlagsResponse(flagsResponse, persistence) expect(persistence.register).toHaveBeenLastCalledWith({ + $minimal_flag_called_events: false, $active_feature_flags: ['beta-feature', 'alpha-feature-2'], $enabled_feature_flags: { 'beta-feature': true, 'alpha-feature-2': true }, }) @@ -3218,6 +3263,7 @@ describe('parseFlagsResponse', () => { parseFlagsResponse(flagsResponse, persistence) expect(persistence.register).toHaveBeenCalledWith({ + $minimal_flag_called_events: false, $active_feature_flags: ['test-flag'], $enabled_feature_flags: { 'test-flag': true }, $feature_flag_details: {}, @@ -3256,6 +3302,7 @@ describe('parseFlagsResponse', () => { parseFlagsResponse(flagsResponse, persistence) expect(persistence.register).toHaveBeenCalledWith({ + $minimal_flag_called_events: false, $active_feature_flags: ['test-flag'], $enabled_feature_flags: { 'test-flag': true }, $feature_flag_details: { @@ -4334,3 +4381,225 @@ describe('$feature_flag_error tracking', () => { }) }) }) + +describe('minimal $feature_flag_called events', () => { + beforeEach(() => { + // Events are dropped via before_send (expected warn) and bootstrap flags go through + // the legacy-shape path (expected upgrade warn). + jest.spyOn(window.console, 'warn').mockImplementation() + jest.spyOn(window.console, 'error').mockImplementation() + }) + + const gatedFlagsResponse = (options: { minimalFlagCalledEvents?: boolean; hasExperiment?: boolean } = {}) => ({ + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { + id: 42, + version: 3, + description: undefined, + payload: undefined, + ...(isUndefined(options.hasExperiment) ? {} : { has_experiment: options.hasExperiment }), + }, + }, + }, + requestId: 'minimal-request-id', + evaluatedAt: Date.now(), + ...(isUndefined(options.minimalFlagCalledEvents) + ? {} + : { minimalFlagCalledEvents: options.minimalFlagCalledEvents }), + }) + + const createInstanceWithCapturedEvents = async (config: Record = {}, token?: string) => { + const events: any[] = [] + const posthog = await createPosthogInstance(token, { + advanced_disable_feature_flags: true, + before_send: (event) => { + events.push(event) + return null + }, + ...config, + }) + return { posthog, events } + } + + const findFlagCalledEvent = (events: any[]) => events.find((e) => e.event === '$feature_flag_called') + + it('sends exactly the allowlisted properties when gated and the flag has no experiment', async () => { + const { posthog, events } = await createInstanceWithCapturedEvents() + // Super properties must be structurally excluded from the minimal event + posthog.register({ super_prop: 'super_value' }) + // $groups must survive minimization — it feeds ingestion dedup and group-flag routing + posthog.group('organization', 'org-1') + posthog.featureFlags.receivedFeatureFlags( + gatedFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: false }) + ) + + expect(posthog.getFeatureFlag('test-flag')).toBe(true) + + const event = findFlagCalledEvent(events) + expect(event).toBeDefined() + expect(Object.keys(event.properties).sort()).toEqual( + [ + // transport-level keys the browser SDK carries inside properties + 'token', + 'distinct_id', + // the strict allowlist + '$feature_flag', + '$feature_flag_response', + '$feature_flag_has_experiment', + '$feature_flag_id', + '$feature_flag_version', + '$feature_flag_request_id', + '$feature_flag_evaluated_at', + '$groups', + '$current_url', + '$pathname', + '$session_id', + '$window_id', + '$lib', + '$lib_version', + '$device_id', + '$process_person_profile', + ].sort() + ) + expect(event.properties).toMatchObject({ + $feature_flag: 'test-flag', + $feature_flag_response: true, + $feature_flag_has_experiment: false, + $feature_flag_id: 42, + $feature_flag_version: 3, + $feature_flag_request_id: 'minimal-request-id', + $groups: { organization: 'org-1' }, + }) + expect(event.$set_once).toBeUndefined() + }) + + it('strips the timestamp-override props when captured with an explicit timestamp', async () => { + const { posthog, events } = await createInstanceWithCapturedEvents() + posthog.featureFlags.receivedFeatureFlags( + gatedFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: false }) + ) + + const overrideTimestamp = new Date(Date.now() - 1000) + posthog.capture( + '$feature_flag_called', + { $feature_flag: 'test-flag', $feature_flag_response: true, $feature_flag_has_experiment: false }, + { timestamp: overrideTimestamp } + ) + + const event = findFlagCalledEvent(events) + expect(event).toBeDefined() + expect(event.properties).not.toHaveProperty('$event_time_override_provided') + expect(event.properties).not.toHaveProperty('$event_time_override_system_time') + expect(Object.keys(event.properties).sort()).toEqual( + [ + 'token', + 'distinct_id', + '$feature_flag', + '$feature_flag_response', + '$feature_flag_has_experiment', + '$feature_flag_request_id', + '$current_url', + '$pathname', + '$session_id', + '$window_id', + '$lib', + '$lib_version', + '$device_id', + '$process_person_profile', + ].sort() + ) + // The transport-level timestamp itself is untouched by minimization + expect(event.timestamp).toEqual(overrideTimestamp) + }) + + it('sends the full event when gated but the flag has an experiment', async () => { + const { posthog, events } = await createInstanceWithCapturedEvents() + posthog.register({ super_prop: 'super_value' }) + posthog.featureFlags.receivedFeatureFlags( + gatedFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: true }) + ) + + posthog.getFeatureFlag('test-flag') + + const event = findFlagCalledEvent(events) + expect(event.properties).toMatchObject({ + $feature_flag_has_experiment: true, + super_prop: 'super_value', + '$feature/test-flag': true, + $active_feature_flags: ['test-flag'], + $used_bootstrap_value: expect.any(Boolean), + }) + }) + + it.each([ + ['the gate field is absent', gatedFlagsResponse({ hasExperiment: false })], + ['the gate field is false', gatedFlagsResponse({ minimalFlagCalledEvents: false, hasExperiment: false })], + ['has_experiment is absent', gatedFlagsResponse({ minimalFlagCalledEvents: true })], + ])('sends the full event when %s', async (_, response) => { + const { posthog, events } = await createInstanceWithCapturedEvents() + posthog.register({ super_prop: 'super_value' }) + posthog.featureFlags.receivedFeatureFlags(response) + + posthog.getFeatureFlag('test-flag') + + const event = findFlagCalledEvent(events) + expect(event.properties).toMatchObject({ + super_prop: 'super_value', + '$feature/test-flag': true, + }) + }) + + it('sends the full event for bootstrap-only flags (no gate until a real flags response)', async () => { + const { posthog, events } = await createInstanceWithCapturedEvents({ + bootstrap: { featureFlags: { 'test-flag': true } }, + }) + + posthog.getFeatureFlag('test-flag') + + const event = findFlagCalledEvent(events) + expect(event.properties).toMatchObject({ + $feature_flag: 'test-flag', + $used_bootstrap_value: true, + '$feature/test-flag': true, + }) + }) + + it('keeps sending minimal events after a reload backed by the same persistence', async () => { + const persistenceName = `reload-test-${uuidv7()}` + const { posthog: firstInstance } = await createInstanceWithCapturedEvents({ + persistence: 'localstorage', + persistence_name: persistenceName, + }) + // First page load receives the gated flags but never evaluates them. + firstInstance.featureFlags.receivedFeatureFlags( + gatedFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: false }) + ) + + // Simulated reload: fresh instance backed by the same persisted state, no flags response. + const events: any[] = [] + const reloadedInstance = await createPosthogInstance(undefined, { + persistence: 'localstorage', + persistence_name: persistenceName, + advanced_disable_feature_flags: true, + before_send: (event) => { + events.push(event) + return null + }, + }) + + expect(reloadedInstance.getFeatureFlag('test-flag')).toBe(true) + + const event = findFlagCalledEvent(events) + expect(event).toBeDefined() + expect(event.properties.$feature_flag_has_experiment).toBe(false) + expect(event.properties).not.toHaveProperty('$feature/test-flag') + expect(event.properties).not.toHaveProperty('$active_feature_flags') + expect(event.properties).not.toHaveProperty('$used_bootstrap_value') + expect(event.properties).not.toHaveProperty('$browser') + }) +}) diff --git a/packages/browser/src/__tests__/persistence-key-policy.test.ts b/packages/browser/src/__tests__/persistence-key-policy.test.ts index 1bd0063076..f8dd091fac 100644 --- a/packages/browser/src/__tests__/persistence-key-policy.test.ts +++ b/packages/browser/src/__tests__/persistence-key-policy.test.ts @@ -32,6 +32,7 @@ const LEGACY_RESERVED_PERSISTENCE_KEYS = new Set([ constants.FLAG_CALL_REPORTED_SESSION_ID, constants.PERSISTENCE_FEATURE_FLAG_ERRORS, constants.PERSISTENCE_FEATURE_FLAG_EVALUATED_AT, + constants.PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS, constants.CLIENT_SESSION_PROPS, constants.CAPTURE_RATE_LIMIT, constants.INITIAL_CAMPAIGN_PARAMS, @@ -506,6 +507,7 @@ describe('persistence key policy', () => { constants.PERSISTENCE_FEATURE_FLAG_PAYLOADS, constants.PERSISTENCE_FEATURE_FLAG_REQUEST_ID, constants.PERSISTENCE_FEATURE_FLAG_EVALUATED_AT, + constants.PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS, ].sort() ) }) diff --git a/packages/browser/src/__tests__/posthog-persistence.test.ts b/packages/browser/src/__tests__/posthog-persistence.test.ts index 02a8b4ea85..de1729023c 100644 --- a/packages/browser/src/__tests__/posthog-persistence.test.ts +++ b/packages/browser/src/__tests__/posthog-persistence.test.ts @@ -64,6 +64,7 @@ const LEGACY_RESERVED_PERSISTENCE_KEYS = new Set([ '$flag_call_reported_session_id', '$feature_flag_errors', '$feature_flag_evaluated_at', + '$minimal_flag_called_events', '$client_session_props', '$capture_rate_limit', '$initial_campaign_params', diff --git a/packages/browser/src/constants.ts b/packages/browser/src/constants.ts index 4cabe24726..c00de3dfa4 100644 --- a/packages/browser/src/constants.ts +++ b/packages/browser/src/constants.ts @@ -59,6 +59,7 @@ export const PERSISTENCE_EARLY_ACCESS_FEATURES = '$early_access_features' export const PERSISTENCE_FEATURE_FLAG_DETAILS = '$feature_flag_details' export const PERSISTENCE_FEATURE_FLAG_PAYLOADS = '$feature_flag_payloads' export const PERSISTENCE_FEATURE_FLAG_REQUEST_ID = '$feature_flag_request_id' +export const PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS = '$minimal_flag_called_events' export const PERSISTENCE_OVERRIDE_FEATURE_FLAGS = '$override_feature_flags' export const PERSISTENCE_OVERRIDE_FEATURE_FLAG_PAYLOADS = '$override_feature_flag_payloads' export const STORED_PERSON_PROPERTIES_KEY = '$stored_person_properties' diff --git a/packages/browser/src/persistence-key-policy.ts b/packages/browser/src/persistence-key-policy.ts index 01036c19df..21f66f0185 100644 --- a/packages/browser/src/persistence-key-policy.ts +++ b/packages/browser/src/persistence-key-policy.ts @@ -29,6 +29,7 @@ import { PERSISTENCE_FEATURE_FLAG_EVALUATED_AT, PERSISTENCE_FEATURE_FLAG_PAYLOADS, PERSISTENCE_FEATURE_FLAG_REQUEST_ID, + PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS, PERSISTENCE_OVERRIDE_FEATURE_FLAGS, PERSISTENCE_OVERRIDE_FEATURE_FLAG_PAYLOADS, PRODUCT_TOURS, @@ -148,6 +149,9 @@ export const PERSISTENCE_KEY_POLICY: Record = [PERSISTENCE_FEATURE_FLAG_DETAILS]: { exposure: 'hidden', storageGroup: 'flags' }, [PERSISTENCE_FEATURE_FLAG_PAYLOADS]: { exposure: 'event', storageGroup: 'flags' }, [PERSISTENCE_FEATURE_FLAG_REQUEST_ID]: { exposure: 'event', storageGroup: 'flags', volatile: true }, + // Server gate for minimal $feature_flag_called events — internal state that must never + // leak into event properties. + [PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS]: { exposure: 'hidden', storageGroup: 'flags' }, [PERSISTENCE_OVERRIDE_FEATURE_FLAGS]: { exposure: 'event' }, [PERSISTENCE_OVERRIDE_FEATURE_FLAG_PAYLOADS]: { exposure: 'hidden' }, [STORED_PERSON_PROPERTIES_KEY]: { exposure: 'hidden' }, diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index 7ed30c5ffa..fef60e4607 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -18,6 +18,7 @@ import { EVENT_PAGEVIEW, FLAG_CALL_REPORTED, PEOPLE_DISTINCT_ID_KEY, + PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS, SDK_DEBUG_EXTENSIONS_INIT_METHOD, SDK_DEBUG_EXTENSIONS_INIT_TIME_MS, SESSION_RECORDING_REMOTE_CONFIG, @@ -114,6 +115,7 @@ import { isObject, isBoolean, getEventUuid, + minimizeFlagCalledEventProperties, } from '@posthog/core' import { uuidv7 } from './uuidv7' import { ExternalIntegrations } from './extensions/external-integration' @@ -172,6 +174,11 @@ const SURVEYS_NOT_AVAILABLE = 'Surveys module not available' const SANITIZE_DEPRECATED = 'sanitize_properties is deprecated. Use before_send instead' const DENYLIST_INVALID = 'Invalid value for property_denylist config: ' +// Transport-level keys the browser SDK carries inside event properties (unlike other SDKs, +// where they live outside `properties`). They are out of scope of the minimal +// $feature_flag_called allowlist and must survive minimization for ingestion to work. +const FLAG_CALLED_TRANSPORT_PROPERTY_KEYS = ['token', 'distinct_id', COOKIELESS_MODE_FLAG_PROPERTY] + const PRIMARY_INSTANCE_NAME = 'posthog' /* @@ -1391,8 +1398,18 @@ export class PostHog implements PostHogInterface { data.properties['$lib_rate_limit_remaining_tokens'] = clientRateLimitContext.remainingTokens } + // When the server gates this project into minimal $feature_flag_called events and the + // evaluated flag is not linked to an experiment, the fully merged properties are rebuilt + // from the strict allowlist below (after the last SDK-added property) so super properties + // and the context envelope are structurally excluded. Any missing signal falls back to + // the full event. + const isMinimalFlagCalledEvent = + event_name === '$feature_flag_called' && + data.properties['$feature_flag_has_experiment'] === false && + this.get_property(PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS) === true + const setProperties = options?.$set - if (setProperties) { + if (setProperties && !isMinimalFlagCalledEvent) { data.$set = options?.$set } const unsetProperties = options?.$unset @@ -1406,11 +1423,12 @@ export class PostHog implements PostHogInterface { // $identify should always include initial props because it creates/merges persons // and may be processed before earlier anonymous events on the server const forceIncludeInitialProps = event_name === EVENT_IDENTIFY - const setOnceProperties = this._calculate_set_once_properties( - options?.$set_once, - markSetOnceAsSent, - forceIncludeInitialProps - ) + // Minimal flag-called events must not carry $set_once. Skipping the calculation (rather + // than dropping its result) avoids marking the initial person props as sent, so they + // still go out with the next full event. + const setOnceProperties = isMinimalFlagCalledEvent + ? undefined + : this._calculate_set_once_properties(options?.$set_once, markSetOnceAsSent, forceIncludeInitialProps) if (setOnceProperties) { data.$set_once = setOnceProperties } @@ -1424,6 +1442,12 @@ export class PostHog implements PostHogInterface { data.properties['$event_time_override_system_time'] = systemTime } + // before_send runs after this filter and may deliberately re-add stripped properties; + // the SDK itself must not enrich beyond allowlisted keys past this point. + if (isMinimalFlagCalledEvent) { + data.properties = minimizeFlagCalledEventProperties(data.properties, FLAG_CALLED_TRANSPORT_PROPERTY_KEYS) + } + if (event_name === SurveyEventName.DISMISSED || event_name === SurveyEventName.SENT) { const surveyId = properties?.[SurveyEventProperties.SURVEY_ID] const surveyIteration = properties?.[SurveyEventProperties.SURVEY_ITERATION] diff --git a/packages/browser/src/posthog-featureflags.ts b/packages/browser/src/posthog-featureflags.ts index 206b610925..22234c0dfb 100644 --- a/packages/browser/src/posthog-featureflags.ts +++ b/packages/browser/src/posthog-featureflags.ts @@ -27,6 +27,7 @@ import { PERSISTENCE_FEATURE_FLAG_ERRORS, PERSISTENCE_FEATURE_FLAG_EVALUATED_AT, PERSISTENCE_FEATURE_FLAG_REQUEST_ID, + PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS, ENABLED_FEATURE_FLAGS, STORED_GROUP_PROPERTIES_KEY, STORED_PERSON_PROPERTIES_KEY, @@ -121,6 +122,8 @@ export const parseFlagsResponse = ( persistence.register({ [PERSISTENCE_ACTIVE_FEATURE_FLAGS]: featureFlags, [ENABLED_FEATURE_FLAGS]: $enabled_feature_flags, + // Legacy responses never carry the gate — fail safe to full events. + [PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS]: false, }) return } @@ -173,6 +176,9 @@ export const parseFlagsResponse = ( [ENABLED_FEATURE_FLAGS]: newFeatureFlags || {}, [PERSISTENCE_FEATURE_FLAG_PAYLOADS]: newFeatureFlagPayloads || {}, [PERSISTENCE_FEATURE_FLAG_DETAILS]: newFeatureFlagDetails || {}, + // Overwritten on every flags response: an absent field flips the gate off, so + // bootstrap/locally injected flags always fail safe to full events. + [PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS]: response.minimalFlagCalledEvents === true, ...(requestId ? { [PERSISTENCE_FEATURE_FLAG_REQUEST_ID]: requestId } : {}), ...(evaluatedAt ? { [PERSISTENCE_FEATURE_FLAG_EVALUATED_AT]: evaluatedAt } : {}), }) diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 778598d2bd..92f9f39de5 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -462,6 +462,11 @@ export interface FlagsResponse extends RemoteConfig { requestId?: string flags: Record evaluatedAt?: number + /** + * Server-controlled gate for minimal `$feature_flag_called` events. `true` only when the + * project opted in; omitted otherwise. Absence always means full events. + */ + minimalFlagCalledEvents?: boolean } export type SiteAppGlobals = { diff --git a/packages/core/src/__tests__/posthog.featureflags.spec.ts b/packages/core/src/__tests__/posthog.featureflags.spec.ts index 027a8c3293..a206bc162d 100644 --- a/packages/core/src/__tests__/posthog.featureflags.spec.ts +++ b/packages/core/src/__tests__/posthog.featureflags.spec.ts @@ -872,6 +872,181 @@ describe('PostHog Feature Flags v4', () => { }) }) + describe('minimal $feature_flag_called events', () => { + const flagsResponseJson = (options: { minimalFlagCalledEvents?: boolean; hasExperiment?: boolean }): any => ({ + flags: { + 'feature-1': { + key: 'feature-1', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { + id: 1, + version: 2, + description: undefined, + payload: undefined, + ...(options.hasExperiment === undefined ? {} : { has_experiment: options.hasExperiment }), + }, + }, + }, + requestId: 'minimal-request-id', + evaluatedAt: 1640995200000, + ...(options.minimalFlagCalledEvents === undefined + ? {} + : { minimalFlagCalledEvents: options.minimalFlagCalledEvents }), + }) + + const mockFlagsEndpoint = (options: { minimalFlagCalledEvents?: boolean; hasExperiment?: boolean }): void => { + mocks.fetch.mockImplementation((url) => { + if (url.includes('/flags/?v=2')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => Promise.resolve(flagsResponseJson(options)), + }) + } + + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => Promise.resolve({ status: 'ok' }), + }) + }) + } + + const getFlagCalledProperties = async (): Promise> => { + // Register a super property to prove it gets stripped from minimal events + posthog.register({ super_prop: 'super_value' }) + await posthog.reloadFeatureFlagsAsync() + posthog.getFeatureFlag('feature-1') + await waitForPromises() + const event = mocks.fetch.mock.calls + .flatMap((call) => parseBody(call)?.batch ?? []) + .find((e: any) => e.event === '$feature_flag_called') + return event.properties + } + + it('should send exactly the allowlisted properties when gated and the flag has no experiment', async () => { + mockFlagsEndpoint({ minimalFlagCalledEvents: true, hasExperiment: false }) + + const properties = await getFlagCalledProperties() + + expect(Object.keys(properties).sort()).toEqual( + [ + '$feature_flag', + '$feature_flag_response', + '$feature_flag_has_experiment', + '$feature_flag_id', + '$feature_flag_version', + '$feature_flag_request_id', + '$feature_flag_evaluated_at', + '$session_id', + '$lib', + '$lib_version', + '$process_person_profile', + ].sort() + ) + expect(properties).toMatchObject({ + $feature_flag: 'feature-1', + $feature_flag_response: true, + $feature_flag_has_experiment: false, + }) + }) + + it('should send the full event when gated but the flag has an experiment', async () => { + mockFlagsEndpoint({ minimalFlagCalledEvents: true, hasExperiment: true }) + + const properties = await getFlagCalledProperties() + + expect(properties).toMatchObject({ + $feature_flag_has_experiment: true, + super_prop: 'super_value', + '$feature/feature-1': true, + $active_feature_flags: ['feature-1'], + }) + }) + + it.each([ + ['the gate field is absent', {}], + ['the gate field is false', { minimalFlagCalledEvents: false }], + ['has_experiment is absent', { minimalFlagCalledEvents: true, hasExperiment: undefined }], + ])('should send the full event when %s', async (_, options) => { + mockFlagsEndpoint({ hasExperiment: false, ...options }) + + const properties = await getFlagCalledProperties() + + expect(properties).toMatchObject({ + super_prop: 'super_value', + '$feature/feature-1': true, + }) + }) + + it('should flip the gate off when a later flags response omits the field', async () => { + mockFlagsEndpoint({ minimalFlagCalledEvents: true, hasExperiment: false }) + await posthog.reloadFeatureFlagsAsync() + + mockFlagsEndpoint({ hasExperiment: false }) + + const properties = await getFlagCalledProperties() + + expect(properties).toMatchObject({ + super_prop: 'super_value', + '$feature/feature-1': true, + }) + }) + + it('should keep sending minimal events after a simulated restart on the same storage', async () => { + const storageCache: Record = {} + const setupFetch = (_mocks: PostHogCoreTestClientMocks): void => { + _mocks.fetch.mockImplementation((url) => { + if (url.includes('/flags/?v=2')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve(flagsResponseJson({ minimalFlagCalledEvents: true, hasExperiment: false })), + }) + } + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => Promise.resolve({ status: 'ok' }), + }) + }) + } + + const [firstClient] = createTestClient('TEST_API_KEY', { flushAt: 1 }, setupFetch, storageCache) + await firstClient.reloadFeatureFlagsAsync() + + // New client on the same persisted storage; flags are never reloaded remotely. + const [restartedClient, restartedMocks] = createTestClient( + 'TEST_API_KEY', + { flushAt: 1, preloadFeatureFlags: false }, + (_mocks) => { + _mocks.fetch.mockImplementation(() => + Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => Promise.resolve({ status: 'ok' }), + }) + ) + }, + storageCache + ) + + expect(restartedClient.getFeatureFlag('feature-1')).toEqual(true) + await waitForPromises() + + const event = restartedMocks.fetch.mock.calls + .flatMap((call) => parseBody(call)?.batch ?? []) + .find((e: any) => e.event === '$feature_flag_called') + expect(event.properties.$feature_flag_has_experiment).toBe(false) + expect(event.properties).not.toHaveProperty('$feature/feature-1') + expect(event.properties).not.toHaveProperty('$active_feature_flags') + expect(event.properties).not.toHaveProperty('$used_bootstrap_value') + }) + }) + it('should not capture $feature_flag_called again if reloaded flags keep the same value', async () => { expect(posthog.getFeatureFlag('feature-1')).toEqual(true) await waitForPromises() @@ -940,6 +1115,7 @@ describe('PostHog Feature Flags v4', () => { evaluatedAt: 1640995200000, errorsWhileComputingFlags: undefined, quotaLimited: undefined, + minimalFlagCalledEvents: false, }) }) diff --git a/packages/core/src/__tests__/posthog.featureflags.v1.spec.ts b/packages/core/src/__tests__/posthog.featureflags.v1.spec.ts index 3e12cff234..6c2927bc1d 100644 --- a/packages/core/src/__tests__/posthog.featureflags.v1.spec.ts +++ b/packages/core/src/__tests__/posthog.featureflags.v1.spec.ts @@ -518,6 +518,7 @@ describe('PostHog Feature Flags v1', () => { evaluatedAt: undefined, errorsWhileComputingFlags: undefined, quotaLimited: undefined, + minimalFlagCalledEvents: false, }) }) diff --git a/packages/core/src/featureFlagUtils.ts b/packages/core/src/featureFlagUtils.ts index 48920c5412..6e0456c91f 100644 --- a/packages/core/src/featureFlagUtils.ts +++ b/packages/core/src/featureFlagUtils.ts @@ -175,3 +175,65 @@ export const flagDetailsToResults = (flagDetails: Record` + * enumeration, `$active_feature_flags`, and the context envelope — is dropped. + * + * The list is the union across client and server SDKs; entries are inert where an + * SDK never sets them. + */ +export const MINIMAL_FLAG_CALLED_EVENT_PROPERTIES: readonly string[] = [ + // Flag identity + '$feature_flag', + '$feature_flag_response', + '$feature_flag_has_experiment', + // Evaluation debug + '$feature_flag_id', + '$feature_flag_version', + '$feature_flag_reason', + '$feature_flag_request_id', + '$feature_flag_evaluated_at', + '$feature_flag_error', + 'locally_evaluated', + // Correctness-required processing controls + '$groups', + '$process_person_profile', + '$geoip_disable', + // Debug location + '$current_url', + '$pathname', + // Linkage / SDK identity + '$session_id', + '$window_id', + '$lib', + '$lib_version', + '$device_id', + '$is_server', +] + +/** + * Builds the minimal `$feature_flag_called` property set from fully assembled event + * properties. Constructs a new object from {@link MINIMAL_FLAG_CALLED_EVENT_PROPERTIES} + * rather than deleting keys, so anything not explicitly allowlisted is structurally + * excluded. Transport-level keys an SDK carries inside `properties` (e.g. the browser + * SDK's `token` and `distinct_id`) can be preserved via `transportKeys`. + */ +export const minimizeFlagCalledEventProperties = ( + properties: Record, + transportKeys: readonly string[] = [] +): Record => { + const minimal: Record = {} + for (const key of [...MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, ...transportKeys]) { + if (properties[key] !== undefined) { + minimal[key] = properties[key] + } + } + return minimal +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b3d541976..c2470f878f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,8 @@ export { getVariantFromValue, parsePayload, flagDetailsToResults, + MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, + minimizeFlagCalledEventProperties, } from './featureFlagUtils' export { gzipCompress, diff --git a/packages/core/src/posthog-core-stateless.ts b/packages/core/src/posthog-core-stateless.ts index bfa2ac722b..3a6a74032e 100644 --- a/packages/core/src/posthog-core-stateless.ts +++ b/packages/core/src/posthog-core-stateless.ts @@ -1,7 +1,7 @@ import type { OtlpLogsPayload, OtlpMetricsPayload } from '@posthog/types' import type { SendMetricsBatchOutcome } from './metrics/types' import { SimpleEventEmitter } from './eventemitter' -import { getFeatureFlagValue, normalizeFlagsResponse } from './featureFlagUtils' +import { getFeatureFlagValue, minimizeFlagCalledEventProperties, normalizeFlagsResponse } from './featureFlagUtils' import { gzipCompress, isGzipSupported } from './gzip' import { PostHogFlagsResponse, @@ -406,11 +406,20 @@ export abstract class PostHogCoreStateless { properties?: PostHogEventProperties }): PostHogEventProperties { const userProperties = payload.properties || {} - const properties: PostHogEventProperties = { + let properties: PostHogEventProperties = { ...userProperties, ...this.getCommonEventProperties(), // Common PH props } applyCallerFeatureFlagOverrides(properties, userProperties) + // Customer hooks (before_send) run after this filter and may deliberately re-add stripped + // properties; the SDK itself must not enrich beyond allowlisted keys past this point. + if ( + payload.event === '$feature_flag_called' && + properties.$feature_flag_has_experiment === false && + this.isMinimalFlagCalledEventsEnabled() + ) { + properties = minimizeFlagCalledEventProperties(properties) + } return { distinct_id: payload.distinct_id, event: payload.event, @@ -418,6 +427,15 @@ export abstract class PostHogCoreStateless { } } + /** + * Whether the server has gated this project into minimal `$feature_flag_called` events. + * Overridden by clients that persist the gate from the v2 `/flags` response + * (`minimalFlagCalledEvents`). The base implementation fails safe to full events. + */ + protected isMinimalFlagCalledEventsEnabled(): boolean { + return false + } + /** * @internal */ diff --git a/packages/core/src/posthog-core.ts b/packages/core/src/posthog-core.ts index 969f2645dc..29bd2500e2 100644 --- a/packages/core/src/posthog-core.ts +++ b/packages/core/src/posthog-core.ts @@ -796,9 +796,12 @@ export abstract class PostHogCore extends PostHogCoreStateless { if (!result.success) { if (!this.disableRemoteFeatureFlags) { + // Keep the persisted gate alongside the kept flags — a failed request is not a + // signal that the server turned minimal flag-called events off. this.setKnownFeatureFlagDetails({ flags: this.getKnownFeatureFlagDetails()?.flags ?? {}, requestError: result.error, + minimalFlagCalledEvents: this.getStoredFlagDetails()?.minimalFlagCalledEvents, }) } return undefined @@ -811,6 +814,7 @@ export abstract class PostHogCore extends PostHogCoreStateless { this.setKnownFeatureFlagDetails({ flags: this.getKnownFeatureFlagDetails()?.flags ?? {}, quotaLimited: res.quotaLimited, + minimalFlagCalledEvents: this.getStoredFlagDetails()?.minimalFlagCalledEvents, }) } this._logger.warn( @@ -854,6 +858,8 @@ export abstract class PostHogCore extends PostHogCoreStateless { evaluatedAt: res.evaluatedAt, errorsWhileComputingFlags: res.errorsWhileComputingFlags, quotaLimited: res.quotaLimited, + // Absence of the field always flips the gate off — fail safe to full events. + minimalFlagCalledEvents: res.minimalFlagCalledEvents === true, }) // Mark that we hit the /flags endpoint so we can capture this in the $feature_flag_called event this.setPersistedProperty(PostHogPersistedProperty.FlagsEndpointWasHit, true) @@ -920,6 +926,10 @@ export abstract class PostHogCore extends PostHogCoreStateless { return this.getPersistedProperty(PostHogPersistedProperty.FeatureFlagDetails) } + protected isMinimalFlagCalledEventsEnabled(): boolean { + return this.getStoredFlagDetails()?.minimalFlagCalledEvents === true + } + protected getKnownFeatureFlags(): PostHogFlagsResponse['featureFlags'] | undefined { const featureFlagDetails = this.getKnownFeatureFlagDetails() if (!featureFlagDetails) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 70af9f7be7..15641c26d5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -449,6 +449,11 @@ export type PostHogFlagsResponse = Omit quotaLimited?: string[] requestId?: string evaluatedAt?: number // Unix timestamp in milliseconds + /** + * Server-controlled gate for minimal `$feature_flag_called` events. `true` only when the + * project opted in; omitted otherwise. Absence always means full events. + */ + minimalFlagCalledEvents?: boolean } export type PostHogFeatureFlagsResponse = PartialWithRequired< @@ -510,7 +515,7 @@ export type PostHogV2FlagsResponse = Omit & - Partial> & { + Partial> & { errorsWhileComputingFlags?: boolean quotaLimited?: string[] requestError?: FeatureFlagRequestError diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 04eb84d92d..02d63e80ed 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2261,6 +2261,11 @@ "description": "Mapping of group type index to group name", "type": "Record", "name": "groupTypeMapping" + }, + { + "description": "Server-controlled gate for minimal `$feature_flag_called` events, from the top-level `minimal_flag_called_events` field of the flag-definitions payload. Absent (e.g. cached data written by an older SDK version) means full events.", + "type": "boolean", + "name": "minimalFlagCalledEvents" } ], "path": "src/extensions/feature-flags/cache.ts" @@ -3024,6 +3029,10 @@ { "type": "number", "name": "evaluatedAt" + }, + { + "type": "boolean", + "name": "minimalFlagCalledEvents" } ], "path": "../core/src/types.ts" @@ -3083,6 +3092,10 @@ { "type": "number", "name": "evaluatedAt" + }, + { + "type": "boolean", + "name": "minimalFlagCalledEvents" } ], "path": "../core/src/types.ts" @@ -3220,6 +3233,11 @@ { "type": "number", "name": "evaluatedAt" + }, + { + "description": "Server-controlled gate for minimal `$feature_flag_called` events. `true` only when the\nproject opted in; omitted otherwise. Absence always means full events.", + "type": "boolean", + "name": "minimalFlagCalledEvents" } ], "path": "../core/src/types.ts" diff --git a/packages/node/src/__tests__/cache.spec.ts b/packages/node/src/__tests__/cache.spec.ts index f8add99ef0..7266cd8a6d 100644 --- a/packages/node/src/__tests__/cache.spec.ts +++ b/packages/node/src/__tests__/cache.spec.ts @@ -45,6 +45,7 @@ describe('FlagDefinitionCacheProvider Integration', () => { ], groupTypeMapping: { '0': 'company' }, cohorts: {}, + minimalFlagCalledEvents: false, } jest.useFakeTimers() diff --git a/packages/node/src/__tests__/evaluate-flags.spec.ts b/packages/node/src/__tests__/evaluate-flags.spec.ts index 26be971de6..37fcfdc853 100644 --- a/packages/node/src/__tests__/evaluate-flags.spec.ts +++ b/packages/node/src/__tests__/evaluate-flags.spec.ts @@ -519,6 +519,51 @@ describe('evaluateFlags', () => { expect(byKey['disabled-flag']).not.toHaveProperty('$feature_flag_has_experiment') }) + it('sends minimal $feature_flag_called events when gated, except for experiment-linked flags', async () => { + const response = flagsResponseFixture() + response.minimalFlagCalledEvents = true + response.flags['boolean-flag'].metadata!.has_experiment = false + response.flags['variant-flag'].metadata!.has_experiment = true + mockedFetch.mockImplementation(apiImplementationV4(response)) + posthog.register({ super_prop: 'super_value' }) + + const flags = await posthog.evaluateFlags('user-1') + flags.isEnabled('boolean-flag') + flags.isEnabled('variant-flag') + + await waitForPromises() + const byKey = Object.fromEntries( + captures + .filter((m) => m.event === '$feature_flag_called') + .map((m) => [m.properties.$feature_flag, m.properties]) + ) + // Gated + no experiment: strict allowlist + expect(Object.keys(byKey['boolean-flag']).sort()).toEqual( + [ + '$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', + 'locally_evaluated', + '$lib', + '$lib_version', + '$is_server', + '$geoip_disable', + ].sort() + ) + expect(byKey['boolean-flag'].$is_server).toBe(true) + // Gated + experiment: full envelope + expect(byKey['variant-flag']).toMatchObject({ + super_prop: 'super_value', + '$feature/variant-flag': 'variant-value', + $feature_flag_has_experiment: true, + }) + }) + it('reports quota_limited from response.quotaLimited', async () => { const response = flagsResponseFixture() ;(response as any).quotaLimited = ['feature_flags'] diff --git a/packages/node/src/__tests__/feature-flags.flags.spec.ts b/packages/node/src/__tests__/feature-flags.flags.spec.ts index 6d9270c5f1..578c0d8bcb 100644 --- a/packages/node/src/__tests__/feature-flags.flags.spec.ts +++ b/packages/node/src/__tests__/feature-flags.flags.spec.ts @@ -1185,3 +1185,277 @@ describe('getFeatureFlagResult', () => { }) }) }) + +describe('minimal $feature_flag_called events', () => { + const remoteFlagsResponse = (options: { + minimalFlagCalledEvents?: boolean + hasExperiment?: boolean + }): PostHogV2FlagsResponse => ({ + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variant: undefined, + reason: { + code: 'condition_match', + condition_index: 0, + description: 'Matched condition set 1', + }, + metadata: { + id: 10, + version: 3, + payload: undefined, + description: 'description', + ...(options.hasExperiment === undefined ? {} : { has_experiment: options.hasExperiment }), + }, + }, + }, + errorsWhileComputingFlags: false, + requestId: 'minimal-request-id', + evaluatedAt: 1640995200000, + ...(options.minimalFlagCalledEvents === undefined + ? {} + : { minimalFlagCalledEvents: options.minimalFlagCalledEvents }), + }) + + const createClient = (options: Partial = {}): { posthog: PostHog; captured: any[] } => { + const posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + ...options, + }) + const captured: any[] = [] + posthog.on('capture', (message) => captured.push(message)) + return { posthog, captured } + } + + const findFlagCalledEvent = (captured: any[]): any => captured.find((m) => m.event === '$feature_flag_called') + + describe('remote evaluation', () => { + it('sends exactly the allowlisted properties when gated and the flag has no experiment', async () => { + mockedFetch.mockImplementation( + apiImplementationV4(remoteFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: false })) + ) + const { posthog, captured } = createClient() + // Super properties must be structurally excluded from the minimal event + posthog.register({ super_prop: 'super_value' }) + + await posthog.getFeatureFlagResult('test-flag', 'some-distinct-id', { groups: { organization: 'org-1' } }) + await waitForPromises() + + const message = findFlagCalledEvent(captured) + expect(message).toBeDefined() + expect(Object.keys(message.properties).sort()).toEqual( + [ + '$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', + 'locally_evaluated', + '$groups', + '$lib', + '$lib_version', + '$is_server', + '$geoip_disable', + ].sort() + ) + expect(message.properties).toMatchObject({ + $feature_flag: 'test-flag', + $feature_flag_response: true, + $feature_flag_has_experiment: false, + $feature_flag_id: 10, + $feature_flag_version: 3, + locally_evaluated: false, + $groups: { organization: 'org-1' }, + $is_server: true, + $geoip_disable: true, + }) + + await posthog.shutdown() + }) + + it('sends the full event when gated but the flag has an experiment', async () => { + mockedFetch.mockImplementation( + apiImplementationV4(remoteFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: true })) + ) + const { posthog, captured } = createClient() + posthog.register({ super_prop: 'super_value' }) + + await posthog.getFeatureFlagResult('test-flag', 'some-distinct-id') + await waitForPromises() + + const message = findFlagCalledEvent(captured) + expect(message.properties).toMatchObject({ + $feature_flag_has_experiment: true, + super_prop: 'super_value', + '$feature/test-flag': true, + }) + + await posthog.shutdown() + }) + + it.each([ + ['the gate field is absent', remoteFlagsResponse({ hasExperiment: false })], + ['the gate field is false', remoteFlagsResponse({ minimalFlagCalledEvents: false, hasExperiment: false })], + ['has_experiment is absent', remoteFlagsResponse({ minimalFlagCalledEvents: true })], + ])('sends the full event when %s', async (_, response) => { + mockedFetch.mockImplementation(apiImplementationV4(response)) + const { posthog, captured } = createClient() + posthog.register({ super_prop: 'super_value' }) + + await posthog.getFeatureFlagResult('test-flag', 'some-distinct-id') + await waitForPromises() + + const message = findFlagCalledEvent(captured) + expect(message.properties).toMatchObject({ + super_prop: 'super_value', + '$feature/test-flag': true, + }) + + await posthog.shutdown() + }) + + it('flips the gate off when a later flags response omits the field', async () => { + mockedFetch.mockImplementation( + apiImplementationV4(remoteFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: false })) + ) + const { posthog, captured } = createClient() + + await posthog.getFeatureFlagResult('test-flag', 'user-1') + await waitForPromises() + expect(findFlagCalledEvent(captured).properties).not.toHaveProperty('$feature/test-flag') + + mockedFetch.mockImplementation(apiImplementationV4(remoteFlagsResponse({ hasExperiment: false }))) + // Different distinct id so the flag-called dedup cache doesn't swallow the event + await posthog.getFeatureFlagResult('test-flag', 'user-2') + await waitForPromises() + + const fullMessage = captured.filter((m) => m.event === '$feature_flag_called')[1] + expect(fullMessage.properties).toMatchObject({ '$feature/test-flag': true }) + + await posthog.shutdown() + }) + }) + + describe('local evaluation', () => { + const localFlagsPayload = (options: { minimalFlagCalledEvents?: boolean; hasExperiment?: boolean }): any => ({ + flags: [ + { + id: 55, + name: 'Simple Flag', + key: 'simple-flag', + active: true, + filters: { + groups: [{ rollout_percentage: 100 }], + }, + ...(options.hasExperiment === undefined ? {} : { has_experiment: options.hasExperiment }), + }, + ], + ...(options.minimalFlagCalledEvents === undefined + ? {} + : { minimal_flag_called_events: options.minimalFlagCalledEvents }), + }) + + it('sends exactly the allowlisted properties when the definitions payload carries the gate', async () => { + mockedFetch.mockImplementation( + apiImplementation({ localFlags: localFlagsPayload({ minimalFlagCalledEvents: true, hasExperiment: false }) }) + ) + const { posthog, captured } = createClient({ personalApiKey: 'TEST_PERSONAL_API_KEY' }) + posthog.register({ super_prop: 'super_value' }) + + await posthog.getFeatureFlagResult('simple-flag', 'some-distinct-id') + await waitForPromises() + + const message = findFlagCalledEvent(captured) + expect(message).toBeDefined() + expect(Object.keys(message.properties).sort()).toEqual( + [ + '$feature_flag', + '$feature_flag_response', + '$feature_flag_has_experiment', + '$feature_flag_id', + '$feature_flag_reason', + '$feature_flag_evaluated_at', + 'locally_evaluated', + '$lib', + '$lib_version', + '$is_server', + '$geoip_disable', + ].sort() + ) + expect(message.properties).toMatchObject({ + $feature_flag: 'simple-flag', + $feature_flag_response: true, + $feature_flag_has_experiment: false, + $feature_flag_id: 55, + locally_evaluated: true, + $is_server: true, + }) + // Not part of the contract allowlist, so the locally-evaluated debug scalar is stripped + expect(message.properties).not.toHaveProperty('$feature_flag_definitions_loaded_at') + + await posthog.shutdown() + }) + + it('sends the full event when the definitions payload omits the gate', async () => { + mockedFetch.mockImplementation(apiImplementation({ localFlags: localFlagsPayload({ hasExperiment: false }) })) + const { posthog, captured } = createClient({ personalApiKey: 'TEST_PERSONAL_API_KEY' }) + posthog.register({ super_prop: 'super_value' }) + + await posthog.getFeatureFlagResult('simple-flag', 'some-distinct-id') + await waitForPromises() + + const message = findFlagCalledEvent(captured) + expect(message.properties).toMatchObject({ + super_prop: 'super_value', + '$feature/simple-flag': true, + $feature_flag_definitions_loaded_at: expect.any(Number), + }) + + await posthog.shutdown() + }) + + it('reads the gate from cached definitions when the cache provider skips fetching', async () => { + mockedFetch.mockImplementation(apiImplementation({ localFlags: { flags: [] } })) + const cacheProvider = { + getFlagDefinitions: () => ({ + flags: [ + { + id: 55, + name: 'Simple Flag', + key: 'simple-flag', + active: true, + filters: { groups: [{ rollout_percentage: 100 }] }, + has_experiment: false, + } as any, + ], + groupTypeMapping: {}, + cohorts: {}, + minimalFlagCalledEvents: true, + }), + shouldFetchFlagDefinitions: () => false, + onFlagDefinitionsReceived: () => {}, + shutdown: () => {}, + } + const { posthog, captured } = createClient({ + personalApiKey: 'TEST_PERSONAL_API_KEY', + flagDefinitionCacheProvider: cacheProvider, + }) + posthog.register({ super_prop: 'super_value' }) + + await posthog.getFeatureFlagResult('simple-flag', 'some-distinct-id') + await waitForPromises() + + const message = findFlagCalledEvent(captured) + expect(message.properties.$feature_flag_has_experiment).toBe(false) + expect(message.properties).not.toHaveProperty('super_prop') + expect(message.properties).not.toHaveProperty('$feature/simple-flag') + + await posthog.shutdown() + }) + }) +}) diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index e5e0145ab8..b675e9ac54 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -5,6 +5,7 @@ import { isBlockedUA, isPlainObject, JsonType, + minimizeFlagCalledEventProperties, PostHogCaptureOptions, PostHogCoreStateless, PostHogEventProperties, @@ -145,6 +146,11 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen private _flagOverrides?: Record private _payloadOverrides?: Record + // Server-controlled gate for minimal $feature_flag_called events. Single client-level gate, + // last-writer-wins across the two signal sources (v2 /flags responses and the poller's + // flag-definition loads) — both derive from the same per-team server config and converge. + private _minimalFlagCalledEvents: boolean = false + distinctIdHasSentFlagCalls: Record> // waitUntil debounce state (per-instance) @@ -238,6 +244,9 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen onLoad: (count: number) => { this._events.emit('localEvaluationFlagsLoaded', count) }, + onMinimalFlagCalledEvents: (enabled: boolean) => { + this._minimalFlagCalledEvents = enabled + }, customHeaders: this.getCustomHeaders(), cacheProvider: normalizedOptions.flagDefinitionCacheProvider, strictLocalEvaluation: normalizedOptions.strictLocalEvaluation, @@ -1180,6 +1189,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen if (flagsResponse === undefined) { featureFlagError = FeatureFlagError.UNKNOWN_ERROR } else { + this._minimalFlagCalledEvents = flagsResponse.minimalFlagCalledEvents === true requestId = flagsResponse.requestId evaluatedAt = flagsResponse.evaluatedAt @@ -1962,6 +1972,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen flagKeys ) if (details) { + this._minimalFlagCalledEvents = details.minimalFlagCalledEvents === true requestId = details.requestId evaluatedAt = details.evaluatedAt errorsWhileComputing = Boolean((details as any).errorsWhileComputingFlags) @@ -2037,6 +2048,17 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen }) } + /** + * Minimal iff the server gate is on and the flag is known to not be linked to an + * experiment. Any missing signal — no gate seen yet, `$feature_flag_has_experiment` + * absent — falls back to the full event. + * + * @internal + */ + private _shouldSendMinimalFlagCalledEvent(properties: PostHogEventProperties): boolean { + return this._minimalFlagCalledEvents && properties.$feature_flag_has_experiment === false + } + /** * Fires a `$feature_flag_called` event for the given flag if the (distinctId, flag, response) * triple hasn't already been reported for this client. Shared by the single-flag evaluation @@ -2798,11 +2820,20 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen mergedProperties.$session_id = contextData.sessionId } + // Minimal $feature_flag_called events: rebuild from the strict allowlist before before_send + // runs, so a customer hook may deliberately re-add stripped properties. Everything the SDK + // itself adds after this point ($groups, $lib/$lib_version/$is_server, $geoip_disable) is + // allowlisted — no SDK enrichment may reintroduce stripped properties. + const finalProperties = + event === '$feature_flag_called' && this._shouldSendMinimalFlagCalledEvent(mergedProperties) + ? minimizeFlagCalledEventProperties(mergedProperties) + : mergedProperties + // Run before_send if configured const eventMessage = this._runBeforeSend({ distinctId: mergedDistinctId, event, - properties: mergedProperties, + properties: finalProperties, groups, flags, sendFeatureFlags, diff --git a/packages/node/src/extensions/feature-flags/cache.ts b/packages/node/src/extensions/feature-flags/cache.ts index f2bb31a492..3c1171856f 100644 --- a/packages/node/src/extensions/feature-flags/cache.ts +++ b/packages/node/src/extensions/feature-flags/cache.ts @@ -12,6 +12,12 @@ export interface FlagDefinitionCacheData { groupTypeMapping: Record /** Cohort property groups for local evaluation */ cohorts: Record + /** + * Server-controlled gate for minimal `$feature_flag_called` events, from the top-level + * `minimal_flag_called_events` field of the flag-definitions payload. Absent (e.g. cached + * data written by an older SDK version) means full events. + */ + minimalFlagCalledEvents?: boolean } /** diff --git a/packages/node/src/extensions/feature-flags/feature-flags.ts b/packages/node/src/extensions/feature-flags/feature-flags.ts index 38e5c0a770..48a9b32024 100644 --- a/packages/node/src/extensions/feature-flags/feature-flags.ts +++ b/packages/node/src/extensions/feature-flags/feature-flags.ts @@ -61,6 +61,13 @@ type FeatureFlagsPollerOptions = { fetch?: (url: string, options: PostHogFetchOptions) => Promise onError?: (error: Error) => void onLoad?: (count: number) => void + /** + * Called whenever flag definitions are (re)loaded — from the API, the cache provider, or a + * quota reset — with the server gate for minimal `$feature_flag_called` events carried by + * that payload. Lets the client keep a single last-writer-wins gate across the local-eval + * and remote `/flags` signal sources. + */ + onMinimalFlagCalledEvents?: (enabled: boolean) => void customHeaders?: { [key: string]: string } cacheProvider?: FlagDefinitionCacheProvider strictLocalEvaluation?: boolean @@ -104,6 +111,7 @@ class FeatureFlagsPoller { private nextFetchAllowedAt?: number private strictLocalEvaluation: boolean private flagDefinitionsLoadedAt?: number + private onMinimalFlagCalledEvents?: (enabled: boolean) => void constructor({ pollingInterval, @@ -129,6 +137,7 @@ class FeatureFlagsPoller { this.onError = options.onError this.customHeaders = customHeaders this.onLoad = options.onLoad + this.onMinimalFlagCalledEvents = options.onMinimalFlagCalledEvents this.cacheProvider = options.cacheProvider this.strictLocalEvaluation = options.strictLocalEvaluation ?? false void this.loadFeatureFlags() @@ -679,6 +688,8 @@ class FeatureFlagsPoller { this.groupTypeMapping = flagData.groupTypeMapping this.cohorts = flagData.cohorts this.loadedSuccessfullyOnce = true + // Absence of the field (older cached data, older servers) always means full events. + this.onMinimalFlagCalledEvents?.(flagData.minimalFlagCalledEvents === true) } /** @@ -896,6 +907,7 @@ class FeatureFlagsPoller { this.featureFlagsByKey = {} this.groupTypeMapping = {} this.cohorts = {} + this.onMinimalFlagCalledEvents?.(false) return case 403: @@ -928,6 +940,8 @@ class FeatureFlagsPoller { flags: (responseJson.flags as PostHogFeatureFlag[]) ?? [], groupTypeMapping: (responseJson.group_type_mapping as Record) || {}, cohorts: (responseJson.cohorts as Record) || {}, + // Absence of the field always flips the gate off — fail safe to full events. + minimalFlagCalledEvents: responseJson.minimal_flag_called_events === true, } this.updateFlagState(flagData) diff --git a/packages/react-native/references/posthog-react-native-references-latest.json b/packages/react-native/references/posthog-react-native-references-latest.json index c19ef79b20..650444f867 100644 --- a/packages/react-native/references/posthog-react-native-references-latest.json +++ b/packages/react-native/references/posthog-react-native-references-latest.json @@ -3369,6 +3369,10 @@ { "type": "number", "name": "evaluatedAt" + }, + { + "type": "boolean", + "name": "minimalFlagCalledEvents" } ], "path": "../core/src/types.ts" @@ -3428,6 +3432,10 @@ { "type": "number", "name": "evaluatedAt" + }, + { + "type": "boolean", + "name": "minimalFlagCalledEvents" } ], "path": "../core/src/types.ts" @@ -3550,6 +3558,11 @@ { "type": "number", "name": "evaluatedAt" + }, + { + "description": "Server-controlled gate for minimal `$feature_flag_called` events. `true` only when the\nproject opted in; omitted otherwise. Absence always means full events.", + "type": "boolean", + "name": "minimalFlagCalledEvents" } ], "path": "../core/src/types.ts" From 91199b1eb0cb93a662684da4a491fdcc85fe2edd Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Fri, 17 Jul 2026 21:23:19 -0700 Subject: [PATCH 2/3] Simplify minimal flag called event handling, align naming across SDKs 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. --- packages/browser/src/posthog-core.ts | 8 +-- packages/core/src/featureFlagUtils.ts | 4 +- .../src/__tests__/feature-flags.flags.spec.ts | 55 +++++++++++++++++++ packages/node/src/client.ts | 21 ++++--- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index fef60e4607..4139540adf 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -1403,13 +1403,13 @@ export class PostHog implements PostHogInterface { // from the strict allowlist below (after the last SDK-added property) so super properties // and the context envelope are structurally excluded. Any missing signal falls back to // the full event. - const isMinimalFlagCalledEvent = + const shouldSendMinimalFlagCalledEvent = event_name === '$feature_flag_called' && data.properties['$feature_flag_has_experiment'] === false && this.get_property(PERSISTENCE_MINIMAL_FLAG_CALLED_EVENTS) === true const setProperties = options?.$set - if (setProperties && !isMinimalFlagCalledEvent) { + if (setProperties && !shouldSendMinimalFlagCalledEvent) { data.$set = options?.$set } const unsetProperties = options?.$unset @@ -1426,7 +1426,7 @@ export class PostHog implements PostHogInterface { // Minimal flag-called events must not carry $set_once. Skipping the calculation (rather // than dropping its result) avoids marking the initial person props as sent, so they // still go out with the next full event. - const setOnceProperties = isMinimalFlagCalledEvent + const setOnceProperties = shouldSendMinimalFlagCalledEvent ? undefined : this._calculate_set_once_properties(options?.$set_once, markSetOnceAsSent, forceIncludeInitialProps) if (setOnceProperties) { @@ -1444,7 +1444,7 @@ export class PostHog implements PostHogInterface { // before_send runs after this filter and may deliberately re-add stripped properties; // the SDK itself must not enrich beyond allowlisted keys past this point. - if (isMinimalFlagCalledEvent) { + if (shouldSendMinimalFlagCalledEvent) { data.properties = minimizeFlagCalledEventProperties(data.properties, FLAG_CALLED_TRANSPORT_PROPERTY_KEYS) } diff --git a/packages/core/src/featureFlagUtils.ts b/packages/core/src/featureFlagUtils.ts index 6e0456c91f..d6e9704b6c 100644 --- a/packages/core/src/featureFlagUtils.ts +++ b/packages/core/src/featureFlagUtils.ts @@ -230,10 +230,12 @@ export const minimizeFlagCalledEventProperties = ( transportKeys: readonly string[] = [] ): Record => { const minimal: Record = {} - for (const key of [...MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, ...transportKeys]) { + const copyKey = (key: string): void => { if (properties[key] !== undefined) { minimal[key] = properties[key] } } + MINIMAL_FLAG_CALLED_EVENT_PROPERTIES.forEach(copyKey) + transportKeys.forEach(copyKey) return minimal } diff --git a/packages/node/src/__tests__/feature-flags.flags.spec.ts b/packages/node/src/__tests__/feature-flags.flags.spec.ts index 578c0d8bcb..13b4dd64b1 100644 --- a/packages/node/src/__tests__/feature-flags.flags.spec.ts +++ b/packages/node/src/__tests__/feature-flags.flags.spec.ts @@ -1302,6 +1302,10 @@ describe('minimal $feature_flag_called events', () => { ['the gate field is absent', remoteFlagsResponse({ hasExperiment: false })], ['the gate field is false', remoteFlagsResponse({ minimalFlagCalledEvents: false, hasExperiment: false })], ['has_experiment is absent', remoteFlagsResponse({ minimalFlagCalledEvents: true })], + [ + 'the gate field is a truthy non-boolean value', + remoteFlagsResponse({ minimalFlagCalledEvents: 'true' as any, hasExperiment: false }), + ], ])('sends the full event when %s', async (_, response) => { mockedFetch.mockImplementation(apiImplementationV4(response)) const { posthog, captured } = createClient() @@ -1339,6 +1343,35 @@ describe('minimal $feature_flag_called events', () => { await posthog.shutdown() }) + + it('lets before_send re-add a property stripped by minimization', async () => { + mockedFetch.mockImplementation( + apiImplementationV4(remoteFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: false })) + ) + let beforeSendProperties: Record | undefined + const { posthog, captured } = createClient({ + before_send: (event) => { + if (event.event === '$feature_flag_called') { + beforeSendProperties = event.properties + return { ...event, properties: { ...event.properties, super_prop: 're-added' } } + } + return event + }, + }) + posthog.register({ super_prop: 'super_value' }) + + await posthog.getFeatureFlagResult('test-flag', 'some-distinct-id') + await waitForPromises() + + // before_send sees the already-minimized properties, not the pre-filter merged set + expect(beforeSendProperties).toBeDefined() + expect(beforeSendProperties).not.toHaveProperty('super_prop') + + const message = findFlagCalledEvent(captured) + expect(message.properties).toMatchObject({ super_prop: 're-added' }) + + await posthog.shutdown() + }) }) describe('local evaluation', () => { @@ -1457,5 +1490,27 @@ describe('minimal $feature_flag_called events', () => { await posthog.shutdown() }) + + it('flips the gate off when a later local-evaluation reload omits the field', async () => { + mockedFetch.mockImplementation( + apiImplementation({ localFlags: localFlagsPayload({ minimalFlagCalledEvents: true, hasExperiment: false }) }) + ) + const { posthog, captured } = createClient({ personalApiKey: 'TEST_PERSONAL_API_KEY' }) + + await posthog.getFeatureFlagResult('simple-flag', 'user-1') + await waitForPromises() + expect(findFlagCalledEvent(captured).properties).not.toHaveProperty('$feature/simple-flag') + + mockedFetch.mockImplementation(apiImplementation({ localFlags: localFlagsPayload({ hasExperiment: false }) })) + await posthog.reloadFeatureFlags() + // Different distinct id so the flag-called dedup cache doesn't swallow the event + await posthog.getFeatureFlagResult('simple-flag', 'user-2') + await waitForPromises() + + const fullMessage = captured.filter((m) => m.event === '$feature_flag_called')[1] + expect(fullMessage.properties).toMatchObject({ '$feature/simple-flag': true }) + + await posthog.shutdown() + }) }) }) diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index b675e9ac54..a949f762bc 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -2049,14 +2049,18 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen } /** - * Minimal iff the server gate is on and the flag is known to not be linked to an - * experiment. Any missing signal — no gate seen yet, `$feature_flag_has_experiment` - * absent — falls back to the full event. + * Minimal iff this is a `$feature_flag_called` event, the server gate is on, and the flag + * is known to not be linked to an experiment. Any missing signal — no gate seen yet, + * `$feature_flag_has_experiment` absent — falls back to the full event. * * @internal */ - private _shouldSendMinimalFlagCalledEvent(properties: PostHogEventProperties): boolean { - return this._minimalFlagCalledEvents && properties.$feature_flag_has_experiment === false + private _shouldSendMinimalFlagCalledEvent(event: string, properties: PostHogEventProperties): boolean { + return ( + event === '$feature_flag_called' && + this._minimalFlagCalledEvents && + properties.$feature_flag_has_experiment === false + ) } /** @@ -2824,10 +2828,9 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen // runs, so a customer hook may deliberately re-add stripped properties. Everything the SDK // itself adds after this point ($groups, $lib/$lib_version/$is_server, $geoip_disable) is // allowlisted — no SDK enrichment may reintroduce stripped properties. - const finalProperties = - event === '$feature_flag_called' && this._shouldSendMinimalFlagCalledEvent(mergedProperties) - ? minimizeFlagCalledEventProperties(mergedProperties) - : mergedProperties + const finalProperties = this._shouldSendMinimalFlagCalledEvent(event, mergedProperties) + ? minimizeFlagCalledEventProperties(mergedProperties) + : mergedProperties // Run before_send if configured const eventMessage = this._runBeforeSend({ From 2c61bd04a8dd7072a8261ff58de1126d680f6d36 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Mon, 20 Jul 2026 10:41:10 -0700 Subject: [PATCH 3/3] Remove unused MINIMAL_FLAG_CALLED_EVENT_PROPERTIES export from core barrel --- packages/core/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c2470f878f..71bd3e8e95 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,7 +4,6 @@ export { getVariantFromValue, parsePayload, flagDetailsToResults, - MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, minimizeFlagCalledEventProperties, } from './featureFlagUtils' export {