From 8966759ff78769b7b04c9c2f63ef919380006b34 Mon Sep 17 00:00:00 2001 From: dylan Date: Fri, 24 Apr 2026 14:36:48 -0700 Subject: [PATCH 1/7] feat(node): add evaluateFlags() API for single-call flag evaluation Introduce `posthog.evaluateFlags(distinctId, options)` returning a `FeatureFlagEvaluations` snapshot. Branch on `isEnabled()` / `getFlag()` and pass the snapshot to `capture()` via a new `flags` option so events carry the exact values the code branched on, with no extra /flags request per capture. Filtering helpers `onlyAccessed()` and `only([keys])` let callers shrink the flag set attached to events. A new `featureFlagsLogWarnings` option toggles the associated user-facing warnings. Existing `isFeatureEnabled` / `getFeatureFlag` / `sendFeatureFlags` continue to work unchanged; `sendFeatureFlags` is marked deprecated in JSDoc ahead of a future major-version removal. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- .changeset/evaluate-flags-node.md | 5 + .../node/src/__tests__/evaluate-flags.spec.ts | 482 ++++++++++++++++++ packages/node/src/client.ts | 372 +++++++++++--- packages/node/src/exports.ts | 2 + packages/node/src/feature-flag-evaluations.ts | 278 ++++++++++ packages/node/src/types.ts | 60 ++- 6 files changed, 1126 insertions(+), 73 deletions(-) create mode 100644 .changeset/evaluate-flags-node.md create mode 100644 packages/node/src/__tests__/evaluate-flags.spec.ts create mode 100644 packages/node/src/feature-flag-evaluations.ts diff --git a/.changeset/evaluate-flags-node.md b/.changeset/evaluate-flags-node.md new file mode 100644 index 0000000000..8774197fa8 --- /dev/null +++ b/.changeset/evaluate-flags-node.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +Add `evaluateFlags()` and the `flags` option on `capture()` so a single `/flags` call can power both flag branching and event enrichment per request. Prefer this over repeated `isFeatureEnabled()` calls and `capture({ sendFeatureFlags: true })`, which remain supported but now carry a deprecation note. diff --git a/packages/node/src/__tests__/evaluate-flags.spec.ts b/packages/node/src/__tests__/evaluate-flags.spec.ts new file mode 100644 index 0000000000..1985cc2280 --- /dev/null +++ b/packages/node/src/__tests__/evaluate-flags.spec.ts @@ -0,0 +1,482 @@ +import { PostHog } from '@/entrypoints/index.node' +import { FeatureFlagEvaluations } from '@/feature-flag-evaluations' +import { PostHogOptions } from '@/types' +import { apiImplementation, apiImplementationV4, waitForPromises } from './utils' +import { PostHogV2FlagsResponse } from '@posthog/core' + +jest.spyOn(console, 'debug').mockImplementation() + +const mockedFetch = jest.spyOn(globalThis, 'fetch').mockImplementation() + +const posthogImmediateResolveOptions: PostHogOptions = { + fetchRetryCount: 0, +} + +const flagsResponseFixture = (): PostHogV2FlagsResponse => ({ + flags: { + 'variant-flag': { + key: 'variant-flag', + enabled: true, + variant: 'variant-value', + reason: { + code: 'variant', + condition_index: 2, + description: 'Matched condition set 3', + }, + metadata: { + id: 2, + version: 23, + payload: '{"key": "value"}', + description: 'description', + }, + }, + 'boolean-flag': { + key: 'boolean-flag', + enabled: true, + variant: undefined, + reason: { + code: 'boolean', + condition_index: 1, + description: 'Matched condition set 1', + }, + metadata: { + id: 1, + version: 12, + payload: undefined, + description: 'description', + }, + }, + 'disabled-flag': { + key: 'disabled-flag', + enabled: false, + variant: undefined, + reason: { + code: 'boolean', + condition_index: 1, + description: 'Did not match any condition', + }, + metadata: { + id: 3, + version: 2, + payload: undefined, + description: 'description', + }, + }, + }, + errorsWhileComputingFlags: false, + requestId: 'request-id-1', + evaluatedAt: 1640995200000, +}) + +describe('evaluateFlags', () => { + let posthog: PostHog + + afterEach(async () => { + await posthog.shutdown() + }) + + describe('remote evaluation', () => { + beforeEach(() => { + mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + }) + + it('makes a single /flags call and returns a FeatureFlagEvaluations instance', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + + const flags = await posthog.evaluateFlags('user-1') + + expect(flags).toBeInstanceOf(FeatureFlagEvaluations) + expect(mockedFetch).toHaveBeenCalledTimes(1) + const [url] = mockedFetch.mock.calls[0] + expect(url).toMatch(/\/flags\/\?v=2(?:&|$)/) + }) + + it('does not fire $feature_flag_called events for flags that are not accessed', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + await posthog.evaluateFlags('user-1') + await waitForPromises() + + const flagCalled = captures.filter((m) => m.event === '$feature_flag_called') + expect(flagCalled).toHaveLength(0) + }) + + it('isEnabled returns true/false and fires $feature_flag_called on first access', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + const flags = await posthog.evaluateFlags('user-1') + + expect(flags.isEnabled('boolean-flag')).toBe(true) + expect(flags.isEnabled('disabled-flag')).toBe(false) + expect(flags.isEnabled('variant-flag')).toBe(true) + + await waitForPromises() + const flagCalled = captures.filter((m) => m.event === '$feature_flag_called') + expect(flagCalled).toHaveLength(3) + expect(flagCalled.map((m) => m.properties.$feature_flag).sort()).toEqual([ + 'boolean-flag', + 'disabled-flag', + 'variant-flag', + ]) + }) + + it('getFlag returns variant/true/false/undefined and carries full metadata', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + const flags = await posthog.evaluateFlags('user-1') + + expect(flags.getFlag('variant-flag')).toBe('variant-value') + expect(flags.getFlag('boolean-flag')).toBe(true) + expect(flags.getFlag('disabled-flag')).toBe(false) + expect(flags.getFlag('missing-flag')).toBeUndefined() + + await waitForPromises() + const byKey = Object.fromEntries( + captures + .filter((m) => m.event === '$feature_flag_called') + .map((m) => [m.properties.$feature_flag, m.properties]) + ) + expect(byKey['variant-flag']).toMatchObject({ + $feature_flag: 'variant-flag', + $feature_flag_response: 'variant-value', + $feature_flag_id: 2, + $feature_flag_version: 23, + $feature_flag_reason: 'Matched condition set 3', + $feature_flag_request_id: 'request-id-1', + locally_evaluated: false, + }) + expect(byKey['missing-flag']).toMatchObject({ + $feature_flag: 'missing-flag', + $feature_flag_response: undefined, + $feature_flag_error: 'flag_missing', + locally_evaluated: false, + }) + }) + + it('dedupes $feature_flag_called events across repeated access for the same distinctId+value', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + const flags = await posthog.evaluateFlags('user-1') + flags.isEnabled('boolean-flag') + flags.isEnabled('boolean-flag') + flags.getFlag('boolean-flag') + + await waitForPromises() + const flagCalled = captures.filter( + (m) => m.event === '$feature_flag_called' && m.properties.$feature_flag === 'boolean-flag' + ) + expect(flagCalled).toHaveLength(1) + }) + + it('getFlagPayload returns parsed payload without firing an event', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + const flags = await posthog.evaluateFlags('user-1') + expect(flags.getFlagPayload('variant-flag')).toEqual({ key: 'value' }) + expect(flags.getFlagPayload('missing-flag')).toBeUndefined() + + await waitForPromises() + expect(captures.filter((m) => m.event === '$feature_flag_called')).toHaveLength(0) + }) + + it('uses distinctId from context when not passed explicitly', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + + const flags = await posthog.withContext({ distinctId: 'context-user' }, () => posthog.evaluateFlags()) + + expect(flags).toBeInstanceOf(FeatureFlagEvaluations) + expect(flags.keys.sort()).toEqual(['boolean-flag', 'disabled-flag', 'variant-flag']) + }) + + it('returns an empty snapshot when no distinctId is available', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + + const flags = await posthog.evaluateFlags() + + expect(flags.keys).toEqual([]) + }) + }) + + describe('filtering helpers', () => { + beforeEach(() => { + mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + }) + + it('onlyAccessed returns a snapshot with only accessed flags', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + + const flags = await posthog.evaluateFlags('user-1') + flags.isEnabled('boolean-flag') + flags.getFlag('variant-flag') + + const accessed = flags.onlyAccessed() + expect(accessed.keys.sort()).toEqual(['boolean-flag', 'variant-flag']) + }) + + it('onlyAccessed warns and falls back to all flags when nothing was accessed', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + + const flags = await posthog.evaluateFlags('user-1') + const accessed = flags.onlyAccessed() + + expect(accessed.keys.sort()).toEqual(['boolean-flag', 'disabled-flag', 'variant-flag']) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('onlyAccessed() was called before any flags were accessed') + ) + warnSpy.mockRestore() + }) + + it('featureFlagsLogWarnings=false silences filter warnings', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + featureFlagsLogWarnings: false, + ...posthogImmediateResolveOptions, + }) + + const flags = await posthog.evaluateFlags('user-1') + flags.onlyAccessed() + flags.only(['does-not-exist']) + + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('FeatureFlagEvaluations')) + warnSpy.mockRestore() + }) + + it('only returns a filtered snapshot and warns about missing keys', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + + const flags = await posthog.evaluateFlags('user-1') + const only = flags.only(['boolean-flag', 'does-not-exist']) + + expect(only.keys).toEqual(['boolean-flag']) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('does-not-exist')) + warnSpy.mockRestore() + }) + + it('filtered snapshots do not back-propagate access to the parent', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + + const flags = await posthog.evaluateFlags('user-1') + flags.isEnabled('boolean-flag') + const filtered = flags.onlyAccessed() + + filtered.isEnabled('variant-flag') + + expect(flags.onlyAccessed().keys).toEqual(['boolean-flag']) + }) + }) + + describe('capture integration', () => { + beforeEach(() => { + mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + }) + + it('capture({ flags }) attaches $feature/* and $active_feature_flags from the snapshot', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + const flags = await posthog.evaluateFlags('user-1') + posthog.capture({ distinctId: 'user-1', event: 'page_viewed', flags }) + await waitForPromises() + + const pageViewed = captures.find((m) => m.event === 'page_viewed') + expect(pageViewed).toBeDefined() + expect(pageViewed.properties).toMatchObject({ + '$feature/variant-flag': 'variant-value', + '$feature/boolean-flag': true, + '$feature/disabled-flag': false, + $active_feature_flags: ['boolean-flag', 'variant-flag'], + }) + }) + + it('capture({ flags: flags.onlyAccessed() }) only attaches accessed flags', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + const flags = await posthog.evaluateFlags('user-1') + flags.isEnabled('boolean-flag') + posthog.capture({ distinctId: 'user-1', event: 'page_viewed', flags: flags.onlyAccessed() }) + await waitForPromises() + + const pageViewed = captures.find((m) => m.event === 'page_viewed') + expect(pageViewed.properties).toMatchObject({ + '$feature/boolean-flag': true, + $active_feature_flags: ['boolean-flag'], + }) + expect(pageViewed.properties['$feature/variant-flag']).toBeUndefined() + expect(pageViewed.properties['$feature/disabled-flag']).toBeUndefined() + }) + + it('does not trigger an additional /flags request on capture', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + + const flags = await posthog.evaluateFlags('user-1') + const callsAfterEvaluate = mockedFetch.mock.calls.length + + posthog.capture({ distinctId: 'user-1', event: 'page_viewed', flags }) + await posthog.flush() + + const flagCallsAfterCapture = mockedFetch.mock.calls.filter((c) => + (c[0] as string).includes('/flags/?v=2') + ).length + const flagCallsBeforeCapture = mockedFetch.mock.calls + .slice(0, callsAfterEvaluate) + .filter((c) => (c[0] as string).includes('/flags/?v=2')).length + expect(flagCallsAfterCapture).toEqual(flagCallsBeforeCapture) + }) + + it('flags option takes precedence over sendFeatureFlags', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + const flags = await posthog.evaluateFlags('user-1') + const callsBefore = mockedFetch.mock.calls.filter((c) => (c[0] as string).includes('/flags/?v=2')).length + + posthog.capture({ + distinctId: 'user-1', + event: 'page_viewed', + flags: flags.only(['boolean-flag']), + sendFeatureFlags: true, + }) + await posthog.flush() + + const callsAfter = mockedFetch.mock.calls.filter((c) => (c[0] as string).includes('/flags/?v=2')).length + expect(callsAfter).toEqual(callsBefore) + + const pageViewed = captures.find((m) => m.event === 'page_viewed') + expect(pageViewed.properties).toMatchObject({ + '$feature/boolean-flag': true, + $active_feature_flags: ['boolean-flag'], + }) + expect(pageViewed.properties['$feature/variant-flag']).toBeUndefined() + }) + }) + + describe('local evaluation', () => { + it('evaluates flags locally and tags events with locally_evaluated=true', async () => { + const localFlags = { + flags: [ + { + id: 42, + name: 'Always on', + key: 'local-flag', + active: true, + filters: { + groups: [{ variant: null, properties: [], rollout_percentage: 100 }], + }, + }, + ], + } + mockedFetch.mockImplementation(apiImplementation({ localFlags })) + + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + personalApiKey: 'TEST_PERSONAL_API_KEY', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + const flags = await posthog.evaluateFlags('user-1') + expect(flags.isEnabled('local-flag')).toBe(true) + + await waitForPromises() + const flagCalled = captures.find((m) => m.event === '$feature_flag_called') + expect(flagCalled).toBeDefined() + expect(flagCalled.properties).toMatchObject({ + $feature_flag: 'local-flag', + $feature_flag_id: 42, + $feature_flag_reason: 'Evaluated locally', + locally_evaluated: true, + }) + + // No remote /flags request since local evaluation covered it. + const remoteFlagCalls = mockedFetch.mock.calls.filter((c) => (c[0] as string).includes('/flags/?v=2')) + expect(remoteFlagCalls).toHaveLength(0) + }) + }) + + describe('overrides', () => { + it('applies flag and payload overrides to the snapshot', async () => { + mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + + posthog.overrideFeatureFlags({ + flags: { 'boolean-flag': false, 'new-flag': 'variant-a' }, + payloads: { 'variant-flag': { overridden: true } }, + }) + + const flags = await posthog.evaluateFlags('user-1') + expect(flags.isEnabled('boolean-flag')).toBe(false) + expect(flags.getFlag('new-flag')).toBe('variant-a') + expect(flags.getFlagPayload('variant-flag')).toEqual({ overridden: true }) + }) + }) +}) diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 9428ac9e59..7a7338ef9d 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -14,6 +14,7 @@ import { PostHogPersistedProperty, } from '@posthog/core' import { + BaseFlagEvaluationOptions, EventMessage, FeatureFlagError, FeatureFlagErrorType, @@ -28,6 +29,12 @@ import { FlagEvaluationOptions, AllFlagsOptions, } from './types' +import { + EvaluatedFlagRecord, + FeatureFlagEvaluations, + FeatureFlagEvaluationsHost, + FlagCalledEventParams, +} from './feature-flag-evaluations' import { FeatureFlagsPoller, type FeatureFlagEvaluationContext, @@ -49,6 +56,27 @@ const MAX_CACHE_SIZE = 50 * 1000 const WAITUNTIL_DEBOUNCE_MS = 50 const WAITUNTIL_MAX_WAIT_MS = 500 +/** + * Derive `$feature/{key}` and `$active_feature_flags` event properties from a flat + * `{ key: value }` map returned by the legacy `sendFeatureFlags` path. + */ +function buildFlagEventProperties(flagValues: Record | undefined): Record { + if (!flagValues) { + return {} + } + const additionalProperties: Record = {} + for (const [feature, variant] of Object.entries(flagValues)) { + additionalProperties[`$feature/${feature}`] = variant + } + const activeFlags = Object.keys(flagValues) + .filter((flag) => flagValues[flag] !== false) + .sort() + if (activeFlags.length > 0) { + additionalProperties['$active_feature_flags'] = activeFlags + } + return additionalProperties +} + // The actual exported Nodejs API. export abstract class PostHogBackendClient extends PostHogCoreStateless implements IPostHog { private _memoryStorage = new PostHogMemoryStorage() @@ -899,56 +927,38 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen // Send feature flag event if configured if (sendFeatureFlagEvents) { - // Compute the response value for event tracking const response = result === undefined ? undefined : result.enabled === false ? false : (result.variant ?? true) - const featureFlagReportedKey = `${key}_${response}` - - if ( - !(distinctId in this.distinctIdHasSentFlagCalls) || - !this.distinctIdHasSentFlagCalls[distinctId].includes(featureFlagReportedKey) - ) { - if (Object.keys(this.distinctIdHasSentFlagCalls).length >= this.maxCacheSize) { - this.distinctIdHasSentFlagCalls = {} - } - if (Array.isArray(this.distinctIdHasSentFlagCalls[distinctId])) { - this.distinctIdHasSentFlagCalls[distinctId].push(featureFlagReportedKey) - } else { - this.distinctIdHasSentFlagCalls[distinctId] = [featureFlagReportedKey] - } - - const properties: Record = { - $feature_flag: key, - $feature_flag_response: response, - $feature_flag_id: flagId, - $feature_flag_version: flagVersion, - $feature_flag_reason: flagReason, - locally_evaluated: flagWasLocallyEvaluated, - [`$feature/${key}`]: response, - $feature_flag_request_id: requestId, - $feature_flag_evaluated_at: flagWasLocallyEvaluated ? Date.now() : evaluatedAt, - } - - // Add local evaluation definition load timestamp - if (flagWasLocallyEvaluated && this.featureFlagsPoller) { - const flagDefinitionsLoadedAt = this.featureFlagsPoller.getFlagDefinitionsLoadedAt() - - if (flagDefinitionsLoadedAt !== undefined) { - properties.$feature_flag_definitions_loaded_at = flagDefinitionsLoadedAt - } - } + const properties: Record = { + $feature_flag: key, + $feature_flag_response: response, + $feature_flag_id: flagId, + $feature_flag_version: flagVersion, + $feature_flag_reason: flagReason, + locally_evaluated: flagWasLocallyEvaluated, + [`$feature/${key}`]: response, + $feature_flag_request_id: requestId, + $feature_flag_evaluated_at: flagWasLocallyEvaluated ? Date.now() : evaluatedAt, + } - if (featureFlagError) { - properties.$feature_flag_error = featureFlagError + if (flagWasLocallyEvaluated && this.featureFlagsPoller) { + const flagDefinitionsLoadedAt = this.featureFlagsPoller.getFlagDefinitionsLoadedAt() + if (flagDefinitionsLoadedAt !== undefined) { + properties.$feature_flag_definitions_loaded_at = flagDefinitionsLoadedAt } + } - this.capture({ - distinctId, - event: '$feature_flag_called', - properties, - groups, - disableGeoip, - }) + if (featureFlagError) { + properties.$feature_flag_error = featureFlagError } + + this._captureFlagCalledEventIfNeeded({ + distinctId, + key, + response, + groups, + disableGeoip, + properties, + }) } // Apply payload override if present (even when there's no flag override) @@ -1432,6 +1442,232 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen return { featureFlags, featureFlagPayloads } } + /** + * Evaluate all feature flags for a user in a single call and return a + * {@link FeatureFlagEvaluations} snapshot. Branch on `.isEnabled()` / `.getFlag()`, + * then pass the same snapshot to `capture()` via the `flags` option so the + * captured event carries the exact flag values the code branched on. + * + * @example + * ```ts + * const flags = await client.evaluateFlags('user_123', { + * personProperties: { plan: 'enterprise' }, + * }) + * if (flags.isEnabled('new-dashboard')) { + * renderNewDashboard() + * } + * client.capture({ distinctId: 'user_123', event: 'page_viewed', flags }) + * ``` + * + * {@label Feature flags} + * + * @param distinctIdOrOptions - The user's distinct ID, or options when the distinctId comes from `withContext()` + * @param options - Optional configuration for flag evaluation + * @returns Promise that resolves to a `FeatureFlagEvaluations` snapshot + */ + async evaluateFlags(options?: BaseFlagEvaluationOptions): Promise + async evaluateFlags(distinctId: string, options?: BaseFlagEvaluationOptions): Promise + async evaluateFlags( + distinctIdOrOptions?: string | BaseFlagEvaluationOptions, + options?: BaseFlagEvaluationOptions + ): Promise { + const { distinctId: resolvedDistinctId, options: resolvedOptions } = this._resolveDistinctId( + distinctIdOrOptions, + options + ) + + if (!resolvedDistinctId) { + this._logger.warn( + '[PostHog] distinctId is required to evaluate feature flags — pass it explicitly or use withContext()' + ) + return new FeatureFlagEvaluations({ + host: this._getFeatureFlagEvaluationsHost(), + distinctId: '', + flags: {}, + }) + } + + const { groups, disableGeoip } = resolvedOptions || {} + let { onlyEvaluateLocally, personProperties, groupProperties } = resolvedOptions || {} + + const adjustedProperties = this.addLocalPersonAndGroupProperties( + resolvedDistinctId, + groups, + personProperties, + groupProperties + ) + personProperties = adjustedProperties.allPersonProperties + groupProperties = adjustedProperties.allGroupProperties + const evaluationContext = this.createFeatureFlagEvaluationContext( + resolvedDistinctId, + groups, + personProperties, + groupProperties + ) + + if (onlyEvaluateLocally == undefined) { + onlyEvaluateLocally = this.options.strictLocalEvaluation ?? false + } + + const records: Record = {} + let requestId: string | undefined = undefined + let evaluatedAt: number | undefined = undefined + + // Try local evaluation first and decorate each flag with metadata from the poller. + const localResult = await this.featureFlagsPoller?.getAllFlagsAndPayloads(evaluationContext) + const locallyEvaluatedKeys = new Set() + if (localResult) { + for (const [key, value] of Object.entries(localResult.response)) { + const flagDef = this.featureFlagsPoller?.featureFlagsByKey[key] + records[key] = { + key, + enabled: value !== false, + variant: typeof value === 'string' ? value : undefined, + payload: localResult.payloads[key], + id: flagDef?.id, + version: undefined, + reason: 'Evaluated locally', + locallyEvaluated: true, + } + locallyEvaluatedKeys.add(key) + } + } + + // Fall back to remote evaluation for any flags the poller couldn't resolve locally. + // We use the detail-shaped endpoint so the resulting records carry id/version/reason + // and fired $feature_flag_called events match what isFeatureEnabled()/getFeatureFlag() emit. + const fallbackToFlags = localResult ? localResult.fallbackToFlags : true + if (fallbackToFlags && !onlyEvaluateLocally) { + const details = await super.getFeatureFlagDetailsStateless( + evaluationContext.distinctId, + evaluationContext.groups, + evaluationContext.personProperties, + evaluationContext.groupProperties, + disableGeoip + ) + if (details) { + requestId = details.requestId + evaluatedAt = details.evaluatedAt + for (const [key, detail] of Object.entries(details.flags)) { + if (locallyEvaluatedKeys.has(key)) { + continue + } + let parsedPayload: JsonType | undefined = undefined + if (detail.metadata?.payload !== undefined) { + try { + parsedPayload = JSON.parse(detail.metadata.payload) + } catch { + parsedPayload = detail.metadata.payload + } + } + records[key] = { + key, + enabled: detail.enabled, + variant: detail.variant, + payload: parsedPayload, + id: detail.metadata?.id, + version: detail.metadata?.version, + reason: detail.reason?.description ?? detail.reason?.code, + locallyEvaluated: false, + } + } + } + } + + // Apply overrides last so they take precedence over evaluation. + if (this._flagOverrides !== undefined) { + for (const [key, value] of Object.entries(this._flagOverrides)) { + if (value === undefined) { + delete records[key] + continue + } + const existing = records[key] + records[key] = { + key, + enabled: value !== false, + variant: typeof value === 'string' ? value : undefined, + payload: existing?.payload, + id: existing?.id, + version: existing?.version, + reason: existing?.reason, + locallyEvaluated: existing?.locallyEvaluated ?? false, + } + } + } + if (this._payloadOverrides !== undefined) { + for (const [key, payload] of Object.entries(this._payloadOverrides)) { + const existing = records[key] + if (existing) { + records[key] = { ...existing, payload } + } + } + } + + return new FeatureFlagEvaluations({ + host: this._getFeatureFlagEvaluationsHost(), + distinctId: resolvedDistinctId, + groups, + disableGeoip, + flags: records, + requestId, + evaluatedAt, + }) + } + + /** + * 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 + * path and `FeatureFlagEvaluations.isEnabled() / getFlag()` so both paths dedupe identically. + * + * @internal + */ + public _captureFlagCalledEventIfNeeded(params: FlagCalledEventParams): void { + const { distinctId, key, response, groups, disableGeoip, properties } = params + const featureFlagReportedKey = `${key}_${response}` + + if ( + distinctId in this.distinctIdHasSentFlagCalls && + this.distinctIdHasSentFlagCalls[distinctId].includes(featureFlagReportedKey) + ) { + return + } + + if (Object.keys(this.distinctIdHasSentFlagCalls).length >= this.maxCacheSize) { + this.distinctIdHasSentFlagCalls = {} + } + if (Array.isArray(this.distinctIdHasSentFlagCalls[distinctId])) { + this.distinctIdHasSentFlagCalls[distinctId].push(featureFlagReportedKey) + } else { + this.distinctIdHasSentFlagCalls[distinctId] = [featureFlagReportedKey] + } + + this.capture({ + distinctId, + event: '$feature_flag_called', + properties, + groups, + disableGeoip, + }) + } + + private _featureFlagEvaluationsHost?: FeatureFlagEvaluationsHost + + private _getFeatureFlagEvaluationsHost(): FeatureFlagEvaluationsHost { + if (!this._featureFlagEvaluationsHost) { + this._featureFlagEvaluationsHost = { + captureFlagCalledEventIfNeeded: (params) => this._captureFlagCalledEventIfNeeded(params), + logWarning: (message) => { + if (this.options.featureFlagsLogWarnings !== false) { + // These warnings guide API usage (misuse of `onlyAccessed()` / `only()`) and + // should always surface — unlike `this._logger.warn` which is gated on debug mode. + console.warn(`[PostHog] ${message}`) + } + }, + } + } + return this._featureFlagEvaluationsHost + } + /** * Create or update a group and its properties. * @@ -1984,8 +2220,17 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen properties: PostHogEventProperties options: PostHogCaptureOptions }> { - const { distinctId, event, properties, groups, sendFeatureFlags, timestamp, disableGeoip, uuid }: EventMessage = - props + const { + distinctId, + event, + properties, + groups, + flags, + sendFeatureFlags, + timestamp, + disableGeoip, + uuid, + }: EventMessage = props const contextData = this.context?.get() @@ -2012,6 +2257,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen event, properties: mergedProperties, groups, + flags, sendFeatureFlags, timestamp, disableGeoip, @@ -2025,40 +2271,28 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen // :TRICKY: If we flush, or need to shut down, to not lose events we want this promise to resolve before we flush const eventProperties = await Promise.resolve() .then(async () => { + // Prefer the explicit `flags` object — it guarantees the event carries the same + // values the developer branched on, with no additional network call. + if (flags) { + return flags._getEventProperties() + } + if (sendFeatureFlags) { // If we are sending feature flags, we evaluate them locally if the user prefers it, otherwise we fall back to remote evaluation const sendFeatureFlagsOptions = typeof sendFeatureFlags === 'object' ? sendFeatureFlags : undefined - return await this.getFeatureFlagsForEvent( + const flagValues = await this.getFeatureFlagsForEvent( eventMessage.distinctId!, groups, disableGeoip, sendFeatureFlagsOptions ) + return buildFlagEventProperties(flagValues) } - if (eventMessage.event === '$feature_flag_called') { - // If we're capturing a $feature_flag_called event, we don't want to enrich the event with cached flags that may be out of date. - return {} - } + // $feature_flag_called events are not enriched with cached flags — the flags + // on that event should reflect the specific call, not a potentially stale snapshot. return {} }) - .then((flags) => { - // Derive the relevant flag properties to add - const additionalProperties: Record = {} - if (flags) { - for (const [feature, variant] of Object.entries(flags)) { - additionalProperties[`$feature/${feature}`] = variant - } - } - const activeFlags = Object.keys(flags || {}) - .filter((flag) => flags?.[flag] !== false) - .sort() - if (activeFlags.length > 0) { - additionalProperties['$active_feature_flags'] = activeFlags - } - - return additionalProperties - }) .catch(() => { // Something went wrong getting the flag info - we should capture the event anyways return {} diff --git a/packages/node/src/exports.ts b/packages/node/src/exports.ts index 3dcac1c748..9ca67517a4 100644 --- a/packages/node/src/exports.ts +++ b/packages/node/src/exports.ts @@ -2,6 +2,8 @@ export * from './extensions/sentry-integration' export * from './extensions/express' export * from './types' +export { FeatureFlagEvaluations } from './feature-flag-evaluations' + // Re-export FeatureFlagError from core for backwards compatibility. // These were originally defined in posthog-node and moved to core for reuse across SDKs. export { FeatureFlagError } from '@posthog/core' diff --git a/packages/node/src/feature-flag-evaluations.ts b/packages/node/src/feature-flag-evaluations.ts new file mode 100644 index 0000000000..c5149bd402 --- /dev/null +++ b/packages/node/src/feature-flag-evaluations.ts @@ -0,0 +1,278 @@ +import { FeatureFlagValue, JsonType } from '@posthog/core' + +import { FeatureFlagError } from './types' + +/** + * Internal per-flag record stored by a {@link FeatureFlagEvaluations} instance. + * Not part of the public API. + * + * @internal + */ +export type EvaluatedFlagRecord = { + key: string + enabled: boolean + variant: string | undefined + payload: JsonType | undefined + id: number | undefined + version: number | undefined + reason: string | undefined + locallyEvaluated: boolean +} + +/** + * Parameters passed to the host when a `$feature_flag_called` event should be captured. + * + * @internal + */ +export type FlagCalledEventParams = { + distinctId: string + key: string + response: FeatureFlagValue | undefined + groups: Record | undefined + disableGeoip: boolean | undefined + properties: Record +} + +/** + * Thin interface the evaluations object uses to talk back to the PostHog client. + * Keeps the class decoupled from the full client surface area. + * + * @internal + */ +export interface FeatureFlagEvaluationsHost { + captureFlagCalledEventIfNeeded(params: FlagCalledEventParams): void + logWarning(message: string): void +} + +/** + * A snapshot of feature flag evaluations for a single distinctId at a point in time. + * + * Returned by {@link IPostHog.evaluateFlags} — branch on `isEnabled()` / `getFlag()` + * and pass the same object to `capture()` via the `flags` option so the captured event + * carries the exact flag values the code branched on. + * + * ```ts + * const flags = await posthog.evaluateFlags(distinctId, { personProperties: { plan: 'enterprise' } }) + * + * if (flags.isEnabled('new-dashboard')) { + * renderNewDashboard() + * } + * + * posthog.capture({ distinctId, event: 'page_viewed', flags }) + * ``` + */ +export class FeatureFlagEvaluations { + private readonly _host: FeatureFlagEvaluationsHost + private readonly _distinctId: string + private readonly _groups: Record | undefined + private readonly _disableGeoip: boolean | undefined + private readonly _flags: Record + private readonly _requestId: string | undefined + private readonly _evaluatedAt: number | undefined + private readonly _accessed: Set + + /** + * @internal — instances are created by the SDK via `posthog.evaluateFlags()`. + */ + constructor(init: { + host: FeatureFlagEvaluationsHost + distinctId: string + groups?: Record + disableGeoip?: boolean + flags: Record + requestId?: string + evaluatedAt?: number + accessed?: Set + }) { + this._host = init.host + this._distinctId = init.distinctId + this._groups = init.groups + this._disableGeoip = init.disableGeoip + this._flags = init.flags + this._requestId = init.requestId + this._evaluatedAt = init.evaluatedAt + this._accessed = init.accessed ?? new Set() + } + + /** + * Check whether a feature flag is enabled. Fires a `$feature_flag_called` event + * on the first access per (distinctId, flag, value) tuple, deduped via the SDK's + * existing cache. + * + * Flags that were not returned from the underlying evaluation are treated as + * disabled (returns `false`). + */ + isEnabled(key: string): boolean { + const flag = this._flags[key] + this._recordAccess(key) + return flag?.enabled ?? false + } + + /** + * Get the evaluated value of a feature flag. Fires a `$feature_flag_called` event + * on the first access per (distinctId, flag, value) tuple. + * + * Returns the variant string for multivariate flags, `true` for enabled flags + * without a variant, `false` for disabled flags, and `undefined` for flags that + * were not returned by the evaluation. + */ + getFlag(key: string): FeatureFlagValue | undefined { + const flag = this._flags[key] + this._recordAccess(key) + if (!flag) { + return undefined + } + if (!flag.enabled) { + return false + } + return flag.variant ?? true + } + + /** + * Get the payload associated with a feature flag. Does not count as an access + * for `onlyAccessed()` and does not fire any event. + */ + getFlagPayload(key: string): JsonType | undefined { + return this._flags[key]?.payload + } + + /** + * Return a filtered copy containing only flags that have been accessed via + * `isEnabled()` or `getFlag()` before this call. If no flags have been accessed, + * logs a warning and returns a copy with all flags (to avoid dropping exposure + * data silently). + */ + onlyAccessed(): FeatureFlagEvaluations { + if (this._accessed.size === 0) { + this._host.logWarning( + 'FeatureFlagEvaluations.onlyAccessed() was called before any flags were accessed — attaching all evaluated flags as a fallback. See https://posthog.com/docs/feature-flags/server-sdks for details.' + ) + return this._cloneWith(this._flags) + } + const filtered: Record = {} + for (const key of this._accessed) { + const flag = this._flags[key] + if (flag) { + filtered[key] = flag + } + } + return this._cloneWith(filtered) + } + + /** + * Return a filtered copy containing only flags with the given keys. Keys that + * are not present in the evaluation are dropped and logged as a warning. + */ + only(keys: string[]): FeatureFlagEvaluations { + const filtered: Record = {} + const missing: string[] = [] + for (const key of keys) { + const flag = this._flags[key] + if (flag) { + filtered[key] = flag + } else { + missing.push(key) + } + } + if (missing.length > 0) { + this._host.logWarning( + `FeatureFlagEvaluations.only() was called with flag keys that are not in the evaluation set and will be dropped: ${missing.join(', ')}` + ) + } + return this._cloneWith(filtered) + } + + /** + * Returns the flag keys that are part of this evaluation. + */ + get keys(): string[] { + return Object.keys(this._flags) + } + + /** + * Build the `$feature/*` and `$active_feature_flags` event properties derived + * from the current flag set. Called by `capture()` when an event is captured + * with `flags: ...`. + * + * @internal + */ + _getEventProperties(): Record { + const properties: Record = {} + const activeFlags: string[] = [] + for (const [key, flag] of Object.entries(this._flags)) { + const value = flag.enabled === false ? false : (flag.variant ?? true) + properties[`$feature/${key}`] = value + if (flag.enabled) { + activeFlags.push(key) + } + } + if (activeFlags.length > 0) { + activeFlags.sort() + properties['$active_feature_flags'] = activeFlags + } + return properties + } + + /** + * @internal + */ + _getDistinctId(): string { + return this._distinctId + } + + /** + * @internal + */ + _getGroups(): Record | undefined { + return this._groups + } + + private _cloneWith(flags: Record): FeatureFlagEvaluations { + return new FeatureFlagEvaluations({ + host: this._host, + distinctId: this._distinctId, + groups: this._groups, + disableGeoip: this._disableGeoip, + flags, + requestId: this._requestId, + evaluatedAt: this._evaluatedAt, + // Copy the accessed set so the child can track further access independently + // of the parent. Callers expect `onlyAccessed()` on the parent to reflect + // only what the parent saw, not what happened on filtered views. + accessed: new Set(this._accessed), + }) + } + + private _recordAccess(key: string): void { + this._accessed.add(key) + + const flag = this._flags[key] + const response: FeatureFlagValue | undefined = + flag === undefined ? undefined : flag.enabled === false ? false : (flag.variant ?? true) + + const properties: Record = { + $feature_flag: key, + $feature_flag_response: response, + $feature_flag_id: flag?.id, + $feature_flag_version: flag?.version, + $feature_flag_reason: flag?.reason, + locally_evaluated: flag?.locallyEvaluated ?? false, + [`$feature/${key}`]: response, + $feature_flag_request_id: this._requestId, + $feature_flag_evaluated_at: flag?.locallyEvaluated ? Date.now() : this._evaluatedAt, + } + + if (flag === undefined) { + properties.$feature_flag_error = FeatureFlagError.FLAG_MISSING + } + + this._host.captureFlagCalledEventIfNeeded({ + distinctId: this._distinctId, + key, + response, + groups: this._groups, + disableGeoip: this._disableGeoip, + properties, + }) + } +} diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 6630773cc0..e704d3adf1 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -8,6 +8,7 @@ import type { } from '@posthog/core' import { ContextData, ContextOptions } from './extensions/context/types' +import type { FeatureFlagEvaluations } from './feature-flag-evaluations' import type { FlagDefinitionCacheProvider } from './extensions/feature-flags/cache' export type IdentifyMessage = { @@ -27,6 +28,17 @@ export type EventMessage = Omit & { distinctId?: string // Optional - can be provided via context event: string groups?: Record // Mapping of group type to group id + /** + * Attach feature flag values evaluated via `posthog.evaluateFlags()` to this event. + * Prefer this over `sendFeatureFlags` — it guarantees the event carries the exact + * values the code branched on and avoids a hidden `/flags` request on every capture. + */ + flags?: FeatureFlagEvaluations + /** + * @deprecated Use the `flags` option with a `FeatureFlagEvaluations` object obtained + * from `posthog.evaluateFlags()` instead. `sendFeatureFlags` fires a separate `/flags` + * request on capture and may return different values than the ones the code branched on. + */ sendFeatureFlags?: boolean | SendFeatureFlagsOptions timestamp?: Date uuid?: string @@ -215,6 +227,14 @@ export type PostHogOptions = Omit & { * @default false */ strictLocalEvaluation?: boolean + /** + * Controls whether `FeatureFlagEvaluations` filter helpers (`onlyAccessed()` and + * `only()`) log warnings when their input is unexpected — for example, calling + * `onlyAccessed()` before accessing any flags, or passing unknown keys to `only()`. + * + * @default true + */ + featureFlagsLogWarnings?: boolean /** * Provides the API to extend the lifetime of a serverless invocation until * background work (like flushing analytics events) completes after the response @@ -311,9 +331,10 @@ export interface IPostHog { * @param event We recommend using [verb] [noun], like movie played or movie updated to easily identify what your events mean later on. * @param properties OPTIONAL | which can be a object with any information you'd like to add * @param groups OPTIONAL | object of what groups are related to this event, example: { company: 'id:5' }. Can be used to analyze companies instead of users. - * @param sendFeatureFlags OPTIONAL | Used with experiments. Determines whether to send feature flag values with the event. + * @param flags OPTIONAL | A `FeatureFlagEvaluations` snapshot from `evaluateFlags()`. Attaches those exact flag values to the event with no extra network call. + * @param sendFeatureFlags OPTIONAL | Deprecated — prefer `flags`. Fires a hidden `/flags` request on capture to enrich the event with flag values. */ - capture({ distinctId, event, properties, groups, sendFeatureFlags }: EventMessage): void + capture({ distinctId, event, properties, groups, flags, sendFeatureFlags }: EventMessage): void /** * @description Capture an event immediately. Useful for edge environments where the usual queue-based sending is not preferable. Do not mix immediate and non-immediate calls. @@ -321,9 +342,10 @@ export interface IPostHog { * @param event We recommend using [verb] [noun], like movie played or movie updated to easily identify what your events mean later on. * @param properties OPTIONAL | which can be a object with any information you'd like to add * @param groups OPTIONAL | object of what groups are related to this event, example: { company: 'id:5' }. Can be used to analyze companies instead of users. - * @param sendFeatureFlags OPTIONAL | Used with experiments. Determines whether to send feature flag values with the event. + * @param flags OPTIONAL | A `FeatureFlagEvaluations` snapshot from `evaluateFlags()`. Attaches those exact flag values to the event with no extra network call. + * @param sendFeatureFlags OPTIONAL | Deprecated — prefer `flags`. Fires a hidden `/flags` request on capture to enrich the event with flag values. */ - captureImmediate({ distinctId, event, properties, groups, sendFeatureFlags }: EventMessage): Promise + captureImmediate({ distinctId, event, properties, groups, flags, sendFeatureFlags }: EventMessage): Promise /** * @description Identify lets you add metadata on your users so you can more easily identify who they are in PostHog, @@ -512,6 +534,36 @@ export interface IPostHog { options?: FlagEvaluationOptions ): Promise + /** + * @description Evaluate all feature flags for a user in a single call and return a + * {@link FeatureFlagEvaluations} snapshot. Branch on `.isEnabled()` / `.getFlag()`, + * then pass the same snapshot to `capture()` via the `flags` option so events carry + * the exact flag values the code branched on. + * + * Prefer this over calling `isFeatureEnabled()` / `getFeatureFlag()` repeatedly and + * over `capture({ sendFeatureFlags: true })` — it avoids multiple `/flags` requests + * per incoming request. + * + * @example + * ```ts + * const flags = await posthog.evaluateFlags('user_123', { personProperties: { plan: 'enterprise' } }) + * if (flags.isEnabled('new-dashboard')) { + * renderNewDashboard() + * } + * posthog.capture({ distinctId: 'user_123', event: 'page_viewed', flags }) + * ``` + * + * @param options - Optional configuration for flag evaluation + */ + evaluateFlags(options?: BaseFlagEvaluationOptions): Promise + /** + * @description Evaluate all feature flags for a specific user. + * + * @param distinctId - The user's distinct ID + * @param options - Optional configuration for flag evaluation + */ + evaluateFlags(distinctId: string, options?: BaseFlagEvaluationOptions): Promise + /** * @description Sets a groups properties, which allows asking questions like "Who are the most active companies" * using my product in PostHog. From c23443152b755b34dd6e3d61b4783740cb989203 Mon Sep 17 00:00:00 2001 From: dylan Date: Fri, 24 Apr 2026 14:47:44 -0700 Subject: [PATCH 2/7] feat(node): support flagKeys option on evaluateFlags() Allow callers to scope the underlying /flags request to a subset of flags. The chained `flags.only([...])` filter still exists for event-attachment scoping after evaluation; `flagKeys` reduces the network payload itself. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- .../node/src/__tests__/evaluate-flags.spec.ts | 14 ++++++++++++++ packages/node/src/client.ts | 17 +++++++++-------- packages/node/src/types.ts | 8 ++++---- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/node/src/__tests__/evaluate-flags.spec.ts b/packages/node/src/__tests__/evaluate-flags.spec.ts index 1985cc2280..3ef8ea6f4e 100644 --- a/packages/node/src/__tests__/evaluate-flags.spec.ts +++ b/packages/node/src/__tests__/evaluate-flags.spec.ts @@ -219,6 +219,20 @@ describe('evaluateFlags', () => { expect(flags.keys.sort()).toEqual(['boolean-flag', 'disabled-flag', 'variant-flag']) }) + it('forwards flagKeys to the /flags request to scope the evaluation', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + + await posthog.evaluateFlags('user-1', { flagKeys: ['boolean-flag', 'variant-flag'] }) + + expect(mockedFetch).toHaveBeenCalledTimes(1) + const [, init] = mockedFetch.mock.calls[0] + const body = JSON.parse((init as any).body as string) + expect(body.flag_keys_to_evaluate).toEqual(['boolean-flag', 'variant-flag']) + }) + it('returns an empty snapshot when no distinctId is available', async () => { posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 7a7338ef9d..4726124aad 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -14,7 +14,6 @@ import { PostHogPersistedProperty, } from '@posthog/core' import { - BaseFlagEvaluationOptions, EventMessage, FeatureFlagError, FeatureFlagErrorType, @@ -1465,11 +1464,11 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * @param options - Optional configuration for flag evaluation * @returns Promise that resolves to a `FeatureFlagEvaluations` snapshot */ - async evaluateFlags(options?: BaseFlagEvaluationOptions): Promise - async evaluateFlags(distinctId: string, options?: BaseFlagEvaluationOptions): Promise + async evaluateFlags(options?: AllFlagsOptions): Promise + async evaluateFlags(distinctId: string, options?: AllFlagsOptions): Promise async evaluateFlags( - distinctIdOrOptions?: string | BaseFlagEvaluationOptions, - options?: BaseFlagEvaluationOptions + distinctIdOrOptions?: string | AllFlagsOptions, + options?: AllFlagsOptions ): Promise { const { distinctId: resolvedDistinctId, options: resolvedOptions } = this._resolveDistinctId( distinctIdOrOptions, @@ -1487,7 +1486,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen }) } - const { groups, disableGeoip } = resolvedOptions || {} + const { groups, disableGeoip, flagKeys } = resolvedOptions || {} let { onlyEvaluateLocally, personProperties, groupProperties } = resolvedOptions || {} const adjustedProperties = this.addLocalPersonAndGroupProperties( @@ -1514,7 +1513,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen let evaluatedAt: number | undefined = undefined // Try local evaluation first and decorate each flag with metadata from the poller. - const localResult = await this.featureFlagsPoller?.getAllFlagsAndPayloads(evaluationContext) + // `flagKeys` scopes the evaluation to a subset of definitions when provided. + const localResult = await this.featureFlagsPoller?.getAllFlagsAndPayloads(evaluationContext, flagKeys) const locallyEvaluatedKeys = new Set() if (localResult) { for (const [key, value] of Object.entries(localResult.response)) { @@ -1543,7 +1543,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen evaluationContext.groups, evaluationContext.personProperties, evaluationContext.groupProperties, - disableGeoip + disableGeoip, + flagKeys ) if (details) { requestId = details.requestId diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index e704d3adf1..8094b4e3db 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -553,16 +553,16 @@ export interface IPostHog { * posthog.capture({ distinctId: 'user_123', event: 'page_viewed', flags }) * ``` * - * @param options - Optional configuration for flag evaluation + * @param options - Optional configuration for flag evaluation. Pass `flagKeys` to scope the underlying `/flags` request to a subset of flags. */ - evaluateFlags(options?: BaseFlagEvaluationOptions): Promise + evaluateFlags(options?: AllFlagsOptions): Promise /** * @description Evaluate all feature flags for a specific user. * * @param distinctId - The user's distinct ID - * @param options - Optional configuration for flag evaluation + * @param options - Optional configuration for flag evaluation. Pass `flagKeys` to scope the underlying `/flags` request to a subset of flags. */ - evaluateFlags(distinctId: string, options?: BaseFlagEvaluationOptions): Promise + evaluateFlags(distinctId: string, options?: AllFlagsOptions): Promise /** * @description Sets a groups properties, which allows asking questions like "Who are the most active companies" From 2b26e055b1a75d9729bf729fa740e72409484a1a Mon Sep 17 00:00:00 2001 From: dylan Date: Fri, 24 Apr 2026 14:50:16 -0700 Subject: [PATCH 3/7] docs(node): expand evaluateFlags() JSDoc with flagKeys and filtering examples Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- packages/node/src/client.ts | 45 ++++++++++++++++++- packages/node/src/feature-flag-evaluations.ts | 4 ++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 4726124aad..009fc0c9d6 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -1447,7 +1447,23 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * then pass the same snapshot to `capture()` via the `flags` option so the * captured event carries the exact flag values the code branched on. * + * Prefer this over repeated `isFeatureEnabled()` / `getFeatureFlag()` calls and + * over `capture({ sendFeatureFlags: true })` — it consolidates flag evaluation + * into a single `/flags` request per incoming request. + * + * **Local evaluation is transparent.** When the poller can resolve a flag from + * cached definitions, no network call is made and the snapshot's `$feature_flag_called` + * events are tagged `locally_evaluated: true`. + * + * **Trim the request.** Pass `flagKeys` to scope the underlying `/flags` request + * to a subset of flags — useful when you only need a few flags and want to reduce + * the response payload. + * + * **Trim the event payload.** Use `flags.only([...])` or `flags.onlyAccessed()` + * to filter which flags get attached to a captured event without re-fetching. + * * @example + * Basic usage: * ```ts * const flags = await client.evaluateFlags('user_123', { * personProperties: { plan: 'enterprise' }, @@ -1458,10 +1474,37 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * client.capture({ distinctId: 'user_123', event: 'page_viewed', flags }) * ``` * + * @example + * Scope the `/flags` request to specific keys: + * ```ts + * const flags = await client.evaluateFlags('user_123', { + * flagKeys: ['new-dashboard', 'checkout-flow'], + * personProperties: { plan: 'enterprise' }, + * }) + * ``` + * + * @example + * Attach only the flags the developer actually checked: + * ```ts + * const flags = await client.evaluateFlags('user_123') + * if (flags.isEnabled('new-dashboard')) { ... } + * client.capture({ distinctId: 'user_123', event: 'page_viewed', flags: flags.onlyAccessed() }) + * ``` + * + * @example + * Use `withContext()` to avoid repeating the distinctId: + * ```ts + * await client.withContext({ distinctId: 'user_123' }, async () => { + * const flags = await client.evaluateFlags() + * if (flags.isEnabled('new-dashboard')) { ... } + * client.capture({ event: 'page_viewed', flags }) + * }) + * ``` + * * {@label Feature flags} * * @param distinctIdOrOptions - The user's distinct ID, or options when the distinctId comes from `withContext()` - * @param options - Optional configuration for flag evaluation + * @param options - Optional configuration for flag evaluation. Supports the same fields as `getAllFlags()`, including `flagKeys` to scope the `/flags` request. * @returns Promise that resolves to a `FeatureFlagEvaluations` snapshot */ async evaluateFlags(options?: AllFlagsOptions): Promise diff --git a/packages/node/src/feature-flag-evaluations.ts b/packages/node/src/feature-flag-evaluations.ts index c5149bd402..cedb879b3c 100644 --- a/packages/node/src/feature-flag-evaluations.ts +++ b/packages/node/src/feature-flag-evaluations.ts @@ -60,6 +60,10 @@ export interface FeatureFlagEvaluationsHost { * * posthog.capture({ distinctId, event: 'page_viewed', flags }) * ``` + * + * To narrow the set of flags that get attached to a captured event, use the in-memory + * helpers `only([...])` and `onlyAccessed()`. To narrow the set of flags requested from + * the server in the first place, pass `flagKeys` to `evaluateFlags()`. */ export class FeatureFlagEvaluations { private readonly _host: FeatureFlagEvaluationsHost From 8db3b996a6d4fbebe6d345a09449aa0079baf247 Mon Sep 17 00:00:00 2001 From: dylan Date: Fri, 24 Apr 2026 17:58:56 -0700 Subject: [PATCH 4/7] fix(node): close parity gaps on FeatureFlagEvaluations $feature_flag_called events - Plumb $feature_flag_definitions_loaded_at into the snapshot at construction so locally-evaluated flag access via the new API emits the same event schema as the existing single-flag path. - Short-circuit $feature_flag_called emission when the snapshot has no resolvable distinctId, so the safety-fallback empty snapshot doesn't leak events with empty distinct_id values. - Demote the shared dedup helper from public to protected; the only external caller is a closure with `this`-scoped access. - Document the onlyAccessed() empty-fallback behavior and clarify that the local-evaluation flag definition has no version field. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- .../node/src/__tests__/evaluate-flags.spec.ts | 64 +++++++++++++++---- packages/node/src/client.ts | 5 +- packages/node/src/feature-flag-evaluations.ts | 25 +++++++- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/packages/node/src/__tests__/evaluate-flags.spec.ts b/packages/node/src/__tests__/evaluate-flags.spec.ts index 3ef8ea6f4e..26b9eb2af4 100644 --- a/packages/node/src/__tests__/evaluate-flags.spec.ts +++ b/packages/node/src/__tests__/evaluate-flags.spec.ts @@ -243,6 +243,22 @@ describe('evaluateFlags', () => { expect(flags.keys).toEqual([]) }) + + it('does not fire $feature_flag_called events from an empty-distinctId snapshot', async () => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + const flags = await posthog.evaluateFlags() + flags.isEnabled('any-flag') + flags.getFlag('any-flag') + + await waitForPromises() + expect(captures.filter((m) => m.event === '$feature_flag_called')).toHaveLength(0) + }) }) describe('filtering helpers', () => { @@ -430,21 +446,22 @@ describe('evaluateFlags', () => { }) describe('local evaluation', () => { - it('evaluates flags locally and tags events with locally_evaluated=true', async () => { - const localFlags = { - flags: [ - { - id: 42, - name: 'Always on', - key: 'local-flag', - active: true, - filters: { - groups: [{ variant: null, properties: [], rollout_percentage: 100 }], - }, + const localFlagsFixture = () => ({ + flags: [ + { + id: 42, + name: 'Always on', + key: 'local-flag', + active: true, + filters: { + groups: [{ variant: null, properties: [], rollout_percentage: 100 }], }, - ], - } - mockedFetch.mockImplementation(apiImplementation({ localFlags })) + }, + ], + }) + + it('evaluates flags locally and tags events with locally_evaluated=true', async () => { + mockedFetch.mockImplementation(apiImplementation({ localFlags: localFlagsFixture() })) posthog = new PostHog('TEST_API_KEY', { host: 'http://example.com', @@ -471,6 +488,25 @@ describe('evaluateFlags', () => { const remoteFlagCalls = mockedFetch.mock.calls.filter((c) => (c[0] as string).includes('/flags/?v=2')) expect(remoteFlagCalls).toHaveLength(0) }) + + it('attaches $feature_flag_definitions_loaded_at on locally-evaluated $feature_flag_called events', async () => { + mockedFetch.mockImplementation(apiImplementation({ localFlags: localFlagsFixture() })) + + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + personalApiKey: 'TEST_PERSONAL_API_KEY', + ...posthogImmediateResolveOptions, + }) + const captures: any[] = [] + posthog.on('capture', (message) => captures.push(message)) + + const flags = await posthog.evaluateFlags('user-1') + flags.isEnabled('local-flag') + + await waitForPromises() + const flagCalled = captures.find((m) => m.event === '$feature_flag_called') + expect(flagCalled.properties.$feature_flag_definitions_loaded_at).toEqual(expect.any(Number)) + }) }) describe('overrides', () => { diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 009fc0c9d6..5b42c027e5 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -1568,6 +1568,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen variant: typeof value === 'string' ? value : undefined, payload: localResult.payloads[key], id: flagDef?.id, + // The local-evaluation flag definition (`PostHogFeatureFlag`) does not carry a + // version field; only the remote `/flags` response does via `metadata.version`. version: undefined, reason: 'Evaluated locally', locallyEvaluated: true, @@ -1655,6 +1657,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen flags: records, requestId, evaluatedAt, + flagDefinitionsLoadedAt: this.featureFlagsPoller?.getFlagDefinitionsLoadedAt(), }) } @@ -1665,7 +1668,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * * @internal */ - public _captureFlagCalledEventIfNeeded(params: FlagCalledEventParams): void { + protected _captureFlagCalledEventIfNeeded(params: FlagCalledEventParams): void { const { distinctId, key, response, groups, disableGeoip, properties } = params const featureFlagReportedKey = `${key}_${response}` diff --git a/packages/node/src/feature-flag-evaluations.ts b/packages/node/src/feature-flag-evaluations.ts index cedb879b3c..30fc8cbe51 100644 --- a/packages/node/src/feature-flag-evaluations.ts +++ b/packages/node/src/feature-flag-evaluations.ts @@ -73,6 +73,7 @@ export class FeatureFlagEvaluations { private readonly _flags: Record private readonly _requestId: string | undefined private readonly _evaluatedAt: number | undefined + private readonly _flagDefinitionsLoadedAt: number | undefined private readonly _accessed: Set /** @@ -86,6 +87,7 @@ export class FeatureFlagEvaluations { flags: Record requestId?: string evaluatedAt?: number + flagDefinitionsLoadedAt?: number accessed?: Set }) { this._host = init.host @@ -95,6 +97,7 @@ export class FeatureFlagEvaluations { this._flags = init.flags this._requestId = init.requestId this._evaluatedAt = init.evaluatedAt + this._flagDefinitionsLoadedAt = init.flagDefinitionsLoadedAt this._accessed = init.accessed ?? new Set() } @@ -142,9 +145,13 @@ export class FeatureFlagEvaluations { /** * Return a filtered copy containing only flags that have been accessed via - * `isEnabled()` or `getFlag()` before this call. If no flags have been accessed, - * logs a warning and returns a copy with all flags (to avoid dropping exposure - * data silently). + * `isEnabled()` or `getFlag()` before this call. + * + * **Empty-access fallback:** if no flags have been accessed yet, this method logs + * a warning and returns a copy with *all* evaluated flags. This avoids silently + * dropping every flag from the captured event when `onlyAccessed()` is called + * out of order (for example, before any branching has occurred). Pre-access + * before calling this if you want a guaranteed-empty result. */ onlyAccessed(): FeatureFlagEvaluations { if (this._accessed.size === 0) { @@ -240,6 +247,7 @@ export class FeatureFlagEvaluations { flags, requestId: this._requestId, evaluatedAt: this._evaluatedAt, + flagDefinitionsLoadedAt: this._flagDefinitionsLoadedAt, // Copy the accessed set so the child can track further access independently // of the parent. Callers expect `onlyAccessed()` on the parent to reflect // only what the parent saw, not what happened on filtered views. @@ -250,6 +258,13 @@ export class FeatureFlagEvaluations { private _recordAccess(key: string): void { this._accessed.add(key) + // Empty snapshots (no resolvable distinctId) are returned by `evaluateFlags()` as a + // safety fallback. Firing $feature_flag_called for them would emit events with an + // empty distinct_id, polluting analytics — short-circuit here instead. + if (this._distinctId === '') { + return + } + const flag = this._flags[key] const response: FeatureFlagValue | undefined = flag === undefined ? undefined : flag.enabled === false ? false : (flag.variant ?? true) @@ -266,6 +281,10 @@ export class FeatureFlagEvaluations { $feature_flag_evaluated_at: flag?.locallyEvaluated ? Date.now() : this._evaluatedAt, } + if (flag?.locallyEvaluated && this._flagDefinitionsLoadedAt !== undefined) { + properties.$feature_flag_definitions_loaded_at = this._flagDefinitionsLoadedAt + } + if (flag === undefined) { properties.$feature_flag_error = FeatureFlagError.FLAG_MISSING } From 97662abb46484cd056ac907445390c57a0bc326b Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 15:16:00 -0700 Subject: [PATCH 5/7] fix(node): suppress flag_missing events on filtered FeatureFlagEvaluations slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #3476: - Filtered snapshots from `only()` / `onlyAccessed()` no longer fire misleading `$feature_flag_called` events with `flag_missing` when branching on a key that was excluded from the slice. The slice tracks whether it's a filtered view via an `_isSlice` flag and short-circuits `_recordAccess` for absent keys. Document this behavior on the filter helpers' JSDoc — slices are intended for `capture()`, not branching. Add a regression test covering the path. - Refactor `evaluate-flags.spec.ts` to extract a `setup(overrides)` helper used by all suites, replacing eight repeated `new PostHog(...)` blocks plus four duplicated capture-listener setups. Per-test deviations (`featureFlagsLogWarnings: false`, `personalApiKey: ...`) now stand out as explicit overrides. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- .../node/src/__tests__/evaluate-flags.spec.ts | 182 +++++------------- packages/node/src/feature-flag-evaluations.ts | 22 +++ 2 files changed, 68 insertions(+), 136 deletions(-) diff --git a/packages/node/src/__tests__/evaluate-flags.spec.ts b/packages/node/src/__tests__/evaluate-flags.spec.ts index 26b9eb2af4..50c83f188b 100644 --- a/packages/node/src/__tests__/evaluate-flags.spec.ts +++ b/packages/node/src/__tests__/evaluate-flags.spec.ts @@ -70,6 +70,21 @@ const flagsResponseFixture = (): PostHogV2FlagsResponse => ({ describe('evaluateFlags', () => { let posthog: PostHog + let captures: any[] = [] + + // Per-test setup helper. The vast majority of tests want the same defaults; tests with + // custom options (`featureFlagsLogWarnings: false`, `personalApiKey: ...`) call this + // explicitly with overrides so the deviation stands out. + const setup = (overrides: Partial = {}): PostHog => { + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + ...overrides, + }) + captures = [] + posthog.on('capture', (message) => captures.push(message)) + return posthog + } afterEach(async () => { await posthog.shutdown() @@ -78,14 +93,10 @@ describe('evaluateFlags', () => { describe('remote evaluation', () => { beforeEach(() => { mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + setup() }) it('makes a single /flags call and returns a FeatureFlagEvaluations instance', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const flags = await posthog.evaluateFlags('user-1') expect(flags).toBeInstanceOf(FeatureFlagEvaluations) @@ -95,13 +106,6 @@ describe('evaluateFlags', () => { }) it('does not fire $feature_flag_called events for flags that are not accessed', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - await posthog.evaluateFlags('user-1') await waitForPromises() @@ -110,13 +114,6 @@ describe('evaluateFlags', () => { }) it('isEnabled returns true/false and fires $feature_flag_called on first access', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - const flags = await posthog.evaluateFlags('user-1') expect(flags.isEnabled('boolean-flag')).toBe(true) @@ -134,13 +131,6 @@ describe('evaluateFlags', () => { }) it('getFlag returns variant/true/false/undefined and carries full metadata', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - const flags = await posthog.evaluateFlags('user-1') expect(flags.getFlag('variant-flag')).toBe('variant-value') @@ -172,13 +162,6 @@ describe('evaluateFlags', () => { }) it('dedupes $feature_flag_called events across repeated access for the same distinctId+value', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - const flags = await posthog.evaluateFlags('user-1') flags.isEnabled('boolean-flag') flags.isEnabled('boolean-flag') @@ -192,13 +175,6 @@ describe('evaluateFlags', () => { }) it('getFlagPayload returns parsed payload without firing an event', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - const flags = await posthog.evaluateFlags('user-1') expect(flags.getFlagPayload('variant-flag')).toEqual({ key: 'value' }) expect(flags.getFlagPayload('missing-flag')).toBeUndefined() @@ -208,11 +184,6 @@ describe('evaluateFlags', () => { }) it('uses distinctId from context when not passed explicitly', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const flags = await posthog.withContext({ distinctId: 'context-user' }, () => posthog.evaluateFlags()) expect(flags).toBeInstanceOf(FeatureFlagEvaluations) @@ -220,11 +191,6 @@ describe('evaluateFlags', () => { }) it('forwards flagKeys to the /flags request to scope the evaluation', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - await posthog.evaluateFlags('user-1', { flagKeys: ['boolean-flag', 'variant-flag'] }) expect(mockedFetch).toHaveBeenCalledTimes(1) @@ -234,24 +200,12 @@ describe('evaluateFlags', () => { }) it('returns an empty snapshot when no distinctId is available', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const flags = await posthog.evaluateFlags() expect(flags.keys).toEqual([]) }) it('does not fire $feature_flag_called events from an empty-distinctId snapshot', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - const flags = await posthog.evaluateFlags() flags.isEnabled('any-flag') flags.getFlag('any-flag') @@ -264,14 +218,10 @@ describe('evaluateFlags', () => { describe('filtering helpers', () => { beforeEach(() => { mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + setup() }) it('onlyAccessed returns a snapshot with only accessed flags', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const flags = await posthog.evaluateFlags('user-1') flags.isEnabled('boolean-flag') flags.getFlag('variant-flag') @@ -282,10 +232,6 @@ describe('evaluateFlags', () => { it('onlyAccessed warns and falls back to all flags when nothing was accessed', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation() - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) const flags = await posthog.evaluateFlags('user-1') const accessed = flags.onlyAccessed() @@ -299,11 +245,7 @@ describe('evaluateFlags', () => { it('featureFlagsLogWarnings=false silences filter warnings', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation() - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - featureFlagsLogWarnings: false, - ...posthogImmediateResolveOptions, - }) + setup({ featureFlagsLogWarnings: false }) const flags = await posthog.evaluateFlags('user-1') flags.onlyAccessed() @@ -315,10 +257,6 @@ describe('evaluateFlags', () => { it('only returns a filtered snapshot and warns about missing keys', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation() - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) const flags = await posthog.evaluateFlags('user-1') const only = flags.only(['boolean-flag', 'does-not-exist']) @@ -329,11 +267,6 @@ describe('evaluateFlags', () => { }) it('filtered snapshots do not back-propagate access to the parent', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const flags = await posthog.evaluateFlags('user-1') flags.isEnabled('boolean-flag') const filtered = flags.onlyAccessed() @@ -342,21 +275,34 @@ describe('evaluateFlags', () => { expect(flags.onlyAccessed().keys).toEqual(['boolean-flag']) }) + + it('branching on a key excluded from a slice is a no-op (no flag_missing event)', async () => { + // Filtered snapshots are intended for `capture()`. Calling `isEnabled()` on a slice + // for a key that was filtered out should not fire `$feature_flag_called` with + // `$feature_flag_error: flag_missing` — the flag wasn't missing, just sliced away. + const flags = await posthog.evaluateFlags('user-1') + const filtered = flags.only(['boolean-flag']) + + expect(filtered.isEnabled('variant-flag')).toBe(false) + + await waitForPromises() + const flagMissing = captures.filter( + (m) => + m.event === '$feature_flag_called' && + m.properties.$feature_flag === 'variant-flag' && + m.properties.$feature_flag_error === 'flag_missing' + ) + expect(flagMissing).toHaveLength(0) + }) }) describe('capture integration', () => { beforeEach(() => { mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + setup() }) it('capture({ flags }) attaches $feature/* and $active_feature_flags from the snapshot', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - const flags = await posthog.evaluateFlags('user-1') posthog.capture({ distinctId: 'user-1', event: 'page_viewed', flags }) await waitForPromises() @@ -372,13 +318,6 @@ describe('evaluateFlags', () => { }) it('capture({ flags: flags.onlyAccessed() }) only attaches accessed flags', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - const flags = await posthog.evaluateFlags('user-1') flags.isEnabled('boolean-flag') posthog.capture({ distinctId: 'user-1', event: 'page_viewed', flags: flags.onlyAccessed() }) @@ -394,11 +333,6 @@ describe('evaluateFlags', () => { }) it('does not trigger an additional /flags request on capture', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const flags = await posthog.evaluateFlags('user-1') const callsAfterEvaluate = mockedFetch.mock.calls.length @@ -415,13 +349,6 @@ describe('evaluateFlags', () => { }) it('flags option takes precedence over sendFeatureFlags', async () => { - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - const flags = await posthog.evaluateFlags('user-1') const callsBefore = mockedFetch.mock.calls.filter((c) => (c[0] as string).includes('/flags/?v=2')).length @@ -460,17 +387,12 @@ describe('evaluateFlags', () => { ], }) - it('evaluates flags locally and tags events with locally_evaluated=true', async () => { + beforeEach(() => { mockedFetch.mockImplementation(apiImplementation({ localFlags: localFlagsFixture() })) + setup({ personalApiKey: 'TEST_PERSONAL_API_KEY' }) + }) - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - personalApiKey: 'TEST_PERSONAL_API_KEY', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - + it('evaluates flags locally and tags events with locally_evaluated=true', async () => { const flags = await posthog.evaluateFlags('user-1') expect(flags.isEnabled('local-flag')).toBe(true) @@ -490,16 +412,6 @@ describe('evaluateFlags', () => { }) it('attaches $feature_flag_definitions_loaded_at on locally-evaluated $feature_flag_called events', async () => { - mockedFetch.mockImplementation(apiImplementation({ localFlags: localFlagsFixture() })) - - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - personalApiKey: 'TEST_PERSONAL_API_KEY', - ...posthogImmediateResolveOptions, - }) - const captures: any[] = [] - posthog.on('capture', (message) => captures.push(message)) - const flags = await posthog.evaluateFlags('user-1') flags.isEnabled('local-flag') @@ -510,14 +422,12 @@ describe('evaluateFlags', () => { }) describe('overrides', () => { - it('applies flag and payload overrides to the snapshot', async () => { + beforeEach(() => { mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + setup() + }) - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - ...posthogImmediateResolveOptions, - }) - + it('applies flag and payload overrides to the snapshot', async () => { posthog.overrideFeatureFlags({ flags: { 'boolean-flag': false, 'new-flag': 'variant-a' }, payloads: { 'variant-flag': { overridden: true } }, diff --git a/packages/node/src/feature-flag-evaluations.ts b/packages/node/src/feature-flag-evaluations.ts index 30fc8cbe51..b53ed6cc91 100644 --- a/packages/node/src/feature-flag-evaluations.ts +++ b/packages/node/src/feature-flag-evaluations.ts @@ -75,6 +75,9 @@ export class FeatureFlagEvaluations { private readonly _evaluatedAt: number | undefined private readonly _flagDefinitionsLoadedAt: number | undefined private readonly _accessed: Set + // True for snapshots produced by `only()` / `onlyAccessed()` — used to suppress + // misleading `flag_missing` events when branching is performed on a filtered slice. + private readonly _isSlice: boolean /** * @internal — instances are created by the SDK via `posthog.evaluateFlags()`. @@ -89,6 +92,7 @@ export class FeatureFlagEvaluations { evaluatedAt?: number flagDefinitionsLoadedAt?: number accessed?: Set + isSlice?: boolean }) { this._host = init.host this._distinctId = init.distinctId @@ -99,6 +103,7 @@ export class FeatureFlagEvaluations { this._evaluatedAt = init.evaluatedAt this._flagDefinitionsLoadedAt = init.flagDefinitionsLoadedAt this._accessed = init.accessed ?? new Set() + this._isSlice = init.isSlice ?? false } /** @@ -152,6 +157,11 @@ export class FeatureFlagEvaluations { * dropping every flag from the captured event when `onlyAccessed()` is called * out of order (for example, before any branching has occurred). Pre-access * before calling this if you want a guaranteed-empty result. + * + * **Note:** the returned snapshot is intended for `capture()`, not for further + * branching. Calling `isEnabled()` / `getFlag()` on it for a key that was filtered + * out is a no-op (no event is fired) — the flag wasn't actually missing, it was + * excluded from the slice. */ onlyAccessed(): FeatureFlagEvaluations { if (this._accessed.size === 0) { @@ -173,6 +183,9 @@ export class FeatureFlagEvaluations { /** * Return a filtered copy containing only flags with the given keys. Keys that * are not present in the evaluation are dropped and logged as a warning. + * + * **Note:** like `onlyAccessed()`, the returned snapshot is intended for `capture()`. + * Branching on a filtered key that was excluded from the slice is a no-op. */ only(keys: string[]): FeatureFlagEvaluations { const filtered: Record = {} @@ -252,6 +265,7 @@ export class FeatureFlagEvaluations { // of the parent. Callers expect `onlyAccessed()` on the parent to reflect // only what the parent saw, not what happened on filtered views. accessed: new Set(this._accessed), + isSlice: true, }) } @@ -265,6 +279,14 @@ export class FeatureFlagEvaluations { return } + // On filtered slices (returned by `only()` / `onlyAccessed()`), a key absent from + // the slice doesn't mean the flag is missing from PostHog — it was filtered out. + // Don't fire a misleading `flag_missing` event; slices are intended for `capture()`, + // not for further branching. + if (this._isSlice && !(key in this._flags)) { + return + } + const flag = this._flags[key] const response: FeatureFlagValue | undefined = flag === undefined ? undefined : flag.enabled === false ? false : (flag.variant ?? true) From 1307b19121372caf1137cffdbf78d00d4264a824 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 29 Apr 2026 14:19:51 -0700 Subject: [PATCH 6/7] feat(node): port Python PR feedback (deprecation warnings, error granularity, captureException flags) Mirrors fixes from PostHog/posthog-python#539: - `onlyAccessed()` returns empty when nothing has been accessed (no fallback to all flags). The previous fallback contradicted the method name and surprised reviewers. - Propagate response-level errors (`errors_while_computing_flags`, `quota_limited`) into `$feature_flag_called` events so each access carries the granular error code(s) the single-flag path emits. - Make `flags` vs `sendFeatureFlags` precedence explicit on `capture()`: `flags` always wins, and we log a warning when both are passed. - Phase 2 deprecation warnings: `getFeatureFlag`, `isFeatureEnabled`, `getFeatureFlagPayload`, and `capture({ sendFeatureFlags })` now log a deduped `[PostHog] ... is deprecated` console warning the first time they're used. `isFeatureEnabled` is restructured to call `_getFeatureFlagResult` directly so a single user-level call emits exactly one warning instead of cascading. - `captureException` and `captureExceptionImmediate` accept an optional `flags` snapshot so `$exception` events carry the same flag context as the rest of the request's events. Adds a process-wide dedup helper `emitDeprecationWarningOnce` matching Python's `warnings.warn` default-dedup behavior. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- .changeset/evaluate-flags-node.md | 14 +- .../node/src/__tests__/evaluate-flags.spec.ts | 166 ++++++++++++++++-- packages/node/src/client.ts | 87 ++++++++- packages/node/src/feature-flag-evaluations.ts | 36 ++-- 4 files changed, 272 insertions(+), 31 deletions(-) diff --git a/.changeset/evaluate-flags-node.md b/.changeset/evaluate-flags-node.md index 8774197fa8..4056af405f 100644 --- a/.changeset/evaluate-flags-node.md +++ b/.changeset/evaluate-flags-node.md @@ -2,4 +2,16 @@ 'posthog-node': minor --- -Add `evaluateFlags()` and the `flags` option on `capture()` so a single `/flags` call can power both flag branching and event enrichment per request. Prefer this over repeated `isFeatureEnabled()` calls and `capture({ sendFeatureFlags: true })`, which remain supported but now carry a deprecation note. +Add `evaluateFlags()` and a new `flags` option on `capture()` so a single `/flags` request powers both flag branching and event enrichment per incoming request: + +```ts +const flags = await posthog.evaluateFlags(distinctId, { personProperties: { plan: 'enterprise' } }) +if (flags.isEnabled('new-dashboard')) { + renderNewDashboard() +} +posthog.capture({ distinctId, event: 'page_viewed', flags }) +``` + +The returned `FeatureFlagEvaluations` snapshot exposes `isEnabled()`, `getFlag()`, `getFlagPayload()` for branching, plus `onlyAccessed()` and `only([keys])` for filtering which flags get attached to a captured event. Pass `flagKeys: [...]` to `evaluateFlags()` to scope the underlying `/flags` request itself. `captureException()` / `captureExceptionImmediate()` accept a `flags` argument so `$exception` events carry the same flag context as the rest of your request's events. + +Deprecates `isFeatureEnabled()`, `getFeatureFlag()`, `getFeatureFlagPayload()`, and `capture({ sendFeatureFlags })`. They continue to work but now log a deduped `[PostHog] ... is deprecated` warning the first time they're used. Removal is planned for the next major version. diff --git a/packages/node/src/__tests__/evaluate-flags.spec.ts b/packages/node/src/__tests__/evaluate-flags.spec.ts index 50c83f188b..2927de7327 100644 --- a/packages/node/src/__tests__/evaluate-flags.spec.ts +++ b/packages/node/src/__tests__/evaluate-flags.spec.ts @@ -1,6 +1,7 @@ +import { _resetDeprecationWarningsForTests } from '@/client' import { PostHog } from '@/entrypoints/index.node' import { FeatureFlagEvaluations } from '@/feature-flag-evaluations' -import { PostHogOptions } from '@/types' +import { EventMessage, PostHogOptions } from '@/types' import { apiImplementation, apiImplementationV4, waitForPromises } from './utils' import { PostHogV2FlagsResponse } from '@posthog/core' @@ -230,17 +231,12 @@ describe('evaluateFlags', () => { expect(accessed.keys.sort()).toEqual(['boolean-flag', 'variant-flag']) }) - it('onlyAccessed warns and falls back to all flags when nothing was accessed', async () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation() - + it('onlyAccessed returns empty when no flags accessed', async () => { + // The method honors its name: nothing accessed → empty snapshot, no fallback. const flags = await posthog.evaluateFlags('user-1') const accessed = flags.onlyAccessed() - expect(accessed.keys.sort()).toEqual(['boolean-flag', 'disabled-flag', 'variant-flag']) - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('onlyAccessed() was called before any flags were accessed') - ) - warnSpy.mockRestore() + expect(accessed.keys).toEqual([]) }) it('featureFlagsLogWarnings=false silences filter warnings', async () => { @@ -348,7 +344,8 @@ describe('evaluateFlags', () => { expect(flagCallsAfterCapture).toEqual(flagCallsBeforeCapture) }) - it('flags option takes precedence over sendFeatureFlags', async () => { + it('flags option takes precedence over sendFeatureFlags and warns when both passed', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() const flags = await posthog.evaluateFlags('user-1') const callsBefore = mockedFetch.mock.calls.filter((c) => (c[0] as string).includes('/flags/?v=2')).length @@ -369,6 +366,155 @@ describe('evaluateFlags', () => { $active_feature_flags: ['boolean-flag'], }) expect(pageViewed.properties['$feature/variant-flag']).toBeUndefined() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Both `flags` and `sendFeatureFlags` were passed to capture()') + ) + warnSpy.mockRestore() + }) + + it('captureException forwards flags through to the $exception event', async () => { + const flags = await posthog.evaluateFlags('user-1') + flags.isEnabled('boolean-flag') + + posthog.captureException(new Error('boom'), 'user-1', undefined, undefined, flags.onlyAccessed()) + + // captureException → addPendingPromise(buildEventMessage().then(msg => capture(...))) + // → capture itself queues async work via prepareEventMessage. The 'capture' event + // fires inside captureStateless before the network flush, so we just need enough + // microtask cycles to let the chain resolve. + await waitForPromises() + await waitForPromises() + await waitForPromises() + + const exception = captures.find((m) => m.event === '$exception') + expect(exception).toBeDefined() + expect(exception.properties).toMatchObject({ + '$feature/boolean-flag': true, + $active_feature_flags: ['boolean-flag'], + }) + expect(exception.properties['$feature/variant-flag']).toBeUndefined() + }) + + it('captureExceptionImmediate forwards the flags snapshot to captureImmediate', async () => { + // captureStatelessImmediate doesn't fire the EventEmitter 'capture' event (it sends + // directly), so we verify forwarding by spying on captureImmediate itself. + const flags = await posthog.evaluateFlags('user-1') + const filtered = flags.only(['boolean-flag']) + const spy = jest.spyOn(posthog, 'captureImmediate').mockResolvedValue(undefined) + + await posthog.captureExceptionImmediate(new Error('boom'), 'user-1', undefined, filtered) + await waitForPromises() + + expect(spy).toHaveBeenCalledTimes(1) + const arg = spy.mock.calls[0][0] as EventMessage + expect(arg.flags).toBe(filtered) + expect(arg.event).toBe('$exception') + + spy.mockRestore() + }) + }) + + describe('error granularity', () => { + beforeEach(() => { + setup() + }) + + it('combines response-level errors_while_computing with per-flag flag_missing', async () => { + const response = flagsResponseFixture() + response.errorsWhileComputingFlags = true + mockedFetch.mockImplementation(apiImplementationV4(response)) + + const flags = await posthog.evaluateFlags('user-1') + flags.isEnabled('boolean-flag') // known flag — only response-level error + flags.isEnabled('missing-flag') // missing — both errors combined + + await waitForPromises() + const byKey = Object.fromEntries( + captures + .filter((m) => m.event === '$feature_flag_called') + .map((m) => [m.properties.$feature_flag, m.properties]) + ) + expect(byKey['boolean-flag'].$feature_flag_error).toEqual('errors_while_computing_flags') + expect(byKey['missing-flag'].$feature_flag_error).toEqual('errors_while_computing_flags,flag_missing') + }) + + it('reports quota_limited from response.quotaLimited', async () => { + const response = flagsResponseFixture() + ;(response as any).quotaLimited = ['feature_flags'] + mockedFetch.mockImplementation(apiImplementationV4(response)) + + const flags = await posthog.evaluateFlags('user-1') + flags.isEnabled('boolean-flag') + + await waitForPromises() + const flagCalled = captures.find((m) => m.event === '$feature_flag_called') + // Quota-limited responses strip flag data; the access becomes a missing-flag lookup + // against the empty snapshot, so the combined error string surfaces both. + expect(flagCalled.properties.$feature_flag_error).toEqual('quota_limited,flag_missing') + }) + }) + + describe('deprecation warnings', () => { + beforeEach(() => { + _resetDeprecationWarningsForTests() + mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + setup() + }) + + it('getFeatureFlag emits a deprecation warning pointing at evaluateFlags', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + await posthog.getFeatureFlag('boolean-flag', 'user-1') + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('`getFeatureFlag` is deprecated')) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('evaluateFlags')) + warnSpy.mockRestore() + }) + + it('isFeatureEnabled emits exactly one deprecation warning per call (no cascade)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + await posthog.isFeatureEnabled('boolean-flag', 'user-1') + + const deprecation = warnSpy.mock.calls.filter( + (call) => typeof call[0] === 'string' && /is deprecated/.test(call[0]) + ) + expect(deprecation).toHaveLength(1) + expect(deprecation[0][0]).toEqual(expect.stringContaining('`isFeatureEnabled` is deprecated')) + warnSpy.mockRestore() + }) + + it('getFeatureFlagPayload emits a deprecation warning', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + await posthog.getFeatureFlagPayload('variant-flag', 'user-1') + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('`getFeatureFlagPayload` is deprecated')) + warnSpy.mockRestore() + }) + + it('capture(sendFeatureFlags: true) emits a deprecation warning', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + posthog.capture({ distinctId: 'user-1', event: 'page_viewed', sendFeatureFlags: true }) + await posthog.flush() + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('`sendFeatureFlags` is deprecated')) + warnSpy.mockRestore() + }) + + it('dedupes deprecation warnings across repeated calls', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + await posthog.getFeatureFlag('boolean-flag', 'user-1') + await posthog.getFeatureFlag('variant-flag', 'user-2') + await posthog.getFeatureFlag('disabled-flag', 'user-3') + + const deprecation = warnSpy.mock.calls.filter( + (call) => typeof call[0] === 'string' && /`getFeatureFlag` is deprecated/.test(call[0]) + ) + expect(deprecation).toHaveLength(1) + warnSpy.mockRestore() }) }) diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 511bba1607..c46b536d80 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -56,6 +56,26 @@ const WAITUNTIL_DEBOUNCE_MS = 50 const WAITUNTIL_MAX_WAIT_MS = 500 const DEFAULT_NODE_HOST = 'https://us.i.posthog.com' +// Process-wide dedup for deprecation warnings — without this, calling a deprecated +// method in a loop would spam logs. Matches Python's `warnings.warn` default-dedup behavior. +const _emittedDeprecations = new Set() + +function emitDeprecationWarningOnce(id: string, message: string): void { + if (_emittedDeprecations.has(id)) { + return + } + _emittedDeprecations.add(id) + // eslint-disable-next-line no-console + console.warn(`[PostHog] ${message}`) +} + +/** + * @internal — clears the process-wide deprecation dedup set. Test-only. + */ +export function _resetDeprecationWarningsForTests(): void { + _emittedDeprecations.clear() +} + function normalizeApiKey(value?: unknown): string { return typeof value === 'string' ? value.trim() : '' } @@ -1047,6 +1067,12 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen disableGeoip?: boolean } ): Promise { + emitDeprecationWarningOnce( + 'getFeatureFlag', + '`getFeatureFlag` is deprecated and will be removed in a future major version. ' + + 'Use `posthog.evaluateFlags(distinctId, ...)` and call `flags.getFlag(key)` instead — ' + + 'this consolidates flag evaluation into a single `/flags` request per incoming request.' + ) const result = await this._getFeatureFlagResult(key, distinctId, { ...options, sendFeatureFlagEvents: options?.sendFeatureFlagEvents ?? this.options.sendFeatureFlagEvent ?? true, @@ -1109,6 +1135,12 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen disableGeoip?: boolean } ): Promise { + emitDeprecationWarningOnce( + 'getFeatureFlagPayload', + '`getFeatureFlagPayload` is deprecated and will be removed in a future major version. ' + + 'Use `posthog.evaluateFlags(distinctId, ...)` and call `flags.getFlagPayload(key)` instead — ' + + 'this consolidates flag evaluation into a single `/flags` request per incoming request.' + ) // Check for payload overrides first - they take precedence over all evaluation // This is checked independently from flag overrides if (this._payloadOverrides !== undefined && key in this._payloadOverrides) { @@ -1280,10 +1312,24 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen disableGeoip?: boolean } ): Promise { - const feat = await this.getFeatureFlag(key, distinctId, options) - if (feat === undefined) { + emitDeprecationWarningOnce( + 'isFeatureEnabled', + '`isFeatureEnabled` is deprecated and will be removed in a future major version. ' + + 'Use `posthog.evaluateFlags(distinctId, ...)` and call `flags.isEnabled(key)` instead — ' + + 'this consolidates flag evaluation into a single `/flags` request per incoming request.' + ) + // Bypass the public `getFeatureFlag` so the user only sees one deprecation warning per call. + const result = await this._getFeatureFlagResult(key, distinctId, { + ...options, + sendFeatureFlagEvents: options?.sendFeatureFlagEvents ?? this.options.sendFeatureFlagEvent ?? true, + }) + if (result === undefined) { return undefined } + if (result.enabled === false) { + return false + } + const feat: FeatureFlagValue = result.variant ?? true return !!feat || false } @@ -1576,6 +1622,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen const records: Record = {} let requestId: string | undefined = undefined let evaluatedAt: number | undefined = undefined + let errorsWhileComputing = false + let quotaLimited = false // Try local evaluation first and decorate each flag with metadata from the poller. // `flagKeys` scopes the evaluation to a subset of definitions when provided. @@ -1616,6 +1664,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen if (details) { requestId = details.requestId evaluatedAt = details.evaluatedAt + errorsWhileComputing = Boolean((details as any).errorsWhileComputingFlags) + quotaLimited = Array.isArray(details.quotaLimited) && details.quotaLimited.includes('feature_flags') for (const [key, detail] of Object.entries(details.flags)) { if (locallyEvaluatedKeys.has(key)) { continue @@ -1680,6 +1730,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen requestId, evaluatedAt, flagDefinitionsLoadedAt: this.featureFlagsPoller?.getFlagDefinitionsLoadedAt(), + errorsWhileComputing, + quotaLimited, }) } @@ -2216,18 +2268,21 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * @param error - The error to capture * @param distinctId - Optional user distinct ID * @param additionalProperties - Optional additional properties to include + * @param uuid - Optional event UUID + * @param flags - Optional `FeatureFlagEvaluations` snapshot to attach the same flag context as your other events */ captureException( error: unknown, distinctId?: string, additionalProperties?: Record, - uuid?: EventMessage['uuid'] + uuid?: EventMessage['uuid'], + flags?: FeatureFlagEvaluations ): void { if (!ErrorTracking.isPreviouslyCapturedError(error)) { const syntheticException = new Error('PostHog syntheticException') this.addPendingPromise( ErrorTracking.buildEventMessage(error, { syntheticException }, distinctId, additionalProperties).then((msg) => - this.capture({ ...msg, uuid }) + this.capture({ ...msg, uuid, flags }) ) ) } @@ -2266,18 +2321,20 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * @param error - The error to capture * @param distinctId - Optional user distinct ID * @param additionalProperties - Optional additional properties to include + * @param flags - Optional `FeatureFlagEvaluations` snapshot to attach the same flag context as your other events * @returns Promise that resolves when the error is captured */ async captureExceptionImmediate( error: unknown, distinctId?: string, - additionalProperties?: Record + additionalProperties?: Record, + flags?: FeatureFlagEvaluations ): Promise { if (!ErrorTracking.isPreviouslyCapturedError(error)) { const syntheticException = new Error('PostHog syntheticException') return this.addPendingPromise( ErrorTracking.buildEventMessage(error, { syntheticException }, distinctId, additionalProperties).then((msg) => - this.captureImmediate(msg) + this.captureImmediate({ ...msg, flags }) ) ) } @@ -2340,13 +2397,27 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen // :TRICKY: If we flush, or need to shut down, to not lose events we want this promise to resolve before we flush const eventProperties = await Promise.resolve() .then(async () => { - // Prefer the explicit `flags` object — it guarantees the event carries the same - // values the developer branched on, with no additional network call. + // Precedence: an explicit `flags` snapshot always wins, regardless of + // `sendFeatureFlags`. The snapshot guarantees the event carries the same + // values the developer branched on with no additional network call. The + // `sendFeatureFlags` path only runs when no snapshot is provided. if (flags) { + if (sendFeatureFlags) { + console.warn( + '[PostHog] Both `flags` and `sendFeatureFlags` were passed to capture(); using `flags` and ignoring `sendFeatureFlags`.' + ) + } return flags._getEventProperties() } if (sendFeatureFlags) { + emitDeprecationWarningOnce( + 'sendFeatureFlags', + '`sendFeatureFlags` is deprecated and will be removed in a future major version. ' + + 'Pass a `flags` snapshot from `posthog.evaluateFlags(...)` instead — it avoids a ' + + 'second `/flags` request per capture and guarantees the event carries the exact ' + + 'flag values your code branched on.' + ) // If we are sending feature flags, we evaluate them locally if the user prefers it, otherwise we fall back to remote evaluation const sendFeatureFlagsOptions = typeof sendFeatureFlags === 'object' ? sendFeatureFlags : undefined const flagValues = await this.getFeatureFlagsForEvent( diff --git a/packages/node/src/feature-flag-evaluations.ts b/packages/node/src/feature-flag-evaluations.ts index b53ed6cc91..794e06559f 100644 --- a/packages/node/src/feature-flag-evaluations.ts +++ b/packages/node/src/feature-flag-evaluations.ts @@ -74,6 +74,8 @@ export class FeatureFlagEvaluations { private readonly _requestId: string | undefined private readonly _evaluatedAt: number | undefined private readonly _flagDefinitionsLoadedAt: number | undefined + private readonly _errorsWhileComputing: boolean + private readonly _quotaLimited: boolean private readonly _accessed: Set // True for snapshots produced by `only()` / `onlyAccessed()` — used to suppress // misleading `flag_missing` events when branching is performed on a filtered slice. @@ -91,6 +93,8 @@ export class FeatureFlagEvaluations { requestId?: string evaluatedAt?: number flagDefinitionsLoadedAt?: number + errorsWhileComputing?: boolean + quotaLimited?: boolean accessed?: Set isSlice?: boolean }) { @@ -102,6 +106,8 @@ export class FeatureFlagEvaluations { this._requestId = init.requestId this._evaluatedAt = init.evaluatedAt this._flagDefinitionsLoadedAt = init.flagDefinitionsLoadedAt + this._errorsWhileComputing = init.errorsWhileComputing ?? false + this._quotaLimited = init.quotaLimited ?? false this._accessed = init.accessed ?? new Set() this._isSlice = init.isSlice ?? false } @@ -152,11 +158,8 @@ export class FeatureFlagEvaluations { * Return a filtered copy containing only flags that have been accessed via * `isEnabled()` or `getFlag()` before this call. * - * **Empty-access fallback:** if no flags have been accessed yet, this method logs - * a warning and returns a copy with *all* evaluated flags. This avoids silently - * dropping every flag from the captured event when `onlyAccessed()` is called - * out of order (for example, before any branching has occurred). Pre-access - * before calling this if you want a guaranteed-empty result. + * Order-dependent: if nothing has been accessed yet, the returned snapshot is + * empty. The method honors its name — pre-access if you want a populated result. * * **Note:** the returned snapshot is intended for `capture()`, not for further * branching. Calling `isEnabled()` / `getFlag()` on it for a key that was filtered @@ -164,12 +167,6 @@ export class FeatureFlagEvaluations { * excluded from the slice. */ onlyAccessed(): FeatureFlagEvaluations { - if (this._accessed.size === 0) { - this._host.logWarning( - 'FeatureFlagEvaluations.onlyAccessed() was called before any flags were accessed — attaching all evaluated flags as a fallback. See https://posthog.com/docs/feature-flags/server-sdks for details.' - ) - return this._cloneWith(this._flags) - } const filtered: Record = {} for (const key of this._accessed) { const flag = this._flags[key] @@ -261,6 +258,8 @@ export class FeatureFlagEvaluations { requestId: this._requestId, evaluatedAt: this._evaluatedAt, flagDefinitionsLoadedAt: this._flagDefinitionsLoadedAt, + errorsWhileComputing: this._errorsWhileComputing, + quotaLimited: this._quotaLimited, // Copy the accessed set so the child can track further access independently // of the parent. Callers expect `onlyAccessed()` on the parent to reflect // only what the parent saw, not what happened on filtered views. @@ -307,8 +306,21 @@ export class FeatureFlagEvaluations { properties.$feature_flag_definitions_loaded_at = this._flagDefinitionsLoadedAt } + // Build the comma-joined `$feature_flag_error` matching the single-flag path's + // granularity: response-level errors (errors-while-computing, quota-limited) are + // combined with per-flag errors (flag-missing) so consumers can filter by type. + const errors: string[] = [] + if (this._errorsWhileComputing) { + errors.push(FeatureFlagError.ERRORS_WHILE_COMPUTING) + } + if (this._quotaLimited) { + errors.push(FeatureFlagError.QUOTA_LIMITED) + } if (flag === undefined) { - properties.$feature_flag_error = FeatureFlagError.FLAG_MISSING + errors.push(FeatureFlagError.FLAG_MISSING) + } + if (errors.length > 0) { + properties.$feature_flag_error = errors.join(',') } this._host.captureFlagCalledEventIfNeeded({ From 67b3a0263419820ea96db4747d396dcd3b06695e Mon Sep 17 00:00:00 2001 From: dylan Date: Fri, 1 May 2026 13:37:30 -0700 Subject: [PATCH 7/7] =?UTF-8?q?fix(node):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20unused=20methods,=20add=20JSDoc=20@deprecate?= =?UTF-8?q?d=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per dustin's feedback on PR #3476: - Remove unused public `_getDistinctId()` / `_getGroups()` methods on `FeatureFlagEvaluations`. They had no callers (verified via grep across the repo) and don't need to ship as part of the public surface. - Add JSDoc `@deprecated` tags to `getFeatureFlag`, `isFeatureEnabled`, and `getFeatureFlagPayload` on both the `PostHogBackendClient` impl (client.ts) and the `IPostHog` interface (types.ts). The runtime `console.warn` was already in place; the JSDoc tag adds IDE strike- through and agent-tooling visibility — code agents reading the public surface will see the deprecation immediately rather than waiting for a runtime call. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- packages/node/src/client.ts | 13 +++++++++++++ packages/node/src/feature-flag-evaluations.ts | 14 -------------- packages/node/src/types.ts | 9 +++++++++ 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index c46b536d80..98d24a0c95 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -1050,6 +1050,11 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * * {@label Feature flags} * + * @deprecated Use {@link evaluateFlags} and call `flags.getFlag(key)` on the returned snapshot. + * This consolidates flag evaluation into a single `/flags` request per incoming request and + * avoids drift between the values your code branched on and the values attached to events. + * Will be removed in the next major version. + * * @param key - The feature flag key * @param distinctId - The user's distinct ID * @param options - Optional configuration for flag evaluation @@ -1115,6 +1120,10 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * * {@label Feature flags} * + * @deprecated Use {@link evaluateFlags} and call `flags.getFlagPayload(key)` on the returned + * snapshot. This consolidates flag evaluation into a single `/flags` request per incoming + * request. Will be removed in the next major version. + * * @param key - The feature flag key * @param distinctId - The user's distinct ID * @param matchValue - Optional match value to get payload for @@ -1295,6 +1304,10 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen * * {@label Feature flags} * + * @deprecated Use {@link evaluateFlags} and call `flags.isEnabled(key)` on the returned snapshot. + * This consolidates flag evaluation into a single `/flags` request per incoming request. + * Will be removed in the next major version. + * * @param key - The feature flag key * @param distinctId - The user's distinct ID * @param options - Optional configuration for flag evaluation diff --git a/packages/node/src/feature-flag-evaluations.ts b/packages/node/src/feature-flag-evaluations.ts index 794e06559f..a6a6bb2fd7 100644 --- a/packages/node/src/feature-flag-evaluations.ts +++ b/packages/node/src/feature-flag-evaluations.ts @@ -234,20 +234,6 @@ export class FeatureFlagEvaluations { return properties } - /** - * @internal - */ - _getDistinctId(): string { - return this._distinctId - } - - /** - * @internal - */ - _getGroups(): Record | undefined { - return this._groups - } - private _cloneWith(flags: Record): FeatureFlagEvaluations { return new FeatureFlagEvaluations({ host: this._host, diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 8094b4e3db..e4f0cfde1f 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -400,6 +400,9 @@ export interface IPostHog { * @param sendFeatureFlagEvents optional - whether to send feature flag events. Used for Experiments. Defaults to true. * * @returns true if the flag is on, false if the flag is off, undefined if there was an error. + * + * @deprecated Use {@link IPostHog.evaluateFlags} and call `flags.isEnabled(key)` on the + * returned snapshot. Will be removed in the next major version. */ isFeatureEnabled( key: string, @@ -428,6 +431,9 @@ export interface IPostHog { * @param sendFeatureFlagEvents optional - whether to send feature flag events. Used for Experiments. Defaults to true. * * @returns true or string(for multivariates) if the flag is on, false if the flag is off, undefined if there was an error. + * + * @deprecated Use {@link IPostHog.evaluateFlags} and call `flags.getFlag(key)` on the + * returned snapshot. Will be removed in the next major version. */ getFeatureFlag( key: string, @@ -466,6 +472,9 @@ export interface IPostHog { * @param onlyEvaluateLocally optional - whether to only evaluate the flag locally. Defaults to false. * * @returns payload of a json type object + * + * @deprecated Use {@link IPostHog.evaluateFlags} and call `flags.getFlagPayload(key)` on + * the returned snapshot. Will be removed in the next major version. */ getFeatureFlagPayload( key: string,