posthog-node 2.0.0-alpha8 → 2.0.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 +20 -0
- package/README.md +0 -2
- package/index.ts +0 -3
- package/lib/index.cjs.js +929 -123
- package/lib/index.cjs.js.map +1 -1
- package/lib/index.d.ts +70 -12
- package/lib/index.esm.js +930 -123
- package/lib/index.esm.js.map +1 -1
- package/lib/posthog-core/src/index.d.ts +21 -8
- package/lib/posthog-core/src/types.d.ts +4 -2
- package/lib/posthog-core/src/utils.d.ts +0 -1
- package/lib/posthog-node/index.d.ts +0 -2
- package/lib/posthog-node/src/feature-flags.d.ts +48 -0
- package/lib/posthog-node/src/posthog-node.d.ts +30 -4
- package/lib/posthog-node/src/types.d.ts +69 -6
- package/package.json +2 -5
- package/src/feature-flags.ts +396 -0
- package/src/posthog-node.ts +184 -30
- package/src/types.ts +72 -8
- package/test/feature-flags.spec.ts +3192 -0
- package/test/posthog-node.spec.ts +300 -24
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { createHash } from 'crypto'
|
|
2
|
+
import { request } from 'undici'
|
|
3
|
+
import { FeatureFlagCondition, PostHogFeatureFlag } from './types'
|
|
4
|
+
import { version } from '../package.json'
|
|
5
|
+
import { ResponseData } from 'undici/types/dispatcher'
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line
|
|
8
|
+
const LONG_SCALE = 0xfffffffffffffff
|
|
9
|
+
|
|
10
|
+
class ClientError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super()
|
|
13
|
+
Error.captureStackTrace(this, this.constructor)
|
|
14
|
+
this.name = 'ClientError'
|
|
15
|
+
this.message = message
|
|
16
|
+
Object.setPrototypeOf(this, ClientError.prototype)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class InconclusiveMatchError extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = this.constructor.name
|
|
24
|
+
Error.captureStackTrace(this, this.constructor)
|
|
25
|
+
// instanceof doesn't work in ES3 or ES5
|
|
26
|
+
// https://www.dannyguo.com/blog/how-to-fix-instanceof-not-working-for-custom-errors-in-typescript/
|
|
27
|
+
// this is the workaround
|
|
28
|
+
Object.setPrototypeOf(this, InconclusiveMatchError.prototype)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type FeatureFlagsPollerOptions = {
|
|
33
|
+
personalApiKey: string
|
|
34
|
+
projectApiKey: string
|
|
35
|
+
host: string
|
|
36
|
+
pollingInterval: number
|
|
37
|
+
timeout?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class FeatureFlagsPoller {
|
|
41
|
+
pollingInterval: number
|
|
42
|
+
personalApiKey: string
|
|
43
|
+
projectApiKey: string
|
|
44
|
+
featureFlags: Array<PostHogFeatureFlag>
|
|
45
|
+
groupTypeMapping: Record<string, string>
|
|
46
|
+
loadedSuccessfullyOnce: boolean
|
|
47
|
+
timeout?: number
|
|
48
|
+
host: FeatureFlagsPollerOptions['host']
|
|
49
|
+
poller?: NodeJS.Timeout
|
|
50
|
+
|
|
51
|
+
constructor({ pollingInterval, personalApiKey, projectApiKey, timeout, host }: FeatureFlagsPollerOptions) {
|
|
52
|
+
this.pollingInterval = pollingInterval
|
|
53
|
+
this.personalApiKey = personalApiKey
|
|
54
|
+
this.featureFlags = []
|
|
55
|
+
this.groupTypeMapping = {}
|
|
56
|
+
this.loadedSuccessfullyOnce = false
|
|
57
|
+
this.timeout = timeout
|
|
58
|
+
this.projectApiKey = projectApiKey
|
|
59
|
+
this.host = host
|
|
60
|
+
this.poller = undefined
|
|
61
|
+
|
|
62
|
+
void this.loadFeatureFlags()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getFeatureFlag(
|
|
66
|
+
key: string,
|
|
67
|
+
distinctId: string,
|
|
68
|
+
groups: Record<string, string> = {},
|
|
69
|
+
personProperties: Record<string, string> = {},
|
|
70
|
+
groupProperties: Record<string, Record<string, string>> = {}
|
|
71
|
+
): Promise<string | boolean | undefined> {
|
|
72
|
+
await this.loadFeatureFlags()
|
|
73
|
+
|
|
74
|
+
let response = undefined
|
|
75
|
+
let featureFlag = undefined
|
|
76
|
+
|
|
77
|
+
if (!this.loadedSuccessfullyOnce) {
|
|
78
|
+
return response
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const flag of this.featureFlags) {
|
|
82
|
+
if (key === flag.key) {
|
|
83
|
+
featureFlag = flag
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (featureFlag !== undefined) {
|
|
89
|
+
try {
|
|
90
|
+
response = this.computeFlagLocally(featureFlag, distinctId, groups, personProperties, groupProperties)
|
|
91
|
+
console.debug(`Successfully computed flag locally: ${key} -> ${response}`)
|
|
92
|
+
} catch (e) {
|
|
93
|
+
if (e instanceof InconclusiveMatchError) {
|
|
94
|
+
console.debug(`Can't compute flag locally: ${key}: ${e}`)
|
|
95
|
+
} else if (e instanceof Error) {
|
|
96
|
+
console.error(`Error computing flag locally: ${key}: ${e}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return response
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async getAllFlags(
|
|
105
|
+
distinctId: string,
|
|
106
|
+
groups: Record<string, string> = {},
|
|
107
|
+
personProperties: Record<string, string> = {},
|
|
108
|
+
groupProperties: Record<string, Record<string, string>> = {}
|
|
109
|
+
): Promise<{ response: Record<string, string | boolean>; fallbackToDecide: boolean }> {
|
|
110
|
+
await this.loadFeatureFlags()
|
|
111
|
+
|
|
112
|
+
const response: Record<string, string | boolean> = {}
|
|
113
|
+
let fallbackToDecide = this.featureFlags.length == 0
|
|
114
|
+
|
|
115
|
+
this.featureFlags.map((flag) => {
|
|
116
|
+
try {
|
|
117
|
+
response[flag.key] = this.computeFlagLocally(flag, distinctId, groups, personProperties, groupProperties)
|
|
118
|
+
} catch (e) {
|
|
119
|
+
if (e instanceof InconclusiveMatchError) {
|
|
120
|
+
// do nothing
|
|
121
|
+
} else if (e instanceof Error) {
|
|
122
|
+
console.error(`Error computing flag locally: ${flag.key}: ${e}`)
|
|
123
|
+
}
|
|
124
|
+
fallbackToDecide = true
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
return { response, fallbackToDecide }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
computeFlagLocally(
|
|
132
|
+
flag: PostHogFeatureFlag,
|
|
133
|
+
distinctId: string,
|
|
134
|
+
groups: Record<string, string> = {},
|
|
135
|
+
personProperties: Record<string, string> = {},
|
|
136
|
+
groupProperties: Record<string, Record<string, string>> = {}
|
|
137
|
+
): string | boolean {
|
|
138
|
+
if (flag.ensure_experience_continuity) {
|
|
139
|
+
throw new InconclusiveMatchError('Flag has experience continuity enabled')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!flag.active) {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const flagFilters = flag.filters || {}
|
|
147
|
+
const aggregation_group_type_index = flagFilters.aggregation_group_type_index
|
|
148
|
+
|
|
149
|
+
if (aggregation_group_type_index != undefined) {
|
|
150
|
+
const groupName = this.groupTypeMapping[String(aggregation_group_type_index)]
|
|
151
|
+
|
|
152
|
+
if (!groupName) {
|
|
153
|
+
console.warn(
|
|
154
|
+
`[FEATURE FLAGS] Unknown group type index ${aggregation_group_type_index} for feature flag ${flag.key}`
|
|
155
|
+
)
|
|
156
|
+
throw new InconclusiveMatchError('Flag has unknown group type index')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!(groupName in groups)) {
|
|
160
|
+
console.warn(`[FEATURE FLAGS] Can't compute group feature flag: ${flag.key} without group names passed in`)
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const focusedGroupProperties = groupProperties[groupName]
|
|
165
|
+
return this.matchFeatureFlagProperties(flag, groups[groupName], focusedGroupProperties)
|
|
166
|
+
} else {
|
|
167
|
+
return this.matchFeatureFlagProperties(flag, distinctId, personProperties)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
matchFeatureFlagProperties(
|
|
172
|
+
flag: PostHogFeatureFlag,
|
|
173
|
+
distinctId: string,
|
|
174
|
+
properties: Record<string, string>
|
|
175
|
+
): string | boolean {
|
|
176
|
+
const flagFilters = flag.filters || {}
|
|
177
|
+
const flagConditions = flagFilters.groups || []
|
|
178
|
+
let isInconclusive = false
|
|
179
|
+
let result = undefined
|
|
180
|
+
|
|
181
|
+
flagConditions.forEach((condition) => {
|
|
182
|
+
try {
|
|
183
|
+
if (this.isConditionMatch(flag, distinctId, condition, properties)) {
|
|
184
|
+
result = this.getMatchingVariant(flag, distinctId) || true
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (e instanceof InconclusiveMatchError) {
|
|
188
|
+
isInconclusive = true
|
|
189
|
+
} else {
|
|
190
|
+
throw e
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
if (result !== undefined) {
|
|
196
|
+
return result
|
|
197
|
+
} else if (isInconclusive) {
|
|
198
|
+
throw new InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// We can only return False when all conditions are False
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
isConditionMatch(
|
|
206
|
+
flag: PostHogFeatureFlag,
|
|
207
|
+
distinctId: string,
|
|
208
|
+
condition: FeatureFlagCondition,
|
|
209
|
+
properties: Record<string, string>
|
|
210
|
+
): boolean {
|
|
211
|
+
const rolloutPercentage = condition.rollout_percentage
|
|
212
|
+
|
|
213
|
+
if ((condition.properties || []).length > 0) {
|
|
214
|
+
const matchAll = condition.properties.every((property) => {
|
|
215
|
+
return matchProperty(property, properties)
|
|
216
|
+
})
|
|
217
|
+
if (!matchAll) {
|
|
218
|
+
return false
|
|
219
|
+
} else if (rolloutPercentage == undefined) {
|
|
220
|
+
// == to include `null` as a match, not just `undefined`
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (rolloutPercentage != undefined && _hash(flag.key, distinctId) > rolloutPercentage / 100.0) {
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return true
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getMatchingVariant(flag: PostHogFeatureFlag, distinctId: string): string | boolean | undefined {
|
|
233
|
+
const hashValue = _hash(flag.key, distinctId, 'variant')
|
|
234
|
+
const matchingVariant = this.variantLookupTable(flag).find((variant) => {
|
|
235
|
+
return hashValue >= variant.valueMin && hashValue < variant.valueMax
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
if (matchingVariant) {
|
|
239
|
+
return matchingVariant.key
|
|
240
|
+
}
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
variantLookupTable(flag: PostHogFeatureFlag): { valueMin: number; valueMax: number; key: string }[] {
|
|
245
|
+
const lookupTable: { valueMin: number; valueMax: number; key: string }[] = []
|
|
246
|
+
let valueMin = 0
|
|
247
|
+
let valueMax = 0
|
|
248
|
+
const flagFilters = flag.filters || {}
|
|
249
|
+
const multivariates: {
|
|
250
|
+
key: string
|
|
251
|
+
rollout_percentage: number
|
|
252
|
+
}[] = flagFilters.multivariate?.variants || []
|
|
253
|
+
|
|
254
|
+
multivariates.forEach((variant) => {
|
|
255
|
+
valueMax = valueMin + variant.rollout_percentage / 100.0
|
|
256
|
+
lookupTable.push({ valueMin, valueMax, key: variant.key })
|
|
257
|
+
valueMin = valueMax
|
|
258
|
+
})
|
|
259
|
+
return lookupTable
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async loadFeatureFlags(forceReload = false): Promise<void> {
|
|
263
|
+
if (!this.loadedSuccessfullyOnce || forceReload) {
|
|
264
|
+
await this._loadFeatureFlags()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async _loadFeatureFlags(): Promise<void> {
|
|
269
|
+
if (this.poller) {
|
|
270
|
+
clearTimeout(this.poller)
|
|
271
|
+
this.poller = undefined
|
|
272
|
+
}
|
|
273
|
+
this.poller = setTimeout(() => this._loadFeatureFlags(), this.pollingInterval)
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const res = await this._requestFeatureFlagDefinitions()
|
|
277
|
+
|
|
278
|
+
if (res && res.statusCode === 401) {
|
|
279
|
+
throw new ClientError(
|
|
280
|
+
`Your personalApiKey is invalid. Are you sure you're not using your Project API key? More information: https://posthog.com/docs/api/overview`
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
const responseJson = await res.body.json()
|
|
284
|
+
if (!('flags' in responseJson)) {
|
|
285
|
+
console.error(`Invalid response when getting feature flags: ${JSON.stringify(responseJson)}`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.featureFlags = responseJson.flags || []
|
|
289
|
+
this.groupTypeMapping = responseJson.group_type_mapping || {}
|
|
290
|
+
this.loadedSuccessfullyOnce = true
|
|
291
|
+
} catch (err) {
|
|
292
|
+
// if an error that is not an instance of ClientError is thrown
|
|
293
|
+
// we silently ignore the error when reloading feature flags
|
|
294
|
+
if (err instanceof ClientError) {
|
|
295
|
+
throw err
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async _requestFeatureFlagDefinitions(): Promise<ResponseData> {
|
|
301
|
+
const url = `${this.host}/api/feature_flag/local_evaluation?token=${this.projectApiKey}`
|
|
302
|
+
const headers = {
|
|
303
|
+
'Content-Type': 'application/json',
|
|
304
|
+
Authorization: `Bearer ${this.personalApiKey}`,
|
|
305
|
+
'user-agent': `posthog-node/${version}`,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const options: Parameters<typeof request>[1] = {
|
|
309
|
+
method: 'GET',
|
|
310
|
+
headers: headers,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (this.timeout && typeof this.timeout === 'number') {
|
|
314
|
+
options.bodyTimeout = this.timeout
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let res
|
|
318
|
+
try {
|
|
319
|
+
res = await request(url, options)
|
|
320
|
+
} catch (err) {
|
|
321
|
+
throw new Error(`Request failed with error: ${err}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return res
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
stopPoller(): void {
|
|
328
|
+
clearTimeout(this.poller)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// # This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
|
333
|
+
// # Given the same distinct_id and key, it'll always return the same float. These floats are
|
|
334
|
+
// # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
|
335
|
+
// # we can do _hash(key, distinct_id) < 0.2
|
|
336
|
+
function _hash(key: string, distinctId: string, salt: string = ''): number {
|
|
337
|
+
const sha1Hash = createHash('sha1')
|
|
338
|
+
sha1Hash.update(`${key}.${distinctId}${salt}`)
|
|
339
|
+
return parseInt(sha1Hash.digest('hex').slice(0, 15), 16) / LONG_SCALE
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function matchProperty(
|
|
343
|
+
property: FeatureFlagCondition['properties'][number],
|
|
344
|
+
propertyValues: Record<string, any>
|
|
345
|
+
): boolean {
|
|
346
|
+
const key = property.key
|
|
347
|
+
const value = property.value
|
|
348
|
+
const operator = property.operator || 'exact'
|
|
349
|
+
|
|
350
|
+
if (!(key in propertyValues)) {
|
|
351
|
+
throw new InconclusiveMatchError(`Property ${key} not found in propertyValues`)
|
|
352
|
+
} else if (operator === 'is_not_set') {
|
|
353
|
+
throw new InconclusiveMatchError(`Operator is_not_set is not supported`)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const overrideValue = propertyValues[key]
|
|
357
|
+
|
|
358
|
+
switch (operator) {
|
|
359
|
+
case 'exact':
|
|
360
|
+
return Array.isArray(value) ? value.indexOf(overrideValue) !== -1 : value === overrideValue
|
|
361
|
+
case 'is_not':
|
|
362
|
+
return Array.isArray(value) ? value.indexOf(overrideValue) === -1 : value !== overrideValue
|
|
363
|
+
case 'is_set':
|
|
364
|
+
return key in propertyValues
|
|
365
|
+
case 'icontains':
|
|
366
|
+
return String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
|
|
367
|
+
case 'not_icontains':
|
|
368
|
+
return !String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
|
|
369
|
+
case 'regex':
|
|
370
|
+
return isValidRegex(String(value)) && String(overrideValue).match(String(value)) !== null
|
|
371
|
+
case 'not_regex':
|
|
372
|
+
return isValidRegex(String(value)) && String(overrideValue).match(String(value)) === null
|
|
373
|
+
case 'gt':
|
|
374
|
+
return typeof overrideValue == typeof value && overrideValue > value
|
|
375
|
+
case 'gte':
|
|
376
|
+
return typeof overrideValue == typeof value && overrideValue >= value
|
|
377
|
+
case 'lt':
|
|
378
|
+
return typeof overrideValue == typeof value && overrideValue < value
|
|
379
|
+
case 'lte':
|
|
380
|
+
return typeof overrideValue == typeof value && overrideValue <= value
|
|
381
|
+
default:
|
|
382
|
+
console.error(`Unknown operator: ${operator}`)
|
|
383
|
+
return false
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isValidRegex(regex: string): boolean {
|
|
388
|
+
try {
|
|
389
|
+
new RegExp(regex)
|
|
390
|
+
return true
|
|
391
|
+
} catch (err) {
|
|
392
|
+
return false
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export { FeatureFlagsPoller, matchProperty, InconclusiveMatchError, ClientError }
|
package/src/posthog-node.ts
CHANGED
|
@@ -9,17 +9,29 @@ import {
|
|
|
9
9
|
} from '../../posthog-core/src'
|
|
10
10
|
import { PostHogMemoryStorage } from '../../posthog-core/src/storage-memory'
|
|
11
11
|
import { EventMessageV1, GroupIdentifyMessage, IdentifyMessageV1, PostHogNodeV1 } from './types'
|
|
12
|
+
import { FeatureFlagsPoller } from './feature-flags'
|
|
12
13
|
|
|
13
14
|
export type PostHogOptions = PosthogCoreOptions & {
|
|
14
15
|
persistence?: 'memory'
|
|
16
|
+
personalApiKey?: string
|
|
17
|
+
// The interval in milliseconds between polls for refreshing feature flag definitions
|
|
18
|
+
featureFlagsPollingInterval?: number
|
|
19
|
+
// Timeout in milliseconds for feature flag definitions calls. Defaults to 30 seconds.
|
|
20
|
+
requestTimeout?: number
|
|
21
|
+
// Maximum size of cache that deduplicates $feature_flag_called calls per user.
|
|
22
|
+
maxCacheSize?: number
|
|
15
23
|
}
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
const THIRTY_SECONDS = 30 * 1000
|
|
26
|
+
const MAX_CACHE_SIZE = 50 * 1000
|
|
27
|
+
|
|
28
|
+
class PostHogClient extends PostHogCore {
|
|
18
29
|
private _memoryStorage = new PostHogMemoryStorage()
|
|
19
30
|
|
|
20
31
|
constructor(apiKey: string, options: PostHogOptions = {}) {
|
|
21
32
|
options.captureMode = options?.captureMode || 'json'
|
|
22
33
|
options.preloadFeatureFlags = false // Don't preload as this makes no sense without a distinctId
|
|
34
|
+
options.sendFeatureFlagEvent = false // Let `posthog-node` handle this on its own, since we're dealing with multiple distinctIDs
|
|
23
35
|
|
|
24
36
|
super(apiKey, options)
|
|
25
37
|
}
|
|
@@ -53,23 +65,34 @@ class PostHog extends PostHogCore {
|
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
// The actual exported Nodejs API.
|
|
56
|
-
export class
|
|
57
|
-
private _sharedClient:
|
|
68
|
+
export class PostHog implements PostHogNodeV1 {
|
|
69
|
+
private _sharedClient: PostHogClient
|
|
70
|
+
private featureFlagsPoller?: FeatureFlagsPoller
|
|
71
|
+
private maxCacheSize: number
|
|
72
|
+
|
|
73
|
+
distinctIdHasSentFlagCalls: Record<string, string[]>
|
|
58
74
|
|
|
59
75
|
constructor(apiKey: string, options: PostHogOptions = {}) {
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
this._sharedClient = new PostHogClient(apiKey, options)
|
|
77
|
+
if (options.personalApiKey) {
|
|
78
|
+
this.featureFlagsPoller = new FeatureFlagsPoller({
|
|
79
|
+
pollingInterval:
|
|
80
|
+
typeof options.featureFlagsPollingInterval === 'number'
|
|
81
|
+
? options.featureFlagsPollingInterval
|
|
82
|
+
: THIRTY_SECONDS,
|
|
83
|
+
personalApiKey: options.personalApiKey,
|
|
84
|
+
projectApiKey: apiKey,
|
|
85
|
+
timeout: options.requestTimeout,
|
|
86
|
+
host: this._sharedClient.host,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
this.distinctIdHasSentFlagCalls = {}
|
|
90
|
+
this.maxCacheSize = options.maxCacheSize || MAX_CACHE_SIZE
|
|
62
91
|
}
|
|
63
92
|
|
|
64
93
|
private reInit(distinctId: string): void {
|
|
65
|
-
// Certain properties we want to persist
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
for (const key in PostHogPersistedProperty) {
|
|
69
|
-
if (!propertiesToKeep.includes(key as any)) {
|
|
70
|
-
this._sharedClient.setPersistedProperty((PostHogPersistedProperty as any)[key], null)
|
|
71
|
-
}
|
|
72
|
-
}
|
|
94
|
+
// Certain properties we want to persist. Queue is persisted always by default.
|
|
95
|
+
this._sharedClient.reset([PostHogPersistedProperty.OptedOut])
|
|
73
96
|
this._sharedClient.setPersistedProperty(PostHogPersistedProperty.DistinctId, distinctId)
|
|
74
97
|
}
|
|
75
98
|
|
|
@@ -81,12 +104,12 @@ export class PostHogGlobal implements PostHogNodeV1 {
|
|
|
81
104
|
return this._sharedClient.optOut()
|
|
82
105
|
}
|
|
83
106
|
|
|
84
|
-
capture({ distinctId, event, properties, groups }: EventMessageV1): void {
|
|
107
|
+
capture({ distinctId, event, properties, groups, sendFeatureFlags }: EventMessageV1): void {
|
|
85
108
|
this.reInit(distinctId)
|
|
86
109
|
if (groups) {
|
|
87
110
|
this._sharedClient.groups(groups)
|
|
88
111
|
}
|
|
89
|
-
this._sharedClient.capture(event, properties)
|
|
112
|
+
this._sharedClient.capture(event, properties, sendFeatureFlags || false)
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
identify({ distinctId, properties }: IdentifyMessageV1): void {
|
|
@@ -102,39 +125,170 @@ export class PostHogGlobal implements PostHogNodeV1 {
|
|
|
102
125
|
async getFeatureFlag(
|
|
103
126
|
key: string,
|
|
104
127
|
distinctId: string,
|
|
105
|
-
|
|
128
|
+
options?: {
|
|
129
|
+
groups?: Record<string, string>
|
|
130
|
+
personProperties?: Record<string, string>
|
|
131
|
+
groupProperties?: Record<string, Record<string, string>>
|
|
132
|
+
onlyEvaluateLocally?: boolean
|
|
133
|
+
sendFeatureFlagEvents?: boolean
|
|
134
|
+
}
|
|
106
135
|
): Promise<string | boolean | undefined> {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
136
|
+
const { groups, personProperties, groupProperties } = options || {}
|
|
137
|
+
let { onlyEvaluateLocally, sendFeatureFlagEvents } = options || {}
|
|
138
|
+
|
|
139
|
+
// set defaults
|
|
140
|
+
if (onlyEvaluateLocally == undefined) {
|
|
141
|
+
onlyEvaluateLocally = false
|
|
142
|
+
}
|
|
143
|
+
if (sendFeatureFlagEvents == undefined) {
|
|
144
|
+
sendFeatureFlagEvents = true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let response = await this.featureFlagsPoller?.getFeatureFlag(
|
|
148
|
+
key,
|
|
149
|
+
distinctId,
|
|
150
|
+
groups,
|
|
151
|
+
personProperties,
|
|
152
|
+
groupProperties
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const flagWasLocallyEvaluated = response !== undefined
|
|
156
|
+
|
|
157
|
+
if (!flagWasLocallyEvaluated && !onlyEvaluateLocally) {
|
|
158
|
+
this.reInit(distinctId)
|
|
159
|
+
if (groups != undefined) {
|
|
160
|
+
this._sharedClient.groups(groups)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (personProperties) {
|
|
164
|
+
this._sharedClient.personProperties(personProperties)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (groupProperties) {
|
|
168
|
+
this._sharedClient.groupProperties(groupProperties)
|
|
169
|
+
}
|
|
170
|
+
await this._sharedClient.reloadFeatureFlagsAsync(false)
|
|
171
|
+
response = this._sharedClient.getFeatureFlag(key)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const featureFlagReportedKey = `${key}_${response}`
|
|
175
|
+
|
|
176
|
+
if (
|
|
177
|
+
sendFeatureFlagEvents &&
|
|
178
|
+
(!(distinctId in this.distinctIdHasSentFlagCalls) ||
|
|
179
|
+
!this.distinctIdHasSentFlagCalls[distinctId].includes(featureFlagReportedKey))
|
|
180
|
+
) {
|
|
181
|
+
if (Object.keys(this.distinctIdHasSentFlagCalls).length >= this.maxCacheSize) {
|
|
182
|
+
this.distinctIdHasSentFlagCalls = {}
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(this.distinctIdHasSentFlagCalls[distinctId])) {
|
|
185
|
+
this.distinctIdHasSentFlagCalls[distinctId].push(featureFlagReportedKey)
|
|
186
|
+
} else {
|
|
187
|
+
this.distinctIdHasSentFlagCalls[distinctId] = [featureFlagReportedKey]
|
|
188
|
+
}
|
|
189
|
+
this.capture({
|
|
190
|
+
distinctId,
|
|
191
|
+
event: '$feature_flag_called',
|
|
192
|
+
properties: {
|
|
193
|
+
$feature_flag: key,
|
|
194
|
+
$feature_flag_response: response,
|
|
195
|
+
locally_evaluated: flagWasLocallyEvaluated,
|
|
196
|
+
},
|
|
197
|
+
groups,
|
|
198
|
+
})
|
|
110
199
|
}
|
|
111
|
-
|
|
112
|
-
return this._sharedClient.getFeatureFlag(key)
|
|
200
|
+
return response
|
|
113
201
|
}
|
|
114
202
|
|
|
115
203
|
async isFeatureEnabled(
|
|
116
204
|
key: string,
|
|
117
205
|
distinctId: string,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
206
|
+
options?: {
|
|
207
|
+
groups?: Record<string, string>
|
|
208
|
+
personProperties?: Record<string, string>
|
|
209
|
+
groupProperties?: Record<string, Record<string, string>>
|
|
210
|
+
onlyEvaluateLocally?: boolean
|
|
211
|
+
sendFeatureFlagEvents?: boolean
|
|
212
|
+
}
|
|
213
|
+
): Promise<boolean | undefined> {
|
|
214
|
+
const feat = await this.getFeatureFlag(key, distinctId, options)
|
|
215
|
+
if (feat === undefined) {
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
218
|
+
return !!feat || false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async getAllFlags(
|
|
222
|
+
distinctId: string,
|
|
223
|
+
options?: {
|
|
224
|
+
groups?: Record<string, string>
|
|
225
|
+
personProperties?: Record<string, string>
|
|
226
|
+
groupProperties?: Record<string, Record<string, string>>
|
|
227
|
+
onlyEvaluateLocally?: boolean
|
|
228
|
+
}
|
|
229
|
+
): Promise<Record<string, string | boolean>> {
|
|
230
|
+
const { groups, personProperties, groupProperties } = options || {}
|
|
231
|
+
let { onlyEvaluateLocally } = options || {}
|
|
232
|
+
|
|
233
|
+
// set defaults
|
|
234
|
+
if (onlyEvaluateLocally == undefined) {
|
|
235
|
+
onlyEvaluateLocally = false
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const localEvaluationResult = await this.featureFlagsPoller?.getAllFlags(
|
|
239
|
+
distinctId,
|
|
240
|
+
groups,
|
|
241
|
+
personProperties,
|
|
242
|
+
groupProperties
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
let response = {}
|
|
246
|
+
let fallbackToDecide = true
|
|
247
|
+
if (localEvaluationResult) {
|
|
248
|
+
response = localEvaluationResult.response
|
|
249
|
+
fallbackToDecide = localEvaluationResult.fallbackToDecide
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (fallbackToDecide && !onlyEvaluateLocally) {
|
|
253
|
+
this.reInit(distinctId)
|
|
254
|
+
if (groups) {
|
|
255
|
+
this._sharedClient.groups(groups)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (personProperties) {
|
|
259
|
+
this._sharedClient.personProperties(personProperties)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (groupProperties) {
|
|
263
|
+
this._sharedClient.groupProperties(groupProperties)
|
|
264
|
+
}
|
|
265
|
+
await this._sharedClient.reloadFeatureFlagsAsync(false)
|
|
266
|
+
const remoteEvaluationResult = this._sharedClient.getFeatureFlags()
|
|
267
|
+
|
|
268
|
+
return { ...response, ...remoteEvaluationResult }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return response
|
|
123
272
|
}
|
|
124
273
|
|
|
125
274
|
groupIdentify({ groupType, groupKey, properties }: GroupIdentifyMessage): void {
|
|
126
275
|
this._sharedClient.groupIdentify(groupType, groupKey, properties)
|
|
127
276
|
}
|
|
128
277
|
|
|
129
|
-
reloadFeatureFlags(): Promise<void> {
|
|
130
|
-
|
|
278
|
+
async reloadFeatureFlags(): Promise<void> {
|
|
279
|
+
await this.featureFlagsPoller?.loadFeatureFlags(true)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
flush(): void {
|
|
283
|
+
this._sharedClient.flush()
|
|
131
284
|
}
|
|
132
285
|
|
|
133
286
|
shutdown(): void {
|
|
134
|
-
void this.
|
|
287
|
+
void this.shutdownAsync()
|
|
135
288
|
}
|
|
136
289
|
|
|
137
|
-
shutdownAsync(): Promise<void> {
|
|
290
|
+
async shutdownAsync(): Promise<void> {
|
|
291
|
+
this.featureFlagsPoller?.stopPoller()
|
|
138
292
|
return this._sharedClient.shutdownAsync()
|
|
139
293
|
}
|
|
140
294
|
|