posthog-node 3.3.0 → 3.5.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.
@@ -12,9 +12,9 @@ export declare abstract class PostHogCoreStateless {
12
12
  private captureMode;
13
13
  private removeDebugCallback?;
14
14
  private debugMode;
15
- private pendingPromises;
16
15
  private disableGeoip;
17
16
  private _optoutOverride;
17
+ private pendingPromises;
18
18
  protected _events: SimpleEventEmitter;
19
19
  protected _flushTimer?: any;
20
20
  protected _retryOptions: RetriableOptions;
@@ -32,6 +32,7 @@ export declare abstract class PostHogCoreStateless {
32
32
  on(event: string, cb: (...args: any[]) => void): () => void;
33
33
  debug(enabled?: boolean): void;
34
34
  private buildPayload;
35
+ protected addPendingPromise(promise: Promise<any>): void;
35
36
  /***
36
37
  *** TRACKING
37
38
  ***/
@@ -55,4 +55,5 @@ declare class FeatureFlagsPoller {
55
55
  stopPoller(): void;
56
56
  }
57
57
  declare function matchProperty(property: FeatureFlagCondition['properties'][number], propertyValues: Record<string, any>): boolean;
58
- export { FeatureFlagsPoller, matchProperty, InconclusiveMatchError, ClientError };
58
+ declare function relativeDateParseForFeatureFlagMatching(value: string): Date | null;
59
+ export { FeatureFlagsPoller, matchProperty, relativeDateParseForFeatureFlagMatching, InconclusiveMatchError, ClientError, };
@@ -73,4 +73,5 @@ export declare class PostHog extends PostHogCoreStateless implements PostHogNode
73
73
  reloadFeatureFlags(): Promise<void>;
74
74
  shutdown(): void;
75
75
  shutdownAsync(): Promise<void>;
76
+ private addLocalPersonAndGroupProperties;
76
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posthog-node",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "description": "PostHog Node.js integration",
5
5
  "repository": {
6
6
  "type": "git",
@@ -121,7 +121,7 @@ class FeatureFlagsPoller {
121
121
  console.debug(`InconclusiveMatchError when computing flag locally: ${key}: ${e}`)
122
122
  }
123
123
  } else if (e instanceof Error) {
124
- console.error(`Error computing flag locally: ${key}: ${e}`)
124
+ this.onError?.(new Error(`Error computing flag locally: ${key}: ${e}`))
125
125
  }
126
126
  }
127
127
  }
