posthog-node 2.1.0 → 2.2.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.
@@ -26,6 +26,7 @@ export declare abstract class PostHogCore {
26
26
  private _optoutOverride;
27
27
  constructor(apiKey: string, options?: PosthogCoreOptions);
28
28
  protected getCommonEventProperties(): any;
29
+ protected setupBootstrap(options?: Partial<PosthogCoreOptions>): void;
29
30
  private get props();
30
31
  private set props(value);
31
32
  private clearProps;
@@ -76,6 +77,7 @@ export declare abstract class PostHogCore {
76
77
  ***/
77
78
  private decideAsync;
78
79
  private _decideAsync;
80
+ private setKnownFeatureFlags;
79
81
  getFeatureFlag(key: string): boolean | string | undefined;
80
82
  getFeatureFlags(): PostHogDecideResponse['featureFlags'] | undefined;
81
83
  isFeatureEnabled(key: string): boolean | undefined;
@@ -6,6 +6,11 @@ export declare type PosthogCoreOptions = {
6
6
  enable?: boolean;
7
7
  sendFeatureFlagEvent?: boolean;
8
8
  preloadFeatureFlags?: boolean;
9
+ bootstrap?: {
10
+ distinctId?: string;
11
+ isIdentifiedId?: boolean;
12
+ featureFlags?: Record<string, boolean | string>;
13
+ };
9
14
  fetchRetryCount?: number;
10
15
  fetchRetryDelay?: number;
11
16
  sessionExpirationTimeSeconds?: number;
@@ -20,6 +20,7 @@ export declare type FeatureFlagCondition = {
20
20
  operator?: string;
21
21
  }[];
22
22
  rollout_percentage?: number;
23
+ variant?: string;
23
24
  };
24
25
  export declare type PostHogFeatureFlag = {
25
26
  id: number;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "posthog-node",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "PostHog Node.js integration",
5
5
  "repository": "PostHog/posthog-node",
6
6
  "scripts": {
7
- "prepublish": "cd .. && yarn build"
7
+ "prepublishOnly": "cd .. && yarn build"
8
8
  },
9
9
  "engines": {
10
10
  "node": ">=14.17.0"
@@ -190,10 +190,34 @@ class FeatureFlagsPoller {
190
190
  let isInconclusive = false
191
191
  let result = undefined
192
192
 
193
- flagConditions.forEach((condition) => {
193
+ // # Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
194
+ // # evaluated first, and the variant override is applied to the first matching condition.
195
+ const sortedFlagConditions = [...flagConditions].sort((conditionA, conditionB) => {
196
+ const AHasVariantOverride = !!conditionA.variant
197
+ const BHasVariantOverride = !!conditionB.variant
198
+
199
+ if (AHasVariantOverride && BHasVariantOverride) {
200
+ return 0
201
+ } else if (AHasVariantOverride) {
202
+ return -1
203
+ } else if (BHasVariantOverride) {
204
+ return 1
205
+ } else {
206
+ return 0
207
+ }
208
+ })
209
+
210
+ for (const condition of sortedFlagConditions) {
194
211
  try {
195
212
  if (this.isConditionMatch(flag, distinctId, condition, properties)) {
196
- result = this.getMatchingVariant(flag, distinctId) || true
213
+ const variantOverride = condition.variant
214
+ const flagVariants = flagFilters.multivariate?.variants || []
215
+ if (variantOverride && flagVariants.some((variant) => variant.key === variantOverride)) {
216
+ result = variantOverride
217
+ } else {
218
+ result = this.getMatchingVariant(flag, distinctId) || true
219
+ }
220
+ break
197
221
  }
198
222
  } catch (e) {
199
223
  if (e instanceof InconclusiveMatchError) {
@@ -202,7 +226,7 @@ class FeatureFlagsPoller {
202
226
  throw e
203
227
  }
204
228
  }
205
- })
229
+ }
206
230
 
207
231
  if (result !== undefined) {
208
232
  return result
@@ -394,6 +418,14 @@ function matchProperty(
394
418
  return typeof overrideValue == typeof value && overrideValue < value
395
419
  case 'lte':
396
420
  return typeof overrideValue == typeof value && overrideValue <= value
421
+ case 'is_date_after':
422
+ case 'is_date_before':
423
+ const parsedDate = convertToDateTime(value)
424
+ const overrideDate = convertToDateTime(overrideValue)
425
+ if (operator === 'is_date_before') {
426
+ return overrideDate < parsedDate
427
+ }
428
+ return overrideDate > parsedDate
397
429
  default:
398
430
  console.error(`Unknown operator: ${operator}`)
399
431
  return false
@@ -409,4 +441,18 @@ function isValidRegex(regex: string): boolean {
409
441
  }
410
442
  }
411
443
 
444
+ function convertToDateTime(value: string | number | (string | number)[] | Date): Date {
445
+ if (value instanceof Date) {
446
+ return value
447
+ } else if (typeof value === 'string' || typeof value === 'number') {
448
+ const date = new Date(value)
449
+ if (!isNaN(date.valueOf())) {
450
+ return date
451
+ }
452
+ throw new InconclusiveMatchError(`${value} is in an invalid date format`)
453
+ } else {
454
+ throw new InconclusiveMatchError(`The date provided ${value} must be a string, number, or date object`)
455
+ }
456
+ }
457
+
412
458
  export { FeatureFlagsPoller, matchProperty, InconclusiveMatchError, ClientError }
package/src/types.ts CHANGED
@@ -23,6 +23,7 @@ export type FeatureFlagCondition = {
23
23
  operator?: string
24
24
  }[]
25
25
  rollout_percentage?: number
26
+ variant?: string
26
27
  }
27
28
 
28
29
  export type PostHogFeatureFlag = {
@@ -929,6 +929,299 @@ describe('local evaluation', () => {
929
929
  expect(await posthog.getAllFlags('distinct-id')).toEqual({ 'beta-feature': false, 'disabled-feature': true })
930
930
  expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
931
931
  })
932
+
933
+ it('gets feature flag with variant overrides', async () => {
934
+ const flags = {
935
+ flags: [
936
+ {
937
+ id: 1,
938
+ name: 'Beta Feature',
939
+ key: 'beta-feature',
940
+ is_simple_flag: true,
941
+ active: true,
942
+ filters: {
943
+ groups: [
944
+ {
945
+ properties: [
946
+ {
947
+ key: 'email',
948
+ operator: 'exact',
949
+ value: 'test@posthog.com',
950
+ type: 'person',
951
+ },
952
+ ],
953
+ rollout_percentage: 100,
954
+ variant: 'second-variant',
955
+ },
956
+ {
957
+ rollout_percentage: 50,
958
+ variant: 'first-variant',
959
+ },
960
+ ],
961
+ multivariate: {
962
+ variants: [
963
+ {
964
+ key: 'first-variant',
965
+ name: 'First Variant',
966
+ rollout_percentage: 50,
967
+ },
968
+ {
969
+ key: 'second-variant',
970
+ name: 'Second Variant',
971
+ rollout_percentage: 25,
972
+ },
973
+ {
974
+ key: 'third-variant',
975
+ name: 'Third Variant',
976
+ rollout_percentage: 25,
977
+ },
978
+ ],
979
+ },
980
+ },
981
+ },
982
+ ],
983
+ }
984
+ mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
985
+
986
+ posthog = new PostHog('TEST_API_KEY', {
987
+ host: 'http://example.com',
988
+ personalApiKey: 'TEST_PERSONAL_API_KEY',
989
+ })
990
+
991
+ expect(
992
+ await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } })
993
+ ).toEqual('second-variant')
994
+ expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('first-variant')
995
+
996
+ expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
997
+ // decide not called
998
+ expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
999
+ })
1000
+
1001
+ it('gets feature flag with clashing variant overrides', async () => {
1002
+ const flags = {
1003
+ flags: [
1004
+ {
1005
+ id: 1,
1006
+ name: 'Beta Feature',
1007
+ key: 'beta-feature',
1008
+ is_simple_flag: true,
1009
+ active: true,
1010
+ filters: {
1011
+ groups: [
1012
+ {
1013
+ properties: [
1014
+ {
1015
+ key: 'email',
1016
+ operator: 'exact',
1017
+ value: 'test@posthog.com',
1018
+ type: 'person',
1019
+ },
1020
+ ],
1021
+ rollout_percentage: 100,
1022
+ variant: 'second-variant',
1023
+ },
1024
+ // # since second-variant comes first in the list, it will be the one that gets picked
1025
+ {
1026
+ properties: [
1027
+ {
1028
+ key: 'email',
1029
+ operator: 'exact',
1030
+ value: 'test@posthog.com',
1031
+ type: 'person',
1032
+ },
1033
+ ],
1034
+ rollout_percentage: 100,
1035
+ variant: 'first-variant',
1036
+ },
1037
+ {
1038
+ rollout_percentage: 50,
1039
+ variant: 'first-variant',
1040
+ },
1041
+ ],
1042
+ multivariate: {
1043
+ variants: [
1044
+ {
1045
+ key: 'first-variant',
1046
+ name: 'First Variant',
1047
+ rollout_percentage: 50,
1048
+ },
1049
+ {
1050
+ key: 'second-variant',
1051
+ name: 'Second Variant',
1052
+ rollout_percentage: 25,
1053
+ },
1054
+ {
1055
+ key: 'third-variant',
1056
+ name: 'Third Variant',
1057
+ rollout_percentage: 25,
1058
+ },
1059
+ ],
1060
+ },
1061
+ },
1062
+ },
1063
+ ],
1064
+ }
1065
+ mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
1066
+
1067
+ posthog = new PostHog('TEST_API_KEY', {
1068
+ host: 'http://example.com',
1069
+ personalApiKey: 'TEST_PERSONAL_API_KEY',
1070
+ })
1071
+
1072
+ expect(
1073
+ await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } })
1074
+ ).toEqual('second-variant')
1075
+ expect(
1076
+ await posthog.getFeatureFlag('beta-feature', 'example_id', { personProperties: { email: 'test@posthog.com' } })
1077
+ ).toEqual('second-variant')
1078
+ expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('first-variant')
1079
+
1080
+ expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
1081
+ // decide not called
1082
+ expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
1083
+ })
1084
+
1085
+ it('gets feature flag with invalid variant overrides', async () => {
1086
+ const flags = {
1087
+ flags: [
1088
+ {
1089
+ id: 1,
1090
+ name: 'Beta Feature',
1091
+ key: 'beta-feature',
1092
+ is_simple_flag: true,
1093
+ active: true,
1094
+ filters: {
1095
+ groups: [
1096
+ {
1097
+ properties: [
1098
+ {
1099
+ key: 'email',
1100
+ operator: 'exact',
1101
+ value: 'test@posthog.com',
1102
+ type: 'person',
1103
+ },
1104
+ ],
1105
+ rollout_percentage: 100,
1106
+ variant: 'second???',
1107
+ },
1108
+ {
1109
+ rollout_percentage: 50,
1110
+ variant: 'first???',
1111
+ },
1112
+ ],
1113
+ multivariate: {
1114
+ variants: [
1115
+ {
1116
+ key: 'first-variant',
1117
+ name: 'First Variant',
1118
+ rollout_percentage: 50,
1119
+ },
1120
+ {
1121
+ key: 'second-variant',
1122
+ name: 'Second Variant',
1123
+ rollout_percentage: 25,
1124
+ },
1125
+ {
1126
+ key: 'third-variant',
1127
+ name: 'Third Variant',
1128
+ rollout_percentage: 25,
1129
+ },
1130
+ ],
1131
+ },
1132
+ },
1133
+ },
1134
+ ],
1135
+ }
1136
+ mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
1137
+
1138
+ posthog = new PostHog('TEST_API_KEY', {
1139
+ host: 'http://example.com',
1140
+ personalApiKey: 'TEST_PERSONAL_API_KEY',
1141
+ })
1142
+
1143
+ expect(
1144
+ await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } })
1145
+ ).toEqual('third-variant')
1146
+ expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('second-variant')
1147
+
1148
+ expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
1149
+ // decide not called
1150
+ expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
1151
+ })
1152
+
1153
+ it('gets feature flag with multiple variant overrides', async () => {
1154
+ const flags = {
1155
+ flags: [
1156
+ {
1157
+ id: 1,
1158
+ name: 'Beta Feature',
1159
+ key: 'beta-feature',
1160
+ is_simple_flag: true,
1161
+ active: true,
1162
+ filters: {
1163
+ groups: [
1164
+ {
1165
+ rollout_percentage: 100,
1166
+ // # The override applies even if the first condition matches all and gives everyone their default group
1167
+ },
1168
+ {
1169
+ properties: [
1170
+ {
1171
+ key: 'email',
1172
+ operator: 'exact',
1173
+ value: 'test@posthog.com',
1174
+ type: 'person',
1175
+ },
1176
+ ],
1177
+ rollout_percentage: 100,
1178
+ variant: 'second-variant',
1179
+ },
1180
+ {
1181
+ rollout_percentage: 50,
1182
+ variant: 'third-variant',
1183
+ },
1184
+ ],
1185
+ multivariate: {
1186
+ variants: [
1187
+ {
1188
+ key: 'first-variant',
1189
+ name: 'First Variant',
1190
+ rollout_percentage: 50,
1191
+ },
1192
+ {
1193
+ key: 'second-variant',
1194
+ name: 'Second Variant',
1195
+ rollout_percentage: 25,
1196
+ },
1197
+ {
1198
+ key: 'third-variant',
1199
+ name: 'Third Variant',
1200
+ rollout_percentage: 25,
1201
+ },
1202
+ ],
1203
+ },
1204
+ },
1205
+ },
1206
+ ],
1207
+ }
1208
+ mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
1209
+
1210
+ posthog = new PostHog('TEST_API_KEY', {
1211
+ host: 'http://example.com',
1212
+ personalApiKey: 'TEST_PERSONAL_API_KEY',
1213
+ })
1214
+
1215
+ expect(
1216
+ await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } })
1217
+ ).toEqual('second-variant')
1218
+ expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('third-variant')
1219
+ expect(await posthog.getFeatureFlag('beta-feature', 'another_id')).toEqual('second-variant')
1220
+
1221
+ expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
1222
+ // decide not called
1223
+ expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
1224
+ })
932
1225
  })
