posthog-node 2.5.3 → 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.
@@ -11,6 +11,7 @@ export declare abstract class PostHogCoreStateless {
11
11
  private requestTimeout;
12
12
  private captureMode;
13
13
  private removeDebugCallback?;
14
+ private debugMode;
14
15
  private pendingPromises;
15
16
  private _optoutOverride;
16
17
  protected _events: SimpleEventEmitter;
@@ -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,12 +22,15 @@ 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'];
28
29
  poller?: NodeJS.Timeout;
29
30
  fetch: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>;
31
+ debugMode: boolean;
30
32
  constructor({ pollingInterval, personalApiKey, projectApiKey, timeout, host, ...options }: FeatureFlagsPollerOptions);
33
+ debug(enabled?: boolean): void;
31
34
  getFeatureFlag(key: string, distinctId: string, groups?: Record<string, string>, personProperties?: Record<string, string>, groupProperties?: Record<string, Record<string, string>>): Promise<string | boolean | undefined>;
32
35
  computeFeatureFlagPayloadLocally(key: string, matchValue: string | boolean): Promise<JsonType | undefined>;
33
36
  getAllFlagsAndPayloads(distinctId: string, groups?: Record<string, string>, personProperties?: Record<string, string>, groupProperties?: Record<string, Record<string, string>>): Promise<{
@@ -23,6 +23,7 @@ export declare class PostHog extends PostHogCoreStateless implements PostHogNode
23
23
  getCustomUserAgent(): string;
24
24
  enable(): void;
25
25
  disable(): void;
26
+ debug(enabled?: boolean): void;
26
27
  capture({ distinctId, event, properties, groups, sendFeatureFlags, timestamp }: EventMessageV1): void;
27
28
  identify({ distinctId, properties }: IdentifyMessageV1): void;
28
29
  alias(data: {
@@ -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.3",
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,11 +46,13 @@ 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']
52
53
  poller?: NodeJS.Timeout
53
54
  fetch: (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>
55
+ debugMode: boolean = false
54
56
 
55
57
  constructor({
56
58
  pollingInterval,
@@ -65,6 +67,7 @@ class FeatureFlagsPoller {
65
67
  this.featureFlags = []
66
68
  this.featureFlagsByKey = {}
67
69
  this.groupTypeMapping = {}
70
+ this.cohorts = {}
68
71
  this.loadedSuccessfullyOnce = false
69
72
  this.timeout = timeout
70
73
  this.projectApiKey = projectApiKey
@@ -76,6 +79,10 @@ class FeatureFlagsPoller {
76
79
  void this.loadFeatureFlags()
77
80
  }
78
81
 
82
+ debug(enabled: boolean = true): void {
83
+ this.debugMode = enabled
84
+ }
85
+
79
86
  async getFeatureFlag(
80
87
  key: string,
81
88
  distinctId: string,
@@ -102,9 +109,14 @@ class FeatureFlagsPoller {
102
109
  if (featureFlag !== undefined) {
103
110
  try {
104
111
  response = this.computeFlagLocally(featureFlag, distinctId, groups, personProperties, groupProperties)
112
+ if (this.debugMode) {
113
+ console.debug(`Successfully computed flag locally: ${key} -> ${response}`)
114
+ }
105
115
  } catch (e) {
106
116
  if (e instanceof InconclusiveMatchError) {
107
- console.error(`InconclusiveMatchError when computing flag locally: ${key}: ${e}`)
117
+ if (this.debugMode) {
118
+ console.debug(`InconclusiveMatchError when computing flag locally: ${key}: ${e}`)
119
+ }
108
120
  } else if (e instanceof Error) {
109
121
  console.error(`Error computing flag locally: ${key}: ${e}`)
110
122
  }
@@ -281,13 +293,22 @@ class FeatureFlagsPoller {
281
293
  const rolloutPercentage = condition.rollout_percentage
282
294
 
283
295
  if ((condition.properties || []).length > 0) {
284
- const matchAll = condition.properties.every((property) => {
285
- return matchProperty(property, properties)
286
- })
287
- if (!matchAll) {
288
- return false
289
- } else if (rolloutPercentage == undefined) {
290
- // == 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) {
291
312
  return true
292
313
  }
293
314
  }
@@ -368,6 +389,7 @@ class FeatureFlagsPoller {
368
389
  <Record<string, PostHogFeatureFlag>>{}
369
390
  )
370
391
  this.groupTypeMapping = responseJson.group_type_mapping || {}
392
+ this.cohorts = responseJson.cohorts || []
371
393
  this.loadedSuccessfullyOnce = true
372
394
  } catch (err) {
373
395
  // if an error that is not an instance of ClientError is thrown
@@ -379,7 +401,7 @@ class FeatureFlagsPoller {
379
401
  }
380
402
 
381
403
  async _requestFeatureFlagDefinitions(): Promise<PostHogFetchResponse> {
382
- 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`
383
405
 
384
406
  const options: PostHogFetchOptions = {
385
407
  method: 'GET',
@@ -477,6 +499,117 @@ function matchProperty(
477
499
  }
478
500
  }
479
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
+
480
613
  function isValidRegex(regex: string): boolean {
481
614
  try {
482
615
  new RegExp(regex)
@@ -92,6 +92,11 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
92
92
  return super.optOut()
93
93
  }
94
94
 
95
+ debug(enabled: boolean = true): void {
96
+ super.debug(enabled)
97
+ this.featureFlagsPoller?.debug(enabled)
98
+ }
99
+
95
100
  capture({ distinctId, event, properties, groups, sendFeatureFlags, timestamp }: EventMessageV1): void {
96
101
  const _capture = (props: EventMessageV1['properties']): void => {
97
102
  super.captureStateless(distinctId, event, props, { timestamp })
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: [