@@ -211,14 +211,18 @@ class FeatureFlagsPoller {
211
211
  const groupName = this.groupTypeMapping[String(aggregation_group_type_index)]
212
212
 
213
213
  if (!groupName) {
214
- console.warn(
215
- `[FEATURE FLAGS] Unknown group type index ${aggregation_group_type_index} for feature flag ${flag.key}`
216
- )
214
+ if (this.debugMode) {
215
+ console.warn(
216
+ `[FEATURE FLAGS] Unknown group type index ${aggregation_group_type_index} for feature flag ${flag.key}`
217
+ )
218
+ }
217
219
  throw new InconclusiveMatchError('Flag has unknown group type index')
218
220
  }
219
221
 
220
222
  if (!(groupName in groups)) {
221
- console.warn(`[FEATURE FLAGS] Can't compute group feature flag: ${flag.key} without group names passed in`)
223
+ if (this.debugMode) {
224
+ console.warn(`[FEATURE FLAGS] Can't compute group feature flag: ${flag.key} without group names passed in`)
225
+ }
222
226
  return false
223
227
  }
224
228
 
@@ -383,7 +387,7 @@ class FeatureFlagsPoller {
383
387
 
384
388
  const responseJson = await res.json()
385
389
  if (!('flags' in responseJson)) {
386
- console.error(`Invalid response when getting feature flags: ${JSON.stringify(responseJson)}`)
390
+ this.onError?.(new Error(`Invalid response when getting feature flags: ${JSON.stringify(responseJson)}`))
387
391
  }
388
392
 
389
393
  this.featureFlags = responseJson.flags || []
@@ -464,11 +468,32 @@ function matchProperty(
464
468
 
465
469
  const overrideValue = propertyValues[key]
466
470
 
471
+ function computeExactMatch(value: any, overrideValue: any): boolean {
472
+ if (Array.isArray(value)) {
473
+ return value.map((val) => String(val).toLowerCase()).includes(String(overrideValue).toLowerCase())
474
+ }
475
+ return String(value).toLowerCase() === String(overrideValue).toLowerCase()
476
+ }
477
+
478
+ function compare(lhs: any, rhs: any, operator: string): boolean {
479
+ if (operator === 'gt') {
480
+ return lhs > rhs
481
+ } else if (operator === 'gte') {
482
+ return lhs >= rhs
483
+ } else if (operator === 'lt') {
484
+ return lhs < rhs
485
+ } else if (operator === 'lte') {
486
+ return lhs <= rhs
487
+ } else {
488
+ throw new Error(`Invalid operator: ${operator}`)
489
+ }
490
+ }
491
+
467
492
  switch (operator) {
468
493
  case 'exact':
469
- return Array.isArray(value) ? value.indexOf(overrideValue) !== -1 : value === overrideValue
494
+ return computeExactMatch(value, overrideValue)
470
495
  case 'is_not':
471
- return Array.isArray(value) ? value.indexOf(overrideValue) === -1 : value !== overrideValue
496
+ return !computeExactMatch(value, overrideValue)
472
497
  case 'is_set':
473
498
  return key in propertyValues
474
499
  case 'icontains':
@@ -480,25 +505,54 @@ function matchProperty(
480
505
  case 'not_regex':
481
506
  return isValidRegex(String(value)) && String(overrideValue).match(String(value)) === null
482
507
  case 'gt':
483
- return typeof overrideValue == typeof value && overrideValue > value
484
508
  case 'gte':
485
- return typeof overrideValue == typeof value && overrideValue >= value
486
509
  case 'lt':
487
- return typeof overrideValue == typeof value && overrideValue < value
488
- case 'lte':
489
- return typeof overrideValue == typeof value && overrideValue <= value
510
+ case 'lte': {
511
+ // :TRICKY: We adjust comparison based on the override value passed in,
512
+ // to make sure we handle both numeric and string comparisons appropriately.
513
+ let parsedValue = typeof value === 'number' ? value : null
514
+
515
+ if (typeof value === 'string') {
516
+ try {
517
+ parsedValue = parseFloat(value)
518
+ } catch (err) {
519
+ // pass
520
+ }
521
+ }
522
+
523
+ if (parsedValue != null && overrideValue != null) {
524
+ // check both null and undefined
525
+ if (typeof overrideValue === 'string') {
526
+ return compare(overrideValue, String(value), operator)
527
+ } else {
528
+ return compare(overrideValue, parsedValue, operator)
529
+ }
530
+ } else {
531
+ return compare(String(overrideValue), String(value), operator)
532
+ }
533
+ }
490
534
  case 'is_date_after':
491
- case 'is_date_before': {
492
- const parsedDate = convertToDateTime(value)
535
+ case 'is_date_before':
536
+ case 'is_relative_date_before':
537
+ case 'is_relative_date_after': {
538
+ let parsedDate = null
539
+ if (['is_relative_date_before', 'is_relative_date_after'].includes(operator)) {
540
+ parsedDate = relativeDateParseForFeatureFlagMatching(String(value))
541
+ } else {
542
+ parsedDate = convertToDateTime(value)
543
+ }
544
+
545
+ if (parsedDate == null) {
546
+ throw new InconclusiveMatchError(`Invalid date: ${value}`)
547
+ }
493
548
  const overrideDate = convertToDateTime(overrideValue)
494
- if (operator === 'is_date_before') {
549
+ if (['is_date_before', 'is_relative_date_before'].includes(operator)) {
495
550
  return overrideDate < parsedDate
496
551
  }
497
552
  return overrideDate > parsedDate
498
553
  }
499
554
  default:
500
- console.error(`Unknown operator: ${operator}`)
501
- return false
555
+ throw new InconclusiveMatchError(`Unknown operator: ${operator}`)
502
556
  }
503
557
  }
504
558
 
@@ -636,4 +690,47 @@ function convertToDateTime(value: string | number | (string | number)[] | Date):
636
690
  }
637
691
  }
638
692
 
639
- export { FeatureFlagsPoller, matchProperty, InconclusiveMatchError, ClientError }
693
+ function relativeDateParseForFeatureFlagMatching(value: string): Date | null {
694
+ const regex = /^(?<number>[0-9]+)(?<interval>[a-z])$/
695
+ const match = value.match(regex)
696
+ const parsedDt = new Date(new Date().toISOString())
697
+
698
+ if (match) {
699
+ if (!match.groups) {
700
+ return null
701
+ }
702
+
703
+ const number = parseInt(match.groups['number'])
704
+
705
+ if (number >= 10000) {
706
+ // Guard against overflow, disallow numbers greater than 10_000
707
+ return null
708
+ }
709
+ const interval = match.groups['interval']
710
+ if (interval == 'h') {
711
+ parsedDt.setUTCHours(parsedDt.getUTCHours() - number)
712
+ } else if (interval == 'd') {
713
+ parsedDt.setUTCDate(parsedDt.getUTCDate() - number)
714
+ } else if (interval == 'w') {
715
+ parsedDt.setUTCDate(parsedDt.getUTCDate() - number * 7)
716
+ } else if (interval == 'm') {
717
+ parsedDt.setUTCMonth(parsedDt.getUTCMonth() - number)
718
+ } else if (interval == 'y') {
719
+ parsedDt.setUTCFullYear(parsedDt.getUTCFullYear() - number)
720
+ } else {
721
+ return null
722
+ }
723
+
724
+ return parsedDt
725
+ } else {
726
+ return null
727
+ }
728
+ }
729
+
730
+ export {
731
+ FeatureFlagsPoller,
732
+ matchProperty,
733
+ relativeDateParseForFeatureFlagMatching,
734
+ InconclusiveMatchError,
735
+ ClientError,
736
+ }
@@ -105,26 +105,54 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
105
105
  super.captureStateless(distinctId, event, props, { timestamp, disableGeoip })
106
106
  }
107
107
 
108
- if (sendFeatureFlags) {
109
- super.getFeatureFlagsStateless(distinctId, groups, undefined, undefined, disableGeoip).then((flags) => {
110
- const featureVariantProperties: Record<string, string | boolean> = {}
108
+ // :TRICKY: If we flush, or need to shut down, to not lose events we want this promise to resolve before we flush
109
+ const capturePromise = Promise.resolve()
110
+ .then(async () => {
111
+ if (sendFeatureFlags) {
112
+ // If we are sending feature flags, we need to make sure we have the latest flags
113
+ return await super.getFeatureFlagsStateless(distinctId, groups, undefined, undefined, disableGeoip)
114
+ }
115
+
116
+ if ((this.featureFlagsPoller?.featureFlags?.length || 0) > 0) {
117
+ // Otherwise we may as well check for the flags locally and include them if there
118
+ const groupsWithStringValues: Record<string, string> = {}
119
+ for (const [key, value] of Object.entries(groups || {})) {
120
+ groupsWithStringValues[key] = String(value)
121
+ }
122
+
123
+ return await this.getAllFlags(distinctId, {
124
+ groups: groupsWithStringValues,
125
+ disableGeoip,
126
+ onlyEvaluateLocally: true,
127
+ })
128
+ }
129
+ return {}
130
+ })
131
+ .then((flags) => {
132
+ // Derive the relevant flag properties to add
133
+ const additionalProperties: Record<string, any> = {}
111
134
  if (flags) {
112
135
  for (const [feature, variant] of Object.entries(flags)) {
113
- if (variant !== false) {
114
- featureVariantProperties[`$feature/${feature}`] = variant
115
- }
136
+ additionalProperties[`$feature/${feature}`] = variant
116
137
  }
117
138
  }
118
139
  const activeFlags = Object.keys(flags || {}).filter((flag) => flags?.[flag] !== false)
119
- const flagProperties = {
120
- $active_feature_flags: activeFlags || undefined,
121
- ...featureVariantProperties,
140
+ if (activeFlags.length > 0) {
141
+ additionalProperties['$active_feature_flags'] = activeFlags
122
142
  }
123
- _capture({ ...properties, $groups: groups, ...flagProperties })
143
+
144
+ return additionalProperties
124
145
  })
125
- } else {
126
- _capture({ ...properties, $groups: groups })
127
- }
146
+ .catch(() => {
147
+ // Something went wrong getting the flag info - we should capture the event anyways
148
+ return {}
149
+ })
150
+ .then((additionalProperties) => {
151
+ // No matter what - capture the event
152
+ _capture({ ...additionalProperties, ...properties, $groups: groups })
153
+ })
154
+
155
+ this.addPendingPromise(capturePromise)
128
156
  }
129
157
 
130
158
  identify({ distinctId, properties, disableGeoip }: IdentifyMessageV1): void {
@@ -156,8 +184,18 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
156
184
  disableGeoip?: boolean
157
185
  }
158
186
  ): Promise<string | boolean | undefined> {
159
- const { groups, personProperties, groupProperties, disableGeoip } = options || {}
160
- let { onlyEvaluateLocally, sendFeatureFlagEvents } = options || {}
187
+ const { groups, disableGeoip } = options || {}
188
+ let { onlyEvaluateLocally, sendFeatureFlagEvents, personProperties, groupProperties } = options || {}
189
+
190
+ const adjustedProperties = this.addLocalPersonAndGroupProperties(
191
+ distinctId,
192
+ groups,
193
+ personProperties,
194
+ groupProperties
195
+ )
196
+
197
+ personProperties = adjustedProperties.allPersonProperties
198
+ groupProperties = adjustedProperties.allGroupProperties
161
199
 
162
200
  // set defaults
163
201
  if (onlyEvaluateLocally == undefined) {
@@ -232,8 +270,19 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
232
270
  disableGeoip?: boolean
233
271
  }
234
272
  ): Promise<JsonType | undefined> {
235
- const { groups, personProperties, groupProperties, disableGeoip } = options || {}
236
- let { onlyEvaluateLocally, sendFeatureFlagEvents } = options || {}
273
+ const { groups, disableGeoip } = options || {}
274
+ let { onlyEvaluateLocally, sendFeatureFlagEvents, personProperties, groupProperties } = options || {}
275
+
276
+ const adjustedProperties = this.addLocalPersonAndGroupProperties(
277
+ distinctId,
278
+ groups,
279
+ personProperties,
280
+ groupProperties
281
+ )
282
+
283
+ personProperties = adjustedProperties.allPersonProperties
284
+ groupProperties = adjustedProperties.allGroupProperties
285
+
237
286
  let response = undefined
238
287
 
239
288
  // Try to get match value locally if not provided
@@ -324,8 +373,18 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
324
373
  disableGeoip?: boolean
325
374
  }
326
375
  ): Promise<PosthogFlagsAndPayloadsResponse> {
327
- const { groups, personProperties, groupProperties, disableGeoip } = options || {}
328
- let { onlyEvaluateLocally } = options || {}
376
+ const { groups, disableGeoip } = options || {}
377
+ let { onlyEvaluateLocally, personProperties, groupProperties } = options || {}
378
+
379
+ const adjustedProperties = this.addLocalPersonAndGroupProperties(
380
+ distinctId,
381
+ groups,
382
+ personProperties,
383
+ groupProperties
384
+ )
385
+
386
+ personProperties = adjustedProperties.allPersonProperties
387
+ groupProperties = adjustedProperties.allGroupProperties
329
388
 
330
389
  // set defaults
331
390
  if (onlyEvaluateLocally == undefined) {
@@ -385,4 +444,25 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
385
444
  this.featureFlagsPoller?.stopPoller()
386
445
  return super.shutdownAsync()
387
446
  }
447
+
448
+ private addLocalPersonAndGroupProperties(
449
+ distinctId: string,
450
+ groups?: Record<string, string>,
451
+ personProperties?: Record<string, string>,
452
+ groupProperties?: Record<string, Record<string, string>>
453
+ ): { allPersonProperties: Record<string, string>; allGroupProperties: Record<string, Record<string, string>> } {
454
+ const allPersonProperties = { $current_distinct_id: distinctId, ...(personProperties || {}) }
455
+
456
+ const allGroupProperties: Record<string, Record<string, string>> = {}
457
+ if (groups) {
458
+ for (const groupName of Object.keys(groups)) {
459
+ allGroupProperties[groupName] = {
460
+ $group_key: groups[groupName],
461
+ ...(groupProperties?.[groupName] || {}),
462
+ }
463
+ }
464
+ }
465
+
466
+ return { allPersonProperties, allGroupProperties }
467
+ }
388
468
  }
@@ -3,6 +3,7 @@ import { PostHog as PostHog } from '../../src/posthog-node'
3
3
  import { PostHogSentryIntegration } from '../../src/extensions/sentry-integration'
4
4
  jest.mock('../../src/fetch')
5
5
  import fetch from '../../src/fetch'
6
+ import { waitForPromises } from 'posthog-core/test/test-utils/test-utils'
6
7
 
7
8
  jest.mock('../../package.json', () => ({ version: '1.2.3' }))
8
9
 
@@ -108,6 +109,7 @@ describe('PostHogSentryIntegration', () => {
108
109
 
109
110
  processorFunction(createMockSentryException())
110
111
 
112
+ await waitForPromises()
111
113
  jest.runOnlyPendingTimers()
112
114
  const batchEvents = getLastBatchEvents()
113
115