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.
- package/CHANGELOG.md +11 -0
- package/lib/edge/{index.cjs.js → index.cjs} +43 -11
- package/lib/edge/index.cjs.map +1 -0
- package/lib/edge/{index.esm.js → index.mjs} +43 -11
- package/lib/edge/index.mjs.map +1 -0
- package/lib/index.d.ts +69 -57
- package/lib/node/{index.cjs.js → index.cjs} +43 -11
- package/lib/node/index.cjs.map +1 -0
- package/lib/node/{index.esm.js → index.mjs} +43 -11
- package/lib/node/index.mjs.map +1 -0
- package/package.json +17 -17
- package/lib/edge/index.cjs.js.map +0 -1
- package/lib/edge/index.esm.js.map +0 -1
- package/lib/node/index.cjs.js.map +0 -1
- package/lib/node/index.esm.js.map +0 -1
- package/src/client.ts +0 -650
- package/src/entrypoints/index.edge.ts +0 -15
- package/src/entrypoints/index.node.ts +0 -17
- package/src/exports.ts +0 -3
- package/src/extensions/error-tracking/autocapture.ts +0 -65
- package/src/extensions/error-tracking/context-lines.node.ts +0 -392
- package/src/extensions/error-tracking/error-conversion.ts +0 -279
- package/src/extensions/error-tracking/get-module.node.ts +0 -57
- package/src/extensions/error-tracking/index.ts +0 -69
- package/src/extensions/error-tracking/reduceable-cache.ts +0 -39
- package/src/extensions/error-tracking/stack-parser.ts +0 -212
- package/src/extensions/error-tracking/type-checking.ts +0 -40
- package/src/extensions/error-tracking/types.ts +0 -69
- package/src/extensions/express.ts +0 -37
- package/src/extensions/feature-flags/crypto-helpers.ts +0 -36
- package/src/extensions/feature-flags/crypto.ts +0 -22
- package/src/extensions/feature-flags/feature-flags.ts +0 -865
- package/src/extensions/feature-flags/lazy.ts +0 -55
- package/src/extensions/sentry-integration.ts +0 -204
- package/src/fetch.ts +0 -39
- package/src/storage-memory.ts +0 -13
- package/src/types.ts +0 -275
- package/test/crypto.spec.ts +0 -36
- package/test/extensions/error-conversion.spec.ts +0 -44
- package/test/extensions/sentry-integration.spec.ts +0 -163
- package/test/feature-flags.decide.spec.ts +0 -381
- package/test/feature-flags.spec.ts +0 -4686
- package/test/lazy.spec.ts +0 -71
- package/test/posthog-node.spec.ts +0 -1341
- package/test/test-utils.ts +0 -111
- 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
|
-
}
|