diff --git a/.changeset/evaluate-flags-node.md b/.changeset/evaluate-flags-node.md new file mode 100644 index 0000000000..4056af405f --- /dev/null +++ b/.changeset/evaluate-flags-node.md @@ -0,0 +1,17 @@ +--- +'posthog-node': minor +--- + +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 new file mode 100644 index 0000000000..2927de7327 --- /dev/null +++ b/packages/node/src/__tests__/evaluate-flags.spec.ts @@ -0,0 +1,588 @@ +import { _resetDeprecationWarningsForTests } from '@/client' +import { PostHog } from '@/entrypoints/index.node' +import { FeatureFlagEvaluations } from '@/feature-flag-evaluations' +import { EventMessage, 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 + 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() + }) + + describe('remote evaluation', () => { + beforeEach(() => { + mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + setup() + }) + + it('makes a single /flags call and returns a FeatureFlagEvaluations instance', async () => { + 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 () => { + 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 () => { + 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 () => { + 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 () => { + 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 () => { + 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 () => { + 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('forwards flagKeys to the /flags request to scope the evaluation', async () => { + 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 () => { + const flags = await posthog.evaluateFlags() + + expect(flags.keys).toEqual([]) + }) + + it('does not fire $feature_flag_called events from an empty-distinctId snapshot', async () => { + 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', () => { + beforeEach(() => { + mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + setup() + }) + + it('onlyAccessed returns a snapshot with only accessed flags', async () => { + 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 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).toEqual([]) + }) + + it('featureFlagsLogWarnings=false silences filter warnings', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + setup({ featureFlagsLogWarnings: false }) + + 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() + + 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 () => { + 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']) + }) + + 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 () => { + 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 () => { + 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 () => { + 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 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 + + 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() + 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() + }) + }) + + describe('local evaluation', () => { + const localFlagsFixture = () => ({ + flags: [ + { + id: 42, + name: 'Always on', + key: 'local-flag', + active: true, + filters: { + groups: [{ variant: null, properties: [], rollout_percentage: 100 }], + }, + }, + ], + }) + + beforeEach(() => { + mockedFetch.mockImplementation(apiImplementation({ localFlags: localFlagsFixture() })) + setup({ personalApiKey: 'TEST_PERSONAL_API_KEY' }) + }) + + 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) + + 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) + }) + + it('attaches $feature_flag_definitions_loaded_at on locally-evaluated $feature_flag_called events', async () => { + 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', () => { + beforeEach(() => { + mockedFetch.mockImplementation(apiImplementationV4(flagsResponseFixture())) + setup() + }) + + 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 } }, + }) + + 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 b83a6476b1..98d24a0c95 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -28,6 +28,12 @@ import { FlagEvaluationOptions, AllFlagsOptions, } from './types' +import { + EvaluatedFlagRecord, + FeatureFlagEvaluations, + FeatureFlagEvaluationsHost, + FlagCalledEventParams, +} from './feature-flag-evaluations' import { FeatureFlagsPoller, type FeatureFlagEvaluationContext, @@ -50,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() : '' } @@ -64,6 +90,27 @@ function normalizeHost(value?: unknown): string { return normalizedValue || DEFAULT_NODE_HOST } +/** + * 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() @@ -921,56 +968,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) @@ -1021,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 @@ -1038,6 +1072,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, @@ -1080,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 @@ -1100,6 +1144,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) { @@ -1254,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 @@ -1271,10 +1325,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 } @@ -1454,6 +1522,286 @@ 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. + * + * 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' }, + * }) + * if (flags.isEnabled('new-dashboard')) { + * renderNewDashboard() + * } + * 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. 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 + async evaluateFlags(distinctId: string, options?: AllFlagsOptions): Promise + async evaluateFlags( + distinctIdOrOptions?: string | AllFlagsOptions, + options?: AllFlagsOptions + ): 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, flagKeys } = 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 + 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. + const localResult = await this.featureFlagsPoller?.getAllFlagsAndPayloads(evaluationContext, flagKeys) + 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, + // 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, + } + 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, + flagKeys + ) + 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 + } + 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, + flagDefinitionsLoadedAt: this.featureFlagsPoller?.getFlagDefinitionsLoadedAt(), + errorsWhileComputing, + quotaLimited, + }) + } + + /** + * 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 + */ + protected _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. * @@ -1933,18 +2281,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 }) ) ) } @@ -1983,18 +2334,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 }) ) ) } @@ -2006,8 +2359,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() @@ -2034,6 +2396,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen event, properties: mergedProperties, groups, + flags, sendFeatureFlags, timestamp, disableGeoip, @@ -2047,40 +2410,42 @@ 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 () => { + // 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 - 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..a6a6bb2fd7 --- /dev/null +++ b/packages/node/src/feature-flag-evaluations.ts @@ -0,0 +1,321 @@ +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 }) + * ``` + * + * 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 + 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 _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. + private readonly _isSlice: boolean + + /** + * @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 + flagDefinitionsLoadedAt?: number + errorsWhileComputing?: boolean + quotaLimited?: boolean + accessed?: Set + isSlice?: boolean + }) { + 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._flagDefinitionsLoadedAt = init.flagDefinitionsLoadedAt + this._errorsWhileComputing = init.errorsWhileComputing ?? false + this._quotaLimited = init.quotaLimited ?? false + this._accessed = init.accessed ?? new Set() + this._isSlice = init.isSlice ?? false + } + + /** + * 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. + * + * 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 + * out is a no-op (no event is fired) — the flag wasn't actually missing, it was + * excluded from the slice. + */ + onlyAccessed(): FeatureFlagEvaluations { + 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. + * + * **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 = {} + 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 + } + + 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, + 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. + accessed: new Set(this._accessed), + isSlice: true, + }) + } + + 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 + } + + // 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) + + 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?.locallyEvaluated && this._flagDefinitionsLoadedAt !== undefined) { + 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) { + errors.push(FeatureFlagError.FLAG_MISSING) + } + if (errors.length > 0) { + properties.$feature_flag_error = errors.join(',') + } + + 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..e4f0cfde1f 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, @@ -378,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, @@ -406,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, @@ -444,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, @@ -512,6 +543,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. Pass `flagKeys` to scope the underlying `/flags` request to a subset of flags. + */ + 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. Pass `flagKeys` to scope the underlying `/flags` request to a subset of flags. + */ + evaluateFlags(distinctId: string, options?: AllFlagsOptions): Promise + /** * @description Sets a groups properties, which allows asking questions like "Who are the most active companies" * using my product in PostHog.