posthog-node
Version:
PostHog Node.js integration
1,362 lines (1,209 loc) • 54.5 kB
text/typescript
import { FeatureFlagCondition, FlagProperty, FlagPropertyValue, PostHogFeatureFlag, PropertyGroup } from '../../types'
import type { FeatureFlagValue, JsonType, PostHogFetchOptions, PostHogFetchResponse } from '@posthog/core'
import { safeSetTimeout } from '@posthog/core'
import { hashSHA1 } from './crypto'
import { FlagDefinitionCacheProvider, FlagDefinitionCacheData } from './cache'
const SIXTY_SECONDS = 60 * 1000
// eslint-disable-next-line
const LONG_SCALE = 0xfffffffffffffff
// Operators that should still run their switch case when the property value is null/undefined.
// `is_not` may legitimately compare against null; `is_set` only cares about key presence and
// must not be short-circuited by the null guard in `matchProperty`.
const NULL_VALUES_ALLOWED_OPERATORS = ['is_not', 'is_set']
class ClientError extends Error {
constructor(message: string) {
super()
Error.captureStackTrace(this, this.constructor)
this.name = 'ClientError'
this.message = message
Object.setPrototypeOf(this, ClientError.prototype)
}
}
class InconclusiveMatchError extends Error {
constructor(message: string) {
super(message)
this.name = this.constructor.name
Error.captureStackTrace(this, this.constructor)
// instanceof doesn't work in ES3 or ES5
// https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
// this is the workaround
Object.setPrototypeOf(this, InconclusiveMatchError.prototype)
}
}
class RequiresServerEvaluation extends Error {
constructor(message: string) {
super(message)
this.name = this.constructor.name
Error.captureStackTrace(this, this.constructor)
// instanceof doesn't work in ES3 or ES5
// https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
// this is the workaround
Object.setPrototypeOf(this, RequiresServerEvaluation.prototype)
}
}
type FeatureFlagsPollerOptions = {
personalApiKey: string
projectApiKey: string
host: string
pollingInterval: number
timeout?: number
fetch?: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>
onError?: (error: Error) => void
onLoad?: (count: number) => void
customHeaders?: { [key: string]: string }
cacheProvider?: FlagDefinitionCacheProvider
strictLocalEvaluation?: boolean
}
export type FeatureFlagEvaluationContext = {
distinctId: string
groups: Record<string, string>
personProperties: Record<string, any>
groupProperties: Record<string, Record<string, any>>
evaluationCache: Record<string, FeatureFlagValue>
}
type ComputeFlagAndPayloadOptions = {
matchValue?: FeatureFlagValue
skipLoadCheck?: boolean
}
class FeatureFlagsPoller {
pollingInterval: number
personalApiKey: string
projectApiKey: string
featureFlags: Array<PostHogFeatureFlag>
featureFlagsByKey: Record<string, PostHogFeatureFlag>
groupTypeMapping: Record<string, string>
cohorts: Record<string, PropertyGroup>
loadedSuccessfullyOnce: boolean
timeout?: number
host: FeatureFlagsPollerOptions['host']
poller?: NodeJS.Timeout
fetch: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>
debugMode: boolean = false
onError?: (error: Error) => void
customHeaders?: { [key: string]: string }
shouldBeginExponentialBackoff: boolean = false
backOffCount: number = 0
onLoad?: (count: number) => void
private cacheProvider?: FlagDefinitionCacheProvider
private loadingPromise?: Promise<void>
private flagsEtag?: string
private nextFetchAllowedAt?: number
private strictLocalEvaluation: boolean
private flagDefinitionsLoadedAt?: number
constructor({
pollingInterval,
personalApiKey,
projectApiKey,
timeout,
host,
customHeaders,
...options
}: FeatureFlagsPollerOptions) {
this.pollingInterval = pollingInterval
this.personalApiKey = personalApiKey
this.featureFlags = []
this.featureFlagsByKey = {}
this.groupTypeMapping = {}
this.cohorts = {}
this.loadedSuccessfullyOnce = false
this.timeout = timeout
this.projectApiKey = projectApiKey
this.host = host
this.poller = undefined
this.fetch = options.fetch || fetch
this.onError = options.onError
this.customHeaders = customHeaders
this.onLoad = options.onLoad
this.cacheProvider = options.cacheProvider
this.strictLocalEvaluation = options.strictLocalEvaluation ?? false
void this.loadFeatureFlags()
}
debug(enabled: boolean = true): void {
this.debugMode = enabled
}
private logMsgIfDebug(fn: () => void): void {
if (this.debugMode) {
fn()
}
}
private createEvaluationContext(
distinctId: string,
groups: Record<string, string> = {},
personProperties: Record<string, any> = {},
groupProperties: Record<string, Record<string, any>> = {},
evaluationCache: Record<string, FeatureFlagValue> = {}
): FeatureFlagEvaluationContext {
return {
distinctId,
groups,
personProperties,
groupProperties,
evaluationCache,
}
}
async getFeatureFlag(
key: string,
distinctId: string,
groups: Record<string, string> = {},
personProperties: Record<string, any> = {},
groupProperties: Record<string, Record<string, any>> = {}
): Promise<FeatureFlagValue | undefined> {
await this.loadFeatureFlags()
let response: FeatureFlagValue | undefined = undefined
let featureFlag = undefined
if (!this.loadedSuccessfullyOnce) {
return response
}
featureFlag = this.featureFlagsByKey[key]
if (featureFlag !== undefined) {
const evaluationContext = this.createEvaluationContext(distinctId, groups, personProperties, groupProperties)
try {
const result = await this.computeFlagAndPayloadLocally(featureFlag, evaluationContext)
response = result.value
this.logMsgIfDebug(() => console.debug(`Successfully computed flag locally: ${key} -> ${response}`))
} catch (e) {
if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) {
this.logMsgIfDebug(() => console.debug(`${e.name} when computing flag locally: ${key}: ${e.message}`))
} else if (e instanceof Error) {
this.onError?.(new Error(`Error computing flag locally: ${key}: ${e}`))
}
}
}
return response
}
async getAllFlagsAndPayloads(
evaluationContext: FeatureFlagEvaluationContext,
flagKeysToExplicitlyEvaluate?: string[]
): Promise<{
response: Record<string, FeatureFlagValue>
payloads: Record<string, JsonType>
fallbackToFlags: boolean
}> {
await this.loadFeatureFlags()
const response: Record<string, FeatureFlagValue> = {}
const payloads: Record<string, JsonType> = {}
let fallbackToFlags = this.featureFlags.length == 0
const flagsToEvaluate = flagKeysToExplicitlyEvaluate
? flagKeysToExplicitlyEvaluate.map((key) => this.featureFlagsByKey[key]).filter(Boolean)
: this.featureFlags
const sharedEvaluationContext = {
...evaluationContext,
evaluationCache: evaluationContext.evaluationCache ?? {},
}
await Promise.all(
flagsToEvaluate.map(async (flag) => {
try {
const { value: matchValue, payload: matchPayload } = await this.computeFlagAndPayloadLocally(
flag,
sharedEvaluationContext
)
response[flag.key] = matchValue
if (matchPayload) {
payloads[flag.key] = matchPayload
}
} catch (e) {
if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) {
this.logMsgIfDebug(() => console.debug(`${e.name} when computing flag locally: ${flag.key}: ${e.message}`))
} else if (e instanceof Error) {
this.onError?.(new Error(`Error computing flag locally: ${flag.key}: ${e}`))
}
fallbackToFlags = true
}
})
)
return { response, payloads, fallbackToFlags }
}
async computeFlagAndPayloadLocally(
flag: PostHogFeatureFlag,
evaluationContext: FeatureFlagEvaluationContext,
options: ComputeFlagAndPayloadOptions = {}
): Promise<{
value: FeatureFlagValue
payload: JsonType | null
}> {
const { matchValue, skipLoadCheck = false } = options
// Only load flags if not already loaded and not skipping the check
if (!skipLoadCheck) {
await this.loadFeatureFlags()
}
if (!this.loadedSuccessfullyOnce) {
return { value: false, payload: null }
}
let flagValue: FeatureFlagValue
// If matchValue is provided, use it directly; otherwise evaluate the flag
if (matchValue !== undefined) {
flagValue = matchValue
} else {
flagValue = await this.computeFlagValueLocally(flag, evaluationContext)
}
// Always compute payload based on the final flagValue (whether provided or computed)
const payload = this.getFeatureFlagPayload(flag.key, flagValue)
return { value: flagValue, payload }
}
private async computeFlagValueLocally(
flag: PostHogFeatureFlag,
evaluationContext: FeatureFlagEvaluationContext
): Promise<FeatureFlagValue> {
const { distinctId, groups, personProperties, groupProperties } = evaluationContext
// Order matters: an inactive flag is always false regardless of continuity. Checking
// `ensure_experience_continuity` first would cause a disabled-but-continuity flag to come
// back as undefined instead of the correct `false`.
if (!flag.active) {
return false
}
if (flag.ensure_experience_continuity) {
throw new InconclusiveMatchError('Flag has experience continuity enabled')
}
const flagFilters = flag.filters || {}
const aggregation_group_type_index = flagFilters.aggregation_group_type_index
if (aggregation_group_type_index != undefined) {
const groupName = this.groupTypeMapping[String(aggregation_group_type_index)]
if (!groupName) {
this.logMsgIfDebug(() =>
console.warn(
`[FEATURE FLAGS] Unknown group type index ${aggregation_group_type_index} for feature flag ${flag.key}`
)
)
throw new InconclusiveMatchError('Flag has unknown group type index')
}
if (!(groupName in groups)) {
this.logMsgIfDebug(() =>
console.warn(`[FEATURE FLAGS] Can't compute group feature flag: ${flag.key} without group names passed in`)
)
return false
}
if (
flag.bucketing_identifier === 'device_id' &&
(personProperties?.$device_id === undefined ||
personProperties?.$device_id === null ||
personProperties?.$device_id === '')
) {
this.logMsgIfDebug(() =>
console.warn(`[FEATURE FLAGS] Ignoring bucketing_identifier for group flag: ${flag.key}`)
)
}
const focusedGroupProperties = groupProperties[groupName]
return await this.matchFeatureFlagProperties(flag, groups[groupName], focusedGroupProperties, evaluationContext)
} else {
const bucketingValue = this.getBucketingValueForFlag(flag, distinctId, personProperties)
if (bucketingValue === undefined) {
this.logMsgIfDebug(() =>
console.warn(
`[FEATURE FLAGS] Can't compute feature flag: ${flag.key} without $device_id, falling back to server evaluation`
)
)
throw new InconclusiveMatchError(`Can't compute feature flag: ${flag.key} without $device_id`)
}
return await this.matchFeatureFlagProperties(flag, bucketingValue, personProperties, evaluationContext)
}
}
private getBucketingValueForFlag(
flag: PostHogFeatureFlag,
distinctId: string,
properties: Record<string, any>
): string | undefined {
if (flag.filters?.aggregation_group_type_index != undefined) {
// Group flags are bucketed by group key in computeFlagValueLocally.
// If a group flag appears in dependency evaluation, ignore bucketing_identifier
// to preserve existing behavior and avoid requiring $device_id unexpectedly.
return distinctId
}
if (flag.bucketing_identifier === 'device_id') {
const deviceId = properties?.$device_id
if (deviceId === undefined || deviceId === null || deviceId === '') {
return undefined
}
return deviceId
}
return distinctId
}
private getFeatureFlagPayload(key: string, flagValue: FeatureFlagValue): JsonType | null {
let payload: JsonType | null = null
if (flagValue !== false && flagValue !== null && flagValue !== undefined) {
if (typeof flagValue == 'boolean') {
payload = this.featureFlagsByKey?.[key]?.filters?.payloads?.[flagValue.toString()] || null
} else if (typeof flagValue == 'string') {
payload = this.featureFlagsByKey?.[key]?.filters?.payloads?.[flagValue] || null
}
if (payload !== null && payload !== undefined) {
// If payload is already an object, return it directly
if (typeof payload === 'object') {
return payload
}
// If payload is a string, try to parse it as JSON
if (typeof payload === 'string') {
try {
return JSON.parse(payload)
} catch {
// If parsing fails, return the string as is
return payload
}
}
// For other types, return as is
return payload
}
}
return null
}
private async evaluateFlagDependency(
property: FlagProperty,
properties: Record<string, any>,
evaluationContext: FeatureFlagEvaluationContext
): Promise<boolean> {
const { evaluationCache } = evaluationContext
const targetFlagKey = property.key
if (!this.featureFlagsByKey) {
throw new InconclusiveMatchError('Feature flags not available for dependency evaluation')
}
// Check if dependency_chain is present - it should always be provided for flag dependencies
if (!('dependency_chain' in property)) {
throw new InconclusiveMatchError(
`Flag dependency property for '${targetFlagKey}' is missing required 'dependency_chain' field`
)
}
const dependencyChain = property.dependency_chain
// Check for missing or invalid dependency chain (This should never happen, but being defensive)
if (!Array.isArray(dependencyChain)) {
throw new InconclusiveMatchError(
`Flag dependency property for '${targetFlagKey}' has an invalid 'dependency_chain' (expected array, got ${typeof dependencyChain})`
)
}
// Handle circular dependency (empty chain means circular) (This should never happen, but being defensive)
if (dependencyChain.length === 0) {
throw new InconclusiveMatchError(
`Circular dependency detected for flag '${targetFlagKey}' (empty dependency chain)`
)
}
// Evaluate all dependencies in the chain order
for (const depFlagKey of dependencyChain) {
if (!(depFlagKey in evaluationCache)) {
// Need to evaluate this dependency first
const depFlag = this.featureFlagsByKey[depFlagKey]
if (!depFlag) {
// Missing flag dependency - cannot evaluate locally
throw new InconclusiveMatchError(`Missing flag dependency '${depFlagKey}' for flag '${targetFlagKey}'`)
} else if (!depFlag.active) {
// Inactive flag evaluates to false
evaluationCache[depFlagKey] = false
} else {
// Reuse full flag evaluation so dependencies respect person vs group bucketing rules.
try {
const depResult = await this.computeFlagValueLocally(depFlag, evaluationContext)
evaluationCache[depFlagKey] = depResult
} catch (error) {
throw new InconclusiveMatchError(
`Error evaluating flag dependency '${depFlagKey}' for flag '${targetFlagKey}': ${error}`
)
}
}
}
// Check if dependency evaluation was inconclusive
const cachedResult = evaluationCache[depFlagKey]
if (cachedResult === null || cachedResult === undefined) {
throw new InconclusiveMatchError(`Dependency '${depFlagKey}' could not be evaluated`)
}
}
// The target flag is specified in property.key (This should match the last element in the dependency chain)
const targetFlagValue = evaluationCache[targetFlagKey]
return this.flagEvaluatesToExpectedValue(property.value, targetFlagValue)
}
private flagEvaluatesToExpectedValue(expectedValue: FlagPropertyValue, flagValue: FeatureFlagValue): boolean {
// If the expected value is a boolean, then return true if the flag evaluated to true (or any string variant)
// If the expected value is false, then only return true if the flag evaluated to false.
if (typeof expectedValue === 'boolean') {
return (
expectedValue === flagValue || (typeof flagValue === 'string' && flagValue !== '' && expectedValue === true)
)
}
// If the expected value is a string, then return true if and only if the flag evaluated to the expected value.
if (typeof expectedValue === 'string') {
return flagValue === expectedValue
}
// The `flag_evaluates_to` operator is not supported for numbers and arrays.
return false
}
async matchFeatureFlagProperties(
flag: PostHogFeatureFlag,
bucketingValue: string,
properties: Record<string, any>,
evaluationContext: FeatureFlagEvaluationContext
): Promise<FeatureFlagValue> {
const flagFilters = flag.filters || {}
const flagConditions = flagFilters.groups || []
const flagAggregation = flagFilters.aggregation_group_type_index
const { groups, groupProperties } = evaluationContext
let isInconclusive = false
let result = undefined
for (const condition of flagConditions) {
try {
// Per-condition aggregation overrides only when the condition explicitly
// sets its own aggregation_group_type_index (mixed targeting).
// When absent, use the properties/bucketing already resolved by the caller.
const conditionAggregation =
condition.aggregation_group_type_index !== undefined
? condition.aggregation_group_type_index
: flagAggregation
let effectiveProperties = properties
let effectiveBucketingValue = bucketingValue
// Mixed-override path: condition-level aggregation differs from flag-level.
// This assumes flag-level aggregation is null/undefined for mixed flags.
if (conditionAggregation !== flagAggregation) {
if (conditionAggregation !== null && conditionAggregation !== undefined) {
const groupName = this.groupTypeMapping[String(conditionAggregation)]
if (!groupName || !(groupName in groups)) {
this.logMsgIfDebug(() =>
console.debug(
`[FEATURE FLAGS] Skipping group condition for flag '${flag.key}': group type index ${conditionAggregation} not available`
)
)
continue
}
if (!(groupName in groupProperties)) {
isInconclusive = true
continue
}
effectiveProperties = groupProperties[groupName]
effectiveBucketingValue = groups[groupName]
}
}
if (
await this.isConditionMatch(flag, effectiveBucketingValue, condition, effectiveProperties, evaluationContext)
) {
const variantOverride = condition.variant
const flagVariants = flagFilters.multivariate?.variants || []
if (variantOverride && flagVariants.some((variant) => variant.key === variantOverride)) {
result = variantOverride
} else {
result = (await this.getMatchingVariant(flag, effectiveBucketingValue)) || true
}
break
}
} catch (e) {
if (e instanceof RequiresServerEvaluation) {
// Static cohort or other missing server-side data - must fallback to API
throw e
} else if (e instanceof InconclusiveMatchError) {
// Evaluation error (bad regex, invalid date, missing property, etc.)
// Track that we had an inconclusive match, but try other conditions
isInconclusive = true
} else {
throw e
}
}
}
if (result !== undefined) {
return result
} else if (isInconclusive) {
// Had evaluation errors and no successful match - can't determine locally
throw new InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties")
}
// We can only return False when all conditions are False
return false
}
async isConditionMatch(
flag: PostHogFeatureFlag,
bucketingValue: string,
condition: FeatureFlagCondition,
properties: Record<string, any>,
evaluationContext: FeatureFlagEvaluationContext
): Promise<boolean> {
const rolloutPercentage = condition.rollout_percentage
const warnFunction = (msg: string): void => {
this.logMsgIfDebug(() => console.warn(msg))
}
if ((condition.properties || []).length > 0) {
for (const prop of condition.properties) {
const propertyType = prop.type
let matches = false
if (propertyType === 'cohort') {
matches = await matchCohort(prop, properties, this.cohorts, this.debugMode, (depProp) =>
this.evaluateFlagDependency(depProp, properties, evaluationContext)
)
} else if (propertyType === 'flag') {
matches = await this.evaluateFlagDependency(prop, properties, evaluationContext)
} else {
matches = matchProperty(prop, properties, warnFunction)
}
if (!matches) {
return false
}
}
if (rolloutPercentage == undefined) {
return true
}
}
if (rolloutPercentage != undefined && (await _hash(flag.key, bucketingValue)) > rolloutPercentage / 100.0) {
return false
}
return true
}
async getMatchingVariant(flag: PostHogFeatureFlag, bucketingValue: string): Promise<FeatureFlagValue | undefined> {
const hashValue = await _hash(flag.key, bucketingValue, 'variant')
const matchingVariant = this.variantLookupTable(flag).find((variant) => {
return hashValue >= variant.valueMin && hashValue < variant.valueMax
})
if (matchingVariant) {
return matchingVariant.key
}
return undefined
}
variantLookupTable(flag: PostHogFeatureFlag): { valueMin: number; valueMax: number; key: string }[] {
const lookupTable: { valueMin: number; valueMax: number; key: string }[] = []
let valueMin = 0
let valueMax = 0
const flagFilters = flag.filters || {}
const multivariates: {
key: string
rollout_percentage: number
}[] = flagFilters.multivariate?.variants || []
multivariates.forEach((variant) => {
valueMax = valueMin + variant.rollout_percentage / 100.0
lookupTable.push({ valueMin, valueMax, key: variant.key })
valueMin = valueMax
})
return lookupTable
}
/**
* Updates the internal flag state with the provided flag data.
*/
private updateFlagState(flagData: FlagDefinitionCacheData): void {
this.featureFlags = flagData.flags
this.featureFlagsByKey = flagData.flags.reduce(
(acc, curr) => ((acc[curr.key] = curr), acc),
<Record<string, PostHogFeatureFlag>>{}
)
this.groupTypeMapping = flagData.groupTypeMapping
this.cohorts = flagData.cohorts
this.loadedSuccessfullyOnce = true
}
/**
* Warn about flags that cannot be evaluated locally.
* Called after loading flag definitions when local evaluation is enabled.
* Only warns if strictLocalEvaluation is NOT enabled (when it's enabled, server fallback is already prevented).
*/
private warnAboutExperienceContinuityFlags(flags: PostHogFeatureFlag[]): void {
// Don't warn if strictLocalEvaluation is enabled - server fallback is already prevented
if (this.strictLocalEvaluation) {
return
}
const experienceContinuityFlags = flags.filter((f) => f.ensure_experience_continuity)
if (experienceContinuityFlags.length > 0) {
console.warn(
`[PostHog] You are using local evaluation but ${experienceContinuityFlags.length} flag(s) have experience ` +
`continuity enabled: ${experienceContinuityFlags.map((f) => f.key).join(', ')}. ` +
`Experience continuity is incompatible with local evaluation and will cause a server request on every ` +
`flag evaluation, negating local evaluation cost savings. ` +
`To avoid server requests and unexpected costs, either disable experience continuity on these flags ` +
`in PostHog, use strictLocalEvaluation: true in client init, or pass onlyEvaluateLocally: true ` +
`per flag call (flags that cannot be evaluated locally will return undefined).`
)
}
}
/**
* Attempts to load flags from cache and update internal state.
* Returns true if flags were successfully loaded from cache, false otherwise.
*/
private async loadFromCache(debugMessage: string): Promise<boolean> {
if (!this.cacheProvider) {
return false
}
try {
const cached = await this.cacheProvider.getFlagDefinitions()
if (cached) {
this.updateFlagState(cached)
this.logMsgIfDebug(() => console.debug(`[FEATURE FLAGS] ${debugMessage} (${cached.flags.length} flags)`))
this.onLoad?.(this.featureFlags.length)
this.warnAboutExperienceContinuityFlags(cached.flags)
return true
}
return false
} catch (err) {
this.onError?.(new Error(`Failed to load from cache: ${err}`))
return false
}
}
async loadFeatureFlags(forceReload = false): Promise<void> {
if (this.loadedSuccessfullyOnce && !forceReload) {
return
}
// Respect backoff for on-demand fetches (e.g., from getFeatureFlag calls).
// The poller uses forceReload=true and has already waited the backoff period.
if (!forceReload && this.nextFetchAllowedAt && Date.now() < this.nextFetchAllowedAt) {
this.logMsgIfDebug(() => console.debug('[FEATURE FLAGS] Skipping fetch, in backoff period'))
return
}
if (!this.loadingPromise) {
this.loadingPromise = this._loadFeatureFlags()
.catch((err) => this.logMsgIfDebug(() => console.debug(`[FEATURE FLAGS] Failed to load feature flags: ${err}`)))
.finally(() => {
this.loadingPromise = undefined
})
}
return this.loadingPromise
}
/**
* Returns true if the feature flags poller has loaded successfully at least once and has more than 0 feature flags.
* This is useful to check if local evaluation is ready before calling getFeatureFlag.
*/
isLocalEvaluationReady(): boolean {
return (this.loadedSuccessfullyOnce ?? false) && (this.featureFlags?.length ?? 0) > 0
}
/**
* Returns the timestamp (in milliseconds) when flag definitions were last loaded.
* Returns undefined if flags have not been loaded yet.
*/
getFlagDefinitionsLoadedAt(): number | undefined {
return this.flagDefinitionsLoadedAt
}
/**
* If a client is misconfigured with an invalid or improper API key, the polling interval is doubled each time
* until a successful request is made, up to a maximum of 60 seconds.
*
* @returns The polling interval to use for the next request.
*/
private getPollingInterval(): number {
if (!this.shouldBeginExponentialBackoff) {
return this.pollingInterval
}
return Math.min(SIXTY_SECONDS, this.pollingInterval * 2 ** this.backOffCount)
}
/**
* Enter backoff state after receiving an error response (401, 403, 429).
* This enables exponential backoff for the poller and blocks on-demand fetches.
*/
private beginBackoff(): void {
this.shouldBeginExponentialBackoff = true
this.backOffCount += 1
// Use the same backoff interval as the poller to avoid overwhelming
// the server with on-demand requests while polling is backed off.
this.nextFetchAllowedAt = Date.now() + this.getPollingInterval()
}
/**
* Clear backoff state after a successful response (200, 304).
* This resets the polling interval and allows on-demand fetches immediately.
*/
private clearBackoff(): void {
this.shouldBeginExponentialBackoff = false
this.backOffCount = 0
this.nextFetchAllowedAt = undefined
}
async _loadFeatureFlags(): Promise<void> {
if (this.poller) {
clearTimeout(this.poller)
this.poller = undefined
}
this.poller = setTimeout(() => this.loadFeatureFlags(true), this.getPollingInterval())
try {
let shouldFetch = true
if (this.cacheProvider) {
try {
shouldFetch = await this.cacheProvider.shouldFetchFlagDefinitions()
} catch (err) {
this.onError?.(new Error(`Error in shouldFetchFlagDefinitions: ${err}`))
// Important: if `shouldFetchFlagDefinitions` throws, we
// default to fetching.
}
}
if (!shouldFetch) {
// If we're not supposed to fetch, we assume another instance
// is handling it. In this case, we'll just reload from cache.
const loaded = await this.loadFromCache('Loaded flags from cache (skipped fetch)')
if (loaded) {
return
}
if (this.loadedSuccessfullyOnce) {
// Respect the decision to not fetch, even if it means
// keeping stale feature flags.
return
}
// If we've gotten here:
// - A cache provider is configured
// - We've been asked not to fetch
// - We failed to load from cache
// - We have no feature flag definitions to work with.
//
// This is the only case where we'll ignore the shouldFetch
// decision and proceed to fetch, because the alternative is
// worse: local evaluation is impossible.
}
const res = await this._requestFeatureFlagDefinitions()
// Handle undefined res case, this shouldn't happen, but it doesn't hurt to handle it anyway
if (!res) {
// Don't override existing flags when something goes wrong
return
}
// NB ON ERROR HANDLING & `loadedSuccessfullyOnce`:
//
// `loadedSuccessfullyOnce` indicates we've successfully loaded a valid set of flags at least once.
// If we set it to `true` in an error scenario (e.g. 402 Over Quota, 401 Invalid Key, etc.),
// any manual call to `loadFeatureFlags()` (without forceReload) will skip refetching entirely,
// leaving us stuck with zero or outdated flags. The poller does keep running, but we also want
// manual reloads to be possible as soon as the error condition is resolved.
//
// Therefore, on error statuses, we do *not* set `loadedSuccessfullyOnce = true`, ensuring that
// both the background poller and any subsequent manual calls can keep trying to load flags
// once the issue (quota, permission, rate limit, etc.) is resolved.
switch (res.status) {
case 304:
// Not Modified - flags haven't changed, keep using cached data
this.logMsgIfDebug(() => console.debug('[FEATURE FLAGS] Flags not modified (304), using cached data'))
// Update ETag if server sent one (304 can include updated ETag per HTTP spec)
this.flagsEtag = res.headers?.get('ETag') ?? this.flagsEtag
this.loadedSuccessfullyOnce = true
this.clearBackoff()
return
case 401:
// Invalid API key
this.beginBackoff()
throw new ClientError(
`Your project key or personal API key is invalid. Setting next polling interval to ${this.getPollingInterval()}ms. More information: https://posthog.com/docs/api#rate-limiting`
)
case 402:
// Quota exceeded - clear all flags
console.warn(
'[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
)
this.featureFlags = []
this.featureFlagsByKey = {}
this.groupTypeMapping = {}
this.cohorts = {}
return
case 403:
// Permissions issue
this.beginBackoff()
throw new ClientError(
`Your personal API key does not have permission to fetch feature flag definitions for local evaluation. Setting next polling interval to ${this.getPollingInterval()}ms. Are you sure you're using the correct personal and Project API key pair? More information: https://posthog.com/docs/api/overview`
)
case 429:
// Rate limited
this.beginBackoff()
throw new ClientError(
`You are being rate limited. Setting next polling interval to ${this.getPollingInterval()}ms. More information: https://posthog.com/docs/api#rate-limiting`
)
case 200: {
// Process successful response
const responseJson = ((await res.json()) as { [key: string]: any }) ?? {}
if (!('flags' in responseJson)) {
this.onError?.(new Error(`Invalid response when getting feature flags: ${JSON.stringify(responseJson)}`))
return
}
// Store ETag from response for subsequent conditional requests
// Clear it if server stops sending one
this.flagsEtag = res.headers?.get('ETag') ?? undefined
const flagData: FlagDefinitionCacheData = {
flags: (responseJson.flags as PostHogFeatureFlag[]) ?? [],
groupTypeMapping: (responseJson.group_type_mapping as Record<string, string>) || {},
cohorts: (responseJson.cohorts as Record<string, PropertyGroup>) || {},
}
this.updateFlagState(flagData)
// Set timestamp to when definitions were actually fetched from server
this.flagDefinitionsLoadedAt = Date.now()
this.clearBackoff()
if (this.cacheProvider && shouldFetch) {
// Only notify the cache if it's actually expecting new data
// E.g., if we weren't supposed to fetch but we missed the
// cache, we may not have a lock, so we skip this step
try {
await this.cacheProvider.onFlagDefinitionsReceived(flagData)
} catch (err) {
this.onError?.(new Error(`Failed to store in cache: ${err}`))
// Continue anyway, the data at least made it to memory
}
}
this.onLoad?.(this.featureFlags.length)
this.warnAboutExperienceContinuityFlags(flagData.flags)
break
}
default:
// Something else went wrong, or the server is down.
// In this case, don't override existing flags
return
}
} catch (err) {
if (err instanceof ClientError) {
this.onError?.(err)
}
}
}
private getPersonalApiKeyRequestOptions(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' = 'GET',
etag?: string
): PostHogFetchOptions {
const headers: { [key: string]: string } = {
...this.customHeaders,
'Content-Type': 'application/json',
Authorization: `Bearer ${this.personalApiKey}`,
}
if (etag) {
headers['If-None-Match'] = etag
}
return {
method,
headers,
}
}
_requestFeatureFlagDefinitions(): Promise<PostHogFetchResponse> {
const url = `${this.host}/flags/definitions?token=${this.projectApiKey}&send_cohorts`
const options = this.getPersonalApiKeyRequestOptions('GET', this.flagsEtag)
let abortTimeout = null
if (this.timeout && typeof this.timeout === 'number') {
const controller = new AbortController()
abortTimeout = safeSetTimeout(() => {
controller.abort()
}, this.timeout)
options.signal = controller.signal
}
try {
// Unbind fetch from `this` to avoid potential issues in edge environments, e.g., Cloudflare Workers:
// https://developers.cloudflare.com/workers/observability/errors/#illegal-invocation-errors
const fetch = this.fetch
return fetch(url, options)
} finally {
clearTimeout(abortTimeout)
}
}
async stopPoller(timeoutMs: number = 30000): Promise<void> {
clearTimeout(this.poller)
if (this.cacheProvider) {
try {
const shutdownResult = this.cacheProvider.shutdown()
if (shutdownResult instanceof Promise) {
// This follows the same timeout logic defined in _shutdown.
// We time out after some period of time to avoid hanging the entire
// shutdown process if the cache provider misbehaves.
await Promise.race([
shutdownResult,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Cache shutdown timeout after ${timeoutMs}ms`)), timeoutMs)
),
])
}
} catch (err) {
this.onError?.(new Error(`Error during cache shutdown: ${err}`))
}
}
}
}
// # This function takes a bucketing identifier and a feature flag key and returns a float between 0 and 1.
// # Given the same bucketing identifier and key, it'll always return the same float. These floats are
// # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
// # we can do _hash(key, bucketing_identifier) < 0.2
async function _hash(key: string, bucketingValue: string, salt: string = ''): Promise<number> {
const hashString = await hashSHA1(`${key}.${bucketingValue}${salt}`)
return parseInt(hashString.slice(0, 15), 16) / LONG_SCALE
}
function matchProperty(
property: FeatureFlagCondition['properties'][number],
propertyValues: Record<string, any>,
warnFunction?: (msg: string) => void
): boolean {
const key = property.key
const value = property.value
const operator = property.operator || 'exact'
if (!(key in propertyValues)) {
// When the property is genuinely absent we can answer `is_not_set` locally — no need to
// bail out as inconclusive and force the flag to return undefined.
if (operator === 'is_not_set') {
return true
}
throw new InconclusiveMatchError(`Property ${key} not found in propertyValues`)
} else if (operator === 'is_not_set') {
return false
}
const overrideValue = propertyValues[key]
if (overrideValue == null && !NULL_VALUES_ALLOWED_OPERATORS.includes(operator)) {
// if the value is null, just fail the feature flag comparison
// this isn't an InconclusiveMatchError because the property value was provided.
if (warnFunction) {
warnFunction(`Property ${key} cannot have a value of null/undefined with the ${operator} operator`)
}
return false
}
function computeExactMatch(value: any, overrideValue: any): boolean {
if (Array.isArray(value)) {
return value.map((val) => String(val).toLowerCase()).includes(String(overrideValue).toLowerCase())
}
return String(value).toLowerCase() === String(overrideValue).toLowerCase()
}
function compare(lhs: any, rhs: any, operator: string): boolean {
if (operator === 'gt') {
return lhs > rhs
} else if (operator === 'gte') {
return lhs >= rhs
} else if (operator === 'lt') {
return lhs < rhs
} else if (operator === 'lte') {
return lhs <= rhs
} else {
throw new Error(`Invalid operator: ${operator}`)
}
}
switch (operator) {
case 'exact':
return computeExactMatch(value, overrideValue)
case 'is_not':
return !computeExactMatch(value, overrideValue)
case 'is_set':
return key in propertyValues
case 'icontains':
return String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
case 'not_icontains':
return !String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
case 'regex':
return isValidRegex(String(value)) && String(overrideValue).match(String(value)) !== null
case 'not_regex':
return isValidRegex(String(value)) && String(overrideValue).match(String(value)) === null
case 'gt':
case 'gte':
case 'lt':
case 'lte': {
// Try a numeric comparison first; only fall back to lexicographic when one side genuinely
// isn't a number. `parseFloat` returns NaN for non-numeric strings, so `Number.isFinite`
// is the right guard — `NaN != null` would slip through and produce nonsense comparisons
// like `NaN > 5`. Likewise, when a person property arrives as the string `"10"` we want
// `"10" > "9"` to evaluate numerically (true), not lexicographically (false).
const parsedValue = typeof value === 'number' ? value : parseFloat(String(value))
let parsedOverride: number
if (typeof overrideValue === 'number') {
parsedOverride = overrideValue
} else if (overrideValue != null) {
parsedOverride = parseFloat(String(overrideValue))
} else {
parsedOverride = NaN
}
if (Number.isFinite(parsedValue) && Number.isFinite(parsedOverride)) {
return compare(parsedOverride, parsedValue, operator)
}
return compare(String(overrideValue), String(value), operator)
}
case 'is_date_after':
case 'is_date_before': {
// Boolean values should never be used with date operations
if (typeof value === 'boolean') {
throw new InconclusiveMatchError(`Date operations cannot be performed on boolean values`)
}
let parsedDate = relativeDateParseForFeatureFlagMatching(String(value))
if (parsedDate == null) {
parsedDate = convertToDateTime(value)
}
if (parsedDate == null) {
throw new InconclusiveMatchError(`Invalid date: ${value}`)
}
const overrideDate = convertToDateTime(overrideValue)
if (['is_date_before'].includes(operator)) {
return overrideDate < parsedDate
}
return overrideDate > parsedDate
}
case 'semver_eq': {
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)))
return cmp === 0
}
case 'semver_neq': {
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)))
return cmp !== 0
}
case 'semver_gt': {
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)))
return cmp > 0
}
case 'semver_gte': {
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)))
return cmp >= 0
}
case 'semver_lt': {
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)))
return cmp < 0
}
case 'semver_lte': {
const cmp = compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value)))
return cmp <= 0
}
case 'semver_tilde': {
const overrideParsed = parseSemver(String(overrideValue))
const { lower, upper } = computeTildeBounds(String(value))
return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0
}
case 'semver_caret': {
const overrideParsed = parseSemver(String(overrideValue))
const { lower, upper } = computeCaretBounds(String(value))
return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0
}
case 'semver_wildcard': {
const overrideParsed = parseSemver(String(overrideValue))
const { lower, upper } = computeWildcardBounds(String(value))
return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0
}
default:
throw new InconclusiveMatchError(`Unknown operator: ${operator}`)
}
}
function checkCohortExists(cohortId: string, cohortProperties: FeatureFlagsPoller['cohorts']): void {
if (!(cohortId in cohortProperties)) {
throw new RequiresServerEvaluation(
`cohort ${cohortId} not found in local cohorts - likely a static cohort that requires server evaluation`
)
}
}
type FlagDependencyEvaluator = (prop: FlagProperty) => Promise<boolean>
async function matchCohort(
property: FeatureFlagCondition['properties'][number],
propertyValues: Record<string, any>,
cohortProperties: FeatureFlagsPoller['cohorts'],
debugMode: boolean = false,
flagDependencyEvaluator?: FlagDependencyEvaluator
): Promise<boolean> {
const cohortId = String(property.value)
checkCohortExists(cohortId, cohortProperties)
const propertyGroup = cohortProperties[cohortId]
return matchPropertyGroup(propertyGroup, propertyValues, cohortProperties, debugMode, flagDependencyEvaluator)
}
async function matchPropertyGroup(
propertyGroup: PropertyGroup,
propertyValues: Record<string, any>,
cohortProperties: FeatureFlagsPoller['cohorts'],
debugMode: boolean = false,
flagDependencyEvaluator?: FlagDependencyEvaluator
): Promise<boolean> {
if (!propertyGroup) {
return true
}
const propertyGroupType = propertyGroup.type
const properties = propertyGroup.values
if (!properties || properties.length === 0) {
// empty groups are no-ops, always match
return true
}
let errorMatchingLocally = false
if ('values' in properties[0]) {
// a nested property group
for (const prop of properties as PropertyGroup[]) {
try {
const matches = await matchPropertyGroup(
prop,
propertyValues,
cohortProperties,
debugMode,
flagDependencyEvaluator
)
if (propertyGroupType === 'AND') {
if (!matches) {
return false
}
} else {
// OR group
if (matches) {
return true
}
}
} catch (err) {
if (err instanceof RequiresServerEvaluation) {
// Immediately propagate - this condition requires server-side data
throw err
} else if (err instanceof InconclusiveMatchError) {
if (debugMode) {
console.debug(`Failed to compute property ${prop} locally: ${err}`)
}
errorMatchingLocally = true
} else {
throw err
}
}
}
if (errorMatchingLocally) {
throw new InconclusiveMatchError("Can't match cohort without a given cohort property value")
}
// if we get here, all matched in AND case, or none matched in OR case
return propertyGroupType === 'AND'
} else {
for (const prop of properties as FlagProperty[]) {
try {
let matches: boolean
if (prop.type === 'cohort') {
matches = await matchCohort(prop, propertyValues, cohortProperties, debugMode, flagDependencyEvaluator)
} else if (prop.type === 'flag') {
if (!flagDependencyEvaluator) {
throw new InconclusiveMatchError(
`Flag dependency '${prop.key || 'unknown'}' cannot be evaluated without a flag dependency evaluator`
)
}
matches = await flagDependencyEvaluator(prop)
} else {
matches = matchProperty(prop, propertyValues)
}
const negation = prop.negation || false
if (propertyGroupType === 'AND') {
// if negated property, do the inverse
if (!matches && !negation) {
return false
}
if (matches && negation) {
return false
}
} else {
// OR group
if (matches && !negation) {
return true
}
if (!matches && negation) {
return true
}
}
} catch (err) {
if (err instanceof RequiresServerEvaluation) {
// Immediately propagate - this condition requires server-side data
throw err
} else if (err instanceof InconclusiveMatchError) {
if (debugMode) {
console.debug(`Failed to compute property ${prop} locally: ${err}`)
}
errorMatchingLocally = true
} else {
throw err
}
}
}
if (errorMatchingLocally) {
throw new InconclusiveMatchError("can't match cohort without a given cohort property value")
}
// if we get here, all matched in AND case, or none matched in OR case
return propertyGroupType === 'AND'
}
}
function isValidRegex(regex: string): boolean {
try {
new RegExp(regex)
return true
} catch (err) {
return false
}
}
type SemverTuple = [number, number, number]
/**
* Parse a single numeric identifier from a semver string.
* Per semver 2.0.0 §2, numeric identifiers MUST NOT include leading zeros.
*/
function parseSemverNumericIdentifier(part: string, raw: string): number {
if (!/^\d+$/.test(part)) {
throw new InconclusiveMatchError(`Invalid semver: ${raw}`)
}
if (part.length > 1 && part[0] === '0') {
throw new InconclusiveMatchError(`Invalid semver: ${raw}`)
}
return parseInt(part, 10)
}
/**
* Parse a version string into a [major, minor, patch] tuple.
* - Strips leading/trailing whitespace
* - Strips 'v' or 'V' prefix
* - Strips pre-release and build metadata (-alpha, +build)
* - Defaults missing components to 0
* - Ignores extra components beyond the third
* - Throws InconclusiveMatchError for invalid input
*/
function parseSemver(value: string): SemverTuple {
const text = String(value).trim().replace(/^[vV]/, '')
// Strip pre-release and build metadata
const baseVersion = text.split('-')[0].split('+')[0]
if (!baseVersion || baseVersion.startsWith('.')) {
t