posthog-node 5.32.1 → 5.33.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/dist/client.d.ts +101 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +202 -46
- package/dist/client.mjs +199 -46
- package/dist/exports.d.ts +1 -0
- package/dist/exports.d.ts.map +1 -1
- package/dist/exports.js +13 -3
- package/dist/exports.mjs +2 -1
- package/dist/feature-flag-evaluations.d.ts +151 -0
- package/dist/feature-flag-evaluations.d.ts.map +1 -0
- package/dist/feature-flag-evaluations.js +151 -0
- package/dist/feature-flag-evaluations.mjs +117 -0
- package/dist/types.d.ts +64 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.mjs +1 -1
- package/package.json +1 -1
- package/src/client.ts +440 -75
- package/src/exports.ts +2 -0
- package/src/feature-flag-evaluations.ts +321 -0
- package/src/types.ts +65 -4
- package/src/version.ts +1 -1
package/src/client.ts
CHANGED
|
@@ -28,6 +28,12 @@ import {
|
|
|
28
28
|
FlagEvaluationOptions,
|
|
29
29
|
AllFlagsOptions,
|
|
30
30
|
} from './types'
|
|
31
|
+
import {
|
|
32
|
+
EvaluatedFlagRecord,
|
|
33
|
+
FeatureFlagEvaluations,
|
|
34
|
+
FeatureFlagEvaluationsHost,
|
|
35
|
+
FlagCalledEventParams,
|
|
36
|
+
} from './feature-flag-evaluations'
|
|
31
37
|
import {
|
|
32
38
|
FeatureFlagsPoller,
|
|
33
39
|
type FeatureFlagEvaluationContext,
|
|
@@ -50,6 +56,26 @@ const WAITUNTIL_DEBOUNCE_MS = 50
|
|
|
50
56
|
const WAITUNTIL_MAX_WAIT_MS = 500
|
|
51
57
|
const DEFAULT_NODE_HOST = 'https://us.i.posthog.com'
|
|
52
58
|
|
|
59
|
+
// Process-wide dedup for deprecation warnings — without this, calling a deprecated
|
|
60
|
+
// method in a loop would spam logs. Matches Python's `warnings.warn` default-dedup behavior.
|
|
61
|
+
const _emittedDeprecations = new Set<string>()
|
|
62
|
+
|
|
63
|
+
function emitDeprecationWarningOnce(id: string, message: string): void {
|
|
64
|
+
if (_emittedDeprecations.has(id)) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
_emittedDeprecations.add(id)
|
|
68
|
+
// eslint-disable-next-line no-console
|
|
69
|
+
console.warn(`[PostHog] ${message}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @internal — clears the process-wide deprecation dedup set. Test-only.
|
|
74
|
+
*/
|
|
75
|
+
export function _resetDeprecationWarningsForTests(): void {
|
|
76
|
+
_emittedDeprecations.clear()
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
function normalizeApiKey(value?: unknown): string {
|
|
54
80
|
return typeof value === 'string' ? value.trim() : ''
|
|
55
81
|
}
|
|
@@ -64,6 +90,27 @@ function normalizeHost(value?: unknown): string {
|
|
|
64
90
|
return normalizedValue || DEFAULT_NODE_HOST
|
|
65
91
|
}
|
|
66
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Derive `$feature/{key}` and `$active_feature_flags` event properties from a flat
|
|
95
|
+
* `{ key: value }` map returned by the legacy `sendFeatureFlags` path.
|
|
96
|
+
*/
|
|
97
|
+
function buildFlagEventProperties(flagValues: Record<string, FeatureFlagValue> | undefined): Record<string, any> {
|
|
98
|
+
if (!flagValues) {
|
|
99
|
+
return {}
|
|
100
|
+
}
|
|
101
|
+
const additionalProperties: Record<string, any> = {}
|
|
102
|
+
for (const [feature, variant] of Object.entries(flagValues)) {
|
|
103
|
+
additionalProperties[`$feature/${feature}`] = variant
|
|
104
|
+
}
|
|
105
|
+
const activeFlags = Object.keys(flagValues)
|
|
106
|
+
.filter((flag) => flagValues[flag] !== false)
|
|
107
|
+
.sort()
|
|
108
|
+
if (activeFlags.length > 0) {
|
|
109
|
+
additionalProperties['$active_feature_flags'] = activeFlags
|
|
110
|
+
}
|
|
111
|
+
return additionalProperties
|
|
112
|
+
}
|
|
113
|
+
|
|
67
114
|
// The actual exported Nodejs API.
|
|
68
115
|
export abstract class PostHogBackendClient extends PostHogCoreStateless implements IPostHog {
|
|
69
116
|
private _memoryStorage = new PostHogMemoryStorage()
|
|
@@ -921,56 +968,38 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
921
968
|
|
|
922
969
|
// Send feature flag event if configured
|
|
923
970
|
if (sendFeatureFlagEvents) {
|
|
924
|
-
// Compute the response value for event tracking
|
|
925
971
|
const response = result === undefined ? undefined : result.enabled === false ? false : (result.variant ?? true)
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
} else {
|
|
938
|
-
this.distinctIdHasSentFlagCalls[distinctId] = [featureFlagReportedKey]
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
const properties: Record<string, any> = {
|
|
942
|
-
$feature_flag: key,
|
|
943
|
-
$feature_flag_response: response,
|
|
944
|
-
$feature_flag_id: flagId,
|
|
945
|
-
$feature_flag_version: flagVersion,
|
|
946
|
-
$feature_flag_reason: flagReason,
|
|
947
|
-
locally_evaluated: flagWasLocallyEvaluated,
|
|
948
|
-
[`$feature/${key}`]: response,
|
|
949
|
-
$feature_flag_request_id: requestId,
|
|
950
|
-
$feature_flag_evaluated_at: flagWasLocallyEvaluated ? Date.now() : evaluatedAt,
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// Add local evaluation definition load timestamp
|
|
954
|
-
if (flagWasLocallyEvaluated && this.featureFlagsPoller) {
|
|
955
|
-
const flagDefinitionsLoadedAt = this.featureFlagsPoller.getFlagDefinitionsLoadedAt()
|
|
956
|
-
|
|
957
|
-
if (flagDefinitionsLoadedAt !== undefined) {
|
|
958
|
-
properties.$feature_flag_definitions_loaded_at = flagDefinitionsLoadedAt
|
|
959
|
-
}
|
|
960
|
-
}
|
|
972
|
+
const properties: Record<string, any> = {
|
|
973
|
+
$feature_flag: key,
|
|
974
|
+
$feature_flag_response: response,
|
|
975
|
+
$feature_flag_id: flagId,
|
|
976
|
+
$feature_flag_version: flagVersion,
|
|
977
|
+
$feature_flag_reason: flagReason,
|
|
978
|
+
locally_evaluated: flagWasLocallyEvaluated,
|
|
979
|
+
[`$feature/${key}`]: response,
|
|
980
|
+
$feature_flag_request_id: requestId,
|
|
981
|
+
$feature_flag_evaluated_at: flagWasLocallyEvaluated ? Date.now() : evaluatedAt,
|
|
982
|
+
}
|
|
961
983
|
|
|
962
|
-
|
|
963
|
-
|
|
984
|
+
if (flagWasLocallyEvaluated && this.featureFlagsPoller) {
|
|
985
|
+
const flagDefinitionsLoadedAt = this.featureFlagsPoller.getFlagDefinitionsLoadedAt()
|
|
986
|
+
if (flagDefinitionsLoadedAt !== undefined) {
|
|
987
|
+
properties.$feature_flag_definitions_loaded_at = flagDefinitionsLoadedAt
|
|
964
988
|
}
|
|
989
|
+
}
|
|
965
990
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
event: '$feature_flag_called',
|
|
969
|
-
properties,
|
|
970
|
-
groups,
|
|
971
|
-
disableGeoip,
|
|
972
|
-
})
|
|
991
|
+
if (featureFlagError) {
|
|
992
|
+
properties.$feature_flag_error = featureFlagError
|
|
973
993
|
}
|
|
994
|
+
|
|
995
|
+
this._captureFlagCalledEventIfNeeded({
|
|
996
|
+
distinctId,
|
|
997
|
+
key,
|
|
998
|
+
response,
|
|
999
|
+
groups,
|
|
1000
|
+
disableGeoip,
|
|
1001
|
+
properties,
|
|
1002
|
+
})
|
|
974
1003
|
}
|
|
975
1004
|
|
|
976
1005
|
// Apply payload override if present (even when there's no flag override)
|
|
@@ -1021,6 +1050,11 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
1021
1050
|
*
|
|
1022
1051
|
* {@label Feature flags}
|
|
1023
1052
|
*
|
|
1053
|
+
* @deprecated Use {@link evaluateFlags} and call `flags.getFlag(key)` on the returned snapshot.
|
|
1054
|
+
* This consolidates flag evaluation into a single `/flags` request per incoming request and
|
|
1055
|
+
* avoids drift between the values your code branched on and the values attached to events.
|
|
1056
|
+
* Will be removed in the next major version.
|
|
1057
|
+
*
|
|
1024
1058
|
* @param key - The feature flag key
|
|
1025
1059
|
* @param distinctId - The user's distinct ID
|
|
1026
1060
|
* @param options - Optional configuration for flag evaluation
|
|
@@ -1038,6 +1072,12 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
1038
1072
|
disableGeoip?: boolean
|
|
1039
1073
|
}
|
|
1040
1074
|
): Promise<FeatureFlagValue | undefined> {
|
|
1075
|
+
emitDeprecationWarningOnce(
|
|
1076
|
+
'getFeatureFlag',
|
|
1077
|
+
'`getFeatureFlag` is deprecated and will be removed in a future major version. ' +
|
|
1078
|
+
'Use `posthog.evaluateFlags(distinctId, ...)` and call `flags.getFlag(key)` instead — ' +
|
|
1079
|
+
'this consolidates flag evaluation into a single `/flags` request per incoming request.'
|
|
1080
|
+
)
|
|
1041
1081
|
const result = await this._getFeatureFlagResult(key, distinctId, {
|
|
1042
1082
|
...options,
|
|
1043
1083
|
sendFeatureFlagEvents: options?.sendFeatureFlagEvents ?? this.options.sendFeatureFlagEvent ?? true,
|
|
@@ -1080,6 +1120,10 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
1080
1120
|
*
|
|
1081
1121
|
* {@label Feature flags}
|
|
1082
1122
|
*
|
|
1123
|
+
* @deprecated Use {@link evaluateFlags} and call `flags.getFlagPayload(key)` on the returned
|
|
1124
|
+
* snapshot. This consolidates flag evaluation into a single `/flags` request per incoming
|
|
1125
|
+
* request. Will be removed in the next major version.
|
|
1126
|
+
*
|
|
1083
1127
|
* @param key - The feature flag key
|
|
1084
1128
|
* @param distinctId - The user's distinct ID
|
|
1085
1129
|
* @param matchValue - Optional match value to get payload for
|
|
@@ -1100,6 +1144,12 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
1100
1144
|
disableGeoip?: boolean
|
|
1101
1145
|
}
|
|
1102
1146
|
): Promise<JsonType | undefined> {
|
|
1147
|
+
emitDeprecationWarningOnce(
|
|
1148
|
+
'getFeatureFlagPayload',
|
|
1149
|
+
'`getFeatureFlagPayload` is deprecated and will be removed in a future major version. ' +
|
|
1150
|
+
'Use `posthog.evaluateFlags(distinctId, ...)` and call `flags.getFlagPayload(key)` instead — ' +
|
|
1151
|
+
'this consolidates flag evaluation into a single `/flags` request per incoming request.'
|
|
1152
|
+
)
|
|
1103
1153
|
// Check for payload overrides first - they take precedence over all evaluation
|
|
1104
1154
|
// This is checked independently from flag overrides
|
|
1105
1155
|
if (this._payloadOverrides !== undefined && key in this._payloadOverrides) {
|
|
@@ -1254,6 +1304,10 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
1254
1304
|
*
|
|
1255
1305
|
* {@label Feature flags}
|
|
1256
1306
|
*
|
|
1307
|
+
* @deprecated Use {@link evaluateFlags} and call `flags.isEnabled(key)` on the returned snapshot.
|
|
1308
|
+
* This consolidates flag evaluation into a single `/flags` request per incoming request.
|
|
1309
|
+
* Will be removed in the next major version.
|
|
1310
|
+
*
|
|
1257
1311
|
* @param key - The feature flag key
|
|
1258
1312
|
* @param distinctId - The user's distinct ID
|
|
1259
1313
|
* @param options - Optional configuration for flag evaluation
|
|
@@ -1271,10 +1325,24 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
1271
1325
|
disableGeoip?: boolean
|
|
1272
1326
|
}
|
|
1273
1327
|
): Promise<boolean | undefined> {
|
|
1274
|
-
|
|
1275
|
-
|
|
1328
|
+
emitDeprecationWarningOnce(
|
|
1329
|
+
'isFeatureEnabled',
|
|
1330
|
+
'`isFeatureEnabled` is deprecated and will be removed in a future major version. ' +
|
|
1331
|
+
'Use `posthog.evaluateFlags(distinctId, ...)` and call `flags.isEnabled(key)` instead — ' +
|
|
1332
|
+
'this consolidates flag evaluation into a single `/flags` request per incoming request.'
|
|
1333
|
+
)
|
|
1334
|
+
// Bypass the public `getFeatureFlag` so the user only sees one deprecation warning per call.
|
|
1335
|
+
const result = await this._getFeatureFlagResult(key, distinctId, {
|
|
1336
|
+
...options,
|
|
1337
|
+
sendFeatureFlagEvents: options?.sendFeatureFlagEvents ?? this.options.sendFeatureFlagEvent ?? true,
|
|
1338
|
+
})
|
|
1339
|
+
if (result === undefined) {
|
|
1276
1340
|
return undefined
|
|
1277
1341
|
}
|
|
1342
|
+
if (result.enabled === false) {
|
|
1343
|
+
return false
|
|
1344
|
+
}
|
|
1345
|
+
const feat: FeatureFlagValue = result.variant ?? true
|
|
1278
1346
|
return !!feat || false
|
|
1279
1347
|
}
|
|
1280
1348
|
|
|
@@ -1454,6 +1522,286 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
1454
1522
|
return { featureFlags, featureFlagPayloads }
|
|
1455
1523
|
}
|
|
1456
1524
|
|
|
1525
|
+
/**
|
|
1526
|
+
* Evaluate all feature flags for a user in a single call and return a
|
|
1527
|
+
* {@link FeatureFlagEvaluations} snapshot. Branch on `.isEnabled()` / `.getFlag()`,
|
|
1528
|
+
* then pass the same snapshot to `capture()` via the `flags` option so the
|
|
1529
|
+
* captured event carries the exact flag values the code branched on.
|
|
1530
|
+
*
|
|
1531
|
+
* Prefer this over repeated `isFeatureEnabled()` / `getFeatureFlag()` calls and
|
|
1532
|
+
* over `capture({ sendFeatureFlags: true })` — it consolidates flag evaluation
|
|
1533
|
+
* into a single `/flags` request per incoming request.
|
|
1534
|
+
*
|
|
1535
|
+
* **Local evaluation is transparent.** When the poller can resolve a flag from
|
|
1536
|
+
* cached definitions, no network call is made and the snapshot's `$feature_flag_called`
|
|
1537
|
+
* events are tagged `locally_evaluated: true`.
|
|
1538
|
+
*
|
|
1539
|
+
* **Trim the request.** Pass `flagKeys` to scope the underlying `/flags` request
|
|
1540
|
+
* to a subset of flags — useful when you only need a few flags and want to reduce
|
|
1541
|
+
* the response payload.
|
|
1542
|
+
*
|
|
1543
|
+
* **Trim the event payload.** Use `flags.only([...])` or `flags.onlyAccessed()`
|
|
1544
|
+
* to filter which flags get attached to a captured event without re-fetching.
|
|
1545
|
+
*
|
|
1546
|
+
* @example
|
|
1547
|
+
* Basic usage:
|
|
1548
|
+
* ```ts
|
|
1549
|
+
* const flags = await client.evaluateFlags('user_123', {
|
|
1550
|
+
* personProperties: { plan: 'enterprise' },
|
|
1551
|
+
* })
|
|
1552
|
+
* if (flags.isEnabled('new-dashboard')) {
|
|
1553
|
+
* renderNewDashboard()
|
|
1554
|
+
* }
|
|
1555
|
+
* client.capture({ distinctId: 'user_123', event: 'page_viewed', flags })
|
|
1556
|
+
* ```
|
|
1557
|
+
*
|
|
1558
|
+
* @example
|
|
1559
|
+
* Scope the `/flags` request to specific keys:
|
|
1560
|
+
* ```ts
|
|
1561
|
+
* const flags = await client.evaluateFlags('user_123', {
|
|
1562
|
+
* flagKeys: ['new-dashboard', 'checkout-flow'],
|
|
1563
|
+
* personProperties: { plan: 'enterprise' },
|
|
1564
|
+
* })
|
|
1565
|
+
* ```
|
|
1566
|
+
*
|
|
1567
|
+
* @example
|
|
1568
|
+
* Attach only the flags the developer actually checked:
|
|
1569
|
+
* ```ts
|
|
1570
|
+
* const flags = await client.evaluateFlags('user_123')
|
|
1571
|
+
* if (flags.isEnabled('new-dashboard')) { ... }
|
|
1572
|
+
* client.capture({ distinctId: 'user_123', event: 'page_viewed', flags: flags.onlyAccessed() })
|
|
1573
|
+
* ```
|
|
1574
|
+
*
|
|
1575
|
+
* @example
|
|
1576
|
+
* Use `withContext()` to avoid repeating the distinctId:
|
|
1577
|
+
* ```ts
|
|
1578
|
+
* await client.withContext({ distinctId: 'user_123' }, async () => {
|
|
1579
|
+
* const flags = await client.evaluateFlags()
|
|
1580
|
+
* if (flags.isEnabled('new-dashboard')) { ... }
|
|
1581
|
+
* client.capture({ event: 'page_viewed', flags })
|
|
1582
|
+
* })
|
|
1583
|
+
* ```
|
|
1584
|
+
*
|
|
1585
|
+
* {@label Feature flags}
|
|
1586
|
+
*
|
|
1587
|
+
* @param distinctIdOrOptions - The user's distinct ID, or options when the distinctId comes from `withContext()`
|
|
1588
|
+
* @param options - Optional configuration for flag evaluation. Supports the same fields as `getAllFlags()`, including `flagKeys` to scope the `/flags` request.
|
|
1589
|
+
* @returns Promise that resolves to a `FeatureFlagEvaluations` snapshot
|
|
1590
|
+
*/
|
|
1591
|
+
async evaluateFlags(options?: AllFlagsOptions): Promise<FeatureFlagEvaluations>
|
|
1592
|
+
async evaluateFlags(distinctId: string, options?: AllFlagsOptions): Promise<FeatureFlagEvaluations>
|
|
1593
|
+
async evaluateFlags(
|
|
1594
|
+
distinctIdOrOptions?: string | AllFlagsOptions,
|
|
1595
|
+
options?: AllFlagsOptions
|
|
1596
|
+
): Promise<FeatureFlagEvaluations> {
|
|
1597
|
+
const { distinctId: resolvedDistinctId, options: resolvedOptions } = this._resolveDistinctId(
|
|
1598
|
+
distinctIdOrOptions,
|
|
1599
|
+
options
|
|
1600
|
+
)
|
|
1601
|
+
|
|
1602
|
+
if (!resolvedDistinctId) {
|
|
1603
|
+
this._logger.warn(
|
|
1604
|
+
'[PostHog] distinctId is required to evaluate feature flags — pass it explicitly or use withContext()'
|
|
1605
|
+
)
|
|
1606
|
+
return new FeatureFlagEvaluations({
|
|
1607
|
+
host: this._getFeatureFlagEvaluationsHost(),
|
|
1608
|
+
distinctId: '',
|
|
1609
|
+
flags: {},
|
|
1610
|
+
})
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const { groups, disableGeoip, flagKeys } = resolvedOptions || {}
|
|
1614
|
+
let { onlyEvaluateLocally, personProperties, groupProperties } = resolvedOptions || {}
|
|
1615
|
+
|
|
1616
|
+
const adjustedProperties = this.addLocalPersonAndGroupProperties(
|
|
1617
|
+
resolvedDistinctId,
|
|
1618
|
+
groups,
|
|
1619
|
+
personProperties,
|
|
1620
|
+
groupProperties
|
|
1621
|
+
)
|
|
1622
|
+
personProperties = adjustedProperties.allPersonProperties
|
|
1623
|
+
groupProperties = adjustedProperties.allGroupProperties
|
|
1624
|
+
const evaluationContext = this.createFeatureFlagEvaluationContext(
|
|
1625
|
+
resolvedDistinctId,
|
|
1626
|
+
groups,
|
|
1627
|
+
personProperties,
|
|
1628
|
+
groupProperties
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
if (onlyEvaluateLocally == undefined) {
|
|
1632
|
+
onlyEvaluateLocally = this.options.strictLocalEvaluation ?? false
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const records: Record<string, EvaluatedFlagRecord> = {}
|
|
1636
|
+
let requestId: string | undefined = undefined
|
|
1637
|
+
let evaluatedAt: number | undefined = undefined
|
|
1638
|
+
let errorsWhileComputing = false
|
|
1639
|
+
let quotaLimited = false
|
|
1640
|
+
|
|
1641
|
+
// Try local evaluation first and decorate each flag with metadata from the poller.
|
|
1642
|
+
// `flagKeys` scopes the evaluation to a subset of definitions when provided.
|
|
1643
|
+
const localResult = await this.featureFlagsPoller?.getAllFlagsAndPayloads(evaluationContext, flagKeys)
|
|
1644
|
+
const locallyEvaluatedKeys = new Set<string>()
|
|
1645
|
+
if (localResult) {
|
|
1646
|
+
for (const [key, value] of Object.entries(localResult.response)) {
|
|
1647
|
+
const flagDef = this.featureFlagsPoller?.featureFlagsByKey[key]
|
|
1648
|
+
records[key] = {
|
|
1649
|
+
key,
|
|
1650
|
+
enabled: value !== false,
|
|
1651
|
+
variant: typeof value === 'string' ? value : undefined,
|
|
1652
|
+
payload: localResult.payloads[key],
|
|
1653
|
+
id: flagDef?.id,
|
|
1654
|
+
// The local-evaluation flag definition (`PostHogFeatureFlag`) does not carry a
|
|
1655
|
+
// version field; only the remote `/flags` response does via `metadata.version`.
|
|
1656
|
+
version: undefined,
|
|
1657
|
+
reason: 'Evaluated locally',
|
|
1658
|
+
locallyEvaluated: true,
|
|
1659
|
+
}
|
|
1660
|
+
locallyEvaluatedKeys.add(key)
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// Fall back to remote evaluation for any flags the poller couldn't resolve locally.
|
|
1665
|
+
// We use the detail-shaped endpoint so the resulting records carry id/version/reason
|
|
1666
|
+
// and fired $feature_flag_called events match what isFeatureEnabled()/getFeatureFlag() emit.
|
|
1667
|
+
const fallbackToFlags = localResult ? localResult.fallbackToFlags : true
|
|
1668
|
+
if (fallbackToFlags && !onlyEvaluateLocally) {
|
|
1669
|
+
const details = await super.getFeatureFlagDetailsStateless(
|
|
1670
|
+
evaluationContext.distinctId,
|
|
1671
|
+
evaluationContext.groups,
|
|
1672
|
+
evaluationContext.personProperties,
|
|
1673
|
+
evaluationContext.groupProperties,
|
|
1674
|
+
disableGeoip,
|
|
1675
|
+
flagKeys
|
|
1676
|
+
)
|
|
1677
|
+
if (details) {
|
|
1678
|
+
requestId = details.requestId
|
|
1679
|
+
evaluatedAt = details.evaluatedAt
|
|
1680
|
+
errorsWhileComputing = Boolean((details as any).errorsWhileComputingFlags)
|
|
1681
|
+
quotaLimited = Array.isArray(details.quotaLimited) && details.quotaLimited.includes('feature_flags')
|
|
1682
|
+
for (const [key, detail] of Object.entries(details.flags)) {
|
|
1683
|
+
if (locallyEvaluatedKeys.has(key)) {
|
|
1684
|
+
continue
|
|
1685
|
+
}
|
|
1686
|
+
let parsedPayload: JsonType | undefined = undefined
|
|
1687
|
+
if (detail.metadata?.payload !== undefined) {
|
|
1688
|
+
try {
|
|
1689
|
+
parsedPayload = JSON.parse(detail.metadata.payload)
|
|
1690
|
+
} catch {
|
|
1691
|
+
parsedPayload = detail.metadata.payload
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
records[key] = {
|
|
1695
|
+
key,
|
|
1696
|
+
enabled: detail.enabled,
|
|
1697
|
+
variant: detail.variant,
|
|
1698
|
+
payload: parsedPayload,
|
|
1699
|
+
id: detail.metadata?.id,
|
|
1700
|
+
version: detail.metadata?.version,
|
|
1701
|
+
reason: detail.reason?.description ?? detail.reason?.code,
|
|
1702
|
+
locallyEvaluated: false,
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Apply overrides last so they take precedence over evaluation.
|
|
1709
|
+
if (this._flagOverrides !== undefined) {
|
|
1710
|
+
for (const [key, value] of Object.entries(this._flagOverrides)) {
|
|
1711
|
+
if (value === undefined) {
|
|
1712
|
+
delete records[key]
|
|
1713
|
+
continue
|
|
1714
|
+
}
|
|
1715
|
+
const existing = records[key]
|
|
1716
|
+
records[key] = {
|
|
1717
|
+
key,
|
|
1718
|
+
enabled: value !== false,
|
|
1719
|
+
variant: typeof value === 'string' ? value : undefined,
|
|
1720
|
+
payload: existing?.payload,
|
|
1721
|
+
id: existing?.id,
|
|
1722
|
+
version: existing?.version,
|
|
1723
|
+
reason: existing?.reason,
|
|
1724
|
+
locallyEvaluated: existing?.locallyEvaluated ?? false,
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
if (this._payloadOverrides !== undefined) {
|
|
1729
|
+
for (const [key, payload] of Object.entries(this._payloadOverrides)) {
|
|
1730
|
+
const existing = records[key]
|
|
1731
|
+
if (existing) {
|
|
1732
|
+
records[key] = { ...existing, payload }
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
return new FeatureFlagEvaluations({
|
|
1738
|
+
host: this._getFeatureFlagEvaluationsHost(),
|
|
1739
|
+
distinctId: resolvedDistinctId,
|
|
1740
|
+
groups,
|
|
1741
|
+
disableGeoip,
|
|
1742
|
+
flags: records,
|
|
1743
|
+
requestId,
|
|
1744
|
+
evaluatedAt,
|
|
1745
|
+
flagDefinitionsLoadedAt: this.featureFlagsPoller?.getFlagDefinitionsLoadedAt(),
|
|
1746
|
+
errorsWhileComputing,
|
|
1747
|
+
quotaLimited,
|
|
1748
|
+
})
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* Fires a `$feature_flag_called` event for the given flag if the (distinctId, flag, response)
|
|
1753
|
+
* triple hasn't already been reported for this client. Shared by the single-flag evaluation
|
|
1754
|
+
* path and `FeatureFlagEvaluations.isEnabled() / getFlag()` so both paths dedupe identically.
|
|
1755
|
+
*
|
|
1756
|
+
* @internal
|
|
1757
|
+
*/
|
|
1758
|
+
protected _captureFlagCalledEventIfNeeded(params: FlagCalledEventParams): void {
|
|
1759
|
+
const { distinctId, key, response, groups, disableGeoip, properties } = params
|
|
1760
|
+
const featureFlagReportedKey = `${key}_${response}`
|
|
1761
|
+
|
|
1762
|
+
if (
|
|
1763
|
+
distinctId in this.distinctIdHasSentFlagCalls &&
|
|
1764
|
+
this.distinctIdHasSentFlagCalls[distinctId].includes(featureFlagReportedKey)
|
|
1765
|
+
) {
|
|
1766
|
+
return
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
if (Object.keys(this.distinctIdHasSentFlagCalls).length >= this.maxCacheSize) {
|
|
1770
|
+
this.distinctIdHasSentFlagCalls = {}
|
|
1771
|
+
}
|
|
1772
|
+
if (Array.isArray(this.distinctIdHasSentFlagCalls[distinctId])) {
|
|
1773
|
+
this.distinctIdHasSentFlagCalls[distinctId].push(featureFlagReportedKey)
|
|
1774
|
+
} else {
|
|
1775
|
+
this.distinctIdHasSentFlagCalls[distinctId] = [featureFlagReportedKey]
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
this.capture({
|
|
1779
|
+
distinctId,
|
|
1780
|
+
event: '$feature_flag_called',
|
|
1781
|
+
properties,
|
|
1782
|
+
groups,
|
|
1783
|
+
disableGeoip,
|
|
1784
|
+
})
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
private _featureFlagEvaluationsHost?: FeatureFlagEvaluationsHost
|
|
1788
|
+
|
|
1789
|
+
private _getFeatureFlagEvaluationsHost(): FeatureFlagEvaluationsHost {
|
|
1790
|
+
if (!this._featureFlagEvaluationsHost) {
|
|
1791
|
+
this._featureFlagEvaluationsHost = {
|
|
1792
|
+
captureFlagCalledEventIfNeeded: (params) => this._captureFlagCalledEventIfNeeded(params),
|
|
1793
|
+
logWarning: (message) => {
|
|
1794
|
+
if (this.options.featureFlagsLogWarnings !== false) {
|
|
1795
|
+
// These warnings guide API usage (misuse of `onlyAccessed()` / `only()`) and
|
|
1796
|
+
// should always surface — unlike `this._logger.warn` which is gated on debug mode.
|
|
1797
|
+
console.warn(`[PostHog] ${message}`)
|
|
1798
|
+
}
|
|
1799
|
+
},
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
return this._featureFlagEvaluationsHost
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1457
1805
|
/**
|
|
1458
1806
|
* Create or update a group and its properties.
|
|
1459
1807
|
*
|
|
@@ -1933,18 +2281,21 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
1933
2281
|
* @param error - The error to capture
|
|
1934
2282
|
* @param distinctId - Optional user distinct ID
|
|
1935
2283
|
* @param additionalProperties - Optional additional properties to include
|
|
2284
|
+
* @param uuid - Optional event UUID
|
|
2285
|
+
* @param flags - Optional `FeatureFlagEvaluations` snapshot to attach the same flag context as your other events
|
|
1936
2286
|
*/
|
|
1937
2287
|
captureException(
|
|
1938
2288
|
error: unknown,
|
|
1939
2289
|
distinctId?: string,
|
|
1940
2290
|
additionalProperties?: Record<string | number, any>,
|
|
1941
|
-
uuid?: EventMessage['uuid']
|
|
2291
|
+
uuid?: EventMessage['uuid'],
|
|
2292
|
+
flags?: FeatureFlagEvaluations
|
|
1942
2293
|
): void {
|
|
1943
2294
|
if (!ErrorTracking.isPreviouslyCapturedError(error)) {
|
|
1944
2295
|
const syntheticException = new Error('PostHog syntheticException')
|
|
1945
2296
|
this.addPendingPromise(
|
|
1946
2297
|
ErrorTracking.buildEventMessage(error, { syntheticException }, distinctId, additionalProperties).then((msg) =>
|
|
1947
|
-
this.capture({ ...msg, uuid })
|
|
2298
|
+
this.capture({ ...msg, uuid, flags })
|
|
1948
2299
|
)
|
|
1949
2300
|
)
|
|
1950
2301
|
}
|
|
@@ -1983,18 +2334,20 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
1983
2334
|
* @param error - The error to capture
|
|
1984
2335
|
* @param distinctId - Optional user distinct ID
|
|
1985
2336
|
* @param additionalProperties - Optional additional properties to include
|
|
2337
|
+
* @param flags - Optional `FeatureFlagEvaluations` snapshot to attach the same flag context as your other events
|
|
1986
2338
|
* @returns Promise that resolves when the error is captured
|
|
1987
2339
|
*/
|
|
1988
2340
|
async captureExceptionImmediate(
|
|
1989
2341
|
error: unknown,
|
|
1990
2342
|
distinctId?: string,
|
|
1991
|
-
additionalProperties?: Record<string | number, any
|
|
2343
|
+
additionalProperties?: Record<string | number, any>,
|
|
2344
|
+
flags?: FeatureFlagEvaluations
|
|
1992
2345
|
): Promise<void> {
|
|
1993
2346
|
if (!ErrorTracking.isPreviouslyCapturedError(error)) {
|
|
1994
2347
|
const syntheticException = new Error('PostHog syntheticException')
|
|
1995
2348
|
return this.addPendingPromise(
|
|
1996
2349
|
ErrorTracking.buildEventMessage(error, { syntheticException }, distinctId, additionalProperties).then((msg) =>
|
|
1997
|
-
this.captureImmediate(msg)
|
|
2350
|
+
this.captureImmediate({ ...msg, flags })
|
|
1998
2351
|
)
|
|
1999
2352
|
)
|
|
2000
2353
|
}
|
|
@@ -2006,8 +2359,17 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
2006
2359
|
properties: PostHogEventProperties
|
|
2007
2360
|
options: PostHogCaptureOptions
|
|
2008
2361
|
}> {
|
|
2009
|
-
const {
|
|
2010
|
-
|
|
2362
|
+
const {
|
|
2363
|
+
distinctId,
|
|
2364
|
+
event,
|
|
2365
|
+
properties,
|
|
2366
|
+
groups,
|
|
2367
|
+
flags,
|
|
2368
|
+
sendFeatureFlags,
|
|
2369
|
+
timestamp,
|
|
2370
|
+
disableGeoip,
|
|
2371
|
+
uuid,
|
|
2372
|
+
}: EventMessage = props
|
|
2011
2373
|
|
|
2012
2374
|
const contextData = this.context?.get()
|
|
2013
2375
|
|
|
@@ -2034,6 +2396,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
2034
2396
|
event,
|
|
2035
2397
|
properties: mergedProperties,
|
|
2036
2398
|
groups,
|
|
2399
|
+
flags,
|
|
2037
2400
|
sendFeatureFlags,
|
|
2038
2401
|
timestamp,
|
|
2039
2402
|
disableGeoip,
|
|
@@ -2047,40 +2410,42 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
|
|
|
2047
2410
|
// :TRICKY: If we flush, or need to shut down, to not lose events we want this promise to resolve before we flush
|
|
2048
2411
|
const eventProperties = await Promise.resolve()
|
|
2049
2412
|
.then(async () => {
|
|
2413
|
+
// Precedence: an explicit `flags` snapshot always wins, regardless of
|
|
2414
|
+
// `sendFeatureFlags`. The snapshot guarantees the event carries the same
|
|
2415
|
+
// values the developer branched on with no additional network call. The
|
|
2416
|
+
// `sendFeatureFlags` path only runs when no snapshot is provided.
|
|
2417
|
+
if (flags) {
|
|
2418
|
+
if (sendFeatureFlags) {
|
|
2419
|
+
console.warn(
|
|
2420
|
+
'[PostHog] Both `flags` and `sendFeatureFlags` were passed to capture(); using `flags` and ignoring `sendFeatureFlags`.'
|
|
2421
|
+
)
|
|
2422
|
+
}
|
|
2423
|
+
return flags._getEventProperties()
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2050
2426
|
if (sendFeatureFlags) {
|
|
2427
|
+
emitDeprecationWarningOnce(
|
|
2428
|
+
'sendFeatureFlags',
|
|
2429
|
+
'`sendFeatureFlags` is deprecated and will be removed in a future major version. ' +
|
|
2430
|
+
'Pass a `flags` snapshot from `posthog.evaluateFlags(...)` instead — it avoids a ' +
|
|
2431
|
+
'second `/flags` request per capture and guarantees the event carries the exact ' +
|
|
2432
|
+
'flag values your code branched on.'
|
|
2433
|
+
)
|
|
2051
2434
|
// If we are sending feature flags, we evaluate them locally if the user prefers it, otherwise we fall back to remote evaluation
|
|
2052
2435
|
const sendFeatureFlagsOptions = typeof sendFeatureFlags === 'object' ? sendFeatureFlags : undefined
|
|
2053
|
-
|
|
2436
|
+
const flagValues = await this.getFeatureFlagsForEvent(
|
|
2054
2437
|
eventMessage.distinctId!,
|
|
2055
2438
|
groups,
|
|
2056
2439
|
disableGeoip,
|
|
2057
2440
|
sendFeatureFlagsOptions
|
|
2058
2441
|
)
|
|
2442
|
+
return buildFlagEventProperties(flagValues)
|
|
2059
2443
|
}
|
|
2060
2444
|
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
return {}
|
|
2064
|
-
}
|
|
2445
|
+
// $feature_flag_called events are not enriched with cached flags — the flags
|
|
2446
|
+
// on that event should reflect the specific call, not a potentially stale snapshot.
|
|
2065
2447
|
return {}
|
|
2066
2448
|
})
|
|
2067
|
-
.then((flags) => {
|
|
2068
|
-
// Derive the relevant flag properties to add
|
|
2069
|
-
const additionalProperties: Record<string, any> = {}
|
|
2070
|
-
if (flags) {
|
|
2071
|
-
for (const [feature, variant] of Object.entries(flags)) {
|
|
2072
|
-
additionalProperties[`$feature/${feature}`] = variant
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
const activeFlags = Object.keys(flags || {})
|
|
2076
|
-
.filter((flag) => flags?.[flag] !== false)
|
|
2077
|
-
.sort()
|
|
2078
|
-
if (activeFlags.length > 0) {
|
|
2079
|
-
additionalProperties['$active_feature_flags'] = activeFlags
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
return additionalProperties
|
|
2083
|
-
})
|
|
2084
2449
|
.catch(() => {
|
|
2085
2450
|
// Something went wrong getting the flag info - we should capture the event anyways
|
|
2086
2451
|
return {}
|
package/src/exports.ts
CHANGED
|
@@ -2,6 +2,8 @@ export * from './extensions/sentry-integration'
|
|
|
2
2
|
export * from './extensions/express'
|
|
3
3
|
export * from './types'
|
|
4
4
|
|
|
5
|
+
export { FeatureFlagEvaluations } from './feature-flag-evaluations'
|
|
6
|
+
|
|
5
7
|
// Re-export FeatureFlagError from core for backwards compatibility.
|
|
6
8
|
// These were originally defined in posthog-node and moved to core for reuse across SDKs.
|
|
7
9
|
export { FeatureFlagError } from '@posthog/core'
|