posthog-node
Version:
PostHog Node.js integration
1,572 lines (1,462 loc) • 84.3 kB
text/typescript
import { version } from './version'
import {
FeatureFlagValue,
isBlockedUA,
isPlainObject,
JsonType,
PostHogCaptureOptions,
PostHogCoreStateless,
PostHogFetchOptions,
PostHogFetchResponse,
PostHogFlagsAndPayloadsResponse,
PostHogFlagsResponse,
PostHogPersistedProperty,
} from '@posthog/core'
import {
EventMessage,
FeatureFlagError,
FeatureFlagErrorType,
FeatureFlagOverrideOptions,
FeatureFlagResult,
GroupIdentifyMessage,
IdentifyMessage,
IPostHog,
OverrideFeatureFlagsOptions,
PostHogOptions,
SendFeatureFlagsOptions,
FlagEvaluationOptions,
AllFlagsOptions,
} from './types'
import {
EvaluatedFlagRecord,
FeatureFlagEvaluations,
FeatureFlagEvaluationsHost,
FlagCalledEventParams,
} from './feature-flag-evaluations'
import {
FeatureFlagsPoller,
type FeatureFlagEvaluationContext,
RequiresServerEvaluation,
InconclusiveMatchError,
} from './extensions/feature-flags/feature-flags'
import ErrorTracking from './extensions/error-tracking'
import { safeSetTimeout, PostHogEventProperties } from '@posthog/core'
import { PostHogMemoryStorage } from './storage-memory'
import { uuidv7 } from '@posthog/core'
import { ContextData, ContextOptions, IPostHogContext } from './extensions/context/types'
// 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)
// eslint-disable-next-line no-console
console.warn(`[PostHog] ${message}`)
}
/**
* @internal — clears the process-wide deprecation dedup set. Test-only.
*/
export function _resetDeprecationWarningsForTests(): void {
_emittedDeprecations.clear()
}
function normalizeApiKey(value?: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
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
}
/**
* 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
// Feature flag overrides for local testing/development
private _flagOverrides?: Record<string, FeatureFlagValue>
private _payloadOverrides?: Record<string, JsonType>
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 personal API key
* const client = new PostHogBackendClient(
* 'your-api-key',
* {
* host: 'https://app.posthog.com',
* personalApiKey: 'your-personal-api-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,
host: normalizeHost(options.host),
personalApiKey: normalizePersonalApiKey(options.personalApiKey),
}
super(normalizedApiKey, normalizedOptions)
this.options = normalizedOptions
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)
},
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 = super.flush()
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 super.flush()
} 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)
}
/**
* 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
}
/**
* 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)
}
/**
* 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 {
if (typeof props === 'string') {
this._logger.warn('Called capture() with a string as the first argument when an object was expected.')
}
if (props.event === '$exception' && !props._originatedFromCaptureException) {
this._logger.warn(
"Using `posthog.capture('$exception')` is unreliable because it does not attach required metadata. Use `posthog.captureException(error)` instead, which attaches required metadata automatically."
)
}
this.addPendingPromise(
this.prepareEventMessage(props)
.then(({ distinctId, event, properties, options }) => {
return super.captureStateless(distinctId, event, properties, {
timestamp: options.timestamp,
disableGeoip: options.disableGeoip,
uuid: options.uuid,
})
})
.catch((err) => {
if (err) {
console.error(err)
}
})
)
}
/**
* 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> {
if (typeof props === 'string') {
this._logger.warn('Called captureImmediate() with a string as the first argument when an object was expected.')
}
if (props.event === '$exception' && !props._originatedFromCaptureException) {
this._logger.warn(
"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.addPendingPromise(
this.prepareEventMessage(props)
.then(({ distinctId, event, properties, options }) => {
return super.captureStatelessImmediate(distinctId, event, properties, {
timestamp: options.timestamp,
disableGeoip: options.disableGeoip,
uuid: options.uuid,
})
})
.catch((err) => {
if (err) {
console.error(err)
}
})
)
}
/**
* 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,
}
super.identifyStateless(distinctId, eventProperties, { disableGeoip })
}
/**
* 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 super.identifyStatelessImmediate(distinctId, eventProperties, { disableGeoip })
}
/**
* 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 {
super.aliasStateless(data.alias, data.distinctId, undefined, { disableGeoip: data.disableGeoip })
}
/**
* 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 super.aliasStatelessImmediate(data.alias, data.distinctId, undefined, { disableGeoip: data.disableGeoip })
}
/**
* 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,
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
// 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'
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,
evaluationContext.personProperties,
evaluationContext.groupProperties,
disableGeoip,
[key]
)
if (flagsResponse === undefined) {
featureFlagError = FeatureFlagError.UNKNOWN_ERROR
} else {
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
// 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 (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 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 {
* // Feature is disabled
* 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 enabled, false if disabled, undefined if not found
*/
async isFeatureEnabled(
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<boolean | undefined> {
emitDeprecationWarningOnce(
'isFeatureEnabled',
'`isFeatureEnabled` is deprecated and will be removed in a future major version. ' +
'Use `posthog.evaluateFlags(distinctId, ...)` and call `flags.isEnabled(key)` instead — ' +
'this consolidates flag evaluation into a single `/flags` request per incoming request.'
)
// Bypass the public `getFeatureFlag` so the user only sees one deprecation warning per call.
const result = await this._getFeatureFlagResult(key, distinctId, {
...options,
sendFeatureFlagEvents: options?.sendFeatureFlagEvents ?? this.options.sendFeatureFlagEvent ?? true,
})
if (result === undefined) {
return undefined
}
if (result.enabled === false) {
return false
}
const feat: FeatureFlagValue = result.variant ?? true
return !!feat || false
}
/**
* 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<Record<string, FeatureFlagValue>>
async getAllFlags(distinctId: string, options?: AllFlagsOptions): Promise<Record<string, FeatureFlagValue>>
async getAllFlags(
distinctIdOrOptions?: string | AllFlagsOptions,
options?: AllFlagsOptions
): Promise<Record<string, FeatureFlagValue>> {
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<PostHogFlagsAndPayloadsResponse>
async getAllFlagsAndPayloads(distinctId: string, options?: AllFlagsOptions): Promise<PostHogFlagsAndPayloadsResponse>
async getAllFlagsAndPayloads(
distinctIdOrOptions?: string | AllFlagsOptions,
options?: AllFlagsOptions
): Promise<PostHogFlagsAndPayloadsResponse> {
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,
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,
evaluationContext.personProperties,
evaluationContext.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` optio