Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/minimal-flag-called-events.md
Original file line number Diff line number Diff line change
@@ -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/<key>` 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.
Original file line number Diff line number Diff line change
Expand Up @@ -2621,6 +2621,11 @@
"type": "Record<string, FeatureFlagDetail>",
"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"
Expand Down
269 changes: 269 additions & 0 deletions packages/browser/src/__tests__/featureflags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -2918,6 +2919,46 @@ describe('featureflags', () => {
)
})
})

describe('minimal flag called events gate persistence', () => {
const receiveFlags = (response: Record<string, any>) => {
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', () => {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
})
Expand Down Expand Up @@ -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: {},
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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<string, any> = {}, 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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const LEGACY_RESERVED_PERSISTENCE_KEYS = new Set<string>([
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,
Expand Down Expand Up @@ -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()
)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading