import { version } from './version' import { allSettled, FeatureFlagValue, getEventUuid, isBlockedUA, isPlainObject, isPostHogFetchContentTooLargeError, JsonType, minimizeFlagCalledEventProperties, PostHogCaptureOptions, PostHogCoreStateless, PostHogEventProperties, PostHogFetchOptions, PostHogFetchResponse, PostHogFlagsAndPayloadsResponse, PostHogFlagsResponse, PostHogMetrics, PostHogPersistedProperty, PostHogTraces, inertSpan, Properties, resolveMetricsConfig, resolveTracesConfig, runWithActiveSpan, RetriableOptions, raceWithTimeout, safeSetTimeout, SyncSpanContextManager, uuidv7, } from '@posthog/core' import type { Metrics, Span, SpanContextManager, StartSpanOptions, TraceSdkContext } from '@posthog/core' import { AllFlagsOptions, EventMessage, FeatureFlagError, FeatureFlagErrorType, FeatureFlagEvaluationRuntime, 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 { getActiveTraceProperties } from './extensions/error-tracking/trace-context' 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' import { eventByteSize, partitionAiBatch } from './ai-capture/batching' import { AI_CAPTURE_ENDPOINT_PATH, AI_CAPTURE_ROUTE, AI_MAX_EVENT_BYTES } from './ai-capture/routing' // 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() 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 | 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() private featureFlagsPoller?: FeatureFlagsPoller protected errorTracking: ErrorTracking private maxCacheSize: number public readonly options: PostHogOptions protected readonly context?: IPostHogContext private _metrics?: PostHogMetrics private _traces?: PostHogTraces private _spanContext?: SpanContextManager private readonly captureMode: CaptureMode private _v1Sender?: V1CaptureSender /** Whether this client captures full AI content: see `enableFullAiCapture` in `PostHogOptions`. */ public readonly enableFullAiCapture: boolean private _aiCaptureRouteActive = false // Feature flag overrides for local testing/development private _flagOverrides?: Record private _payloadOverrides?: Record // Server-controlled gate for minimal $feature_flag_called events. Single client-level gate, // last-writer-wins across the two signal sources (v2 /flags responses and the poller's // flag-definition loads) — both derive from the same per-team server config and converge. private _minimalFlagCalledEvents: boolean = false // Insertion-ordered LRU of the `$feature_flag_called` keys already reported per distinct id. // Map iteration order is insertion order, so the first entry is always the least recently used. private _distinctIdHasSentFlagCalls: Map> /** * Snapshot of the `$feature_flag_called` dedupe state, keyed by distinct id. * Adding or deleting keys on the returned object does not affect deduplication; * assigning a whole object replaces the tracker. * @internal * @deprecated Not part of the public API; kept for backwards compatibility. */ get distinctIdHasSentFlagCalls(): Record> { return Object.fromEntries(this._distinctIdHasSentFlagCalls) } set distinctIdHasSentFlagCalls(value: Record>) { this._distinctIdHasSentFlagCalls = new Map(Object.entries(value ?? {})) } // waitUntil debounce state (per-instance) private _waitUntilCycle?: { resolve: () => void startedAt: number timer: ReturnType | 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.enableFullAiCapture = normalizedOptions.enableFullAiCapture === true this.context = this.initializeContext() this.options.featureFlagsPollingInterval = typeof normalizedOptions.featureFlagsPollingInterval === 'number' ? Math.max(normalizedOptions.featureFlagsPollingInterval, MINIMUM_POLLING_INTERVAL) : normalizedOptions.featureFlagsPollingInterval === null ? null : 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 || normalizedOptions.flagDefinitionCacheProvider)) { if (normalizedOptions.personalApiKey?.includes('phc_')) { throw new Error( 'A project API key ("phc_...") was provided as secretKey or personalApiKey. Pass the project API key as the first constructor argument. For local evaluation, set secretKey (or the deprecated personalApiKey alias) to a server-only Project Secret API Key ("phs_...") or Personal API Key ("phx_...") with feature flag read access.' ) } // 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, evaluationContexts: normalizedOptions.evaluationContexts ?? normalizedOptions.evaluationEnvironments, }) } } this.errorTracking = new ErrorTracking(this, normalizedOptions, this._logger) this._distinctIdHasSentFlagCalls = new Map() this.maxCacheSize = normalizedOptions.maxCacheSize || MAX_CACHE_SIZE } protected override enqueue( type: string, message: any, options?: PostHogCaptureOptions, explicitRoute?: string ): void { super.enqueue(type, message, options, explicitRoute) this.scheduleDebouncedFlush() } /** * Concurrent so a serverless handler waits for one round trip, not two. A * failed span export leaves the spans queued rather than rejecting, since * callers already treat `flush()` as safe to leave unwrapped. */ private _flushEventsAndSpans(skipThrottledSpans = false): Promise { const events = this.flushWithPendingPromises() if (!this._traces || (skipThrottledSpans && this._traces.throttled)) { return events } // Settled, not `all`: `all` rejects the moment the event flush does, and a // serverless host that treats this promise as the end of the invocation can // freeze it with the span request still open. The events rejection is still // the one the caller sees. return allSettled([events, this._traces.flush().catch(() => {})]).then(([eventsResult]) => { if (eventsResult.status === 'rejected') { throw eventsResult.reason } }) } protected override flushAutomatic(): Promise { // The events timer must not drag spans through a `Retry-After` window the // traces queue is honouring; its own timer picks them up when it closes. return this._flushKeepingRuntimeAlive(true) } override async flush(): Promise { return this._flushKeepingRuntimeAlive(false) } private _flushKeepingRuntimeAlive(skipThrottledSpans: boolean): Promise { const flushPromise = this._flushEventsAndSpans(skipThrottledSpans) 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((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 { const resolve = this._consumeWaitUntilCycle() try { await this._flushEventsAndSpans() } 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 { 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 { if (route === AI_CAPTURE_ROUTE) { return PostHogPersistedProperty.AiCaptureQueue } return route === AI_ROUTE ? PostHogPersistedProperty.AiQueue : PostHogPersistedProperty.Queue } protected getActiveQueueRoutes(): string[] { const routes = this.captureMode === 'v1' ? [ANALYTICS_ROUTE, AI_ROUTE] : [ANALYTICS_ROUTE] if (this._aiCaptureRouteActive) { routes.push(AI_CAPTURE_ROUTE) } return routes } protected getBatchEndpointPath(route: string): string { return route === AI_CAPTURE_ROUTE ? AI_CAPTURE_ENDPOINT_PATH : super.getBatchEndpointPath(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, route: string = ANALYTICS_ROUTE ): Promise { if (route === AI_CAPTURE_ROUTE) { return this.sendAiCaptureBatch(batchMessages, retryOptions) } 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 async sendAiCaptureBatch( batchMessages: (PostHogEventProperties | undefined)[], retryOptions?: Partial ): Promise { const { batches, dropped } = partitionAiBatch(batchMessages) for (const { event, bytes } of dropped) { const message = `Event ${event} (${bytes} bytes) exceeds the ${AI_MAX_EVENT_BYTES / (1024 * 1024)}MiB limit for ${AI_CAPTURE_ENDPOINT_PATH}, dropping.` this._logger.error(message) this._events.emit('error', new Error(message)) } for (const batch of batches) { await this.sendAiSubBatch(batch, retryOptions) } } // A single-event 413 means the server's cap is below that event's size, so no split can save it. private async sendAiSubBatch( batch: PostHogEventProperties[], retryOptions?: Partial ): Promise { try { await super.sendBatch(batch, retryOptions, AI_CAPTURE_ROUTE) } catch (err) { if (!isPostHogFetchContentTooLargeError(err)) { throw err } if (batch.length === 1) { const [event] = batch const eventName = typeof event.event === 'string' ? event.event : 'unknown' const message = `Event ${eventName} (${eventByteSize(event)} bytes) was rejected with 413 by ${AI_CAPTURE_ENDPOINT_PATH} on its own, dropping.` this._logger.error(message) this._events.emit('error', new Error(message)) return } const mid = Math.ceil(batch.length / 2) await this.sendAiSubBatch(batch.slice(0, mid), retryOptions) await this.sendAiSubBatch(batch.slice(mid), retryOptions) } } 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), compress: (payload) => this.compressPayload(payload), } ) } 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 } /** * Active-span tracking. Overridden by the Node entrypoint with an * `AsyncLocalStorage`-backed manager; the edge build keeps this synchronous * fallback, matching how `initializeContext` already differs between them. */ protected initializeSpanContextManager(): SpanContextManager { return new SyncSpanContextManager() } /** * Runtime-detected OTLP resource attributes for every span. Overridden by the * Node entrypoint with the host OS; the edge build contributes none, keeping * `node:os` out of an edge bundle, which cannot resolve it. */ protected hostResourceAttributes(): Record { return {} } /** * Active-span tracking, built on first use. Lives on the client rather than * on the traces pipeline because a client with tracing off still activates a * pass-through span, so it needs the same store the pipeline would use. */ private get _spanContextManager(): SpanContextManager { if (!this._spanContext) { this._spanContext = this.initializeSpanContextManager() } return this._spanContext } /** * The traces pipeline, built on first use. Returns `undefined` when the * `traces` client option is absent — tracing is off until configured. */ private get _tracesPipeline(): PostHogTraces | undefined { if (!this.options.traces) { return undefined } if (!this._traces) { this._traces = new PostHogTraces( this, resolveTracesConfig(this.options.traces, this.hostResourceAttributes(), this._logger), this._logger, () => this._tracingContext(), this._spanContextManager, // A handler that only traces still has to hold the invocation open. () => this.scheduleDebouncedFlush() ) } return this._traces } /** * PostHog context attached to every span, so traces join back to persons and * sessions. A server process has no ambient identity, so these come from the * current request context — the Express/NestJS middleware or `withContext`. */ private _tracingContext(): TraceSdkContext { const context = this.context?.get() return { distinctId: context?.distinctId, sessionId: context?.sessionId } } /** * Starts a span without making it active — for work that can't wrap a * callback. Prefer `withSpan`, which ends the span for you. * * Always returns a handle, so calling code never has to branch: when the * `traces` option is absent, the SDK is disabled, or the user has opted out, * the handle is inert and nothing is exported. An inert handle given a * `parent` header still returns it from `traceparent()`, so a service with * tracing off passes a distributed trace through instead of severing it. * * {@label Traces} * * @experimental Subject to change in a minor release. * * @example * ```ts * const span = posthog.startSpan('checkout', { attributes: { plan: 'pro' } }) * span.setAttribute('cart.items', 3) * span.end() * ``` */ startSpan(name: string, options?: StartSpanOptions): Span { return this._tracesPipeline?.startSpan(name, options) ?? inertSpan(options, this._spanContextManager.active()) } /** * Runs a callback with a span active for its duration and ends the span for * you — at return for a sync callback, at settle for an async one. Takes an * optional `StartSpanOptions` between the name and the callback: * `withSpan(name, fn)` or `withSpan(name, options, fn)`. * * Spans started inside the callback nest under it automatically. If the * callback throws or rejects, the span records the exception and the original * error is rethrown unchanged. A callback that ends the span itself gets the * rethrow but not the recording, since the span is already exported by then. * * Spans nest across `await` only on the Node runtime, which tracks the active * span with `AsyncLocalStorage`. The edge build restores the active span when * the callback returns its promise, so spans started after an `await` there * begin a new trace. * * {@label Traces} * * @experimental Subject to change in a minor release. * * @example * ```ts * await posthog.withSpan('POST /checkout', { parent: req.get('traceparent') }, async (span) => { * span.setAttribute('plan', user.plan) * return processOrder() * }) * ``` */ withSpan(name: string, fn: (span: Span) => T): T withSpan(name: string, options: StartSpanOptions, fn: (span: Span) => T): T withSpan(name: string, optionsOrFn: StartSpanOptions | ((span: Span) => T), maybeFn?: (span: Span) => T): T { const options = typeof optionsOrFn === 'function' ? undefined : optionsOrFn const fn = (typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn) as (span: Span) => T const pipeline = this._tracesPipeline if (!pipeline) { // Tracing off: still run the callback exactly once, with an inert handle. // A handle carrying an inbound `parent` is activated, so `getActiveSpan()` // inside the callback can propagate the trace onward. return runWithActiveSpan(this._spanContextManager, inertSpan(options, this._spanContextManager.active()), fn) } return options ? pipeline.withSpan(name, options, fn) : pipeline.withSpan(name, fn) } /** * The span currently active on this async execution path, or `null` outside * any `withSpan` callback. * * On the edge build this returns `null` after an `await`, because the active * span is tracked synchronously there. * * {@label Traces} * * @experimental Subject to change in a minor release. * * @example * ```ts * // Propagate the trace to another service * const traceparent = posthog.getActiveSpan()?.traceparent() * await fetch(url, { headers: traceparent ? { traceparent } : {} }) * ``` */ getActiveSpan(): Span | null { // Read from the store directly rather than through the pipeline: the two // share one manager, and with tracing off there is no pipeline to ask. return this._spanContextManager.active() ?? null } /** * 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()}` } /** * posthog-node and posthog-edge are server runtimes, so `/flags` requests declare * `evaluation_runtime: 'server'` and receive `server` and `all` flags. This matches what the * server already infers from a recognized `posthog-node` / `posthog-edge` User-Agent, but * does not depend on that header surviving proxies, gateways, or edge runtimes. */ protected override getEvaluationRuntime(): 'server' { return 'server' } /** * 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 { 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 { 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 }, explicitRoute?: string ): Promise { 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, explicitRoute) : this.enqueue(type, message, captureOptions, explicitRoute) }) .catch((err) => { if (err) { console.error(err) } }) ) } _capturePreparedEvent(props: EventMessage, immediate: boolean): Promise { 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 { 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) } /** * Capture an AI event on the dedicated AI capture endpoint. * * Beta: the signature is stable; operational limits (per-event size cap, * batching, endpoint) may change without notice. Delivery is async, and no * redaction or truncation is applied to the payload. * * {@label Capture} * * @param props - The event properties * @returns The event UUID, or `undefined` when the client is disabled */ captureAi(props: EventMessage): string | undefined { if (this.disabled) { return undefined } const uuid = getEventUuid(props.uuid, uuidv7) void this._sendPreparedAiEvent({ ...props, uuid }, false) return uuid } /** * Capture an AI event on the dedicated AI capture endpoint, resolving after * the send completes. Use in short-lived processes (serverless) where the * runtime may freeze before a background flush runs. * * {@label Capture} * * @param props - The event properties * @returns The event UUID, or `undefined` when the client is disabled */ async captureAiImmediate(props: EventMessage): Promise { if (this.disabled) { return undefined } const uuid = getEventUuid(props.uuid, uuidv7) await this._sendPreparedAiEvent({ ...props, uuid }, true) return uuid } private _sendPreparedAiEvent(props: EventMessage, immediate: boolean): Promise { if (typeof props?.event === 'string' && !props.event.startsWith('$ai_')) { this._logger.debug(`captureAi called with non-AI event ${props.event}; routing it to the AI endpoint anyway.`) } this._aiCaptureRouteActive = true return this._sendPreparedEvent('capture', props, immediate, undefined, AI_CAPTURE_ROUTE) } /** * 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 { // 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 = {} 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 { 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 } /** * Get where a locally loaded feature flag is meant to be evaluated. * * @remarks * Read from the definitions local evaluation already holds, so no request is made. A * definition that carries no runtime reports `all`, the default PostHog applies. * * @example * ```ts * const runtime = client.getFeatureFlagEvaluationRuntime('my-flag') * // Returns: 'client' * ``` * * {@label Feature flags} * * @param key - The feature flag key * @returns The flag's evaluation runtime, or undefined when local evaluation has not loaded a * definition for this key */ getFeatureFlagEvaluationRuntime(key: string): FeatureFlagEvaluationRuntime | undefined { return this.featureFlagsPoller?.getEvaluationRuntimeForFlag(key) } /** * Get the keys of locally loaded flags that a runtime can evaluate. * * @remarks * A flag set to `all` suits either runtime, so it is returned for `client` and for `server`, * and asking for `all` returns every loaded flag. Use this to decide which flags to hand to a * browser when a backend serves flags to its own frontend. * * @example * ```ts * const clientKeys = client.getFeatureFlagKeysByEvaluationRuntime('client') * // Returns: ['web-banner', 'shared-copy'] * ``` * * {@label Feature flags} * * @param evaluationRuntime - The runtime to match * @returns The matching flag keys, in the order local evaluation loaded them. Empty when no * definitions are loaded */ getFeatureFlagKeysByEvaluationRuntime(evaluationRuntime: FeatureFlagEvaluationRuntime): string[] { return this.featureFlagsPoller?.getFlagKeysByEvaluationRuntime(evaluationRuntime) ?? [] } /** * 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 { 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( 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 when no evaluation is available */ private async _getFeatureFlagResult( key: string, distinctId: string, options: { groups?: Record personProperties?: Properties groupProperties?: Record onlyEvaluateLocally?: boolean sendFeatureFlagEvents?: boolean disableGeoip?: boolean } = {}, matchValue?: FeatureFlagValue ): Promise { 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 feature-flag-called events. 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, reason: flagReason, reasonCode: flag.active === false ? 'flag_disabled' : 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, // The flags API serializes missing variants as null variant: flagDetail.variant ?? undefined, payload: parsedPayload, reason: flagReason, reasonCode: flagDetail.reason?.code, } } 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 = { $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. * * A boolean `false` is a conclusive off evaluation. `undefined` means no result is available, * for example because the key was not returned or local-only evaluation was inconclusive. * Local evaluation resolves cached inactive definitions to `false`; remote evaluation omits * globally inactive flags, so the result depends on which evaluation path is available. * * @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 evaluated off, or no value was returned * } * ``` * * @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 when no evaluation is available */ async getFeatureFlag( key: string, distinctId: string, options?: { groups?: Record personProperties?: Properties groupProperties?: Record onlyEvaluateLocally?: boolean sendFeatureFlagEvents?: boolean 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, }) 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 personProperties?: Properties groupProperties?: Record onlyEvaluateLocally?: boolean /** @deprecated THIS OPTION HAS NO EFFECT, kept here for backwards compatibility reasons. */ sendFeatureFlagEvents?: boolean 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) { 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. * A result with `enabled: false` is a conclusive off evaluation; `undefined` means no evaluation * is available. Local evaluation resolves cached inactive definitions to `enabled: false`, while * remote evaluation omits globally inactive flags. * * @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 async getFeatureFlagResult( key: string, distinctId: string, options?: FlagEvaluationOptions ): Promise async getFeatureFlagResult( key: string, distinctIdOrOptions?: string | FlagEvaluationOptions, options?: FlagEvaluationOptions ): Promise { 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 { 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 response.json() // The payload from the endpoint is stored as a JSON encoded string. So when we return // it, it's effectively double encoded. As far as we know, we should never get single-encoded // JSON, but we'll be defensive here just in case. if (typeof parsed === 'string') { try { // If the parsed value is a string, try parsing it again to handle double-encoded JSON return JSON.parse(parsed) } catch (e) { // If second parse fails, return the string as is return parsed } } return parsed } /** * Check if a feature flag is enabled for a specific user. * * @example * ```ts * // Basic feature flag check * const isEnabled = await client.isFeatureEnabled('new-feature', 'user_123') * if (isEnabled) { * // Feature is enabled * console.log('New feature is active') * } else { * // Flag evaluated off, or no value was returned * console.log('New feature is not active') * } * ``` * * @example * ```ts * // With groups and properties * const isEnabled = await client.isFeatureEnabled('org-feature', 'user_123', { * groups: { organization: 'acme-corp' }, * personProperties: { plan: 'enterprise' } * }) * ``` * * {@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 * @returns Promise that resolves to true if the flag evaluates on, false if it conclusively evaluates off, or undefined when no evaluation is available */ async isFeatureEnabled( key: string, distinctId: string, options?: { groups?: Record personProperties?: Properties groupProperties?: Record onlyEvaluateLocally?: boolean sendFeatureFlagEvents?: boolean disableGeoip?: boolean } ): Promise { 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 } /** * Get all feature flag values for a specific user. * * @example * ```ts * // Get all flags for a user * const allFlags = await client.getAllFlags('user_123') * console.log('User flags:', allFlags) * // Output: { 'flag-1': 'variant-a', 'flag-2': false, 'flag-3': 'variant-b' } * ``` * * @example * ```ts * // With specific flag keys * const specificFlags = await client.getAllFlags('user_123', { * flagKeys: ['flag-1', 'flag-2'] * }) * ``` * * @example * ```ts * // With groups and properties * const orgFlags = await client.getAllFlags('user_123', { * groups: { organization: 'acme-corp' }, * personProperties: { plan: 'enterprise' } * }) * ``` * * {@label Feature flags} * * @param distinctId - The user's distinct ID * @param options - Optional configuration for flag evaluation * @returns Promise that resolves to a record of flag keys and their values */ async getAllFlags(options?: AllFlagsOptions): Promise> async getAllFlags(distinctId: string, options?: AllFlagsOptions): Promise> async getAllFlags( 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 get feature flags — pass it explicitly or use withContext()' ) return {} } const response = await this.getAllFlagsAndPayloads(resolvedDistinctId, resolvedOptions) return response.featureFlags || {} } /** * Get all feature flag values and payloads for a specific user. * * @example * ```ts * // Get all flags and payloads for a user * const result = await client.getAllFlagsAndPayloads('user_123') * console.log('Flags:', result.featureFlags) * console.log('Payloads:', result.featureFlagPayloads) * ``` * * @example * ```ts * // With specific flag keys * const result = await client.getAllFlagsAndPayloads('user_123', { * flagKeys: ['flag-1', 'flag-2'] * }) * ``` * * @example * ```ts * // Only evaluate locally * const result = await client.getAllFlagsAndPayloads('user_123', { * onlyEvaluateLocally: true * }) * ``` * * {@label Feature flags} * * @param distinctId - The user's distinct ID * @param options - Optional configuration for flag evaluation * @returns Promise that resolves to flags and payloads */ async getAllFlagsAndPayloads(options?: AllFlagsOptions): Promise async getAllFlagsAndPayloads(distinctId: string, options?: AllFlagsOptions): Promise async getAllFlagsAndPayloads( 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 get feature flags and payloads — pass it explicitly or use withContext()' ) return { featureFlags: {}, featureFlagPayloads: {} } } if (this.disabled) { this._logger.warn('The client is disabled') return { featureFlags: {}, featureFlagPayloads: {} } } 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, this.personPropertiesForLocalEvaluation(resolvedDistinctId, personProperties), groupProperties ) // set defaults if (onlyEvaluateLocally == undefined) { onlyEvaluateLocally = this.options.strictLocalEvaluation ?? false } const localEvaluationResult = await this.featureFlagsPoller?.getAllFlagsAndPayloads(evaluationContext, flagKeys) let featureFlags = {} let featureFlagPayloads = {} let fallbackToFlags = true if (localEvaluationResult) { featureFlags = localEvaluationResult.response featureFlagPayloads = localEvaluationResult.payloads fallbackToFlags = localEvaluationResult.fallbackToFlags } if (fallbackToFlags && !onlyEvaluateLocally) { const remoteEvaluationResult = await super.getFeatureFlagsAndPayloadsStateless( evaluationContext.distinctId, evaluationContext.groups, personProperties, groupProperties, disableGeoip, flagKeys ) featureFlags = { ...featureFlags, ...(remoteEvaluationResult.flags || {}), } featureFlagPayloads = { ...featureFlagPayloads, ...(remoteEvaluationResult.payloads || {}), } } // Apply overrides last - they take precedence over all evaluation if (this._flagOverrides !== undefined) { featureFlags = { ...featureFlags, ...this._flagOverrides, } } if (this._payloadOverrides !== undefined) { featureFlagPayloads = { ...featureFlagPayloads, ...this._payloadOverrides, } } 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`. A requested key missing from local * definitions is included in a `/flags` fallback unless `onlyEvaluateLocally` is true. * Locally resolved values remain authoritative when remote results are merged. In particular, * a cached inactive definition is a conclusive `false` result locally, while remote evaluation * omits globally inactive flags and therefore leaves those keys absent. * * **Trim the request.** Pass `flagKeys` to scope local evaluation, the underlying * `/flags` request, and the returned snapshot to a subset of flags. Remote evaluation * responses are not cached, so a key missing both locally and remotely costs one * `/flags` request per `evaluateFlags()` call. * * **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()`. `flagKeys` scopes local evaluation, the `/flags` request, and the returned snapshot. `onlyEvaluateLocally` prevents fallback and leaves unresolved keys absent. * @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: {}, }) } if (this.disabled) { this._logger.warn('The client is disabled') return new FeatureFlagEvaluations({ host: this._getFeatureFlagEvaluationsHost(), distinctId: resolvedDistinctId, flags: {}, }) } const { groups, disableGeoip } = resolvedOptions || {} const flagKeys = resolvedOptions?.flagKeys ?? undefined if (flagKeys?.length === 0) { return new FeatureFlagEvaluations({ host: this._getFeatureFlagEvaluationsHost(), distinctId: resolvedDistinctId, groups, disableGeoip, flags: {}, }) } 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, this.personPropertiesForLocalEvaluation(resolvedDistinctId, personProperties), groupProperties ) if (onlyEvaluateLocally == undefined) { onlyEvaluateLocally = this.options.strictLocalEvaluation ?? false } const requestedFlagKeys = flagKeys ? new Set(flagKeys) : undefined 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, hasExperiment: flagDef?.has_experiment, } 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 requestedFlagMissingLocally = requestedFlagKeys !== undefined && Array.from(requestedFlagKeys).some((key) => this.featureFlagsPoller?.featureFlagsByKey[key] === undefined) const fallbackToFlags = localResult ? localResult.fallbackToFlags || requestedFlagMissingLocally : true if (fallbackToFlags && !onlyEvaluateLocally) { const details = await super.getFeatureFlagDetailsStateless( evaluationContext.distinctId, evaluationContext.groups, personProperties, groupProperties, disableGeoip, flagKeys ) if (details) { this._minimalFlagCalledEvents = details.minimalFlagCalledEvents === true 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, hasExperiment: detail.metadata?.has_experiment, } } } } // 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, hasExperiment: existing?.hasExperiment, } } } if (this._payloadOverrides !== undefined) { for (const [key, payload] of Object.entries(this._payloadOverrides)) { const existing = records[key] if (existing) { records[key] = { ...existing, payload } } } } if (requestedFlagKeys !== undefined) { for (const key of Object.keys(records)) { if (!requestedFlagKeys.has(key)) { delete records[key] } } } return new FeatureFlagEvaluations({ host: this._getFeatureFlagEvaluationsHost(), distinctId: resolvedDistinctId, groups, disableGeoip, flags: records, requestId, evaluatedAt, flagDefinitionsLoadedAt: this.featureFlagsPoller?.getFlagDefinitionsLoadedAt(), errorsWhileComputing, quotaLimited, }) } /** * Minimal iff this is a `$feature_flag_called` event, the server gate is on, and the flag * is known to not be linked to an experiment. Any missing signal — no gate seen yet, * `$feature_flag_has_experiment` absent — falls back to the full event. * * @internal */ private _shouldSendMinimalFlagCalledEvent(event: string, properties: PostHogEventProperties): boolean { return ( event === '$feature_flag_called' && this._minimalFlagCalledEvents && properties.$feature_flag_has_experiment === false ) } /** * Fires a `$feature_flag_called` event for the given flag if the (distinctId, flag, response) * triple hasn't already been reported for this client. Shared by the single-flag evaluation * 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 groupSuffix = groups && Object.keys(groups).length > 0 ? `_${JSON.stringify(Object.entries(groups).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))}` : '' const featureFlagReportedKey = `${key}_${response}${groupSuffix}` const reported = this._distinctIdHasSentFlagCalls.get(distinctId) ?? new Set() // Re-insert so the distinct id moves to the most-recently-used end of the map. this._distinctIdHasSentFlagCalls.delete(distinctId) this._distinctIdHasSentFlagCalls.set(distinctId, reported) if (reported.has(featureFlagReportedKey)) { return } reported.add(featureFlagReportedKey) // Evict only the least recently used distinct ids. Clearing the whole tracker under capacity // pressure would re-send `$feature_flag_called` for every active user at once. The current id // is always kept, even when maxCacheSize is below 1. while (this._distinctIdHasSentFlagCalls.size > Math.max(this.maxCacheSize, 1)) { const lruDistinctId = this._distinctIdHasSentFlagCalls.keys().next().value if (lruDistinctId === undefined) { break } this._distinctIdHasSentFlagCalls.delete(lruDistinctId) } 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. * * @example * ```ts * // Create a company group * client.groupIdentify({ * groupType: 'company', * groupKey: 'acme-corp', * properties: { * name: 'Acme Corporation', * industry: 'Technology', * employee_count: 500 * }, * distinctId: 'user_123' * }) * ``` * * @example * ```ts * // Update organization properties * client.groupIdentify({ * groupType: 'organization', * groupKey: 'org-456', * properties: { * plan: 'enterprise', * region: 'US-West' * } * }) * ``` * * {@label Identification} * * @param data - The group identify data */ groupIdentify({ groupType, groupKey, properties, distinctId, disableGeoip }: GroupIdentifyMessage): void { this._sendPreparedEvent( 'capture', { distinctId: distinctId || `$${groupType}_${groupKey}`, event: '$groupidentify', properties: { $group_type: groupType, $group_key: groupKey, $group_set: properties || {}, }, disableGeoip, }, false, { includeContextProperties: false } ) } /** * Create or update a group and its properties immediately (synchronously). * * @example * ```ts * // Immediately create or update a company group * await client.groupIdentifyImmediate({ * groupType: 'company', * groupKey: 'acme-corp', * properties: { * name: 'Acme Corporation', * industry: 'Technology', * employee_count: 500 * } * }) * ``` * * {@label Identification} * * @param data - The group identify data * @returns Promise that resolves when the group identify is processed */ async groupIdentifyImmediate({ groupType, groupKey, properties, distinctId, disableGeoip, }: GroupIdentifyMessage): Promise { await this._sendPreparedEvent( 'capture', { distinctId: distinctId || `$${groupType}_${groupKey}`, event: '$groupidentify', properties: { $group_type: groupType, $group_key: groupKey, $group_set: properties || {}, }, disableGeoip, }, true, { includeContextProperties: false } ) } /** * Reload feature flag definitions from the configured cache provider or PostHog for local evaluation. * * @example * ```ts * // Force reload of feature flags * await client.reloadFeatureFlags() * console.log('Feature flags reloaded') * ``` * * @example * ```ts * // Reload before checking a specific flag * await client.reloadFeatureFlags() * const flag = await client.getFeatureFlag('flag-key', 'user_123') * ``` * * {@label Feature flags} * * @returns Promise that resolves when flags are reloaded */ async reloadFeatureFlags(): Promise { await this.featureFlagsPoller?.loadFeatureFlags(true) } /** * Override feature flags locally. Useful for testing and local development. * Overridden flags take precedence over both local evaluation and remote evaluation. * * @example * ```ts * // Clear all overrides * client.overrideFeatureFlags(false) * * // Enable a list of flags (sets them to true) * client.overrideFeatureFlags(['flag-a', 'flag-b']) * * // Set specific flag values/variants * client.overrideFeatureFlags({ 'my-flag': 'variant-a', 'other-flag': true }) * * // Set both flags and payloads * client.overrideFeatureFlags({ * flags: { 'my-flag': 'variant-a' }, * payloads: { 'my-flag': { discount: 20 } } * }) * ``` * * {@label Feature flags} * * @param overrides - Flag overrides configuration */ overrideFeatureFlags(overrides: OverrideFeatureFlagsOptions): void { const flagArrayToRecord = (flags: string[]) => Object.fromEntries(flags.map((f) => [f, true])) if (overrides === false) { this._flagOverrides = undefined this._payloadOverrides = undefined return } // Array syntax: ['flag-a', 'flag-b'] -> { 'flag-a': true, 'flag-b': true } if (Array.isArray(overrides)) { this._flagOverrides = flagArrayToRecord(overrides) return } if (this._isFeatureFlagOverrideOptions(overrides)) { if ('flags' in overrides) { if (overrides.flags === false) { this._flagOverrides = undefined } else if (Array.isArray(overrides.flags)) { this._flagOverrides = flagArrayToRecord(overrides.flags) } else if (overrides.flags !== undefined) { this._flagOverrides = { ...overrides.flags } } } if ('payloads' in overrides) { if (overrides.payloads === false) { this._payloadOverrides = undefined } else if (overrides.payloads !== undefined) { this._payloadOverrides = { ...overrides.payloads } } } return } // Fallback: treat as Record this._flagOverrides = { ...overrides } } /** * Type guard to check if overrides is a FeatureFlagOverrideOptions object. * * This distinguishes between: * - { flags: { 'flag-a': true } } -> FeatureFlagOverrideOptions (flags is an object/array/false) * - { flags: true } -> Record (a flag named "flags" with value true) */ private _isFeatureFlagOverrideOptions(overrides: unknown): overrides is FeatureFlagOverrideOptions { if (typeof overrides !== 'object' || overrides === null || Array.isArray(overrides)) { return false } const obj = overrides as Record // Check if 'flags' key exists and has a valid structure for FeatureFlagOverrideOptions // Valid values: false, string[], or Record (an object) if ('flags' in obj) { const flagsValue = obj['flags'] // If flags is false, an array, or a plain object - it's FeatureFlagOverrideOptions // If flags is a boolean true or a string - it's a flag named "flags" with that value if ( flagsValue === false || Array.isArray(flagsValue) || (typeof flagsValue === 'object' && flagsValue !== null) ) { return true } } // Check if 'payloads' key exists and has a valid structure for FeatureFlagOverrideOptions // Valid values: false or Record (an object) if ('payloads' in obj) { const payloadsValue = obj['payloads'] // If payloads is false or a plain object - it's FeatureFlagOverrideOptions // If payloads is a string or boolean true - it's a flag named "payloads" with that value if (payloadsValue === false || (typeof payloadsValue === 'object' && payloadsValue !== null)) { return true } } return false } protected abstract initializeContext(): IPostHogContext | undefined /** * Run a function with specific context that will be applied to all events captured within that context. * It propagates the context to all subsequent calls down the call stack. * Context properties like tags and sessionId will be automatically attached to all events. * By default, nested contexts inherit from parent contexts. Use `{ fresh: true }` to start with a clean context. * * @example * ```ts * posthog.withContext({ distinctId: 'user_123' }, () => { * posthog.capture({ event: 'button clicked' }) * }) * ``` * * {@label Context} * * @param data - Context data to apply (sessionId, distinctId, properties, enableExceptionAutocapture) * @param fn - Function to run with the context * @param options - Context options (fresh: true to start with clean context instead of inheriting) * @returns The return value of the function */ withContext(data: Partial, fn: () => T, options?: ContextOptions): T { if (!this.context) { // Context not supported in this environment (e.g., edge runtime) return fn() } return this.context.run(data, fn, options) } /** * Get the current context data. * * @example * ```ts * // Get current context within a withContext block * posthog.withContext({ distinctId: 'user_123' }, () => { * const context = posthog.getContext() * console.log(context?.distinctId) // 'user_123' * }) * ``` * * {@label Context} * * @returns The current context data, or undefined if no context is set */ getContext(): ContextData | undefined { return this.context?.get() } /** * Set context without a callback wrapper. * * Uses `AsyncLocalStorage.enterWith()` to attach context to the current * async execution context. The context lives until that async context ends. * * Must be called in the same async scope that makes PostHog calls. * Calling this outside a request-scoped async context will leak context * across unrelated work. Prefer `withContext()` when you can wrap code * in a callback — it creates an isolated scope that cleans up automatically. * * @param data - Context data to apply (distinctId, sessionId, properties) * @param options - Context options (fresh: true to start with clean context instead of inheriting) */ enterContext(data: Partial, options?: ContextOptions): void { this.context?.enter(data as ContextData, options) } /** * Shutdown the PostHog client gracefully. * * @example * ```ts * // Shutdown with default timeout * await client._shutdown() * ``` * * @example * ```ts * // Shutdown with custom timeout * await client._shutdown(5000) // 5 seconds * ``` * * {@label Shutdown} * * @param shutdownTimeoutMs - Timeout in milliseconds for shutdown * @returns Promise that resolves when shutdown is complete */ async _shutdown(shutdownTimeoutMs?: number): Promise { // One absolute deadline for the whole shutdown: the metrics flush race and // super._shutdown spend from the same budget, so shutdown(500) can't take // ~1000ms by giving each phase a fresh full timeout. const shutdownDeadlineMs = Date.now() + (shutdownTimeoutMs ?? 30000) // Cancel any pending debounced flush — shutdown will flush directly. const resolve = this._consumeWaitUntilCycle() await this.featureFlagsPoller?.stopPoller(shutdownTimeoutMs) this.errorTracking.shutdown() if (this._metrics) { // Send whatever is aggregated in the current window, then clear the flush // timer so it can't fire after teardown. Raced against the shutdown budget: // this flush runs before super._shutdown starts its own timeout, so an // unresponsive transport must not be able to hold shutdown past the // caller's deadline (its retry stack alone can take ~49s). await raceWithTimeout( this._metrics.flush().catch(() => {}), Math.max(0, shutdownDeadlineMs - Date.now()) ) // reset() also invalidates a flush that lost the race above: when its // send finally settles, the stale window is discarded instead of being // merged back onto a re-armed timer after teardown. this._metrics.reset() } if (this._traces) { // Same treatment as metrics: send what's queued, raced against the shared // shutdown budget, then reset so a losing flush can't re-arm a timer. await raceWithTimeout( this._traces.flush().catch(() => {}), Math.max(0, shutdownDeadlineMs - Date.now()) ) this._traces.reset() } try { return await super._shutdown(Math.max(0, shutdownDeadlineMs - Date.now())) } finally { this._distinctIdHasSentFlagCalls.clear() resolve?.() } } private async _requestRemoteConfigPayload(flagKey: string): Promise { if (this.disabled || !this.apiKey || !this.options.personalApiKey) { return undefined } const url = `${this.host}/api/projects/@current/feature_flags/${flagKey}/remote_config?token=${encodeURIComponent(this.apiKey)}` const options: PostHogFetchOptions = { method: 'GET', headers: { ...this.getCustomHeaders(), 'Content-Type': 'application/json', Authorization: `Bearer ${this.options.personalApiKey}`, }, } let abortTimeout = null if (this.options.requestTimeout && typeof this.options.requestTimeout === 'number') { const controller = new AbortController() abortTimeout = safeSetTimeout(() => { controller.abort() }, this.options.requestTimeout) options.signal = controller.signal } try { return await this.fetch(url, options) } catch (error) { this._events.emit('error', error) return undefined } finally { if (abortTimeout) { clearTimeout(abortTimeout) } } } private extractPropertiesFromEvent( eventProperties?: Record, groups?: Record ): { personProperties: Record groupProperties: Record> } { if (!eventProperties) { return { personProperties: {}, groupProperties: {} } } const personProperties: Record = {} const groupProperties: Record> = {} for (const [key, value] of Object.entries(eventProperties)) { // If the value is a plain object and the key exists in groups, treat it as group properties if (isPlainObject(value) && groups && key in groups) { const groupProps: Record = {} for (const [groupKey, groupValue] of Object.entries(value as Record)) { groupProps[String(groupKey)] = String(groupValue) } groupProperties[String(key)] = groupProps } else { // Otherwise treat as person property personProperties[String(key)] = String(value) } } return { personProperties, groupProperties } } private async getFeatureFlagsForEvent( distinctId: string, groups?: Record, disableGeoip?: boolean, sendFeatureFlagsOptions?: SendFeatureFlagsOptions ): Promise { if (this.disabled || !this.apiKey) { this._logger.warn('The client is disabled') return undefined } // Use properties directly from options if they exist const finalPersonProperties = sendFeatureFlagsOptions?.personProperties || {} const finalGroupProperties = sendFeatureFlagsOptions?.groupProperties || {} const flagKeys = sendFeatureFlagsOptions?.flagKeys // Check if we should only evaluate locally const onlyEvaluateLocally = sendFeatureFlagsOptions?.onlyEvaluateLocally ?? this.options.strictLocalEvaluation ?? false // If onlyEvaluateLocally is true, only use local evaluation if (onlyEvaluateLocally) { if ((this.featureFlagsPoller?.featureFlags?.length || 0) > 0) { const groupsWithStringValues: Record = {} for (const [key, value] of Object.entries(groups || {})) { groupsWithStringValues[key] = String(value) } return await this.getAllFlags(distinctId, { groups: groupsWithStringValues, personProperties: finalPersonProperties, groupProperties: finalGroupProperties, disableGeoip, onlyEvaluateLocally: true, flagKeys, }) } else { // If onlyEvaluateLocally is true but we don't have local flags, return empty return {} } } // Prefer local evaluation if available (default behavior; I'd rather not penalize users who haven't updated to the new API but still want to use local evaluation) if ((this.featureFlagsPoller?.featureFlags?.length || 0) > 0) { const groupsWithStringValues: Record = {} for (const [key, value] of Object.entries(groups || {})) { groupsWithStringValues[key] = String(value) } return await this.getAllFlags(distinctId, { groups: groupsWithStringValues, personProperties: finalPersonProperties, groupProperties: finalGroupProperties, disableGeoip, onlyEvaluateLocally: true, flagKeys, }) } // Fall back to remote evaluation if local evaluation is not available return ( await super.getFeatureFlagsStateless( distinctId, groups, finalPersonProperties, finalGroupProperties, disableGeoip ) ).flags } private addLocalPersonAndGroupProperties( distinctId: string, groups?: Record, personProperties?: Properties, groupProperties?: Record ): { allPersonProperties: Properties; allGroupProperties: Record } { const allPersonProperties = { ...(personProperties || {}) } const allGroupProperties: Record = {} if (groups) { for (const groupName of Object.keys(groups)) { allGroupProperties[groupName] = { $group_key: groups[groupName], ...(groupProperties?.[groupName] || {}), } } } return { allPersonProperties, allGroupProperties } } private personPropertiesForLocalEvaluation(distinctId: string, personProperties?: Properties): Record { return { distinct_id: distinctId, ...(personProperties || {}) } } private createFeatureFlagEvaluationContext( distinctId: string, groups?: Record, personProperties?: Properties, groupProperties?: Record ): FeatureFlagEvaluationContext { return { distinctId, groups: groups || {}, personProperties: personProperties || {}, groupProperties: groupProperties || {}, evaluationCache: {}, } } /** * Capture an error exception as an event. * * @example * ```ts * // Capture an error with user ID * try { * // Some risky operation * riskyOperation() * } catch (error) { * client.captureException(error, 'user_123') * } * ``` * * @example * ```ts * // Capture with additional properties * try { * apiCall() * } catch (error) { * client.captureException(error, 'user_123', { * endpoint: '/api/users', * method: 'POST', * status_code: 500 * }) * } * ``` * * {@label Error tracking} * * @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'], flags?: FeatureFlagEvaluations ): void { if (!ErrorTracking.isPreviouslyCapturedError(error)) { const syntheticException = new Error('PostHog syntheticException') this.addPendingPromise( ErrorTracking.buildEventMessage( this.getErrorPropertiesBuilder(), error, { syntheticException }, distinctId, additionalProperties ).then((msg) => this._capturePreparedEvent({ ...msg, uuid, flags }, false)) ) } } /** * Capture an error exception as an event immediately (synchronously). * * @example * ```ts * // Capture an error immediately with user ID * try { * // Some risky operation * riskyOperation() * } catch (error) { * await client.captureExceptionImmediate(error, 'user_123') * } * ``` * * @example * ```ts * // Capture with additional properties * try { * apiCall() * } catch (error) { * await client.captureExceptionImmediate(error, 'user_123', { * endpoint: '/api/users', * method: 'POST', * status_code: 500 * }) * } * ``` * * {@label Error tracking} * * @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, flags?: FeatureFlagEvaluations ): Promise { if (!ErrorTracking.isPreviouslyCapturedError(error)) { const syntheticException = new Error('PostHog syntheticException') return this.addPendingPromise( ErrorTracking.buildEventMessage( this.getErrorPropertiesBuilder(), error, { syntheticException }, distinctId, additionalProperties ).then((msg) => this.captureImmediate({ ...msg, flags })) ) } } public async prepareEventMessage(props: EventMessage): Promise<{ distinctId: string event: string properties: PostHogEventProperties options: PostHogCaptureOptions }> { return this._prepareEventMessage(props) } private async _prepareEventMessage( props: EventMessage, options: { includeContextProperties?: boolean } = {} ): Promise<{ distinctId: string event: string properties: PostHogEventProperties options: PostHogCaptureOptions }> { const { distinctId, event, properties, groups, flags, sendFeatureFlags, timestamp, disableGeoip, uuid, }: EventMessage = props const contextData = this.context?.get() const includeContextProperties = options.includeContextProperties ?? true let mergedDistinctId = distinctId || contextData?.distinctId const mergedProperties = includeContextProperties ? { ...this.props, ...(contextData?.properties || {}), ...(properties || {}), } : { ...(properties || {}) } if (event === '$exception' && !('$trace_id' in mergedProperties || '$span_id' in mergedProperties)) { Object.assign(mergedProperties, await getActiveTraceProperties()) } if (!mergedDistinctId) { mergedDistinctId = uuidv7() mergedProperties.$process_person_profile = false } if (includeContextProperties && contextData?.sessionId && !mergedProperties.$session_id) { mergedProperties.$session_id = contextData.sessionId } // Minimal $feature_flag_called events: rebuild from the strict allowlist before before_send // runs, so a customer hook may deliberately re-add stripped properties. Everything the SDK // itself adds after this point ($groups, $lib/$lib_version/$is_server, $geoip_disable) is // allowlisted — no SDK enrichment may reintroduce stripped properties. const finalProperties = this._shouldSendMinimalFlagCalledEvent(event, mergedProperties) ? minimizeFlagCalledEventProperties(mergedProperties) : mergedProperties // Run before_send if configured const eventMessage = this._runBeforeSend({ distinctId: mergedDistinctId, event, properties: finalProperties, groups, flags, sendFeatureFlags, timestamp, disableGeoip, uuid, }) if (!eventMessage) { return Promise.reject(null) } // :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 const flagValues = await this.getFeatureFlagsForEvent( eventMessage.distinctId!, groups, disableGeoip, sendFeatureFlagsOptions ) return buildFlagEventProperties(flagValues) } // $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 {} }) .catch(() => { // Something went wrong getting the flag info - we should capture the event anyways return {} }) .then((additionalProperties): PostHogEventProperties => { // No matter what - capture the event const resolvedGroups = eventMessage.groups || groups return { ...additionalProperties, ...(eventMessage.properties || {}), // Only stamp $groups from the top-level `groups` when present — otherwise a // caller-provided properties.$groups would be clobbered with undefined (#3888). ...(resolvedGroups !== undefined && Object.keys(resolvedGroups).length > 0 ? { $groups: resolvedGroups } : {}), } }) // Handle bot pageview collection based on preview flag if ( eventMessage.event === '$pageview' && this.options.__preview_capture_bot_pageviews && typeof eventProperties.$raw_user_agent === 'string' ) { if (isBlockedUA(eventProperties.$raw_user_agent, this.options.custom_blocked_useragents || [])) { eventMessage.event = '$bot_pageview' eventProperties.$browser_type = 'bot' } } return { distinctId: eventMessage.distinctId!, event: eventMessage.event, properties: eventProperties, options: { timestamp: eventMessage.timestamp, disableGeoip: eventMessage.disableGeoip, uuid: eventMessage.uuid, }, } } private _runBeforeSend(eventMessage: EventMessage): EventMessage | null { const beforeSend = this.options.before_send if (!beforeSend) { return eventMessage } const fns = Array.isArray(beforeSend) ? beforeSend : [beforeSend] let result: EventMessage | null = eventMessage for (const fn of fns) { try { result = fn(result) if (!result) { this._logger.info(`Event '${eventMessage.event}' was rejected in beforeSend function`) return null } if (!result.properties || Object.keys(result.properties).length === 0) { const message = `Event '${result.event}' has no properties after beforeSend function, this is likely an error.` this._logger.warn(message) } } catch (error) { this._logger.error(`Error in before_send function for event '${eventMessage.event}':`, error) return null } } return result } }