UNPKG

posthog-node

Version:

PostHog Node.js integration

1,547 lines (1,433 loc) 98.4 kB
import { version } from './version' import { FeatureFlagValue, isBlockedUA, isPlainObject, JsonType, minimizeFlagCalledEventProperties, PostHogCaptureOptions, PostHogCoreStateless, PostHogEventProperties, PostHogFetchOptions, PostHogFetchResponse, PostHogFlagsAndPayloadsResponse, PostHogFlagsResponse, PostHogMetrics, PostHogPersistedProperty, resolveMetricsConfig, RetriableOptions, safeSetTimeout, uuidv7, } from '@posthog/core' import type { Metrics } from '@posthog/core' import { AllFlagsOptions, EventMessage, FeatureFlagError, FeatureFlagErrorType, FeatureFlagOverrideOptions, FeatureFlagResult, FlagEvaluationOptions, GroupIdentifyMessage, IdentifyMessage, IPostHog, OverrideFeatureFlagsOptions, PostHogOptions, SendFeatureFlagsOptions, SetPersonPropertiesMessage, UnsetPersonPropertiesMessage, } from './types' import { EvaluatedFlagRecord, FeatureFlagEvaluations, FeatureFlagEvaluationsHost, FlagCalledEventParams, } from './feature-flag-evaluations' import { type FeatureFlagEvaluationContext, FeatureFlagsPoller, InconclusiveMatchError, RequiresServerEvaluation, } from './extensions/feature-flags/feature-flags' import ErrorTracking from './extensions/error-tracking' import { PostHogMemoryStorage } from './storage-memory' import { ContextData, ContextOptions, IPostHogContext } from './extensions/context/types' import { type CaptureMode, resolveCaptureMode } from './capture-v1/config' import { AI_ROUTE, ANALYTICS_ROUTE, isLegacyOnlyEvent } from './capture-v1/routing' import { V1CaptureSender } from './capture-v1/sender' // Standard local evaluation rate limit is 600 per minute (10 per second), // so the fastest a poller should ever be set is 100ms. const MINIMUM_POLLING_INTERVAL = 100 const THIRTY_SECONDS = 30 * 1000 const MAX_CACHE_SIZE = 50 * 1000 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<string>() function emitDeprecationWarningOnce(id: string, message: string): void { if (_emittedDeprecations.has(id)) { return } _emittedDeprecations.add(id) 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() : '' } function normalizePersonalApiKey(value?: unknown): string | undefined { const normalizedValue = typeof value === 'string' ? value.trim() : '' return normalizedValue || undefined } function normalizeHost(value?: unknown): string { const normalizedValue = typeof value === 'string' ? value.trim() : '' return normalizedValue || DEFAULT_NODE_HOST } function normalizeUnsetPersonProperties(value: string | string[]): string[] { const propertyNames = Array.isArray(value) ? value : [value] return propertyNames.filter( (propertyName): propertyName is string => typeof propertyName === 'string' && propertyName.trim().length > 0 ) } /** * 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<string, FeatureFlagValue> | undefined): Record<string, any> { if (!flagValues) { return {} } const additionalProperties: Record<string, any> = {} 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() private featureFlagsPoller?: FeatureFlagsPoller protected errorTracking: ErrorTracking private maxCacheSize: number public readonly options: PostHogOptions protected readonly context?: IPostHogContext private _metrics?: PostHogMetrics private readonly captureMode: CaptureMode private _v1Sender?: V1CaptureSender // Feature flag overrides for local testing/development private _flagOverrides?: Record<string, FeatureFlagValue> private _payloadOverrides?: Record<string, JsonType> // Server-controlled gate for minimal $feature_flag_called events. Single client-level gate, // last-writer-wins across the two signal sources (v2 /flags responses and the poller's // flag-definition loads) — both derive from the same per-team server config and converge. private _minimalFlagCalledEvents: boolean = false distinctIdHasSentFlagCalls: Record<string, Set<string>> // waitUntil debounce state (per-instance) private _waitUntilCycle?: { resolve: () => void startedAt: number timer: ReturnType<typeof setTimeout> | undefined } /** * Initialize a new PostHog client instance. * * @example * ```ts * // Basic initialization * const client = new PostHogBackendClient( * 'your-api-key', * { host: 'https://app.posthog.com' } * ) * ``` * * @example * ```ts * // With a secret key (Personal API Key or Project Secret API Key) for local evaluation * const client = new PostHogBackendClient( * 'your-api-key', * { * host: 'https://app.posthog.com', * secretKey: 'your-secret-key' * } * ) * ``` * * {@label Initialization} * * @param apiKey - Your PostHog project API key * @param options - Configuration options for the client */ constructor(apiKey: string, options: PostHogOptions = {}) { const normalizedApiKey = normalizeApiKey(apiKey) const normalizedOptions = { ...options, // Node's default is higher than the shared core default (1000) because backend // workloads are more likely to burst-enqueue synchronously ahead of a flush. // Applied after the spread with a nullish fallback so a wrapper forwarding // `maxQueueSize: undefined` still gets the Node default, not the core one. maxQueueSize: options.maxQueueSize ?? 10000, flushInterval: options.flushInterval ?? 5000, host: normalizeHost(options.host), personalApiKey: normalizePersonalApiKey(options.secretKey ?? options.personalApiKey), } super(normalizedApiKey, normalizedOptions) this.options = normalizedOptions this.captureMode = resolveCaptureMode() this.context = this.initializeContext() this.options.featureFlagsPollingInterval = typeof normalizedOptions.featureFlagsPollingInterval === 'number' ? Math.max(normalizedOptions.featureFlagsPollingInterval, MINIMUM_POLLING_INTERVAL) : THIRTY_SECONDS if (typeof normalizedOptions.waitUntilDebounceMs === 'number') { this.options.waitUntilDebounceMs = Math.max(normalizedOptions.waitUntilDebounceMs, 0) } if (typeof normalizedOptions.waitUntilMaxWaitMs === 'number') { this.options.waitUntilMaxWaitMs = Math.max(normalizedOptions.waitUntilMaxWaitMs, 0) } if (!this.disabled && normalizedOptions.personalApiKey) { if (normalizedOptions.personalApiKey.includes('phc_')) { throw new Error( 'Your Personal API key is invalid. These keys are prefixed with "phx_" and can be created in PostHog project settings.' ) } // Only start the poller if local evaluation is enabled (defaults to true for backward compatibility) const shouldEnableLocalEvaluation = normalizedOptions.enableLocalEvaluation !== false if (shouldEnableLocalEvaluation) { this.featureFlagsPoller = new FeatureFlagsPoller({ pollingInterval: this.options.featureFlagsPollingInterval, personalApiKey: normalizedOptions.personalApiKey, projectApiKey: normalizedApiKey, timeout: normalizedOptions.requestTimeout ?? 10000, // 10 seconds host: this.host, fetch: normalizedOptions.fetch, onError: (err: Error) => { this._events.emit('error', err) }, onLoad: (count: number) => { this._events.emit('localEvaluationFlagsLoaded', count) }, onMinimalFlagCalledEvents: (enabled: boolean) => { this._minimalFlagCalledEvents = enabled }, customHeaders: this.getCustomHeaders(), cacheProvider: normalizedOptions.flagDefinitionCacheProvider, strictLocalEvaluation: normalizedOptions.strictLocalEvaluation, }) } } this.errorTracking = new ErrorTracking(this, normalizedOptions, this._logger) this.distinctIdHasSentFlagCalls = {} this.maxCacheSize = normalizedOptions.maxCacheSize || MAX_CACHE_SIZE } protected override enqueue(type: string, message: any, options?: PostHogCaptureOptions): void { super.enqueue(type, message, options) this.scheduleDebouncedFlush() } override async flush(): Promise<void> { const flushPromise = this.flushWithPendingPromises() const waitUntil = this.options.waitUntil // Only register when no debounce promise is already keeping runtime alive if (waitUntil && !this._waitUntilCycle) { try { waitUntil(flushPromise.catch(() => {})) } catch { // waitUntil may throw outside request context } } return flushPromise } private scheduleDebouncedFlush(): void { // `waitUntil` is a serverless construct // if it doesn't exist, we can skip all the debounce logic and flush as normal const waitUntil = this.options.waitUntil if (!waitUntil) { return } if (this.disabled || this.optedOut) { return } if (!this._waitUntilCycle) { let resolve: () => void const promise = new Promise<void>((r) => { resolve = r }) try { waitUntil(promise) } catch { // waitUntil may throw outside request context return } this._waitUntilCycle = { resolve: resolve!, startedAt: Date.now(), timer: undefined } } // Max time cap: if we've been debouncing too long, flush now to prevent // starvation from rapid concurrent captures. I.e., don't let a steady // stream of captures keep pushing the flush back indefinitely. const elapsed = Date.now() - this._waitUntilCycle.startedAt const maxWaitMs = this.options.waitUntilMaxWaitMs ?? WAITUNTIL_MAX_WAIT_MS const flushNow = elapsed >= maxWaitMs if (this._waitUntilCycle.timer !== undefined) { clearTimeout(this._waitUntilCycle.timer) } if (flushNow) { void this.resolveWaitUntilFlush() return } const debounceMs = this.options.waitUntilDebounceMs ?? WAITUNTIL_DEBOUNCE_MS this._waitUntilCycle.timer = safeSetTimeout(() => { void this.resolveWaitUntilFlush() }, debounceMs) } private _consumeWaitUntilCycle(): (() => void) | undefined { const cycle = this._waitUntilCycle if (cycle) { clearTimeout(cycle.timer) this._waitUntilCycle = undefined } return cycle?.resolve } private async resolveWaitUntilFlush(): Promise<void> { const resolve = this._consumeWaitUntilCycle() try { await this.flushWithPendingPromises() } catch { // Flush errors are already logged by flush() internals } finally { resolve?.() } } /** * Get a persisted property value from memory storage. * * @example * ```ts * // Get user ID * const userId = client.getPersistedProperty('userId') * ``` * * @example * ```ts * // Get session ID * const sessionId = client.getPersistedProperty('sessionId') * ``` * * {@label Initialization} * * @param key - The property key to retrieve * @returns The stored property value or undefined if not found */ getPersistedProperty(key: PostHogPersistedProperty): any | undefined { return this._memoryStorage.getProperty(key) } /** * Set a persisted property value in memory storage. * * @example * ```ts * // Set user ID * client.setPersistedProperty('userId', 'user_123') * ``` * * @example * ```ts * // Set session ID * client.setPersistedProperty('sessionId', 'session_456') * ``` * * {@label Initialization} * * @param key - The property key to set * @param value - The value to store (null to remove) */ setPersistedProperty(key: PostHogPersistedProperty, value: any | null): void { return this._memoryStorage.setProperty(key, value) } /** * Make an HTTP request using the configured fetch function or default fetch. * * @example * ```ts * // POST request * const response = await client.fetch('/api/endpoint', { * method: 'POST', * headers: { 'Content-Type': 'application/json' }, * body: JSON.stringify(data) * }) * ``` * * @internal * * {@label Initialization} * * @param url - The URL to fetch * @param options - Fetch options * @returns Promise resolving to the fetch response */ fetch(url: string, options: PostHogFetchOptions): Promise<PostHogFetchResponse> { return this.options.fetch ? this.options.fetch(url, options) : fetch(url, options) } /** * Route an event to its queue. In v1 mode `$ai_*` events go to the isolated {@link AI_ROUTE} * (legacy transport) and everything else to {@link ANALYTICS_ROUTE} (Capture V1); in v0 mode * every event stays on {@link ANALYTICS_ROUTE}, which maps to the historical queue. Routing runs * on the post-`before_send`, normalized event name (core calls this after `processBeforeEnqueue`). */ protected getQueueRouteKey(message: PostHogEventProperties): string { return this.captureMode === 'v1' && isLegacyOnlyEvent(message) ? AI_ROUTE : ANALYTICS_ROUTE } protected persistedQueueKeyForRoute(route: string): PostHogPersistedProperty { return route === AI_ROUTE ? PostHogPersistedProperty.AiQueue : PostHogPersistedProperty.Queue } protected getActiveQueueRoutes(): string[] { // Only surface the AI route in v1 mode — v0 mode never enqueues onto it, so keeping it out // keeps v0's flush/shutdown identical to before (a single queue on ANALYTICS_ROUTE). return this.captureMode === 'v1' ? [ANALYTICS_ROUTE, AI_ROUTE] : [ANALYTICS_ROUTE] } /** * Capture submission seam shared by the batched (`_flush`) and immediate (`sendImmediate`) * paths. Events are routed to homogeneous per-route queues at enqueue time, so a batch here is * never mixed: the {@link AI_ROUTE} (and all of v0 mode) uses the legacy `/batch/` transport, * which throws so `_flush` can shrink/persist/retry its own queue; the {@link ANALYTICS_ROUTE} * in v1 mode uses the Capture V1 endpoint. Because the routes flush independently, a v0/AI leg * failure can never re-send analytics events already accepted on the V1 leg. */ protected async sendBatch( batchMessages: (PostHogEventProperties | undefined)[], retryOptions?: Partial<RetriableOptions>, route: string = ANALYTICS_ROUTE ): Promise<void> { if (this.captureMode !== 'v1' || route === AI_ROUTE) { return super.sendBatch(batchMessages, retryOptions, route) } // Analytics route in v1 mode: homogeneous (no `$ai_*`, routed away at enqueue) and never // undefined for a freshly-enqueued event; drop any legacy undefined queue artifacts. const v1Events = batchMessages.filter((message): message is PostHogEventProperties => message !== undefined) await this.getV1Sender().sendV1Batch(v1Events) } private getV1Sender(): V1CaptureSender { if (!this._v1Sender) { this._v1Sender = new V1CaptureSender( { host: this.host, apiKey: this.apiKey, libraryId: this.getLibraryId(), libraryVersion: this.getLibraryVersion(), userAgent: this.getCustomUserAgent() || undefined, historicalMigration: this.historicalMigration, compressionEnabled: !this.disableCompression, requestTimeoutMs: this.requestTimeout, // Reuse the existing v0 retry knobs: 1 initial attempt + fetchRetryCount retries. maxAttempts: (this.options.fetchRetryCount ?? 3) + 1, initialRetryDelayMs: this.options.fetchRetryDelay ?? 3000, isDebug: this.isDebug, }, { fetch: (url, fetchOptions) => this.fetch(url, fetchOptions), onError: (error) => this._events.emit('error', error), } ) } return this._v1Sender } /** * Get the library version from package.json. * * @example * ```ts * // Get version * const version = client.getLibraryVersion() * console.log(`Using PostHog SDK version: ${version}`) * ``` * * {@label Initialization} * * @returns The current library version string */ getLibraryVersion(): string { return version } /** * The `posthog.metrics` API: a statsd-style pre-aggregating metrics client — alpha. * Samples are folded into per-series aggregates in memory and flushed * periodically as one OTLP data point per series per window, so recording * from hot paths is cheap. Configure via the `metrics` client option. * * @example * ```ts * client.metrics.count('jobs.processed', 1, { attributes: { queue: 'default' } }) * client.metrics.gauge('queue.depth', 42) * client.metrics.histogram('job.duration', 187, { unit: 'ms' }) * ``` * * {@label Metrics} */ public get metrics(): Metrics { if (!this._metrics) { // Lazy: `this` is the MetricsHost (isDisabled/optedOut/_sendMetricsBatch // live on PostHogCoreStateless), so nothing is set up until first use. this._metrics = new PostHogMetrics(this, resolveMetricsConfig(this.options.metrics), this._logger) } return this._metrics } /** * Get the custom user agent string for this client. * * @example * ```ts * // Get user agent * const userAgent = client.getCustomUserAgent() * // Returns: "posthog-node/5.7.0" * ``` * * {@label Identification} * * @returns The formatted user agent string */ getCustomUserAgent(): string { return `${this.getLibraryId()}/${this.getLibraryVersion()}` } /** * Returns the common properties attached to every captured event. * * @remarks * Extends the shared core properties (`$lib`, `$lib_version`) with * `$is_server: true` so that events emitted from the server-side SDKs * (posthog-node and posthog-edge, which both extend this class) are * distinguishable from browser and react-native events. Browser and * react-native clients do not extend `PostHogBackendClient`, so they * never receive this property. * * This is controlled by the `isServer` option, which defaults to `true`. * When `isServer` is `false` (e.g. when using the SDK as a client/CLI), the * `$is_server` property is omitted entirely so the device OS is attributed * normally. * * @returns The common event properties, including `$is_server: true` when * the `isServer` option is enabled. */ protected override getCommonEventProperties(): PostHogEventProperties { const commonProperties = super.getCommonEventProperties() if (this.options.isServer ?? true) { commonProperties.$is_server = true } return commonProperties } /** * Enable the PostHog client (opt-in). * * @example * ```ts * // Enable client * await client.enable() * // Client is now enabled and will capture events * ``` * * {@label Privacy} * * @returns Promise that resolves when the client is enabled */ enable(): Promise<void> { return super.optIn() } /** * Disable the PostHog client (opt-out). * * @example * ```ts * // Disable client * await client.disable() * // Client is now disabled and will not capture events * ``` * * {@label Privacy} * * @returns Promise that resolves when the client is disabled */ disable(): Promise<void> { return super.optOut() } /** * Enable or disable debug logging. * * @example * ```ts * // Enable debug logging * client.debug(true) * ``` * * @example * ```ts * // Disable debug logging * client.debug(false) * ``` * * {@label Initialization} * * @param enabled - Whether to enable debug logging */ debug(enabled: boolean = true): void { super.debug(enabled) this.featureFlagsPoller?.debug(enabled) } private _warnIfInvalidCapture( props: EventMessage, stringArgumentWarning: string, exceptionCaptureWarning: string ): void { if (typeof props === 'string') { this._logger.warn(stringArgumentWarning) } if (props.event === '$exception' && !props._originatedFromCaptureException) { this._logger.warn(exceptionCaptureWarning) } } private _sendPreparedEvent( type: string, props: EventMessage, immediate: boolean, prepareOptions?: { includeContextProperties?: boolean } ): Promise<void> { return this.addPendingPromise( this._prepareEventMessage(props, prepareOptions) .then(({ distinctId, event, properties, options }) => { const captureOptions: PostHogCaptureOptions = { timestamp: options.timestamp, disableGeoip: options.disableGeoip, uuid: options.uuid, } const message = { distinctId, event, properties: { ...properties, ...this.getCommonEventProperties(), }, } return immediate ? this.sendImmediate(type, message, captureOptions) : this.enqueue(type, message, captureOptions) }) .catch((err) => { if (err) { console.error(err) } }) ) } _capturePreparedEvent(props: EventMessage, immediate: boolean): Promise<void> { return this._sendPreparedEvent('capture', props, immediate) } /** * Capture an event manually. * * @example * ```ts * // Basic capture * client.capture({ * distinctId: 'user_123', * event: 'button_clicked', * properties: { button_color: 'red' } * }) * ``` * * {@label Capture} * * @param props - The event properties * @returns void */ capture(props: EventMessage): void { this._warnIfInvalidCapture( props, 'Called capture() with a string as the first argument when an object was expected.', "Using `posthog.capture('$exception')` is unreliable because it does not attach required metadata. Use `posthog.captureException(error)` instead, which attaches required metadata automatically." ) this._capturePreparedEvent(props, false) } /** * Capture an event immediately (synchronously). * * @example * ```ts * // Basic immediate capture * await client.captureImmediate({ * distinctId: 'user_123', * event: 'button_clicked', * properties: { button_color: 'red' } * }) * ``` * * @example * ```ts * // With feature flags * await client.captureImmediate({ * distinctId: 'user_123', * event: 'user_action', * sendFeatureFlags: true * }) * ``` * * @example * ```ts * // With custom feature flags options * await client.captureImmediate({ * distinctId: 'user_123', * event: 'user_action', * sendFeatureFlags: { * onlyEvaluateLocally: true, * personProperties: { plan: 'premium' }, * groupProperties: { org: { tier: 'enterprise' } } * flagKeys: ['flag1', 'flag2'] * } * }) * ``` * * {@label Capture} * * @param props - The event properties * @returns Promise that resolves when the event is captured */ async captureImmediate(props: EventMessage): Promise<void> { this._warnIfInvalidCapture( props, 'Called captureImmediate() with a string as the first argument when an object was expected.', "Capturing a `$exception` event via `posthog.captureImmediate('$exception')` is unreliable because it does not attach required metadata. Use `posthog.captureExceptionImmediate(error)` instead, which attaches this metadata by default." ) return this._capturePreparedEvent(props, true) } /** * Identify a user and set their properties. * * @example * ```ts * // Basic identify with properties * client.identify({ * distinctId: 'user_123', * properties: { * name: 'John Doe', * email: 'john@example.com', * plan: 'premium' * } * }) * ``` * * @example * ```ts * // Using $set and $set_once * client.identify({ * distinctId: 'user_123', * properties: { * $set: { name: 'John Doe', email: 'john@example.com' }, * $set_once: { first_login: new Date().toISOString() } * $anon_distinct_id: 'anonymous_user_456' * } * }) * ``` * * {@label Identification} * * @param data - The identify data containing distinctId and properties */ identify({ distinctId, properties = {}, disableGeoip }: IdentifyMessage): void { // Catch properties passed as $set and move them to the top level const { $set, $set_once, $anon_distinct_id, ...rest } = properties // if no $set is provided we assume all rest properties are $set const setProps = $set || rest const setOnceProps = $set_once || {} const eventProperties = { $set: setProps, $set_once: setOnceProps, $anon_distinct_id: $anon_distinct_id ?? undefined, } this._sendPreparedEvent( 'identify', { distinctId, event: '$identify', properties: eventProperties, disableGeoip }, false, { includeContextProperties: false } ) } /** * Identify a user and set their properties immediately (synchronously). * * @example * ```ts * // Basic immediate identify * await client.identifyImmediate({ * distinctId: 'user_123', * properties: { * name: 'John Doe', * email: 'john@example.com' * } * }) * ``` * * {@label Identification} * * @param data - The identify data containing distinctId and properties * @returns Promise that resolves when the identify is processed */ async identifyImmediate({ distinctId, properties = {}, disableGeoip }: IdentifyMessage): Promise<void> { // Catch properties passed as $set and move them to the top level const { $set, $set_once, $anon_distinct_id, ...rest } = properties // if no $set is provided we assume all rest properties are $set const setProps = $set || rest const setOnceProps = $set_once || {} const eventProperties = { $set: setProps, $set_once: setOnceProps, $anon_distinct_id: $anon_distinct_id ?? undefined, } await this._sendPreparedEvent( 'identify', { distinctId, event: '$identify', properties: eventProperties, disableGeoip }, true, { includeContextProperties: false } ) } /** * Set properties on a person profile. * * @example * ```ts * client.setPersonProperties({ * distinctId: 'user_123', * properties: { plan: 'premium' }, * propertiesOnce: { first_seen: '2026-06-15' } * }) * ``` * * {@label Identification} * * @param data - The data containing distinctId and properties to set */ setPersonProperties({ distinctId, properties = {}, propertiesOnce = {} }: SetPersonPropertiesMessage): void { if (Object.keys(properties).length === 0 && Object.keys(propertiesOnce).length === 0) { return } const eventProperties: Record<string, any> = {} if (Object.keys(properties).length > 0) { eventProperties.$set = properties } if (Object.keys(propertiesOnce).length > 0) { eventProperties.$set_once = propertiesOnce } this.capture({ distinctId, event: '$set', properties: eventProperties, }) } /** * Remove properties from a person profile. * * @example * ```ts * client.unsetPersonProperties({ * distinctId: 'user_123', * properties: ['plan', 'email'] * }) * ``` * * {@label Identification} * * @param data - The data containing distinctId and property names to unset */ unsetPersonProperties({ distinctId, properties }: UnsetPersonPropertiesMessage): void { const propertyNames = normalizeUnsetPersonProperties(properties) if (propertyNames.length === 0) { return } this.capture({ distinctId, event: '$set', properties: { $unset: propertyNames } }) } /** * Create an alias to link two distinct IDs together. * * @example * ```ts * // Link an anonymous user to an identified user * client.alias({ * distinctId: 'anonymous_123', * alias: 'user_456' * }) * ``` * * {@label Identification} * * @param data - The alias data containing distinctId and alias */ alias(data: { distinctId: string; alias: string; disableGeoip?: boolean }): void { this._sendPreparedEvent( 'alias', { distinctId: data.distinctId, event: '$create_alias', properties: { distinct_id: data.distinctId, alias: data.alias }, disableGeoip: data.disableGeoip, }, false, { includeContextProperties: false } ) } /** * Create an alias to link two distinct IDs together immediately (synchronously). * * @example * ```ts * // Link an anonymous user to an identified user immediately * await client.aliasImmediate({ * distinctId: 'anonymous_123', * alias: 'user_456' * }) * ``` * * {@label Identification} * * @param data - The alias data containing distinctId and alias * @returns Promise that resolves when the alias is processed */ async aliasImmediate(data: { distinctId: string; alias: string; disableGeoip?: boolean }): Promise<void> { await this._sendPreparedEvent( 'alias', { distinctId: data.distinctId, event: '$create_alias', properties: { distinct_id: data.distinctId, alias: data.alias }, disableGeoip: data.disableGeoip, }, true, { includeContextProperties: false } ) } /** * Check if local evaluation of feature flags is ready. * * @example * ```ts * // Check if ready * if (client.isLocalEvaluationReady()) { * // Local evaluation is ready, can evaluate flags locally * const flag = await client.getFeatureFlag('flag-key', 'user_123') * } else { * // Local evaluation not ready, will use remote evaluation * const flag = await client.getFeatureFlag('flag-key', 'user_123') * } * ``` * * {@label Feature flags} * * @returns true if local evaluation is ready, false otherwise */ isLocalEvaluationReady(): boolean { return this.featureFlagsPoller?.isLocalEvaluationReady() ?? false } /** * Wait for local evaluation of feature flags to be ready. * * @example * ```ts * // Wait for local evaluation * const isReady = await client.waitForLocalEvaluationReady() * if (isReady) { * console.log('Local evaluation is ready') * } else { * console.log('Local evaluation timed out') * } * ``` * * @example * ```ts * // Wait with custom timeout * const isReady = await client.waitForLocalEvaluationReady(10000) // 10 seconds * ``` * * {@label Feature flags} * * @param timeoutMs - Timeout in milliseconds (default: 30000) * @returns Promise that resolves to true if ready, false if timed out */ async waitForLocalEvaluationReady(timeoutMs: number = THIRTY_SECONDS): Promise<boolean> { if (this.isLocalEvaluationReady()) { return true } if (this.featureFlagsPoller === undefined) { return false } return new Promise((resolve) => { const timeout = setTimeout(() => { cleanup() resolve(false) }, timeoutMs) const cleanup = this._events.on('localEvaluationFlagsLoaded', (count: number) => { clearTimeout(timeout) cleanup() resolve(count > 0) }) }) } private _resolveDistinctId<T>( distinctIdOrOptions: string | T | undefined, options: T | undefined ): { distinctId: string | undefined; options: T | undefined } { if (typeof distinctIdOrOptions === 'string') { return { distinctId: distinctIdOrOptions, options } } return { distinctId: this.context?.get()?.distinctId, options: distinctIdOrOptions } } /** * Internal method that handles feature flag evaluation with full details. * Used by getFeatureFlag, getFeatureFlagPayload, and getFeatureFlagResult. * * @param key - The feature flag key * @param distinctId - The user's distinct ID * @param options - Evaluation options (includes sendFeatureFlagEvents, defaults to true) * @param matchValue - Optional match value for payload lookup (used by getFeatureFlagPayload) * @returns Promise that resolves to the flag result or undefined */ private async _getFeatureFlagResult( key: string, distinctId: string, options: { groups?: Record<string, string> personProperties?: Record<string, string> groupProperties?: Record<string, Record<string, string>> onlyEvaluateLocally?: boolean sendFeatureFlagEvents?: boolean disableGeoip?: boolean } = {}, matchValue?: FeatureFlagValue ): Promise<FeatureFlagResult | undefined> { if (this.disabled) { this._logger.warn('The client is disabled') return undefined } const sendFeatureFlagEvents = options.sendFeatureFlagEvents ?? true // Check for overrides first - they take precedence over all evaluation if (this._flagOverrides !== undefined && key in this._flagOverrides) { const overrideValue = this._flagOverrides[key] // undefined override simulates "flag doesn't exist" if (overrideValue === undefined) { return undefined } const overridePayload = this._payloadOverrides?.[key] return { key, enabled: overrideValue !== false, variant: typeof overrideValue === 'string' ? overrideValue : undefined, payload: overridePayload, } } const { groups, disableGeoip } = options let { onlyEvaluateLocally, personProperties, groupProperties } = options const adjustedProperties = this.addLocalPersonAndGroupProperties( distinctId, groups, personProperties, groupProperties ) personProperties = adjustedProperties.allPersonProperties groupProperties = adjustedProperties.allGroupProperties const evaluationContext = this.createFeatureFlagEvaluationContext( distinctId, groups, this.personPropertiesForLocalEvaluation(distinctId, personProperties), groupProperties ) // set defaults if (onlyEvaluateLocally == undefined) { onlyEvaluateLocally = this.options.strictLocalEvaluation ?? false } let result: FeatureFlagResult | undefined = undefined let flagWasLocallyEvaluated = false let requestId: string | undefined = undefined let evaluatedAt: number | undefined = undefined let featureFlagError: FeatureFlagErrorType | undefined = undefined // Track metadata for event tracking (not exposed in FeatureFlagResult) let flagId: number | undefined = undefined let flagVersion: number | undefined = undefined let flagReason: string | undefined = undefined let flagHasExperiment: boolean | undefined = undefined // Try local evaluation first const localEvaluationEnabled = this.featureFlagsPoller !== undefined if (localEvaluationEnabled) { await this.featureFlagsPoller?.loadFeatureFlags() const flag = this.featureFlagsPoller?.featureFlagsByKey[key] if (flag) { try { const localResult = await this.featureFlagsPoller?.computeFlagAndPayloadLocally(flag, evaluationContext, { matchValue, }) if (localResult) { flagWasLocallyEvaluated = true const value = localResult.value flagId = flag.id flagReason = 'Evaluated locally' flagHasExperiment = flag.has_experiment result = { key, enabled: value !== false, variant: typeof value === 'string' ? value : undefined, payload: localResult.payload ?? undefined, } } } catch (e) { if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) { // Fall through to server evaluation this._logger?.info(`${e.name} when computing flag locally: ${key}: ${e.message}`) } else { throw e } } } } // Fall back to remote evaluation if needed if (!flagWasLocallyEvaluated && !onlyEvaluateLocally) { const flagsResponse = await super.getFeatureFlagDetailsStateless( evaluationContext.distinctId, evaluationContext.groups, personProperties, groupProperties, disableGeoip, [key] ) if (flagsResponse === undefined) { featureFlagError = FeatureFlagError.UNKNOWN_ERROR } else { this._minimalFlagCalledEvents = flagsResponse.minimalFlagCalledEvents === true requestId = flagsResponse.requestId evaluatedAt = flagsResponse.evaluatedAt const errors: string[] = [] if (flagsResponse.errorsWhileComputingFlags) { errors.push(FeatureFlagError.ERRORS_WHILE_COMPUTING) } if (flagsResponse.quotaLimited?.includes('feature_flags')) { errors.push(FeatureFlagError.QUOTA_LIMITED) } const flagDetail = flagsResponse.flags[key] if (flagDetail === undefined) { errors.push(FeatureFlagError.FLAG_MISSING) } else { // Extract metadata for event tracking flagId = flagDetail.metadata?.id flagVersion = flagDetail.metadata?.version flagReason = flagDetail.reason?.description ?? flagDetail.reason?.code flagHasExperiment = flagDetail.metadata?.has_experiment // Parse payload once from the API response let parsedPayload: JsonType | undefined = undefined if (flagDetail.metadata?.payload !== undefined) { try { parsedPayload = JSON.parse(flagDetail.metadata.payload) } catch { // If parsing fails, return the raw string (matches parsePayload behavior) parsedPayload = flagDetail.metadata.payload } } result = { key, enabled: flagDetail.enabled, variant: flagDetail.variant, payload: parsedPayload, } } if (errors.length > 0) { featureFlagError = errors.join(',') } } } // Send feature flag event if configured if (sendFeatureFlagEvents) { const response = result === undefined ? undefined : result.enabled === false ? false : (result.variant ?? true) const properties: Record<string, any> = { $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 (flagHasExperiment !== undefined) { properties.$feature_flag_has_experiment = flagHasExperiment } if (flagWasLocallyEvaluated && this.featureFlagsPoller) { const flagDefinitionsLoadedAt = this.featureFlagsPoller.getFlagDefinitionsLoadedAt() if (flagDefinitionsLoadedAt !== undefined) { properties.$feature_flag_definitions_loaded_at = flagDefinitionsLoadedAt } } 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) // This ensures consistency with getFeatureFlagPayload behavior if (result !== undefined && this._payloadOverrides !== undefined && key in this._payloadOverrides) { result = { ...result, payload: this._payloadOverrides[key], } } return result } /** * Get the value of a feature flag for a specific user. * * @example * ```ts * // Basic feature flag check * const flagValue = await client.getFeatureFlag('new-feature', 'user_123') * if (flagValue === 'variant-a') { * // Show variant A * } else if (flagValue === 'variant-b') { * // Show variant B * } else { * // Flag is disabled or not found * } * ``` * * @example * ```ts * // With groups and properties * const flagValue = await client.getFeatureFlag('org-feature', 'user_123', { * groups: { organization: 'acme-corp' }, * personProperties: { plan: 'enterprise' }, * groupProperties: { organization: { tier: 'premium' } } * }) * ``` * * @example * ```ts * // Only evaluate locally * const flagValue = await client.getFeatureFlag('local-flag', 'user_123', { * onlyEvaluateLocally: true * }) * ``` * * {@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 * @returns Promise that resolves to the flag value or undefined */ async getFeatureFlag( key: string, distinctId: string, options?: { groups?: Record<string, string> personProperties?: Record<string, string> groupProperties?: Record<string, Record<string, string>> onlyEvaluateLocally?: boolean sendFeatureFlagEvents?: boolean disableGeoip?: boolean } ): Promise<FeatureFlagValue | undefined> { 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, }) if (result === undefined) { return undefined } if (result.enabled === false) { return false } return result.variant ?? true } /** * Get the payload for a feature flag. * * @example * ```ts * // Get payload for a feature flag * const payload = await client.getFeatureFlagPayload('flag-key', 'user_123') * if (payload) { * console.log('Flag payload:', payload) * } * ``` * * @example * ```ts * // Get payload with specific match value * const payload = await client.getFeatureFlagPayload('flag-key', 'user_123', 'variant-a') * ``` * * @example * ```ts * // With groups and properties * const payload = await client.getFeatureFlagPayload('org-flag', 'user_123', undefined, { * groups: { organization: 'acme-corp' }, * personProperties: { plan: 'enterprise' } * }) * ``` * * {@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 * @param options - Optional configuration for flag evaluation * @returns Promise that resolves to the flag payload or undefined */ async getFeatureFlagPayload( key: string, distinctId: string, matchValue?: FeatureFlagValue, options?: { groups?: Record<string, string> personProperties?: Record<string, string> groupProperties?: Record<string, Record<string, string>> onlyEvaluateLocally?: boolean /** @deprecated THIS OPTION HAS NO EFFECT, kept here for backwards compatibility reasons. */ sendFeatureFlagEvents?: boolean disableGeoip?: boolean } ): Promise<JsonType | undefined> { 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) { return this._payloadOverrides[key] } // sendFeatureFlagEvents is intentionally ignored for payload-only calls. // getFeatureFlagPayload never sends $feature_flag_called events, matching pre-refactoring behavior. // The option is kept in the signature for backwards compatibility (marked @deprecated above). const result = await this._getFeatureFlagResult( key, distinctId, { ...options, sendFeatureFlagEvents: false }, matchValue ) // Return undefined when API fails or flag not found if (result === undefined) { return undefined } // Return payload if available, null if flag exists but no payload return result.payload ?? null } /** * Get the result of evaluating a feature flag, including its value and payload. * This is more efficient than calling getFeatureFlag and getFeatureFlagPayload separately when you need both. * * @example * ```ts * // Get flag result * const result = await client.getFeatureFlagResult('my-flag', 'user_123') * if (result) { * console.log('Flag enabled:', result.enabled) * console.log('Variant:', result.variant) * console.log('Payload:', result.payload) * } * ``` * * @example * ```ts * // With groups and properties * const result = await client.getFeatureFlagResult('org-feature', 'user_123', { * groups: { organization: 'acme-corp' }, * personProperties: { plan: 'enterprise' } * }) * ``` * * {@label Feature flags} * * @param key - The feature flag key * @param distinctId - The user's distinct ID * @param options - Optional configuration for flag evaluation * @returns Promise that resolves to the flag result or undefined */ async getFeatureFlagResult(key: string, options?: FlagEvaluationOptions): Promise<FeatureFlagResult | undefined> async getFeatureFlagResult( key: string, distinctId: string, options?: FlagEvaluationOptions ): Promise<FeatureFlagResult | undefined> async getFeatureFlagResult( key: string, distinctIdOrOptions?: string | FlagEvaluationOptions, options?: FlagEvaluationOptions ): Promise<FeatureFlagResult | undefined> { const { distinctId: resolvedDistinctId, options: resolvedOptions } = this._resolveDistinctId( distinctIdOrOptions, options ) if (!resolvedDistinctId) { this._logger.warn('[PostHog] distinctId is required — pass it explicitly or use withContext()') return undefined } return this._getFeatureFlagResult(key, resolvedDistinctId, { ...resolvedOptions, sendFeatureFlagEvents: resolvedOptions?.sendFeatureFlagEvents ?? this.options.sendFeatureFlagEvent ?? true, }) } /** * Get the remote config payload for a feature flag. * * @example * ```ts * // Get remote config payload * const payload = await client.getRemoteConfigPayload('flag-key') * if (payload) { * console.log('Remote config payload:', payload) * } * ``` * * {@label Feature flags} * * @param flagKey - The feature flag key * @returns Promise that resolves to the remote config payload or undefined * @throws Error if personal API key is not provided */ async getRemoteConfigPayload(flagKey: string): Promise<JsonType | undefined> { if (this.disabled) { this._logger.warn('The client is disabled') return undefined } if (!this.options.personalApiKey) { throw new Error('Personal API key is required for remote config payload decryption') } const response = await this._requestRemoteConfigPayload(flagKey) if (!response) { return undefined } const parsed = await resp