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.
- package/CHANGELOG.md +10 -0
- package/lib/index.cjs.js +286 -85
- package/lib/index.cjs.js.map +1 -1
- package/lib/index.d.ts +3 -1
- package/lib/index.esm.js +286 -85
- package/lib/index.esm.js.map +1 -1
- package/lib/posthog-core/src/index.d.ts +2 -1
- package/lib/posthog-node/src/feature-flags.d.ts +2 -1
- package/lib/posthog-node/src/posthog-node.d.ts +1 -0
- package/package.json +1 -1
- package/src/feature-flags.ts +116 -19
- package/src/posthog-node.ts +99 -19
- package/test/extensions/sentry-integration.spec.ts +2 -0
- package/test/feature-flags.spec.ts +273 -5
- package/test/posthog-node.spec.ts +407 -10
|
@@ -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
|
-
|
|
58
|
+
declare function relativeDateParseForFeatureFlagMatching(value: string): Date | null;
|
|
59
|
+
export { FeatureFlagsPoller, matchProperty, relativeDateParseForFeatureFlagMatching, InconclusiveMatchError, ClientError, };
|
package/package.json
CHANGED
package/src/feature-flags.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
494
|
+
return computeExactMatch(value, overrideValue)
|
|
470
495
|
case 'is_not':
|
|
471
|
-
return
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/posthog-node.ts
CHANGED
|
@@ -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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
$active_feature_flags
|
|
121
|
-
...featureVariantProperties,
|
|
140
|
+
if (activeFlags.length > 0) {
|
|
141
|
+
additionalProperties['$active_feature_flags'] = activeFlags
|
|
122
142
|
}
|
|
123
|
-
|
|
143
|
+
|
|
144
|
+
return additionalProperties
|
|
124
145
|
})
|
|
125
|
-
|
|
126
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|