posthog-node 5.8.8 → 5.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/{index.d.ts → client.d.ts} +7 -378
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +480 -0
  4. package/dist/client.mjs +436 -0
  5. package/dist/entrypoints/index.edge.d.ts +6 -0
  6. package/dist/entrypoints/index.edge.d.ts.map +1 -0
  7. package/dist/entrypoints/index.edge.js +96 -0
  8. package/dist/entrypoints/index.edge.mjs +19 -0
  9. package/dist/entrypoints/index.node.d.ts +6 -0
  10. package/dist/entrypoints/index.node.d.ts.map +1 -0
  11. package/dist/entrypoints/index.node.js +107 -0
  12. package/dist/entrypoints/index.node.mjs +24 -0
  13. package/dist/exports.d.ts +4 -0
  14. package/dist/exports.d.ts.map +1 -0
  15. package/dist/exports.js +78 -0
  16. package/dist/exports.mjs +3 -0
  17. package/dist/extensions/error-tracking/autocapture.d.ts +4 -0
  18. package/dist/extensions/error-tracking/autocapture.d.ts.map +1 -0
  19. package/dist/extensions/error-tracking/autocapture.js +68 -0
  20. package/dist/extensions/error-tracking/autocapture.mjs +31 -0
  21. package/dist/extensions/error-tracking/index.d.ts +19 -0
  22. package/dist/extensions/error-tracking/index.d.ts.map +1 -0
  23. package/dist/extensions/error-tracking/index.js +97 -0
  24. package/dist/extensions/error-tracking/index.mjs +63 -0
  25. package/dist/extensions/error-tracking/modifiers/context-lines.node.d.ts +5 -0
  26. package/dist/extensions/error-tracking/modifiers/context-lines.node.d.ts.map +1 -0
  27. package/dist/extensions/error-tracking/modifiers/context-lines.node.js +227 -0
  28. package/dist/extensions/error-tracking/modifiers/context-lines.node.mjs +187 -0
  29. package/dist/extensions/error-tracking/modifiers/module.node.d.ts +3 -0
  30. package/dist/extensions/error-tracking/modifiers/module.node.d.ts.map +1 -0
  31. package/dist/extensions/error-tracking/modifiers/module.node.js +64 -0
  32. package/dist/extensions/error-tracking/modifiers/module.node.mjs +30 -0
  33. package/dist/extensions/express.d.ts +17 -0
  34. package/dist/extensions/express.d.ts.map +1 -0
  35. package/dist/extensions/express.js +61 -0
  36. package/dist/extensions/express.mjs +17 -0
  37. package/dist/extensions/feature-flags/crypto-helpers.d.ts +3 -0
  38. package/dist/extensions/feature-flags/crypto-helpers.d.ts.map +1 -0
  39. package/dist/extensions/feature-flags/crypto-helpers.js +77 -0
  40. package/dist/extensions/feature-flags/crypto-helpers.mjs +22 -0
  41. package/dist/extensions/feature-flags/crypto.d.ts +2 -0
  42. package/dist/extensions/feature-flags/crypto.d.ts.map +1 -0
  43. package/dist/extensions/feature-flags/crypto.js +47 -0
  44. package/dist/extensions/feature-flags/crypto.mjs +13 -0
  45. package/dist/extensions/feature-flags/feature-flags.d.ts +89 -0
  46. package/dist/extensions/feature-flags/feature-flags.d.ts.map +1 -0
  47. package/dist/extensions/feature-flags/feature-flags.js +529 -0
  48. package/dist/extensions/feature-flags/feature-flags.mjs +483 -0
  49. package/dist/extensions/feature-flags/lazy.d.ts +24 -0
  50. package/dist/extensions/feature-flags/lazy.d.ts.map +1 -0
  51. package/dist/extensions/feature-flags/lazy.js +60 -0
  52. package/dist/extensions/feature-flags/lazy.mjs +26 -0
  53. package/dist/extensions/sentry-integration.d.ts +54 -0
  54. package/dist/extensions/sentry-integration.d.ts.map +1 -0
  55. package/dist/extensions/sentry-integration.js +113 -0
  56. package/dist/extensions/sentry-integration.mjs +73 -0
  57. package/dist/storage-memory.d.ts +7 -0
  58. package/dist/storage-memory.d.ts.map +1 -0
  59. package/dist/storage-memory.js +46 -0
  60. package/dist/storage-memory.mjs +12 -0
  61. package/dist/types.d.ts +253 -0
  62. package/dist/types.d.ts.map +1 -0
  63. package/dist/types.js +18 -0
  64. package/dist/types.mjs +0 -0
  65. package/dist/utils/logger.d.ts +3 -0
  66. package/dist/utils/logger.d.ts.map +1 -0
  67. package/dist/utils/logger.js +63 -0
  68. package/dist/utils/logger.mjs +29 -0
  69. package/dist/version.d.ts +2 -0
  70. package/dist/version.d.ts.map +1 -0
  71. package/dist/version.js +36 -0
  72. package/dist/version.mjs +2 -0
  73. package/package.json +32 -31
  74. package/src/client.ts +1532 -0
  75. package/src/entrypoints/index.edge.ts +22 -0
  76. package/src/entrypoints/index.node.ts +26 -0
  77. package/src/exports.ts +3 -0
  78. package/src/extensions/error-tracking/autocapture.ts +67 -0
  79. package/src/extensions/error-tracking/index.ts +104 -0
  80. package/src/extensions/error-tracking/modifiers/context-lines.node.ts +404 -0
  81. package/src/extensions/error-tracking/modifiers/module.node.ts +68 -0
  82. package/src/extensions/express.ts +40 -0
  83. package/src/extensions/feature-flags/crypto-helpers.ts +36 -0
  84. package/src/extensions/feature-flags/crypto.ts +22 -0
  85. package/src/extensions/feature-flags/feature-flags.ts +1003 -0
  86. package/src/extensions/feature-flags/lazy.ts +55 -0
  87. package/src/extensions/sentry-integration.ts +216 -0
  88. package/src/storage-memory.ts +13 -0
  89. package/src/types.ts +294 -0
  90. package/src/utils/logger.ts +39 -0
  91. package/src/version.ts +1 -0
  92. package/dist/edge/index.cjs +0 -3150
  93. package/dist/edge/index.cjs.map +0 -1
  94. package/dist/edge/index.mjs +0 -3144
  95. package/dist/edge/index.mjs.map +0 -1
  96. package/dist/node/index.cjs +0 -3556
  97. package/dist/node/index.cjs.map +0 -1
  98. package/dist/node/index.mjs +0 -3550
  99. package/dist/node/index.mjs.map +0 -1
