posthog-node 5.0.0-alpha.1 → 5.1.0

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