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.
- package/dist/{index.d.ts → client.d.ts} +7 -378
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +480 -0
- package/dist/client.mjs +436 -0
- package/dist/entrypoints/index.edge.d.ts +6 -0
- package/dist/entrypoints/index.edge.d.ts.map +1 -0
- package/dist/entrypoints/index.edge.js +96 -0
- package/dist/entrypoints/index.edge.mjs +19 -0
- package/dist/entrypoints/index.node.d.ts +6 -0
- package/dist/entrypoints/index.node.d.ts.map +1 -0
- package/dist/entrypoints/index.node.js +107 -0
- package/dist/entrypoints/index.node.mjs +24 -0
- package/dist/exports.d.ts +4 -0
- package/dist/exports.d.ts.map +1 -0
- package/dist/exports.js +78 -0
- package/dist/exports.mjs +3 -0
- package/dist/extensions/error-tracking/autocapture.d.ts +4 -0
- package/dist/extensions/error-tracking/autocapture.d.ts.map +1 -0
- package/dist/extensions/error-tracking/autocapture.js +68 -0
- package/dist/extensions/error-tracking/autocapture.mjs +31 -0
- package/dist/extensions/error-tracking/index.d.ts +19 -0
- package/dist/extensions/error-tracking/index.d.ts.map +1 -0
- package/dist/extensions/error-tracking/index.js +97 -0
- package/dist/extensions/error-tracking/index.mjs +63 -0
- package/dist/extensions/error-tracking/modifiers/context-lines.node.d.ts +5 -0
- package/dist/extensions/error-tracking/modifiers/context-lines.node.d.ts.map +1 -0
- package/dist/extensions/error-tracking/modifiers/context-lines.node.js +227 -0
- package/dist/extensions/error-tracking/modifiers/context-lines.node.mjs +187 -0
- package/dist/extensions/error-tracking/modifiers/module.node.d.ts +3 -0
- package/dist/extensions/error-tracking/modifiers/module.node.d.ts.map +1 -0
- package/dist/extensions/error-tracking/modifiers/module.node.js +64 -0
- package/dist/extensions/error-tracking/modifiers/module.node.mjs +30 -0
- package/dist/extensions/express.d.ts +17 -0
- package/dist/extensions/express.d.ts.map +1 -0
- package/dist/extensions/express.js +61 -0
- package/dist/extensions/express.mjs +17 -0
- package/dist/extensions/feature-flags/crypto-helpers.d.ts +3 -0
- package/dist/extensions/feature-flags/crypto-helpers.d.ts.map +1 -0
- package/dist/extensions/feature-flags/crypto-helpers.js +77 -0
- package/dist/extensions/feature-flags/crypto-helpers.mjs +22 -0
- package/dist/extensions/feature-flags/crypto.d.ts +2 -0
- package/dist/extensions/feature-flags/crypto.d.ts.map +1 -0
- package/dist/extensions/feature-flags/crypto.js +47 -0
- package/dist/extensions/feature-flags/crypto.mjs +13 -0
- package/dist/extensions/feature-flags/feature-flags.d.ts +89 -0
- package/dist/extensions/feature-flags/feature-flags.d.ts.map +1 -0
- package/dist/extensions/feature-flags/feature-flags.js +529 -0
- package/dist/extensions/feature-flags/feature-flags.mjs +483 -0
- package/dist/extensions/feature-flags/lazy.d.ts +24 -0
- package/dist/extensions/feature-flags/lazy.d.ts.map +1 -0
- package/dist/extensions/feature-flags/lazy.js +60 -0
- package/dist/extensions/feature-flags/lazy.mjs +26 -0
- package/dist/extensions/sentry-integration.d.ts +54 -0
- package/dist/extensions/sentry-integration.d.ts.map +1 -0
- package/dist/extensions/sentry-integration.js +113 -0
- package/dist/extensions/sentry-integration.mjs +73 -0
- package/dist/storage-memory.d.ts +7 -0
- package/dist/storage-memory.d.ts.map +1 -0
- package/dist/storage-memory.js +46 -0
- package/dist/storage-memory.mjs +12 -0
- package/dist/types.d.ts +253 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.mjs +0 -0
- package/dist/utils/logger.d.ts +3 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +63 -0
- package/dist/utils/logger.mjs +29 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +36 -0
- package/dist/version.mjs +2 -0
- package/package.json +32 -31
- package/src/client.ts +1532 -0
- package/src/entrypoints/index.edge.ts +22 -0
- package/src/entrypoints/index.node.ts +26 -0
- package/src/exports.ts +3 -0
- package/src/extensions/error-tracking/autocapture.ts +67 -0
- package/src/extensions/error-tracking/index.ts +104 -0
- package/src/extensions/error-tracking/modifiers/context-lines.node.ts +404 -0
- package/src/extensions/error-tracking/modifiers/module.node.ts +68 -0
- package/src/extensions/express.ts +40 -0
- package/src/extensions/feature-flags/crypto-helpers.ts +36 -0
- package/src/extensions/feature-flags/crypto.ts +22 -0
- package/src/extensions/feature-flags/feature-flags.ts +1003 -0
- package/src/extensions/feature-flags/lazy.ts +55 -0
- package/src/extensions/sentry-integration.ts +216 -0
- package/src/storage-memory.ts +13 -0
- package/src/types.ts +294 -0
- package/src/utils/logger.ts +39 -0
- package/src/version.ts +1 -0
- package/dist/edge/index.cjs +0 -3150
- package/dist/edge/index.cjs.map +0 -1
- package/dist/edge/index.mjs +0 -3144
- package/dist/edge/index.mjs.map +0 -1
- package/dist/node/index.cjs +0 -3556
- package/dist/node/index.cjs.map +0 -1
- package/dist/node/index.mjs +0 -3550
- 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
|
+
}
|