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/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 featureFlagReportedKey = `${key}_${response}`
927
-
928
- if (
929
- !(distinctId in this.distinctIdHasSentFlagCalls) ||
930
- !this.distinctIdHasSentFlagCalls[distinctId].includes(featureFlagReportedKey)
931
- ) {
932
- if (Object.keys(this.distinctIdHasSentFlagCalls).length >= this.maxCacheSize) {
933
- this.distinctIdHasSentFlagCalls = {}
934
- }
935
- if (Array.isArray(this.distinctIdHasSentFlagCalls[distinctId])) {
936
- this.distinctIdHasSentFlagCalls[distinctId].push(featureFlagReportedKey)
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
- if (featureFlagError) {
963
- properties.$feature_flag_error = featureFlagError
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
- this.capture({
967
- distinctId,
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
- const feat = await this.getFeatureFlag(key, distinctId, options)
1275
- if (feat === undefined) {
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 { distinctId, event, properties, groups, sendFeatureFlags, timestamp, disableGeoip, uuid }: EventMessage =
2010
- props
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
- return await this.getFeatureFlagsForEvent(
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
- if (eventMessage.event === '$feature_flag_called') {
2062
- // If we're capturing a $feature_flag_called event, we don't want to enrich the event with cached flags that may be out of date.
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'