@@ -0,0 +1,1003 @@
1
+ import { FeatureFlagCondition, FlagProperty, FlagPropertyValue, PostHogFeatureFlag, PropertyGroup } from '../../types'
2
+ import type { FeatureFlagValue, JsonType, PostHogFetchOptions, PostHogFetchResponse } from '@posthog/core'
3
+ import { safeSetTimeout } from '@posthog/core'
4
+ import { hashSHA1 } from './crypto'
5
+
6
+ const SIXTY_SECONDS = 60 * 1000
7
+
8
+ // eslint-disable-next-line
9
+ const LONG_SCALE = 0xfffffffffffffff
10
+
11
+ const NULL_VALUES_ALLOWED_OPERATORS = ['is_not']
12
+ class ClientError extends Error {
13
+ constructor(message: string) {
14
+ super()
15
+ Error.captureStackTrace(this, this.constructor)
16
+ this.name = 'ClientError'
17
+ this.message = message
18
+ Object.setPrototypeOf(this, ClientError.prototype)
19
+ }
20
+ }
21
+
22
+ class InconclusiveMatchError extends Error {
23
+ constructor(message: string) {
24
+ super(message)
25
+ this.name = this.constructor.name
26
+ Error.captureStackTrace(this, this.constructor)
27
+ // instanceof doesn't work in ES3 or ES5
28
+ // https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
29
+ // this is the workaround
30
+ Object.setPrototypeOf(this, InconclusiveMatchError.prototype)
31
+ }
32
+ }
33
+
34
+ type FeatureFlagsPollerOptions = {
35
+ personalApiKey: string
36
+ projectApiKey: string
37
+ host: string
38
+ pollingInterval: number
39
+ timeout?: number
40
+ fetch?: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>
41
+ onError?: (error: Error) => void
42
+ onLoad?: (count: number) => void
43
+ customHeaders?: { [key: string]: string }
44
+ }
45
+
46
+ class FeatureFlagsPoller {
47
+ pollingInterval: number
48
+ personalApiKey: string
49
+ projectApiKey: string
50
+ featureFlags: Array<PostHogFeatureFlag>
51
+ featureFlagsByKey: Record<string, PostHogFeatureFlag>
52
+ groupTypeMapping: Record<string, string>
53
+ cohorts: Record<string, PropertyGroup>
54
+ loadedSuccessfullyOnce: boolean
55
+ timeout?: number
56
+ host: FeatureFlagsPollerOptions['host']
57
+ poller?: NodeJS.Timeout
58
+ fetch: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>
59
+ debugMode: boolean = false
60
+ onError?: (error: Error) => void
61
+ customHeaders?: { [key: string]: string }
62
+ shouldBeginExponentialBackoff: boolean = false
63
+ backOffCount: number = 0
64
+ onLoad?: (count: number) => void
65
+
66
+ constructor({
67
+ pollingInterval,
68
+ personalApiKey,
69
+ projectApiKey,
70
+ timeout,
71
+ host,
72
+ customHeaders,
73
+ ...options
74
+ }: FeatureFlagsPollerOptions) {
75
+ this.pollingInterval = pollingInterval
76
+ this.personalApiKey = personalApiKey
77
+ this.featureFlags = []
78
+ this.featureFlagsByKey = {}
79
+ this.groupTypeMapping = {}
80
+ this.cohorts = {}
81
+ this.loadedSuccessfullyOnce = false
82
+ this.timeout = timeout
83
+ this.projectApiKey = projectApiKey
84
+ this.host = host
85
+ this.poller = undefined
86
+ this.fetch = options.fetch || fetch
87
+ this.onError = options.onError
88
+ this.customHeaders = customHeaders
89
+ this.onLoad = options.onLoad
90
+ void this.loadFeatureFlags()
91
+ }
92
+
93
+ debug(enabled: boolean = true): void {
94
+ this.debugMode = enabled
95
+ }
96
+
97
+ private logMsgIfDebug(fn: () => void): void {
98
+ if (this.debugMode) {
99
+ fn()
100
+ }
101
+ }
102
+
103
+ async getFeatureFlag(
104
+ key: string,
105
+ distinctId: string,
106
+ groups: Record<string, string> = {},
107
+ personProperties: Record<string, string> = {},
108
+ groupProperties: Record<string, Record<string, string>> = {}
109
+ ): Promise<FeatureFlagValue | undefined> {
110
+ await this.loadFeatureFlags()
111
+
112
+ let response: FeatureFlagValue | undefined = undefined
113
+ let featureFlag = undefined
114
+
115
+ if (!this.loadedSuccessfullyOnce) {
116
+ return response
117
+ }
118
+
119
+ featureFlag = this.featureFlagsByKey[key]
120
+
121
+ if (featureFlag !== undefined) {
122
+ try {
123
+ const result = await this.computeFlagAndPayloadLocally(
124
+ featureFlag,
125
+ distinctId,
126
+ groups,
127
+ personProperties,
128
+ groupProperties
129
+ )
130
+ response = result.value
131
+ this.logMsgIfDebug(() => console.debug(`Successfully computed flag locally: ${key} -> ${response}`))
132
+ } catch (e) {
133
+ if (e instanceof InconclusiveMatchError) {
134
+ this.logMsgIfDebug(() => console.debug(`InconclusiveMatchError when computing flag locally: ${key}: ${e}`))
135
+ } else if (e instanceof Error) {
136
+ this.onError?.(new Error(`Error computing flag locally: ${key}: ${e}`))
137
+ }
138
+ }
139
+ }
140
+
141
+ return response
142
+ }
143
+
144
+ async getAllFlagsAndPayloads(
145
+ distinctId: string,
146
+ groups: Record<string, string> = {},
147
+ personProperties: Record<string, string> = {},
148
+ groupProperties: Record<string, Record<string, string>> = {},
149
+ flagKeysToExplicitlyEvaluate?: string[]
150
+ ): Promise<{
151
+ response: Record<string, FeatureFlagValue>
152
+ payloads: Record<string, JsonType>
153
+ fallbackToFlags: boolean
154
+ }> {
155
+ await this.loadFeatureFlags()
156
+
157
+ const response: Record<string, FeatureFlagValue> = {}
158
+ const payloads: Record<string, JsonType> = {}
159
+ let fallbackToFlags = this.featureFlags.length == 0
160
+
161
+ const flagsToEvaluate = flagKeysToExplicitlyEvaluate
162
+ ? flagKeysToExplicitlyEvaluate.map((key) => this.featureFlagsByKey[key]).filter(Boolean)
163
+ : this.featureFlags
164
+
165
+ // Create a shared evaluation cache to prevent memory leaks when processing many flags
166
+ const sharedEvaluationCache: Record<string, FeatureFlagValue> = {}
167
+
168
+ await Promise.all(
169
+ flagsToEvaluate.map(async (flag) => {
170
+ try {
171
+ const { value: matchValue, payload: matchPayload } = await this.computeFlagAndPayloadLocally(
172
+ flag,
173
+ distinctId,
174
+ groups,
175
+ personProperties,
176
+ groupProperties,
177
+ undefined /* matchValue */,
178
+ sharedEvaluationCache
179
+ )
180
+ response[flag.key] = matchValue
181
+ if (matchPayload) {
182
+ payloads[flag.key] = matchPayload
183
+ }
184
+ } catch (e) {
185
+ if (e instanceof InconclusiveMatchError) {
186
+ this.logMsgIfDebug(() =>
187
+ console.debug(`InconclusiveMatchError when computing flag locally: ${flag.key}: ${e}`)
188
+ )
189
+ } else if (e instanceof Error) {
190
+ this.onError?.(new Error(`Error computing flag locally: ${flag.key}: ${e}`))
191
+ }
192
+ fallbackToFlags = true
193
+ }
194
+ })
195
+ )
196
+
197
+ return { response, payloads, fallbackToFlags }
198
+ }
199
+
200
+ async computeFlagAndPayloadLocally(
201
+ flag: PostHogFeatureFlag,
202
+ distinctId: string,
203
+ groups: Record<string, string> = {},
204
+ personProperties: Record<string, string> = {},
205
+ groupProperties: Record<string, Record<string, string>> = {},
206
+ matchValue?: FeatureFlagValue,
207
+ evaluationCache?: Record<string, FeatureFlagValue>,
208
+ skipLoadCheck: boolean = false
209
+ ): Promise<{
210
+ value: FeatureFlagValue
211
+ payload: JsonType | null
212
+ }> {
213
+ // Only load flags if not already loaded and not skipping the check
214
+ if (!skipLoadCheck) {
215
+ await this.loadFeatureFlags()
216
+ }
217
+
218
+ if (!this.loadedSuccessfullyOnce) {
219
+ return { value: false, payload: null }
220
+ }
221
+
222
+ let flagValue: FeatureFlagValue
223
+
224
+ // If matchValue is provided, use it directly; otherwise evaluate the flag
225
+ if (matchValue !== undefined) {
226
+ flagValue = matchValue
227
+ } else {
228
+ flagValue = await this.computeFlagValueLocally(
229
+ flag,
230
+ distinctId,
231
+ groups,
232
+ personProperties,
233
+ groupProperties,
234
+ evaluationCache
235
+ )
236
+ }
237
+
238
+ // Always compute payload based on the final flagValue (whether provided or computed)
239
+ const payload = this.getFeatureFlagPayload(flag.key, flagValue)
240
+
241
+ return { value: flagValue, payload }
242
+ }
243
+
244
+ private async computeFlagValueLocally(
245
+ flag: PostHogFeatureFlag,
246
+ distinctId: string,
247
+ groups: Record<string, string> = {},
248
+ personProperties: Record<string, string> = {},
249
+ groupProperties: Record<string, Record<string, string>> = {},
250
+ evaluationCache: Record<string, FeatureFlagValue> = {}
251
+ ): Promise<FeatureFlagValue> {
252
+ if (flag.ensure_experience_continuity) {
253
+ throw new InconclusiveMatchError('Flag has experience continuity enabled')
254
+ }
255
+
256
+ if (!flag.active) {
257
+ return false
258
+ }
259
+
260
+ const flagFilters = flag.filters || {}
261
+ const aggregation_group_type_index = flagFilters.aggregation_group_type_index
262
+
263
+ if (aggregation_group_type_index != undefined) {
264
+ const groupName = this.groupTypeMapping[String(aggregation_group_type_index)]
265
+
266
+ if (!groupName) {
267
+ this.logMsgIfDebug(() =>
268
+ console.warn(
269
+ `[FEATURE FLAGS] Unknown group type index ${aggregation_group_type_index} for feature flag ${flag.key}`
270
+ )
271
+ )
272
+ throw new InconclusiveMatchError('Flag has unknown group type index')
273
+ }
274
+
275
+ if (!(groupName in groups)) {
276
+ this.logMsgIfDebug(() =>
277
+ console.warn(`[FEATURE FLAGS] Can't compute group feature flag: ${flag.key} without group names passed in`)
278
+ )
279
+ return false
280
+ }
281
+
282
+ const focusedGroupProperties = groupProperties[groupName]
283
+ return await this.matchFeatureFlagProperties(flag, groups[groupName], focusedGroupProperties, evaluationCache)
284
+ } else {
285
+ return await this.matchFeatureFlagProperties(flag, distinctId, personProperties, evaluationCache)
286
+ }
287
+ }
288
+
289
+ private getFeatureFlagPayload(key: string, flagValue: FeatureFlagValue): JsonType | null {
290
+ let payload: JsonType | null = null
291
+
292
+ if (flagValue !== false && flagValue !== null && flagValue !== undefined) {
293
+ if (typeof flagValue == 'boolean') {
294
+ payload = this.featureFlagsByKey?.[key]?.filters?.payloads?.[flagValue.toString()] || null
295
+ } else if (typeof flagValue == 'string') {
296
+ payload = this.featureFlagsByKey?.[key]?.filters?.payloads?.[flagValue] || null
297
+ }
298
+
299
+ if (payload !== null && payload !== undefined) {
300
+ // If payload is already an object, return it directly
301
+ if (typeof payload === 'object') {
302
+ return payload
303
+ }
304
+ // If payload is a string, try to parse it as JSON
305
+ if (typeof payload === 'string') {
306
+ try {
307
+ return JSON.parse(payload)
308
+ } catch {
309
+ // If parsing fails, return the string as is
310
+ return payload
311
+ }
312
+ }
313
+ // For other types, return as is
314
+ return payload
315
+ }
316
+ }
317
+ return null
318
+ }
319
+
320
+ private async evaluateFlagDependency(
321
+ property: FlagProperty,
322
+ distinctId: string,
323
+ properties: Record<string, string>,
324
+ evaluationCache: Record<string, FeatureFlagValue>
325
+ ): Promise<boolean> {
326
+ const targetFlagKey = property.key
327
+
328
+ if (!this.featureFlagsByKey) {
329
+ throw new InconclusiveMatchError('Feature flags not available for dependency evaluation')
330
+ }
331
+
332
+ // Check if dependency_chain is present - it should always be provided for flag dependencies
333
+ if (!('dependency_chain' in property)) {
334
+ throw new InconclusiveMatchError(
335
+ `Flag dependency property for '${targetFlagKey}' is missing required 'dependency_chain' field`
336
+ )
337
+ }
338
+
339
+ const dependencyChain = property.dependency_chain
340
+
341
+ // Check for missing or invalid dependency chain (This should never happen, but being defensive)
342
+ if (!Array.isArray(dependencyChain)) {
343
+ throw new InconclusiveMatchError(
344
+ `Flag dependency property for '${targetFlagKey}' has an invalid 'dependency_chain' (expected array, got ${typeof dependencyChain})`
345
+ )
346
+ }
347
+
348
+ // Handle circular dependency (empty chain means circular) (This should never happen, but being defensive)
349
+ if (dependencyChain.length === 0) {
350
+ throw new InconclusiveMatchError(
351
+ `Circular dependency detected for flag '${targetFlagKey}' (empty dependency chain)`
352
+ )
353
+ }
354
+
355
+ // Evaluate all dependencies in the chain order
356
+ for (const depFlagKey of dependencyChain) {
357
+ if (!(depFlagKey in evaluationCache)) {
358
+ // Need to evaluate this dependency first
359
+ const depFlag = this.featureFlagsByKey[depFlagKey]
360
+ if (!depFlag) {
361
+ // Missing flag dependency - cannot evaluate locally
362
+ throw new InconclusiveMatchError(`Missing flag dependency '${depFlagKey}' for flag '${targetFlagKey}'`)
363
+ } else if (!depFlag.active) {
364
+ // Inactive flag evaluates to false
365
+ evaluationCache[depFlagKey] = false
366
+ } else {
367
+ // Recursively evaluate the dependency
368
+ try {
369
+ const depResult = await this.matchFeatureFlagProperties(depFlag, distinctId, properties, evaluationCache)
370
+ evaluationCache[depFlagKey] = depResult
371
+ } catch (error) {
372
+ // If we can't evaluate a dependency, store throw InconclusiveMatchError(`Missing flag dependency '${depFlagKey}' for flag '${targetFlagKey}'`)
373
+ throw new InconclusiveMatchError(
374
+ `Error evaluating flag dependency '${depFlagKey}' for flag '${targetFlagKey}': ${error}`
375
+ )
376
+ }
377
+ }
378
+ }
379
+
380
+ // Check if dependency evaluation was inconclusive
381
+ const cachedResult = evaluationCache[depFlagKey]
382
+ if (cachedResult === null || cachedResult === undefined) {
383
+ throw new InconclusiveMatchError(`Dependency '${depFlagKey}' could not be evaluated`)
384
+ }
385
+ }
386
+
387
+ // The target flag is specified in property.key (This should match the last element in the dependency chain)
388
+ const targetFlagValue = evaluationCache[targetFlagKey]
389
+
390
+ return this.flagEvaluatesToExpectedValue(property.value, targetFlagValue)
391
+ }
392
+
393
+ private flagEvaluatesToExpectedValue(expectedValue: FlagPropertyValue, flagValue: FeatureFlagValue): boolean {
394
+ // If the expected value is a boolean, then return true if the flag evaluated to true (or any string variant)
395
+ // If the expected value is false, then only return true if the flag evaluated to false.
396
+ if (typeof expectedValue === 'boolean') {
397
+ return (
398
+ expectedValue === flagValue || (typeof flagValue === 'string' && flagValue !== '' && expectedValue === true)
399
+ )
400
+ }
401
+
402
+ // If the expected value is a string, then return true if and only if the flag evaluated to the expected value.
403
+ if (typeof expectedValue === 'string') {
404
+ return flagValue === expectedValue
405
+ }
406
+
407
+ // The `flag_evaluates_to` operator is not supported for numbers and arrays.
408
+ return false
409
+ }
410
+
411
+ async matchFeatureFlagProperties(
412
+ flag: PostHogFeatureFlag,
413
+ distinctId: string,
414
+ properties: Record<string, string>,
415
+ evaluationCache: Record<string, FeatureFlagValue> = {}
416
+ ): Promise<FeatureFlagValue> {
417
+ const flagFilters = flag.filters || {}
418
+ const flagConditions = flagFilters.groups || []
419
+ let isInconclusive = false
420
+ let result = undefined
421
+
422
+ for (const condition of flagConditions) {
423
+ try {
424
+ if (await this.isConditionMatch(flag, distinctId, condition, properties, evaluationCache)) {
425
+ const variantOverride = condition.variant
426
+ const flagVariants = flagFilters.multivariate?.variants || []
427
+ if (variantOverride && flagVariants.some((variant) => variant.key === variantOverride)) {
428
+ result = variantOverride
429
+ } else {
430
+ result = (await this.getMatchingVariant(flag, distinctId)) || true
431
+ }
432
+ break
433
+ }
434
+ } catch (e) {
435
+ if (e instanceof InconclusiveMatchError) {
436
+ isInconclusive = true
437
+ } else {
438
+ throw e
439
+ }
440
+ }
441
+ }
442
+
443
+ if (result !== undefined) {
444
+ return result
445
+ } else if (isInconclusive) {
446
+ throw new InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties")
447
+ }
448
+
449
+ // We can only return False when all conditions are False
450
+ return false
451
+ }
452
+
453
+ async isConditionMatch(
454
+ flag: PostHogFeatureFlag,
455
+ distinctId: string,
456
+ condition: FeatureFlagCondition,
457
+ properties: Record<string, string>,
458
+ evaluationCache: Record<string, FeatureFlagValue> = {}
459
+ ): Promise<boolean> {
460
+ const rolloutPercentage = condition.rollout_percentage
461
+ const warnFunction = (msg: string): void => {
462
+ this.logMsgIfDebug(() => console.warn(msg))
463
+ }
464
+ if ((condition.properties || []).length > 0) {
465
+ for (const prop of condition.properties) {
466
+ const propertyType = prop.type
467
+ let matches = false
468
+
469
+ if (propertyType === 'cohort') {
470
+ matches = matchCohort(prop, properties, this.cohorts, this.debugMode)
471
+ } else if (propertyType === 'flag') {
472
+ matches = await this.evaluateFlagDependency(prop, distinctId, properties, evaluationCache)
473
+ } else {
474
+ matches = matchProperty(prop, properties, warnFunction)
475
+ }
476
+
477
+ if (!matches) {
478
+ return false
479
+ }
480
+ }
481
+
482
+ if (rolloutPercentage == undefined) {
483
+ return true
484
+ }
485
+ }
486
+
487
+ if (rolloutPercentage != undefined && (await _hash(flag.key, distinctId)) > rolloutPercentage / 100.0) {
488
+ return false
489
+ }
490
+
491
+ return true
492
+ }
493
+
494
+ async getMatchingVariant(flag: PostHogFeatureFlag, distinctId: string): Promise<FeatureFlagValue | undefined> {
495
+ const hashValue = await _hash(flag.key, distinctId, 'variant')
496
+ const matchingVariant = this.variantLookupTable(flag).find((variant) => {
497
+ return hashValue >= variant.valueMin && hashValue < variant.valueMax
498
+ })
499
+
500
+ if (matchingVariant) {
501
+ return matchingVariant.key
502
+ }
503
+ return undefined
504
+ }
505
+
506
+ variantLookupTable(flag: PostHogFeatureFlag): { valueMin: number; valueMax: number; key: string }[] {
507
+ const lookupTable: { valueMin: number; valueMax: number; key: string }[] = []
508
+ let valueMin = 0
509
+ let valueMax = 0
510
+ const flagFilters = flag.filters || {}
511
+ const multivariates: {
512
+ key: string
513
+ rollout_percentage: number
514
+ }[] = flagFilters.multivariate?.variants || []
515
+
516
+ multivariates.forEach((variant) => {
517
+ valueMax = valueMin + variant.rollout_percentage / 100.0
518
+ lookupTable.push({ valueMin, valueMax, key: variant.key })
519
+ valueMin = valueMax
520
+ })
521
+ return lookupTable
522
+ }
523
+
524
+ async loadFeatureFlags(forceReload = false): Promise<void> {
525
+ if (!this.loadedSuccessfullyOnce || forceReload) {
526
+ await this._loadFeatureFlags()
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Returns true if the feature flags poller has loaded successfully at least once and has more than 0 feature flags.
532
+ * This is useful to check if local evaluation is ready before calling getFeatureFlag.
533
+ */
534
+ isLocalEvaluationReady(): boolean {
535
+ return (this.loadedSuccessfullyOnce ?? false) && (this.featureFlags?.length ?? 0) > 0
536
+ }
537
+
538
+ /**
539
+ * If a client is misconfigured with an invalid or improper API key, the polling interval is doubled each time
540
+ * until a successful request is made, up to a maximum of 60 seconds.
541
+ *
542
+ * @returns The polling interval to use for the next request.
543
+ */
544
+ private getPollingInterval(): number {
545
+ if (!this.shouldBeginExponentialBackoff) {
546
+ return this.pollingInterval
547
+ }
548
+
549
+ return Math.min(SIXTY_SECONDS, this.pollingInterval * 2 ** this.backOffCount)
550
+ }
551
+
552
+ async _loadFeatureFlags(): Promise<void> {
553
+ if (this.poller) {
554
+ clearTimeout(this.poller)
555
+ this.poller = undefined
556
+ }
557
+
558
+ this.poller = setTimeout(() => this._loadFeatureFlags(), this.getPollingInterval())
559
+
560
+ try {
561
+ const res = await this._requestFeatureFlagDefinitions()
562
+
563
+ // Handle undefined res case, this shouldn't happen, but it doesn't hurt to handle it anyway
564
+ if (!res) {
565
+ // Don't override existing flags when something goes wrong
566
+ return
567
+ }
568
+
569
+ // NB ON ERROR HANDLING & `loadedSuccessfullyOnce`:
570
+ //
571
+ // `loadedSuccessfullyOnce` indicates we've successfully loaded a valid set of flags at least once.
572
+ // If we set it to `true` in an error scenario (e.g. 402 Over Quota, 401 Invalid Key, etc.),
573
+ // any manual call to `loadFeatureFlags()` (without forceReload) will skip refetching entirely,
574
+ // leaving us stuck with zero or outdated flags. The poller does keep running, but we also want
575
+ // manual reloads to be possible as soon as the error condition is resolved.
576
+ //
577
+ // Therefore, on error statuses, we do *not* set `loadedSuccessfullyOnce = true`, ensuring that
578
+ // both the background poller and any subsequent manual calls can keep trying to load flags
579
+ // once the issue (quota, permission, rate limit, etc.) is resolved.
580
+ switch (res.status) {
581
+ case 401:
582
+ // Invalid API key
583
+ this.shouldBeginExponentialBackoff = true
584
+ this.backOffCount += 1
585
+ throw new ClientError(
586
+ `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`
587
+ )
588
+
589
+ case 402:
590
+ // Quota exceeded - clear all flags
591
+ console.warn(
592
+ '[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
593
+ )
594
+ this.featureFlags = []
595
+ this.featureFlagsByKey = {}
596
+ this.groupTypeMapping = {}
597
+ this.cohorts = {}
598
+ return
599
+
600
+ case 403:
601
+ // Permissions issue
602
+ this.shouldBeginExponentialBackoff = true
603
+ this.backOffCount += 1
604
+ throw new ClientError(
605
+ `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`
606
+ )
607
+
608
+ case 429:
609
+ // Rate limited
610
+ this.shouldBeginExponentialBackoff = true
611
+ this.backOffCount += 1
612
+ throw new ClientError(
613
+ `You are being rate limited. Setting next polling interval to ${this.getPollingInterval()}ms. More information: https://posthog.com/docs/api#rate-limiting`
614
+ )
615
+
616
+ case 200: {
617
+ // Process successful response
618
+ const responseJson = ((await res.json()) as { [key: string]: any }) ?? {}
619
+ if (!('flags' in responseJson)) {
620
+ this.onError?.(new Error(`Invalid response when getting feature flags: ${JSON.stringify(responseJson)}`))
621
+ return
622
+ }
623
+
624
+ this.featureFlags = (responseJson.flags as PostHogFeatureFlag[]) ?? []
625
+ this.featureFlagsByKey = this.featureFlags.reduce(
626
+ (acc, curr) => ((acc[curr.key] = curr), acc),
627
+ <Record<string, PostHogFeatureFlag>>{}
628
+ )
629
+ this.groupTypeMapping = (responseJson.group_type_mapping as Record<string, string>) || {}
630
+ this.cohorts = (responseJson.cohorts as Record<string, PropertyGroup>) || {}
631
+ this.loadedSuccessfullyOnce = true
632
+ this.shouldBeginExponentialBackoff = false
633
+ this.backOffCount = 0
634
+ this.onLoad?.(this.featureFlags.length)
635
+ break
636
+ }
637
+
638
+ default:
639
+ // Something else went wrong, or the server is down.
640
+ // In this case, don't override existing flags
641
+ return
642
+ }
643
+ } catch (err) {
644
+ if (err instanceof ClientError) {
645
+ this.onError?.(err)
646
+ }
647
+ }
648
+ }
649
+
650
+ private getPersonalApiKeyRequestOptions(method: 'GET' | 'POST' | 'PUT' | 'PATCH' = 'GET'): PostHogFetchOptions {
651
+ return {
652
+ method,
653
+ headers: {
654
+ ...this.customHeaders,
655
+ 'Content-Type': 'application/json',
656
+ Authorization: `Bearer ${this.personalApiKey}`,
657
+ },
658
+ }
659
+ }
660
+
661
+ async _requestFeatureFlagDefinitions(): Promise<PostHogFetchResponse> {
662
+ const url = `${this.host}/api/feature_flag/local_evaluation?token=${this.projectApiKey}&send_cohorts`
663
+
664
+ const options = this.getPersonalApiKeyRequestOptions()
665
+
666
+ let abortTimeout = null
667
+
668
+ if (this.timeout && typeof this.timeout === 'number') {
669
+ const controller = new AbortController()
670
+ abortTimeout = safeSetTimeout(() => {
671
+ controller.abort()
672
+ }, this.timeout)
673
+ options.signal = controller.signal
674
+ }
675
+
676
+ try {
677
+ return await this.fetch(url, options)
678
+ } finally {
679
+ clearTimeout(abortTimeout)
680
+ }
681
+ }
682
+
683
+ stopPoller(): void {
684
+ clearTimeout(this.poller)
685
+ }
686
+ }
687
+
688
+ // # This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
689
+ // # Given the same distinct_id and key, it'll always return the same float. These floats are
690
+ // # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
691
+ // # we can do _hash(key, distinct_id) < 0.2
692
+ async function _hash(key: string, distinctId: string, salt: string = ''): Promise<number> {
693
+ const hashString = await hashSHA1(`${key}.${distinctId}${salt}`)
694
+ return parseInt(hashString.slice(0, 15), 16) / LONG_SCALE
695
+ }
696
+
697
+ function matchProperty(
698
+ property: FeatureFlagCondition['properties'][number],
699
+ propertyValues: Record<string, any>,
700
+ warnFunction?: (msg: string) => void
701
+ ): boolean {
702
+ const key = property.key
703
+ const value = property.value
704
+ const operator = property.operator || 'exact'
705
+
706
+ if (!(key in propertyValues)) {
707
+ throw new InconclusiveMatchError(`Property ${key} not found in propertyValues`)
708
+ } else if (operator === 'is_not_set') {
709
+ throw new InconclusiveMatchError(`Operator is_not_set is not supported`)
710
+ }
711
+
712
+ const overrideValue = propertyValues[key]
713
+ if (overrideValue == null && !NULL_VALUES_ALLOWED_OPERATORS.includes(operator)) {
714
+ // if the value is null, just fail the feature flag comparison
715
+ // this isn't an InconclusiveMatchError because the property value was provided.
716
+ if (warnFunction) {
717
+ warnFunction(`Property ${key} cannot have a value of null/undefined with the ${operator} operator`)
718
+ }
719
+
720
+ return false
721
+ }
722
+
723
+ function computeExactMatch(value: any, overrideValue: any): boolean {
724
+ if (Array.isArray(value)) {
725
+ return value.map((val) => String(val).toLowerCase()).includes(String(overrideValue).toLowerCase())
726
+ }
727
+ return String(value).toLowerCase() === String(overrideValue).toLowerCase()
728
+ }
729
+
730
+ function compare(lhs: any, rhs: any, operator: string): boolean {
731
+ if (operator === 'gt') {
732
+ return lhs > rhs
733
+ } else if (operator === 'gte') {
734
+ return lhs >= rhs
735
+ } else if (operator === 'lt') {
736
+ return lhs < rhs
737
+ } else if (operator === 'lte') {
738
+ return lhs <= rhs
739
+ } else {
740
+ throw new Error(`Invalid operator: ${operator}`)
741
+ }
742
+ }
743
+
744
+ switch (operator) {
745
+ case 'exact':
746
+ return computeExactMatch(value, overrideValue)
747
+ case 'is_not':
748
+ return !computeExactMatch(value, overrideValue)
749
+ case 'is_set':
750
+ return key in propertyValues
751
+ case 'icontains':
752
+ return String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
753
+ case 'not_icontains':
754
+ return !String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
755
+ case 'regex':
756
+ return isValidRegex(String(value)) && String(overrideValue).match(String(value)) !== null
757
+ case 'not_regex':
758
+ return isValidRegex(String(value)) && String(overrideValue).match(String(value)) === null
759
+ case 'gt':
760
+ case 'gte':
761
+ case 'lt':
762
+ case 'lte': {
763
+ // :TRICKY: We adjust comparison based on the override value passed in,
764
+ // to make sure we handle both numeric and string comparisons appropriately.
765
+ let parsedValue = typeof value === 'number' ? value : null
766
+
767
+ if (typeof value === 'string') {
768
+ try {
769
+ parsedValue = parseFloat(value)
770
+ } catch (err) {
771
+ // pass
772
+ }
773
+ }
774
+
775
+ if (parsedValue != null && overrideValue != null) {
776
+ // check both null and undefined
777
+ if (typeof overrideValue === 'string') {
778
+ return compare(overrideValue, String(value), operator)
779
+ } else {
780
+ return compare(overrideValue, parsedValue, operator)
781
+ }
782
+ } else {
783
+ return compare(String(overrideValue), String(value), operator)
784
+ }
785
+ }
786
+ case 'is_date_after':
787
+ case 'is_date_before': {
788
+ // Boolean values should never be used with date operations
789
+ if (typeof value === 'boolean') {
790
+ throw new InconclusiveMatchError(`Date operations cannot be performed on boolean values`)
791
+ }
792
+
793
+ let parsedDate = relativeDateParseForFeatureFlagMatching(String(value))
794
+ if (parsedDate == null) {
795
+ parsedDate = convertToDateTime(value)
796
+ }
797
+
798
+ if (parsedDate == null) {
799
+ throw new InconclusiveMatchError(`Invalid date: ${value}`)
800
+ }
801
+ const overrideDate = convertToDateTime(overrideValue)
802
+ if (['is_date_before'].includes(operator)) {
803
+ return overrideDate < parsedDate
804
+ }
805
+ return overrideDate > parsedDate
806
+ }
807
+ default:
808
+ throw new InconclusiveMatchError(`Unknown operator: ${operator}`)
809
+ }
810
+ }
811
+
812
+ function matchCohort(
813
+ property: FeatureFlagCondition['properties'][number],
814
+ propertyValues: Record<string, any>,
815
+ cohortProperties: FeatureFlagsPoller['cohorts'],
816
+ debugMode: boolean = false
817
+ ): boolean {
818
+ const cohortId = String(property.value)
819
+ if (!(cohortId in cohortProperties)) {
820
+ throw new InconclusiveMatchError("can't match cohort without a given cohort property value")
821
+ }
822
+
823
+ const propertyGroup = cohortProperties[cohortId]
824
+ return matchPropertyGroup(propertyGroup, propertyValues, cohortProperties, debugMode)
825
+ }
826
+
827
+ function matchPropertyGroup(
828
+ propertyGroup: PropertyGroup,
829
+ propertyValues: Record<string, any>,
830
+ cohortProperties: FeatureFlagsPoller['cohorts'],
831
+ debugMode: boolean = false
832
+ ): boolean {
833
+ if (!propertyGroup) {
834
+ return true
835
+ }
836
+
837
+ const propertyGroupType = propertyGroup.type
838
+ const properties = propertyGroup.values
839
+
840
+ if (!properties || properties.length === 0) {
841
+ // empty groups are no-ops, always match
842
+ return true
843
+ }
844
+
845
+ let errorMatchingLocally = false
846
+
847
+ if ('values' in properties[0]) {
848
+ // a nested property group
849
+ for (const prop of properties as PropertyGroup[]) {
850
+ try {
851
+ const matches = matchPropertyGroup(prop, propertyValues, cohortProperties, debugMode)
852
+ if (propertyGroupType === 'AND') {
853
+ if (!matches) {
854
+ return false
855
+ }
856
+ } else {
857
+ // OR group
858
+ if (matches) {
859
+ return true
860
+ }
861
+ }
862
+ } catch (err) {
863
+ if (err instanceof InconclusiveMatchError) {
864
+ if (debugMode) {
865
+ console.debug(`Failed to compute property ${prop} locally: ${err}`)
866
+ }
867
+ errorMatchingLocally = true
868
+ } else {
869
+ throw err
870
+ }
871
+ }
872
+ }
873
+
874
+ if (errorMatchingLocally) {
875
+ throw new InconclusiveMatchError("Can't match cohort without a given cohort property value")
876
+ }
877
+ // if we get here, all matched in AND case, or none matched in OR case
878
+ return propertyGroupType === 'AND'
879
+ } else {
880
+ for (const prop of properties as FlagProperty[]) {
881
+ try {
882
+ let matches: boolean
883
+ if (prop.type === 'cohort') {
884
+ matches = matchCohort(prop, propertyValues, cohortProperties, debugMode)
885
+ } else if (prop.type === 'flag') {
886
+ if (debugMode) {
887
+ console.warn(
888
+ `[FEATURE FLAGS] Flag dependency filters are not supported in local evaluation. ` +
889
+ `Skipping condition with dependency on flag '${prop.key || 'unknown'}'`
890
+ )
891
+ }
892
+ continue
893
+ } else {
894
+ matches = matchProperty(prop, propertyValues)
895
+ }
896
+
897
+ const negation = prop.negation || false
898
+
899
+ if (propertyGroupType === 'AND') {
900
+ // if negated property, do the inverse
901
+ if (!matches && !negation) {
902
+ return false
903
+ }
904
+ if (matches && negation) {
905
+ return false
906
+ }
907
+ } else {
908
+ // OR group
909
+ if (matches && !negation) {
910
+ return true
911
+ }
912
+ if (!matches && negation) {
913
+ return true
914
+ }
915
+ }
916
+ } catch (err) {
917
+ if (err instanceof InconclusiveMatchError) {
918
+ if (debugMode) {
919
+ console.debug(`Failed to compute property ${prop} locally: ${err}`)
920
+ }
921
+ errorMatchingLocally = true
922
+ } else {
923
+ throw err
924
+ }
925
+ }
926
+ }
927
+
928
+ if (errorMatchingLocally) {
929
+ throw new InconclusiveMatchError("can't match cohort without a given cohort property value")
930
+ }
931
+
932
+ // if we get here, all matched in AND case, or none matched in OR case
933
+ return propertyGroupType === 'AND'
934
+ }
935
+ }
936
+
937
+ function isValidRegex(regex: string): boolean {
938
+ try {
939
+ new RegExp(regex)
940
+ return true
941
+ } catch (err) {
942
+ return false
943
+ }
944
+ }
945
+
946
+ function convertToDateTime(value: FlagPropertyValue | Date): Date {
947
+ if (value instanceof Date) {
948
+ return value
949
+ } else if (typeof value === 'string' || typeof value === 'number') {
950
+ const date = new Date(value)
951
+ if (!isNaN(date.valueOf())) {
952
+ return date
953
+ }
954
+ throw new InconclusiveMatchError(`${value} is in an invalid date format`)
955
+ } else {
956
+ throw new InconclusiveMatchError(`The date provided ${value} must be a string, number, or date object`)
957
+ }
958
+ }
959
+
960
+ function relativeDateParseForFeatureFlagMatching(value: string): Date | null {
961
+ const regex = /^-?(?<number>[0-9]+)(?<interval>[a-z])$/
962
+ const match = value.match(regex)
963
+ const parsedDt = new Date(new Date().toISOString())
964
+
965
+ if (match) {
966
+ if (!match.groups) {
967
+ return null
968
+ }
969
+
970
+ const number = parseInt(match.groups['number'])
971
+
972
+ if (number >= 10000) {
973
+ // Guard against overflow, disallow numbers greater than 10_000
974
+ return null
975
+ }
976
+ const interval = match.groups['interval']
977
+ if (interval == 'h') {
978
+ parsedDt.setUTCHours(parsedDt.getUTCHours() - number)
979
+ } else if (interval == 'd') {
980
+ parsedDt.setUTCDate(parsedDt.getUTCDate() - number)
981
+ } else if (interval == 'w') {
982
+ parsedDt.setUTCDate(parsedDt.getUTCDate() - number * 7)
983
+ } else if (interval == 'm') {
984
+ parsedDt.setUTCMonth(parsedDt.getUTCMonth() - number)
985
+ } else if (interval == 'y') {
986
+ parsedDt.setUTCFullYear(parsedDt.getUTCFullYear() - number)
987
+ } else {
988
+ return null
989
+ }
990
+
991
+ return parsedDt
992
+ } else {
993
+ return null
994
+ }
995
+ }
996
+
997
+ export {
998
+ FeatureFlagsPoller,
999
+ matchProperty,
1000
+ relativeDateParseForFeatureFlagMatching,
1001
+ InconclusiveMatchError,
1002
+ ClientError,
1003
+ }