posthog-node 4.10.2 → 4.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -2
- package/lib/index.cjs.js +293 -64
- package/lib/index.cjs.js.map +1 -1
- package/lib/index.d.ts +73 -13
- package/lib/index.esm.js +275 -64
- package/lib/index.esm.js.map +1 -1
- package/lib/posthog-core/src/featureFlagUtils.d.ts +34 -0
- package/lib/posthog-core/src/index.d.ts +18 -7
- package/lib/posthog-core/src/types.d.ts +74 -6
- package/lib/posthog-node/src/crypto-helpers.d.ts +3 -0
- package/lib/posthog-node/src/crypto.d.ts +2 -0
- package/lib/posthog-node/src/feature-flags.d.ts +8 -8
- package/lib/posthog-node/src/lazy.d.ts +23 -0
- package/lib/posthog-node/src/posthog-node.d.ts +4 -3
- package/lib/posthog-node/src/types.d.ts +3 -3
- package/lib/posthog-node/test/test-utils.d.ts +2 -0
- package/package.json +1 -1
- package/src/crypto-helpers.ts +36 -0
- package/src/crypto.ts +22 -0
- package/src/feature-flags.ts +42 -41
- package/src/lazy.ts +55 -0
- package/src/posthog-node.ts +36 -17
- package/src/types.ts +3 -3
- package/test/crypto.spec.ts +36 -0
- package/test/feature-flags.decide.spec.ts +293 -0
- package/test/feature-flags.spec.ts +2 -2
- package/test/lazy.spec.ts +71 -0
- package/test/posthog-node.spec.ts +18 -18
- package/test/test-utils.ts +24 -1
- package/benchmarks/rusha-vs-native.mjs +0 -70
package/src/lazy.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lazy value that is only computed when needed. Inspired by C#'s Lazy<T> class.
|
|
3
|
+
*/
|
|
4
|
+
export class Lazy<T> {
|
|
5
|
+
private value: T | undefined
|
|
6
|
+
private factory: () => Promise<T>
|
|
7
|
+
private initializationPromise: Promise<T> | undefined
|
|
8
|
+
|
|
9
|
+
constructor(factory: () => Promise<T>) {
|
|
10
|
+
this.factory = factory
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Gets the value, initializing it if necessary.
|
|
15
|
+
* Multiple concurrent calls will share the same initialization promise.
|
|
16
|
+
*/
|
|
17
|
+
async getValue(): Promise<T> {
|
|
18
|
+
if (this.value !== undefined) {
|
|
19
|
+
return this.value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (this.initializationPromise === undefined) {
|
|
23
|
+
this.initializationPromise = (async () => {
|
|
24
|
+
try {
|
|
25
|
+
const result = await this.factory()
|
|
26
|
+
this.value = result
|
|
27
|
+
return result
|
|
28
|
+
} finally {
|
|
29
|
+
// Clear the promise so we can retry if needed
|
|
30
|
+
this.initializationPromise = undefined
|
|
31
|
+
}
|
|
32
|
+
})()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return this.initializationPromise
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns true if the value has been initialized.
|
|
40
|
+
*/
|
|
41
|
+
isInitialized(): boolean {
|
|
42
|
+
return this.value !== undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns a promise that resolves when the value is initialized.
|
|
47
|
+
* If already initialized, resolves immediately.
|
|
48
|
+
*/
|
|
49
|
+
async waitForInitialization(): Promise<void> {
|
|
50
|
+
if (this.isInitialized()) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
await this.getValue()
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/posthog-node.ts
CHANGED
|
@@ -12,9 +12,11 @@ import {
|
|
|
12
12
|
} from '../../posthog-core/src'
|
|
13
13
|
import { PostHogMemoryStorage } from '../../posthog-core/src/storage-memory'
|
|
14
14
|
import { EventMessage, GroupIdentifyMessage, IdentifyMessage, PostHogNodeV1 } from './types'
|
|
15
|
+
import { FeatureFlagDetail, FeatureFlagValue } from '../../posthog-core/src/types'
|
|
15
16
|
import { FeatureFlagsPoller } from './feature-flags'
|
|
16
17
|
import fetch from './fetch'
|
|
17
18
|
import ErrorTracking from './error-tracking'
|
|
19
|
+
import { getFeatureFlagValue } from 'posthog-core/src/featureFlagUtils'
|
|
18
20
|
|
|
19
21
|
export type PostHogOptions = PostHogCoreOptions & {
|
|
20
22
|
persistence?: 'memory'
|
|
@@ -173,7 +175,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
|
|
|
173
175
|
additionalProperties[`$feature/${feature}`] = variant
|
|
174
176
|
}
|
|
175
177
|
}
|
|
176
|
-
const activeFlags = Object.keys(flags || {})
|
|
178
|
+
const activeFlags = Object.keys(flags || {})
|
|
179
|
+
.filter((flag) => flags?.[flag] !== false)
|
|
180
|
+
.sort()
|
|
177
181
|
if (activeFlags.length > 0) {
|
|
178
182
|
additionalProperties['$active_feature_flags'] = activeFlags
|
|
179
183
|
}
|
|
@@ -227,7 +231,7 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
|
|
|
227
231
|
sendFeatureFlagEvents?: boolean
|
|
228
232
|
disableGeoip?: boolean
|
|
229
233
|
}
|
|
230
|
-
): Promise<
|
|
234
|
+
): Promise<FeatureFlagValue | undefined> {
|
|
231
235
|
const { groups, disableGeoip } = options || {}
|
|
232
236
|
let { onlyEvaluateLocally, sendFeatureFlagEvents, personProperties, groupProperties } = options || {}
|
|
233
237
|
|
|
@@ -259,8 +263,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
|
|
|
259
263
|
|
|
260
264
|
const flagWasLocallyEvaluated = response !== undefined
|
|
261
265
|
let requestId = undefined
|
|
266
|
+
let flagDetail: FeatureFlagDetail | undefined = undefined
|
|
262
267
|
if (!flagWasLocallyEvaluated && !onlyEvaluateLocally) {
|
|
263
|
-
const remoteResponse = await super.
|
|
268
|
+
const remoteResponse = await super.getFeatureFlagDetailStateless(
|
|
264
269
|
key,
|
|
265
270
|
distinctId,
|
|
266
271
|
groups,
|
|
@@ -268,8 +273,14 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
|
|
|
268
273
|
groupProperties,
|
|
269
274
|
disableGeoip
|
|
270
275
|
)
|
|
271
|
-
|
|
272
|
-
|
|
276
|
+
|
|
277
|
+
if (remoteResponse === undefined) {
|
|
278
|
+
return undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
flagDetail = remoteResponse.response
|
|
282
|
+
response = getFeatureFlagValue(flagDetail) ?? false
|
|
283
|
+
requestId = remoteResponse?.requestId
|
|
273
284
|
}
|
|
274
285
|
|
|
275
286
|
const featureFlagReportedKey = `${key}_${response}`
|
|
@@ -293,6 +304,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
|
|
|
293
304
|
properties: {
|
|
294
305
|
$feature_flag: key,
|
|
295
306
|
$feature_flag_response: response,
|
|
307
|
+
$feature_flag_id: flagDetail?.metadata?.id,
|
|
308
|
+
$feature_flag_version: flagDetail?.metadata?.version,
|
|
309
|
+
$feature_flag_reason: flagDetail?.reason?.description ?? flagDetail?.reason?.code,
|
|
296
310
|
locally_evaluated: flagWasLocallyEvaluated,
|
|
297
311
|
[`$feature/${key}`]: response,
|
|
298
312
|
$feature_flag_request_id: requestId,
|
|
@@ -307,7 +321,7 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
|
|
|
307
321
|
async getFeatureFlagPayload(
|
|
308
322
|
key: string,
|
|
309
323
|
distinctId: string,
|
|
310
|
-
matchValue?:
|
|
324
|
+
matchValue?: FeatureFlagValue,
|
|
311
325
|
options?: {
|
|
312
326
|
groups?: Record<string, string>
|
|
313
327
|
personProperties?: Record<string, string>
|
|
@@ -332,17 +346,22 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
|
|
|
332
346
|
|
|
333
347
|
let response = undefined
|
|
334
348
|
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
349
|
+
const localEvaluationEnabled = this.featureFlagsPoller !== undefined
|
|
350
|
+
if (localEvaluationEnabled) {
|
|
351
|
+
// Try to get match value locally if not provided
|
|
352
|
+
if (!matchValue) {
|
|
353
|
+
matchValue = await this.getFeatureFlag(key, distinctId, {
|
|
354
|
+
...options,
|
|
355
|
+
onlyEvaluateLocally: true,
|
|
356
|
+
sendFeatureFlagEvents: false,
|
|
357
|
+
})
|
|
358
|
+
}
|
|
342
359
|
|
|
343
|
-
|
|
344
|
-
|
|
360
|
+
if (matchValue) {
|
|
361
|
+
response = await this.featureFlagsPoller?.computeFeatureFlagPayloadLocally(key, matchValue)
|
|
362
|
+
}
|
|
345
363
|
}
|
|
364
|
+
//}
|
|
346
365
|
|
|
347
366
|
// set defaults
|
|
348
367
|
if (onlyEvaluateLocally == undefined) {
|
|
@@ -404,9 +423,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
|
|
|
404
423
|
onlyEvaluateLocally?: boolean
|
|
405
424
|
disableGeoip?: boolean
|
|
406
425
|
}
|
|
407
|
-
): Promise<Record<string,
|
|
426
|
+
): Promise<Record<string, FeatureFlagValue>> {
|
|
408
427
|
const response = await this.getAllFlagsAndPayloads(distinctId, options)
|
|
409
|
-
return response.featureFlags
|
|
428
|
+
return response.featureFlags || {}
|
|
410
429
|
}
|
|
411
430
|
|
|
412
431
|
async getAllFlagsAndPayloads(
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { JsonType } from '../../posthog-core/src'
|
|
1
|
+
import { FeatureFlagValue, JsonType } from '../../posthog-core/src'
|
|
2
2
|
|
|
3
3
|
export interface IdentifyMessage {
|
|
4
4
|
distinctId: string
|
|
@@ -155,7 +155,7 @@ export type PostHogNodeV1 = {
|
|
|
155
155
|
onlyEvaluateLocally?: boolean
|
|
156
156
|
sendFeatureFlagEvents?: boolean
|
|
157
157
|
}
|
|
158
|
-
): Promise<
|
|
158
|
+
): Promise<FeatureFlagValue | undefined>
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
161
|
* @description Retrieves payload associated with the specified flag and matched value that is passed in.
|
|
@@ -186,7 +186,7 @@ export type PostHogNodeV1 = {
|
|
|
186
186
|
getFeatureFlagPayload(
|
|
187
187
|
key: string,
|
|
188
188
|
distinctId: string,
|
|
189
|
-
matchValue?:
|
|
189
|
+
matchValue?: FeatureFlagValue,
|
|
190
190
|
options?: {
|
|
191
191
|
onlyEvaluateLocally?: boolean
|
|
192
192
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as crypto from '../src/crypto'
|
|
2
|
+
import * as cryptoHelpers from '../src/crypto-helpers'
|
|
3
|
+
|
|
4
|
+
describe('crypto', () => {
|
|
5
|
+
describe('hashSHA1', () => {
|
|
6
|
+
const testString = 'some-flag.some_distinct_id'
|
|
7
|
+
const expectedHash = 'e4ce124e800a818c63099f95fa085dc2b620e173'
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
jest.restoreAllMocks() // <- Reset all mocks after each test
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should hash correctly using Node.js crypto', async () => {
|
|
14
|
+
jest.spyOn(cryptoHelpers, 'getWebCrypto').mockResolvedValue(undefined)
|
|
15
|
+
|
|
16
|
+
const hash = await crypto.hashSHA1(testString)
|
|
17
|
+
expect(hash).toBe(expectedHash)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should hash correctly using Web Crypto API', async () => {
|
|
21
|
+
jest.spyOn(cryptoHelpers, 'getNodeCrypto').mockResolvedValue(undefined)
|
|
22
|
+
|
|
23
|
+
const hash = await crypto.hashSHA1(testString)
|
|
24
|
+
expect(hash).toBe(expectedHash)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should throw if no crypto implementation is available', async () => {
|
|
28
|
+
jest.spyOn(cryptoHelpers, 'getNodeCrypto').mockResolvedValue(undefined)
|
|
29
|
+
jest.spyOn(cryptoHelpers, 'getWebCrypto').mockResolvedValue(undefined)
|
|
30
|
+
|
|
31
|
+
await expect(crypto.hashSHA1(testString)).rejects.toThrow(
|
|
32
|
+
'No crypto implementation available. Tried Node Crypto API and Web SubtleCrypto API'
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { PostHog as PostHog, PostHogOptions } from '../src/posthog-node'
|
|
2
|
+
import fetch from '../src/fetch'
|
|
3
|
+
import { apiImplementation, apiImplementationV4 } from './test-utils'
|
|
4
|
+
import { waitForPromises } from 'posthog-core/test/test-utils/test-utils'
|
|
5
|
+
import { PostHogV4DecideResponse } from 'posthog-core/src/types'
|
|
6
|
+
jest.mock('../src/fetch')
|
|
7
|
+
|
|
8
|
+
jest.spyOn(console, 'debug').mockImplementation()
|
|
9
|
+
|
|
10
|
+
const mockedFetch = jest.mocked(fetch, true)
|
|
11
|
+
|
|
12
|
+
const posthogImmediateResolveOptions: PostHogOptions = {
|
|
13
|
+
fetchRetryCount: 0,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('decide v4', () => {
|
|
17
|
+
describe('getFeatureFlag v4', () => {
|
|
18
|
+
it('returns false if the flag is not found', async () => {
|
|
19
|
+
const decideResponse: PostHogV4DecideResponse = {
|
|
20
|
+
flags: {},
|
|
21
|
+
errorsWhileComputingFlags: false,
|
|
22
|
+
requestId: '0152a345-295f-4fba-adac-2e6ea9c91082',
|
|
23
|
+
}
|
|
24
|
+
mockedFetch.mockImplementation(apiImplementationV4(decideResponse))
|
|
25
|
+
|
|
26
|
+
const posthog = new PostHog('TEST_API_KEY', {
|
|
27
|
+
host: 'http://example.com',
|
|
28
|
+
...posthogImmediateResolveOptions,
|
|
29
|
+
})
|
|
30
|
+
let capturedMessage: any
|
|
31
|
+
posthog.on('capture', (message) => {
|
|
32
|
+
capturedMessage = message
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const result = await posthog.getFeatureFlag('non-existent-flag', 'some-distinct-id')
|
|
36
|
+
|
|
37
|
+
expect(result).toBe(false)
|
|
38
|
+
expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object))
|
|
39
|
+
|
|
40
|
+
await waitForPromises()
|
|
41
|
+
expect(capturedMessage).toMatchObject({
|
|
42
|
+
distinct_id: 'some-distinct-id',
|
|
43
|
+
event: '$feature_flag_called',
|
|
44
|
+
library: posthog.getLibraryId(),
|
|
45
|
+
library_version: posthog.getLibraryVersion(),
|
|
46
|
+
properties: {
|
|
47
|
+
'$feature/non-existent-flag': false,
|
|
48
|
+
$feature_flag: 'non-existent-flag',
|
|
49
|
+
$feature_flag_response: false,
|
|
50
|
+
$feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082',
|
|
51
|
+
$groups: undefined,
|
|
52
|
+
$lib: posthog.getLibraryId(),
|
|
53
|
+
$lib_version: posthog.getLibraryVersion(),
|
|
54
|
+
locally_evaluated: false,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it.each([
|
|
60
|
+
{
|
|
61
|
+
key: 'variant-flag',
|
|
62
|
+
expectedResponse: 'variant-value',
|
|
63
|
+
expectedReason: 'Matched condition set 3',
|
|
64
|
+
expectedId: 2,
|
|
65
|
+
expectedVersion: 23,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: 'boolean-flag',
|
|
69
|
+
expectedResponse: true,
|
|
70
|
+
expectedReason: 'Matched condition set 1',
|
|
71
|
+
expectedId: 1,
|
|
72
|
+
expectedVersion: 12,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: 'non-matching-flag',
|
|
76
|
+
expectedResponse: false,
|
|
77
|
+
expectedReason: 'Did not match any condition',
|
|
78
|
+
expectedId: 3,
|
|
79
|
+
expectedVersion: 2,
|
|
80
|
+
},
|
|
81
|
+
])(
|
|
82
|
+
'captures a feature flag called event with extra metadata when the flag is found',
|
|
83
|
+
async ({ key, expectedResponse, expectedReason, expectedId, expectedVersion }) => {
|
|
84
|
+
const decideResponse: PostHogV4DecideResponse = {
|
|
85
|
+
flags: {
|
|
86
|
+
'variant-flag': {
|
|
87
|
+
key: 'variant-flag',
|
|
88
|
+
enabled: true,
|
|
89
|
+
variant: 'variant-value',
|
|
90
|
+
reason: {
|
|
91
|
+
code: 'variant',
|
|
92
|
+
condition_index: 2,
|
|
93
|
+
description: 'Matched condition set 3',
|
|
94
|
+
},
|
|
95
|
+
metadata: {
|
|
96
|
+
id: 2,
|
|
97
|
+
version: 23,
|
|
98
|
+
payload: '{"key": "value"}',
|
|
99
|
+
description: 'description',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
'boolean-flag': {
|
|
103
|
+
key: 'boolean-flag',
|
|
104
|
+
enabled: true,
|
|
105
|
+
variant: undefined,
|
|
106
|
+
reason: {
|
|
107
|
+
code: 'boolean',
|
|
108
|
+
condition_index: 1,
|
|
109
|
+
description: 'Matched condition set 1',
|
|
110
|
+
},
|
|
111
|
+
metadata: {
|
|
112
|
+
id: 1,
|
|
113
|
+
version: 12,
|
|
114
|
+
payload: undefined,
|
|
115
|
+
description: 'description',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
'non-matching-flag': {
|
|
119
|
+
key: 'non-matching-flag',
|
|
120
|
+
enabled: false,
|
|
121
|
+
variant: undefined,
|
|
122
|
+
reason: {
|
|
123
|
+
code: 'boolean',
|
|
124
|
+
condition_index: 1,
|
|
125
|
+
description: 'Did not match any condition',
|
|
126
|
+
},
|
|
127
|
+
metadata: {
|
|
128
|
+
id: 3,
|
|
129
|
+
version: 2,
|
|
130
|
+
payload: undefined,
|
|
131
|
+
description: 'description',
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
errorsWhileComputingFlags: false,
|
|
136
|
+
requestId: '0152a345-295f-4fba-adac-2e6ea9c91082',
|
|
137
|
+
}
|
|
138
|
+
mockedFetch.mockImplementation(apiImplementationV4(decideResponse))
|
|
139
|
+
|
|
140
|
+
const posthog = new PostHog('TEST_API_KEY', {
|
|
141
|
+
host: 'http://example.com',
|
|
142
|
+
...posthogImmediateResolveOptions,
|
|
143
|
+
})
|
|
144
|
+
let capturedMessage: any
|
|
145
|
+
posthog.on('capture', (message) => {
|
|
146
|
+
capturedMessage = message
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const result = await posthog.getFeatureFlag(key, 'some-distinct-id')
|
|
150
|
+
|
|
151
|
+
expect(result).toBe(expectedResponse)
|
|
152
|
+
expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object))
|
|
153
|
+
|
|
154
|
+
await waitForPromises()
|
|
155
|
+
expect(capturedMessage).toMatchObject({
|
|
156
|
+
distinct_id: 'some-distinct-id',
|
|
157
|
+
event: '$feature_flag_called',
|
|
158
|
+
library: posthog.getLibraryId(),
|
|
159
|
+
library_version: posthog.getLibraryVersion(),
|
|
160
|
+
properties: {
|
|
161
|
+
[`$feature/${key}`]: expectedResponse,
|
|
162
|
+
$feature_flag: key,
|
|
163
|
+
$feature_flag_response: expectedResponse,
|
|
164
|
+
$feature_flag_id: expectedId,
|
|
165
|
+
$feature_flag_version: expectedVersion,
|
|
166
|
+
$feature_flag_reason: expectedReason,
|
|
167
|
+
$feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082',
|
|
168
|
+
$groups: undefined,
|
|
169
|
+
$lib: posthog.getLibraryId(),
|
|
170
|
+
$lib_version: posthog.getLibraryVersion(),
|
|
171
|
+
locally_evaluated: false,
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
describe('getFeatureFlagPayload v4', () => {
|
|
178
|
+
it('returns payload', async () => {
|
|
179
|
+
mockedFetch.mockImplementation(
|
|
180
|
+
apiImplementationV4({
|
|
181
|
+
flags: {
|
|
182
|
+
'flag-with-payload': {
|
|
183
|
+
key: 'flag-with-payload',
|
|
184
|
+
enabled: true,
|
|
185
|
+
variant: undefined,
|
|
186
|
+
reason: {
|
|
187
|
+
code: 'boolean',
|
|
188
|
+
condition_index: 1,
|
|
189
|
+
description: 'Matched condition set 2',
|
|
190
|
+
},
|
|
191
|
+
metadata: {
|
|
192
|
+
id: 1,
|
|
193
|
+
version: 12,
|
|
194
|
+
payload: '[0, 1, 2]',
|
|
195
|
+
description: 'description',
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
errorsWhileComputingFlags: false,
|
|
200
|
+
})
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
const posthog = new PostHog('TEST_API_KEY', {
|
|
204
|
+
host: 'http://example.com',
|
|
205
|
+
...posthogImmediateResolveOptions,
|
|
206
|
+
})
|
|
207
|
+
let capturedMessage: any
|
|
208
|
+
posthog.on('capture', (message) => {
|
|
209
|
+
capturedMessage = message
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const result = await posthog.getFeatureFlagPayload('flag-with-payload', 'some-distinct-id')
|
|
213
|
+
|
|
214
|
+
expect(result).toEqual([0, 1, 2])
|
|
215
|
+
expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object))
|
|
216
|
+
|
|
217
|
+
await waitForPromises()
|
|
218
|
+
expect(capturedMessage).toBeUndefined()
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('decide v3', () => {
|
|
225
|
+
describe('getFeatureFlag v3', () => {
|
|
226
|
+
it('returns false if the flag is not found', async () => {
|
|
227
|
+
mockedFetch.mockImplementation(apiImplementation({ decideFlags: {} }))
|
|
228
|
+
|
|
229
|
+
const posthog = new PostHog('TEST_API_KEY', {
|
|
230
|
+
host: 'http://example.com',
|
|
231
|
+
...posthogImmediateResolveOptions,
|
|
232
|
+
})
|
|
233
|
+
let capturedMessage: any
|
|
234
|
+
posthog.on('capture', (message) => {
|
|
235
|
+
capturedMessage = message
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const result = await posthog.getFeatureFlag('non-existent-flag', 'some-distinct-id')
|
|
239
|
+
|
|
240
|
+
expect(result).toBe(false)
|
|
241
|
+
expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object))
|
|
242
|
+
|
|
243
|
+
await waitForPromises()
|
|
244
|
+
expect(capturedMessage).toMatchObject({
|
|
245
|
+
distinct_id: 'some-distinct-id',
|
|
246
|
+
event: '$feature_flag_called',
|
|
247
|
+
library: posthog.getLibraryId(),
|
|
248
|
+
library_version: posthog.getLibraryVersion(),
|
|
249
|
+
properties: {
|
|
250
|
+
'$feature/non-existent-flag': false,
|
|
251
|
+
$feature_flag: 'non-existent-flag',
|
|
252
|
+
$feature_flag_response: false,
|
|
253
|
+
$groups: undefined,
|
|
254
|
+
$lib: posthog.getLibraryId(),
|
|
255
|
+
$lib_version: posthog.getLibraryVersion(),
|
|
256
|
+
locally_evaluated: false,
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('getFeatureFlagPayload v3', () => {
|
|
262
|
+
it('returns payload', async () => {
|
|
263
|
+
mockedFetch.mockImplementation(
|
|
264
|
+
apiImplementation({
|
|
265
|
+
decideFlags: {
|
|
266
|
+
'flag-with-payload': true,
|
|
267
|
+
},
|
|
268
|
+
decideFlagPayloads: {
|
|
269
|
+
'flag-with-payload': [0, 1, 2],
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
const posthog = new PostHog('TEST_API_KEY', {
|
|
275
|
+
host: 'http://example.com',
|
|
276
|
+
...posthogImmediateResolveOptions,
|
|
277
|
+
})
|
|
278
|
+
let capturedMessage: any = undefined
|
|
279
|
+
posthog.on('capture', (message) => {
|
|
280
|
+
capturedMessage = message
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const result = await posthog.getFeatureFlagPayload('flag-with-payload', 'some-distinct-id')
|
|
284
|
+
|
|
285
|
+
expect(result).toEqual([0, 1, 2])
|
|
286
|
+
expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object))
|
|
287
|
+
|
|
288
|
+
await waitForPromises()
|
|
289
|
+
expect(capturedMessage).toBeUndefined()
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
})
|
|
@@ -347,7 +347,7 @@ describe('local evaluation', () => {
|
|
|
347
347
|
})
|
|
348
348
|
).toEqual('decide-fallback-value')
|
|
349
349
|
expect(mockedFetch).toHaveBeenCalledWith(
|
|
350
|
-
'http://example.com/decide/?v=
|
|
350
|
+
'http://example.com/decide/?v=4',
|
|
351
351
|
expect.objectContaining({
|
|
352
352
|
body: JSON.stringify({
|
|
353
353
|
token: 'TEST_API_KEY',
|
|
@@ -371,7 +371,7 @@ describe('local evaluation', () => {
|
|
|
371
371
|
await posthog.getFeatureFlag('complex-flag', 'some-distinct-id', { personProperties: { doesnt_matter: '1' } })
|
|
372
372
|
).toEqual('decide-fallback-value')
|
|
373
373
|
expect(mockedFetch).toHaveBeenCalledWith(
|
|
374
|
-
'http://example.com/decide/?v=
|
|
374
|
+
'http://example.com/decide/?v=4',
|
|
375
375
|
expect.objectContaining({
|
|
376
376
|
body: JSON.stringify({
|
|
377
377
|
token: 'TEST_API_KEY',
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Lazy } from '../src/lazy'
|
|
2
|
+
|
|
3
|
+
describe('Lazy', () => {
|
|
4
|
+
it('should only call the factory once', async (): Promise<void> => {
|
|
5
|
+
let callCount = 0
|
|
6
|
+
const factory = async (): Promise<string> => {
|
|
7
|
+
callCount++
|
|
8
|
+
return 'value'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const lazy = new Lazy(factory)
|
|
12
|
+
expect(callCount).toBe(0)
|
|
13
|
+
|
|
14
|
+
const value1 = await lazy.getValue()
|
|
15
|
+
expect(value1).toBe('value')
|
|
16
|
+
expect(callCount).toBe(1)
|
|
17
|
+
|
|
18
|
+
const value2 = await lazy.getValue()
|
|
19
|
+
expect(value2).toBe('value')
|
|
20
|
+
expect(callCount).toBe(1)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should handle errors in the factory', async (): Promise<void> => {
|
|
24
|
+
const factory = async (): Promise<string> => {
|
|
25
|
+
throw new Error('Factory error')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const lazy = new Lazy(factory)
|
|
29
|
+
await expect(lazy.getValue()).rejects.toThrow('Factory error')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should handle undefined values', async (): Promise<void> => {
|
|
33
|
+
const factory = async (): Promise<undefined> => {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lazy = new Lazy(factory)
|
|
38
|
+
const value = await lazy.getValue()
|
|
39
|
+
expect(value).toBeUndefined()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should handle complex types', async (): Promise<void> => {
|
|
43
|
+
interface ComplexType {
|
|
44
|
+
id: number
|
|
45
|
+
name: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const factory = async (): Promise<ComplexType> => {
|
|
49
|
+
return { id: 1, name: 'test' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lazy = new Lazy<ComplexType>(factory)
|
|
53
|
+
const value = await lazy.getValue()
|
|
54
|
+
expect(value).toEqual({ id: 1, name: 'test' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should handle concurrent calls', async (): Promise<void> => {
|
|
58
|
+
let callCount = 0
|
|
59
|
+
const factory = async (): Promise<string> => {
|
|
60
|
+
callCount++
|
|
61
|
+
return 'value'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lazy = new Lazy(factory)
|
|
65
|
+
const [value1, value2] = await Promise.all([lazy.getValue(), lazy.getValue()])
|
|
66
|
+
|
|
67
|
+
expect(value1).toBe('value')
|
|
68
|
+
expect(value2).toBe('value')
|
|
69
|
+
expect(callCount).toBe(1)
|
|
70
|
+
})
|
|
71
|
+
})
|