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