posthog-node 2.5.4 → 2.6.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 +4 -0
- package/lib/index.cjs.js +133 -9
- package/lib/index.cjs.js.map +1 -1
- package/lib/index.esm.js +133 -9
- package/lib/index.esm.js.map +1 -1
- package/lib/posthog-node/src/feature-flags.d.ts +2 -1
- package/lib/posthog-node/src/types.d.ts +12 -6
- package/package.json +1 -1
- package/src/feature-flags.ts +132 -9
- package/src/types.ts +14 -6
- package/test/feature-flags.spec.ts +173 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
|
-
import { FeatureFlagCondition, PostHogFeatureFlag } from './types';
|
|
2
|
+
import { FeatureFlagCondition, PostHogFeatureFlag, PropertyGroup } from './types';
|
|
3
3
|
import { JsonType, PostHogFetchOptions, PostHogFetchResponse } from 'posthog-core/src';
|
|
4
4
|
declare class ClientError extends Error {
|
|
5
5
|
constructor(message: string);
|
|
@@ -22,6 +22,7 @@ declare class FeatureFlagsPoller {
|
|
|
22
22
|
featureFlags: Array<PostHogFeatureFlag>;
|
|
23
23
|
featureFlagsByKey: Record<string, PostHogFeatureFlag>;
|
|
24
24
|
groupTypeMapping: Record<string, string>;
|
|
25
|
+
cohorts: Record<string, PropertyGroup>;
|
|
25
26
|
loadedSuccessfullyOnce: boolean;
|
|
26
27
|
timeout?: number;
|
|
27
28
|
host: FeatureFlagsPollerOptions['host'];
|
|
@@ -15,13 +15,19 @@ export interface GroupIdentifyMessage {
|
|
|
15
15
|
properties?: Record<string | number, any>;
|
|
16
16
|
distinctId?: string;
|
|
17
17
|
}
|
|
18
|
+
export declare type PropertyGroup = {
|
|
19
|
+
type: 'AND' | 'OR';
|
|
20
|
+
values: PropertyGroup[] | FlagProperty[];
|
|
21
|
+
};
|
|
22
|
+
export declare type FlagProperty = {
|
|
23
|
+
key: string;
|
|
24
|
+
type?: string;
|
|
25
|
+
value: string | number | (string | number)[];
|
|
26
|
+
operator?: string;
|
|
27
|
+
negation?: boolean;
|
|
28
|
+
};
|
|
18
29
|
export declare type FeatureFlagCondition = {
|
|
19
|
-
properties:
|
|
20
|
-
key: string;
|
|
21
|
-
type?: string;
|
|
22
|
-
value: string | number | (string | number)[];
|
|
23
|
-
operator?: string;
|
|
24
|
-
}[];
|
|
30
|
+
properties: FlagProperty[];
|
|
25
31
|
rollout_percentage?: number;
|
|
26
32
|
variant?: string;
|
|
27
33
|
};
|
package/package.json
CHANGED
package/src/feature-flags.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'crypto'
|
|
2
|
-
import { FeatureFlagCondition, PostHogFeatureFlag } from './types'
|
|
2
|
+
import { FeatureFlagCondition, FlagProperty, PostHogFeatureFlag, PropertyGroup } from './types'
|
|
3
3
|
import { version } from '../package.json'
|
|
4
4
|
import { JsonType, PostHogFetchOptions, PostHogFetchResponse } from 'posthog-core/src'
|
|
5
5
|
import { safeSetTimeout } from 'posthog-core/src/utils'
|
|
@@ -46,6 +46,7 @@ class FeatureFlagsPoller {
|
|
|
46
46
|
featureFlags: Array<PostHogFeatureFlag>
|
|
47
47
|
featureFlagsByKey: Record<string, PostHogFeatureFlag>
|
|
48
48
|
groupTypeMapping: Record<string, string>
|
|
49
|
+
cohorts: Record<string, PropertyGroup>
|
|
49
50
|
loadedSuccessfullyOnce: boolean
|
|
50
51
|
timeout?: number
|
|
51
52
|
host: FeatureFlagsPollerOptions['host']
|
|
@@ -66,6 +67,7 @@ class FeatureFlagsPoller {
|
|
|
66
67
|
this.featureFlags = []
|
|
67
68
|
this.featureFlagsByKey = {}
|
|
68
69
|
this.groupTypeMapping = {}
|
|
70
|
+
this.cohorts = {}
|
|
69
71
|
this.loadedSuccessfullyOnce = false
|
|
70
72
|
this.timeout = timeout
|
|
71
73
|
this.projectApiKey = projectApiKey
|
|
@@ -291,13 +293,22 @@ class FeatureFlagsPoller {
|
|
|
291
293
|
const rolloutPercentage = condition.rollout_percentage
|
|
292
294
|
|
|
293
295
|
if ((condition.properties || []).length > 0) {
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
296
|
+
for (const prop of condition.properties) {
|
|
297
|
+
const propertyType = prop.type
|
|
298
|
+
let matches = false
|
|
299
|
+
|
|
300
|
+
if (propertyType === 'cohort') {
|
|
301
|
+
matches = matchCohort(prop, properties, this.cohorts)
|
|
302
|
+
} else {
|
|
303
|
+
matches = matchProperty(prop, properties)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!matches) {
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (rolloutPercentage == undefined) {
|
|
301
312
|
return true
|
|
302
313
|
}
|
|
303
314
|
}
|
|
@@ -378,6 +389,7 @@ class FeatureFlagsPoller {
|
|
|
378
389
|
<Record<string, PostHogFeatureFlag>>{}
|
|
379
390
|
)
|
|
380
391
|
this.groupTypeMapping = responseJson.group_type_mapping || {}
|
|
392
|
+
this.cohorts = responseJson.cohorts || []
|
|
381
393
|
this.loadedSuccessfullyOnce = true
|
|
382
394
|
} catch (err) {
|
|
383
395
|
// if an error that is not an instance of ClientError is thrown
|
|
@@ -389,7 +401,7 @@ class FeatureFlagsPoller {
|
|
|
389
401
|
}
|
|
390
402
|
|
|
391
403
|
async _requestFeatureFlagDefinitions(): Promise<PostHogFetchResponse> {
|
|
392
|
-
const url = `${this.host}/api/feature_flag/local_evaluation?token=${this.projectApiKey}`
|
|
404
|
+
const url = `${this.host}/api/feature_flag/local_evaluation?token=${this.projectApiKey}&send_cohorts`
|
|
393
405
|
|
|
394
406
|
const options: PostHogFetchOptions = {
|
|
395
407
|
method: 'GET',
|
|
@@ -487,6 +499,117 @@ function matchProperty(
|
|
|
487
499
|
}
|
|
488
500
|
}
|
|
489
501
|
|
|
502
|
+
function matchCohort(
|
|
503
|
+
property: FeatureFlagCondition['properties'][number],
|
|
504
|
+
propertyValues: Record<string, any>,
|
|
505
|
+
cohortProperties: FeatureFlagsPoller['cohorts']
|
|
506
|
+
): boolean {
|
|
507
|
+
const cohortId = String(property.value)
|
|
508
|
+
if (!(cohortId in cohortProperties)) {
|
|
509
|
+
throw new InconclusiveMatchError("can't match cohort without a given cohort property value")
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const propertyGroup = cohortProperties[cohortId]
|
|
513
|
+
return matchPropertyGroup(propertyGroup, propertyValues, cohortProperties)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function matchPropertyGroup(
|
|
517
|
+
propertyGroup: PropertyGroup,
|
|
518
|
+
propertyValues: Record<string, any>,
|
|
519
|
+
cohortProperties: FeatureFlagsPoller['cohorts']
|
|
520
|
+
): boolean {
|
|
521
|
+
if (!propertyGroup) {
|
|
522
|
+
return true
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const propertyGroupType = propertyGroup.type
|
|
526
|
+
const properties = propertyGroup.values
|
|
527
|
+
|
|
528
|
+
if (!properties || properties.length === 0) {
|
|
529
|
+
// empty groups are no-ops, always match
|
|
530
|
+
return true
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let errorMatchingLocally = false
|
|
534
|
+
|
|
535
|
+
if ('values' in properties[0]) {
|
|
536
|
+
// a nested property group
|
|
537
|
+
for (const prop of properties as PropertyGroup[]) {
|
|
538
|
+
try {
|
|
539
|
+
const matches = matchPropertyGroup(prop, propertyValues, cohortProperties)
|
|
540
|
+
if (propertyGroupType === 'AND') {
|
|
541
|
+
if (!matches) {
|
|
542
|
+
return false
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
// OR group
|
|
546
|
+
if (matches) {
|
|
547
|
+
return true
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} catch (err) {
|
|
551
|
+
if (err instanceof InconclusiveMatchError) {
|
|
552
|
+
console.debug(`Failed to compute property ${prop} locally: ${err}`)
|
|
553
|
+
errorMatchingLocally = true
|
|
554
|
+
} else {
|
|
555
|
+
throw err
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (errorMatchingLocally) {
|
|
561
|
+
throw new InconclusiveMatchError("Can't match cohort without a given cohort property value")
|
|
562
|
+
}
|
|
563
|
+
// if we get here, all matched in AND case, or none matched in OR case
|
|
564
|
+
return propertyGroupType === 'AND'
|
|
565
|
+
} else {
|
|
566
|
+
for (const prop of properties as FlagProperty[]) {
|
|
567
|
+
try {
|
|
568
|
+
let matches: boolean
|
|
569
|
+
if (prop.type === 'cohort') {
|
|
570
|
+
matches = matchCohort(prop, propertyValues, cohortProperties)
|
|
571
|
+
} else {
|
|
572
|
+
matches = matchProperty(prop, propertyValues)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const negation = prop.negation || false
|
|
576
|
+
|
|
577
|
+
if (propertyGroupType === 'AND') {
|
|
578
|
+
// if negated property, do the inverse
|
|
579
|
+
if (!matches && !negation) {
|
|
580
|
+
return false
|
|
581
|
+
}
|
|
582
|
+
if (matches && negation) {
|
|
583
|
+
return false
|
|
584
|
+
}
|
|
585
|
+
} else {
|
|
586
|
+
// OR group
|
|
587
|
+
if (matches && !negation) {
|
|
588
|
+
return true
|
|
589
|
+
}
|
|
590
|
+
if (!matches && negation) {
|
|
591
|
+
return true
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
} catch (err) {
|
|
595
|
+
if (err instanceof InconclusiveMatchError) {
|
|
596
|
+
console.debug(`Failed to compute property ${prop} locally: ${err}`)
|
|
597
|
+
errorMatchingLocally = true
|
|
598
|
+
} else {
|
|
599
|
+
throw err
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (errorMatchingLocally) {
|
|
605
|
+
throw new InconclusiveMatchError("can't match cohort without a given cohort property value")
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// if we get here, all matched in AND case, or none matched in OR case
|
|
609
|
+
return propertyGroupType === 'AND'
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
490
613
|
function isValidRegex(regex: string): boolean {
|
|
491
614
|
try {
|
|
492
615
|
new RegExp(regex)
|
package/src/types.ts
CHANGED
|
@@ -19,13 +19,21 @@ export interface GroupIdentifyMessage {
|
|
|
19
19
|
distinctId?: string // optional distinctId to associate message with a person
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export type PropertyGroup = {
|
|
23
|
+
type: 'AND' | 'OR'
|
|
24
|
+
values: PropertyGroup[] | FlagProperty[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type FlagProperty = {
|
|
28
|
+
key: string
|
|
29
|
+
type?: string
|
|
30
|
+
value: string | number | (string | number)[]
|
|
31
|
+
operator?: string
|
|
32
|
+
negation?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
export type FeatureFlagCondition = {
|
|
23
|
-
properties:
|
|
24
|
-
key: string
|
|
25
|
-
type?: string
|
|
26
|
-
value: string | number | (string | number)[]
|
|
27
|
-
operator?: string
|
|
28
|
-
}[]
|
|
36
|
+
properties: FlagProperty[]
|
|
29
37
|
rollout_percentage?: number
|
|
30
38
|
variant?: string
|
|
31
39
|
}
|
|
@@ -38,7 +38,7 @@ export const apiImplementation = ({
|
|
|
38
38
|
}) as any
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
if ((url as any).includes('api/feature_flag/local_evaluation?token=TEST_API_KEY')) {
|
|
41
|
+
if ((url as any).includes('api/feature_flag/local_evaluation?token=TEST_API_KEY&send_cohorts')) {
|
|
42
42
|
return Promise.resolve({
|
|
43
43
|
status: 200,
|
|
44
44
|
text: () => Promise.resolve('ok'),
|
|
@@ -58,7 +58,7 @@ export const apiImplementation = ({
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export const anyLocalEvalCall = [
|
|
61
|
-
'http://example.com/api/feature_flag/local_evaluation?token=TEST_API_KEY',
|
|
61
|
+
'http://example.com/api/feature_flag/local_evaluation?token=TEST_API_KEY&send_cohorts',
|
|
62
62
|
expect.any(Object),
|
|
63
63
|
]
|
|
64
64
|
export const anyDecideCall = ['http://example.com/decide/?v=3', expect.any(Object)]
|
|
@@ -1179,6 +1179,177 @@ describe('local evaluation', () => {
|
|
|
1179
1179
|
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
|
|
1180
1180
|
})
|
|
1181
1181
|
|
|
1182
|
+
it('computes complex cohorts locally', async () => {
|
|
1183
|
+
const flags = {
|
|
1184
|
+
flags: [
|
|
1185
|
+
{
|
|
1186
|
+
id: 1,
|
|
1187
|
+
name: 'Beta Feature',
|
|
1188
|
+
key: 'beta-feature',
|
|
1189
|
+
is_simple_flag: false,
|
|
1190
|
+
active: true,
|
|
1191
|
+
rollout_percentage: 100,
|
|
1192
|
+
filters: {
|
|
1193
|
+
groups: [
|
|
1194
|
+
{
|
|
1195
|
+
properties: [
|
|
1196
|
+
{
|
|
1197
|
+
key: 'region',
|
|
1198
|
+
operator: 'exact',
|
|
1199
|
+
value: ['USA'],
|
|
1200
|
+
type: 'person',
|
|
1201
|
+
},
|
|
1202
|
+
{ key: 'id', value: 98, type: 'cohort' },
|
|
1203
|
+
],
|
|
1204
|
+
rollout_percentage: 100,
|
|
1205
|
+
},
|
|
1206
|
+
],
|
|
1207
|
+
},
|
|
1208
|
+
},
|
|
1209
|
+
],
|
|
1210
|
+
cohorts: {
|
|
1211
|
+
'98': {
|
|
1212
|
+
type: 'OR',
|
|
1213
|
+
values: [
|
|
1214
|
+
{ key: 'id', value: 1, type: 'cohort' },
|
|
1215
|
+
{
|
|
1216
|
+
key: 'nation',
|
|
1217
|
+
operator: 'exact',
|
|
1218
|
+
value: ['UK'],
|
|
1219
|
+
type: 'person',
|
|
1220
|
+
},
|
|
1221
|
+
],
|
|
1222
|
+
},
|
|
1223
|
+
'1': {
|
|
1224
|
+
type: 'AND',
|
|
1225
|
+
values: [{ key: 'other', operator: 'exact', value: ['thing'], type: 'person' }],
|
|
1226
|
+
},
|
|
1227
|
+
},
|
|
1228
|
+
}
|
|
1229
|
+
mockedFetch.mockImplementation(
|
|
1230
|
+
apiImplementation({
|
|
1231
|
+
localFlags: flags,
|
|
1232
|
+
decideFlags: {},
|
|
1233
|
+
})
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
posthog = new PostHog('TEST_API_KEY', {
|
|
1237
|
+
host: 'http://example.com',
|
|
1238
|
+
personalApiKey: 'TEST_PERSONAL_API_KEY',
|
|
1239
|
+
})
|
|
1240
|
+
|
|
1241
|
+
expect(
|
|
1242
|
+
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'UK' } })
|
|
1243
|
+
).toEqual(false)
|
|
1244
|
+
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
|
|
1245
|
+
|
|
1246
|
+
// # even though 'other' property is not present, the cohort should still match since it's an OR condition
|
|
1247
|
+
expect(
|
|
1248
|
+
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
|
|
1249
|
+
personProperties: { region: 'USA', nation: 'UK' },
|
|
1250
|
+
})
|
|
1251
|
+
).toEqual(true)
|
|
1252
|
+
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
|
|
1253
|
+
|
|
1254
|
+
// # even though 'other' property is not present, the cohort should still match since it's an OR condition
|
|
1255
|
+
expect(
|
|
1256
|
+
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
|
|
1257
|
+
personProperties: { region: 'USA', other: 'thing' },
|
|
1258
|
+
})
|
|
1259
|
+
).toEqual(true)
|
|
1260
|
+
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
|
|
1261
|
+
})
|
|
1262
|
+
|
|
1263
|
+
it('computes complex cohorts with negation locally', async () => {
|
|
1264
|
+
const flags = {
|
|
1265
|
+
flags: [
|
|
1266
|
+
{
|
|
1267
|
+
id: 1,
|
|
1268
|
+
name: 'Beta Feature',
|
|
1269
|
+
key: 'beta-feature',
|
|
1270
|
+
is_simple_flag: false,
|
|
1271
|
+
active: true,
|
|
1272
|
+
rollout_percentage: 100,
|
|
1273
|
+
filters: {
|
|
1274
|
+
groups: [
|
|
1275
|
+
{
|
|
1276
|
+
properties: [
|
|
1277
|
+
{
|
|
1278
|
+
key: 'region',
|
|
1279
|
+
operator: 'exact',
|
|
1280
|
+
value: ['USA'],
|
|
1281
|
+
type: 'person',
|
|
1282
|
+
},
|
|
1283
|
+
{ key: 'id', value: 98, type: 'cohort' },
|
|
1284
|
+
],
|
|
1285
|
+
rollout_percentage: 100,
|
|
1286
|
+
},
|
|
1287
|
+
],
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
],
|
|
1291
|
+
cohorts: {
|
|
1292
|
+
'98': {
|
|
1293
|
+
type: 'OR',
|
|
1294
|
+
values: [
|
|
1295
|
+
{ key: 'id', value: 1, type: 'cohort' },
|
|
1296
|
+
{
|
|
1297
|
+
key: 'nation',
|
|
1298
|
+
operator: 'exact',
|
|
1299
|
+
value: ['UK'],
|
|
1300
|
+
type: 'person',
|
|
1301
|
+
},
|
|
1302
|
+
],
|
|
1303
|
+
},
|
|
1304
|
+
'1': {
|
|
1305
|
+
type: 'AND',
|
|
1306
|
+
values: [{ key: 'other', operator: 'exact', value: ['thing'], type: 'person', negation: true }],
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1309
|
+
}
|
|
1310
|
+
mockedFetch.mockImplementation(
|
|
1311
|
+
apiImplementation({
|
|
1312
|
+
localFlags: flags,
|
|
1313
|
+
decideFlags: {},
|
|
1314
|
+
})
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
posthog = new PostHog('TEST_API_KEY', {
|
|
1318
|
+
host: 'http://example.com',
|
|
1319
|
+
personalApiKey: 'TEST_PERSONAL_API_KEY',
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
expect(
|
|
1323
|
+
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'UK' } })
|
|
1324
|
+
).toEqual(false)
|
|
1325
|
+
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
|
|
1326
|
+
|
|
1327
|
+
// # even though 'other' property is not present, the cohort should still match since it's an OR condition
|
|
1328
|
+
expect(
|
|
1329
|
+
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
|
|
1330
|
+
personProperties: { region: 'USA', nation: 'UK' },
|
|
1331
|
+
})
|
|
1332
|
+
).toEqual(true)
|
|
1333
|
+
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
|
|
1334
|
+
|
|
1335
|
+
// # since 'other' is negated, we return False. Since 'nation' is not present, we can't tell whether the flag should be true or false, so go to decide
|
|
1336
|
+
expect(
|
|
1337
|
+
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
|
|
1338
|
+
personProperties: { region: 'USA', other: 'thing' },
|
|
1339
|
+
})
|
|
1340
|
+
).toEqual(false)
|
|
1341
|
+
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
|
|
1342
|
+
|
|
1343
|
+
mockedFetch.mockClear()
|
|
1344
|
+
|
|
1345
|
+
expect(
|
|
1346
|
+
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
|
|
1347
|
+
personProperties: { region: 'USA', other: 'thing2' },
|
|
1348
|
+
})
|
|
1349
|
+
).toEqual(true)
|
|
1350
|
+
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
|
|
1351
|
+
})
|
|
1352
|
+
|
|
1182
1353
|
it('gets feature flag with variant overrides', async () => {
|
|
1183
1354
|
const flags = {
|
|
1184
1355
|
flags: [
|