933
1226
 
934
1227
  describe('match properties', () => {
@@ -1098,6 +1391,42 @@ describe('match properties', () => {
1098
1391
  expect(matchProperty(property_d, { key: '44' })).toBe(false)
1099
1392
  expect(matchProperty(property_d, { key: 44 })).toBe(false)
1100
1393
  })
1394
+
1395
+ it('with date operators', () => {
1396
+ // is date before
1397
+ const property_a = { key: 'key', value: '2022-05-01', operator: 'is_date_before' }
1398
+ expect(matchProperty(property_a, { key: '2022-03-01' })).toBe(true)
1399
+ expect(matchProperty(property_a, { key: '2022-04-30' })).toBe(true)
1400
+ expect(matchProperty(property_a, { key: new Date(2022, 3, 30) })).toBe(true)
1401
+ expect(matchProperty(property_a, { key: new Date(2022, 3, 30, 1, 2, 3) })).toBe(true)
1402
+ expect(matchProperty(property_a, { key: new Date('2022-04-30T00:00:00+02:00') })).toBe(true) // europe/madrid
1403
+ expect(matchProperty(property_a, { key: new Date('2022-04-30') })).toBe(true)
1404
+ expect(matchProperty(property_a, { key: '2022-05-30' })).toBe(false)
1405
+
1406
+ // is date after
1407
+ const property_b = { key: 'key', value: '2022-05-01', operator: 'is_date_after' }
1408
+ expect(matchProperty(property_b, { key: '2022-05-02' })).toBe(true)
1409
+ expect(matchProperty(property_b, { key: '2022-05-30' })).toBe(true)
1410
+ expect(matchProperty(property_b, { key: new Date(2022, 4, 30) })).toBe(true)
1411
+ expect(matchProperty(property_b, { key: new Date('2022-05-30') })).toBe(true)
1412
+ expect(matchProperty(property_b, { key: '2022-04-30' })).toBe(false)
1413
+
1414
+ // can't be an invalid number or invalid string
1415
+ expect(() => matchProperty(property_a, { key: parseInt('62802180000012345') })).toThrow(InconclusiveMatchError)
1416
+ expect(() => matchProperty(property_a, { key: 'abcdef' })).toThrow(InconclusiveMatchError)
1417
+ // invalid flag property
1418
+ const property_c = { key: 'key', value: 'abcd123', operator: 'is_date_before' }
1419
+ expect(() => matchProperty(property_c, { key: '2022-05-30' })).toThrow(InconclusiveMatchError)
1420
+
1421
+ // Timezone
1422
+ const property_d = { key: 'key', value: '2022-04-05 12:34:12 +01:00', operator: 'is_date_before' }
1423
+ expect(matchProperty(property_d, { key: '2022-05-30' })).toBe(false)
1424
+
1425
+ expect(matchProperty(property_d, { key: '2022-03-30' })).toBe(true)
1426
+ expect(matchProperty(property_d, { key: '2022-04-05 12:34:11+01:00' })).toBe(true)
1427
+ expect(matchProperty(property_d, { key: '2022-04-05 11:34:11 +00:00' })).toBe(true)
1428
+ expect(matchProperty(property_d, { key: '2022-04-05 11:34:13 +00:00' })).toBe(false)
1429
+ })
1101
1430
  })
1102
1431
 
1103
1432
  describe('consistency tests', () => {