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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posthog-node",
3
- "version": "2.5.4",
3
+ "version": "2.6.0",
4
4
  "description": "PostHog Node.js integration",
5
5
  "repository": "PostHog/posthog-node",
6
6
  "scripts": {
@@ -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 matchAll = condition.properties.every((property) => {
295
- return matchProperty(property, properties)
296
- })
297
- if (!matchAll) {
298
- return false
299
- } else if (rolloutPercentage == undefined) {
300
- // == to include `null` as a match, not just `undefined`
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: [