posthog-node 4.17.0 → 4.17.2

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