posthog-node 5.25.0 → 5.26.1

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
@@ -1,7 +1,6 @@
1
1
  import { version } from './version'
2
2
 
3
3
  import {
4
- FeatureFlagDetail,
5
4
  FeatureFlagValue,
6
5
  isBlockedUA,
7
6
  isPlainObject,
@@ -26,9 +25,12 @@ import {
26
25
  OverrideFeatureFlagsOptions,
27
26
  PostHogOptions,
28
27
  SendFeatureFlagsOptions,
28
+ FlagEvaluationOptions,
29
+ AllFlagsOptions,
29
30
  } from './types'
30
31
  import {
31
32
  FeatureFlagsPoller,
33
+ type FeatureFlagEvaluationContext,
32
34
  RequiresServerEvaluation,
33
35
  InconclusiveMatchError,
34
36
  } from './extensions/feature-flags/feature-flags'
@@ -611,6 +613,16 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
611
613
  })
612
614
  }
613
615
 
616
+ private _resolveDistinctId<T>(
617
+ distinctIdOrOptions: string | T | undefined,
618
+ options: T | undefined
619
+ ): { distinctId: string | undefined; options: T | undefined } {
620
+ if (typeof distinctIdOrOptions === 'string') {
621
+ return { distinctId: distinctIdOrOptions, options }
622
+ }
623
+ return { distinctId: this.context?.get()?.distinctId, options: distinctIdOrOptions }
624
+ }
625
+
614
626
  /**
615
627
  * Internal method that handles feature flag evaluation with full details.
616
628
  * Used by getFeatureFlag, getFeatureFlagPayload, and getFeatureFlagResult.
@@ -663,6 +675,12 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
663
675
 
664
676
  personProperties = adjustedProperties.allPersonProperties
665
677
  groupProperties = adjustedProperties.allGroupProperties
678
+ const evaluationContext = this.createFeatureFlagEvaluationContext(
679
+ distinctId,
680
+ groups,
681
+ personProperties,
682
+ groupProperties
683
+ )
666
684
 
667
685
  // set defaults
668
686
  if (onlyEvaluateLocally == undefined) {
@@ -687,14 +705,9 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
687
705
  const flag = this.featureFlagsPoller?.featureFlagsByKey[key]
688
706
  if (flag) {
689
707
  try {
690
- const localResult = await this.featureFlagsPoller?.computeFlagAndPayloadLocally(
691
- flag,
692
- distinctId,
693
- groups,
694
- personProperties,
695
- groupProperties,
696
- matchValue
697
- )
708
+ const localResult = await this.featureFlagsPoller?.computeFlagAndPayloadLocally(flag, evaluationContext, {
709
+ matchValue,
710
+ })
698
711
  if (localResult) {
699
712
  flagWasLocallyEvaluated = true
700
713
  const value = localResult.value
@@ -721,10 +734,10 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
721
734
  // Fall back to remote evaluation if needed
722
735
  if (!flagWasLocallyEvaluated && !onlyEvaluateLocally) {
723
736
  const flagsResponse = await super.getFeatureFlagDetailsStateless(
724
- distinctId,
725
- groups,
726
- personProperties,
727
- groupProperties,
737
+ evaluationContext.distinctId,
738
+ evaluationContext.groups,
739
+ evaluationContext.personProperties,
740
+ evaluationContext.groupProperties,
728
741
  disableGeoip,
729
742
  [key]
730
743
  )
@@ -808,7 +821,16 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
808
821
  locally_evaluated: flagWasLocallyEvaluated,
809
822
  [`$feature/${key}`]: response,
810
823
  $feature_flag_request_id: requestId,
811
- $feature_flag_evaluated_at: evaluatedAt,
824
+ $feature_flag_evaluated_at: flagWasLocallyEvaluated ? Date.now() : evaluatedAt,
825
+ }
826
+
827
+ // Add local evaluation definition load timestamp
828
+ if (flagWasLocallyEvaluated && this.featureFlagsPoller) {
829
+ const flagDefinitionsLoadedAt = this.featureFlagsPoller.getFlagDefinitionsLoadedAt()
830
+
831
+ if (flagDefinitionsLoadedAt !== undefined) {
832
+ properties.$feature_flag_definitions_loaded_at = flagDefinitionsLoadedAt
833
+ }
812
834
  }
813
835
 
814
836
  if (featureFlagError) {
@@ -1008,21 +1030,30 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
1008
1030
  * @param options - Optional configuration for flag evaluation
1009
1031
  * @returns Promise that resolves to the flag result or undefined
1010
1032
  */
1033
+ async getFeatureFlagResult(key: string, options?: FlagEvaluationOptions): Promise<FeatureFlagResult | undefined>
1011
1034
  async getFeatureFlagResult(
1012
1035
  key: string,
1013
1036
  distinctId: string,
1014
- options?: {
1015
- groups?: Record<string, string>
1016
- personProperties?: Record<string, string>
1017
- groupProperties?: Record<string, Record<string, string>>
1018
- onlyEvaluateLocally?: boolean
1019
- sendFeatureFlagEvents?: boolean
1020
- disableGeoip?: boolean
1021
- }
1037
+ options?: FlagEvaluationOptions
1038
+ ): Promise<FeatureFlagResult | undefined>
1039
+ async getFeatureFlagResult(
1040
+ key: string,
1041
+ distinctIdOrOptions?: string | FlagEvaluationOptions,
1042
+ options?: FlagEvaluationOptions
1022
1043
  ): Promise<FeatureFlagResult | undefined> {
1023
- return this._getFeatureFlagResult(key, distinctId, {
1024
- ...options,
1025
- sendFeatureFlagEvents: options?.sendFeatureFlagEvents ?? this.options.sendFeatureFlagEvent ?? true,
1044
+ const { distinctId: resolvedDistinctId, options: resolvedOptions } = this._resolveDistinctId(
1045
+ distinctIdOrOptions,
1046
+ options
1047
+ )
1048
+
1049
+ if (!resolvedDistinctId) {
1050
+ this._logger.warn('[PostHog] distinctId is required — pass it explicitly or use withContext()')
1051
+ return undefined
1052
+ }
1053
+
1054
+ return this._getFeatureFlagResult(key, resolvedDistinctId, {
1055
+ ...resolvedOptions,
1056
+ sendFeatureFlagEvents: resolvedOptions?.sendFeatureFlagEvents ?? this.options.sendFeatureFlagEvent ?? true,
1026
1057
  })
1027
1058
  }
1028
1059
 
@@ -1155,18 +1186,24 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
1155
1186
  * @param options - Optional configuration for flag evaluation
1156
1187
  * @returns Promise that resolves to a record of flag keys and their values
1157
1188
  */
1189
+ async getAllFlags(options?: AllFlagsOptions): Promise<Record<string, FeatureFlagValue>>
1190
+ async getAllFlags(distinctId: string, options?: AllFlagsOptions): Promise<Record<string, FeatureFlagValue>>
1158
1191
  async getAllFlags(
1159
- distinctId: string,
1160
- options?: {
1161
- groups?: Record<string, string>
1162
- personProperties?: Record<string, string>
1163
- groupProperties?: Record<string, Record<string, string>>
1164
- onlyEvaluateLocally?: boolean
1165
- disableGeoip?: boolean
1166
- flagKeys?: string[]
1167
- }
1192
+ distinctIdOrOptions?: string | AllFlagsOptions,
1193
+ options?: AllFlagsOptions
1168
1194
  ): Promise<Record<string, FeatureFlagValue>> {
1169
- const response = await this.getAllFlagsAndPayloads(distinctId, options)
1195
+ const { distinctId: resolvedDistinctId, options: resolvedOptions } = this._resolveDistinctId(
1196
+ distinctIdOrOptions,
1197
+ options
1198
+ )
1199
+ if (!resolvedDistinctId) {
1200
+ this._logger.warn(
1201
+ '[PostHog] distinctId is required to get feature flags — pass it explicitly or use withContext()'
1202
+ )
1203
+ return {}
1204
+ }
1205
+
1206
+ const response = await this.getAllFlagsAndPayloads(resolvedDistinctId, resolvedOptions)
1170
1207
  return response.featureFlags || {}
1171
1208
  }
1172
1209
 
@@ -1203,22 +1240,28 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
1203
1240
  * @param options - Optional configuration for flag evaluation
1204
1241
  * @returns Promise that resolves to flags and payloads
1205
1242
  */
1243
+ async getAllFlagsAndPayloads(options?: AllFlagsOptions): Promise<PostHogFlagsAndPayloadsResponse>
1244
+ async getAllFlagsAndPayloads(distinctId: string, options?: AllFlagsOptions): Promise<PostHogFlagsAndPayloadsResponse>
1206
1245
  async getAllFlagsAndPayloads(
1207
- distinctId: string,
1208
- options?: {
1209
- groups?: Record<string, string>
1210
- personProperties?: Record<string, string>
1211
- groupProperties?: Record<string, Record<string, string>>
1212
- onlyEvaluateLocally?: boolean
1213
- disableGeoip?: boolean
1214
- flagKeys?: string[]
1215
- }
1246
+ distinctIdOrOptions?: string | AllFlagsOptions,
1247
+ options?: AllFlagsOptions
1216
1248
  ): Promise<PostHogFlagsAndPayloadsResponse> {
1217
- const { groups, disableGeoip, flagKeys } = options || {}
1218
- let { onlyEvaluateLocally, personProperties, groupProperties } = options || {}
1249
+ const { distinctId: resolvedDistinctId, options: resolvedOptions } = this._resolveDistinctId(
1250
+ distinctIdOrOptions,
1251
+ options
1252
+ )
1253
+ if (!resolvedDistinctId) {
1254
+ this._logger.warn(
1255
+ '[PostHog] distinctId is required to get feature flags and payloads — pass it explicitly or use withContext()'
1256
+ )
1257
+ return { featureFlags: {}, featureFlagPayloads: {} }
1258
+ }
1259
+
1260
+ const { groups, disableGeoip, flagKeys } = resolvedOptions || {}
1261
+ let { onlyEvaluateLocally, personProperties, groupProperties } = resolvedOptions || {}
1219
1262
 
1220
1263
  const adjustedProperties = this.addLocalPersonAndGroupProperties(
1221
- distinctId,
1264
+ resolvedDistinctId,
1222
1265
  groups,
1223
1266
  personProperties,
1224
1267
  groupProperties
@@ -1226,19 +1269,19 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
1226
1269
 
1227
1270
  personProperties = adjustedProperties.allPersonProperties
1228
1271
  groupProperties = adjustedProperties.allGroupProperties
1272
+ const evaluationContext = this.createFeatureFlagEvaluationContext(
1273
+ resolvedDistinctId,
1274
+ groups,
1275
+ personProperties,
1276
+ groupProperties
1277
+ )
1229
1278
 
1230
1279
  // set defaults
1231
1280
  if (onlyEvaluateLocally == undefined) {
1232
1281
  onlyEvaluateLocally = this.options.strictLocalEvaluation ?? false
1233
1282
  }
1234
1283
 
1235
- const localEvaluationResult = await this.featureFlagsPoller?.getAllFlagsAndPayloads(
1236
- distinctId,
1237
- groups,
1238
- personProperties,
1239
- groupProperties,
1240
- flagKeys
1241
- )
1284
+ const localEvaluationResult = await this.featureFlagsPoller?.getAllFlagsAndPayloads(evaluationContext, flagKeys)
1242
1285
 
1243
1286
  let featureFlags = {}
1244
1287
  let featureFlagPayloads = {}
@@ -1251,10 +1294,10 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
1251
1294
 
1252
1295
  if (fallbackToFlags && !onlyEvaluateLocally) {
1253
1296
  const remoteEvaluationResult = await super.getFeatureFlagsAndPayloadsStateless(
1254
- distinctId,
1255
- groups,
1256
- personProperties,
1257
- groupProperties,
1297
+ evaluationContext.distinctId,
1298
+ evaluationContext.groups,
1299
+ evaluationContext.personProperties,
1300
+ evaluationContext.groupProperties,
1258
1301
  disableGeoip,
1259
1302
  flagKeys
1260
1303
  )
@@ -1510,6 +1553,24 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
1510
1553
  return this.context?.get()
1511
1554
  }
1512
1555
 
1556
+ /**
1557
+ * Set context without a callback wrapper.
1558
+ *
1559
+ * Uses `AsyncLocalStorage.enterWith()` to attach context to the current
1560
+ * async execution context. The context lives until that async context ends.
1561
+ *
1562
+ * Must be called in the same async scope that makes PostHog calls.
1563
+ * Calling this outside a request-scoped async context will leak context
1564
+ * across unrelated work. Prefer `withContext()` when you can wrap code
1565
+ * in a callback — it creates an isolated scope that cleans up automatically.
1566
+ *
1567
+ * @param data - Context data to apply (distinctId, sessionId, properties)
1568
+ * @param options - Context options (fresh: true to start with clean context instead of inheriting)
1569
+ */
1570
+ enterContext(data: Partial<ContextData>, options?: ContextOptions): void {
1571
+ this.context?.enter(data as ContextData, options)
1572
+ }
1573
+
1513
1574
  /**
1514
1575
  * Shutdown the PostHog client gracefully.
1515
1576
  *
@@ -1691,6 +1752,21 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
1691
1752
  return { allPersonProperties, allGroupProperties }
1692
1753
  }
1693
1754
 
1755
+ private createFeatureFlagEvaluationContext(
1756
+ distinctId: string,
1757
+ groups?: Record<string, string>,
1758
+ personProperties?: Record<string, any>,
1759
+ groupProperties?: Record<string, Record<string, any>>
1760
+ ): FeatureFlagEvaluationContext {
1761
+ return {
1762
+ distinctId,
1763
+ groups: groups || {},
1764
+ personProperties: personProperties || {},
1765
+ groupProperties: groupProperties || {},
1766
+ evaluationCache: {},
1767
+ }
1768
+ }
1769
+
1694
1770
  /**
1695
1771
  * Capture an error exception as an event.
1696
1772
  *
@@ -13,21 +13,26 @@ export class PostHogContext implements IPostHogContext {
13
13
  }
14
14
 
15
15
  run<T>(context: ContextData, fn: () => T, options?: ContextOptions): T {
16
- const fresh = options?.fresh === true
16
+ return this.storage.run(this.resolve(context, options), fn)
17
+ }
18
+
19
+ enter(context: ContextData, options?: ContextOptions): void {
20
+ this.storage.enterWith(this.resolve(context, options))
21
+ }
22
+
23
+ private resolve(context: ContextData, options?: ContextOptions): ContextData {
24
+ if (options?.fresh === true) {
25
+ return context
26
+ }
17
27
 
18
- if (fresh) {
19
- return this.storage.run(context, fn)
20
- } else {
21
- const currentContext = this.get() || {}
22
- const mergedContext: ContextData = {
23
- distinctId: context.distinctId ?? currentContext.distinctId,
24
- sessionId: context.sessionId ?? currentContext.sessionId,
25
- properties: {
26
- ...(currentContext.properties || {}),
27
- ...(context.properties || {}),
28
- },
29
- }
30
- return this.storage.run(mergedContext, fn)
28
+ const current = this.get() || {}
29
+ return {
30
+ distinctId: context.distinctId ?? current.distinctId,
31
+ sessionId: context.sessionId ?? current.sessionId,
32
+ properties: {
33
+ ...(current.properties || {}),
34
+ ...(context.properties || {}),
35
+ },
31
36
  }
32
37
  }
33
38
  }
@@ -16,4 +16,5 @@ export interface ContextOptions {
16
16
  export interface IPostHogContext {
17
17
  get(): ContextData | undefined
18
18
  run<T>(context: ContextData, fn: () => T, options?: ContextOptions): T
19
+ enter(context: ContextData, options?: ContextOptions): void
19
20
  }
@@ -58,6 +58,19 @@ type FeatureFlagsPollerOptions = {
58
58
  strictLocalEvaluation?: boolean
59
59
  }
60
60
 
61
+ export type FeatureFlagEvaluationContext = {
62
+ distinctId: string
63
+ groups: Record<string, string>
64
+ personProperties: Record<string, any>
65
+ groupProperties: Record<string, Record<string, any>>
66
+ evaluationCache: Record<string, FeatureFlagValue>
67
+ }
68
+
69
+ type ComputeFlagAndPayloadOptions = {
70
+ matchValue?: FeatureFlagValue
71
+ skipLoadCheck?: boolean
72
+ }
73
+
61
74
  class FeatureFlagsPoller {
62
75
  pollingInterval: number
63
76
  personalApiKey: string
@@ -82,6 +95,7 @@ class FeatureFlagsPoller {
82
95
  private flagsEtag?: string
83
96
  private nextFetchAllowedAt?: number
84
97
  private strictLocalEvaluation: boolean
98
+ private flagDefinitionsLoadedAt?: number
85
99
 
86
100
  constructor({
87
101
  pollingInterval,
@@ -122,6 +136,22 @@ class FeatureFlagsPoller {
122
136
  }
123
137
  }
124
138
 
139
+ private createEvaluationContext(
140
+ distinctId: string,
141
+ groups: Record<string, string> = {},
142
+ personProperties: Record<string, any> = {},
143
+ groupProperties: Record<string, Record<string, any>> = {},
144
+ evaluationCache: Record<string, FeatureFlagValue> = {}
145
+ ): FeatureFlagEvaluationContext {
146
+ return {
147
+ distinctId,
148
+ groups,
149
+ personProperties,
150
+ groupProperties,
151
+ evaluationCache,
152
+ }
153
+ }
154
+
125
155
  async getFeatureFlag(
126
156
  key: string,
127
157
  distinctId: string,
@@ -141,14 +171,9 @@ class FeatureFlagsPoller {
141
171
  featureFlag = this.featureFlagsByKey[key]
142
172
 
143
173
  if (featureFlag !== undefined) {
174
+ const evaluationContext = this.createEvaluationContext(distinctId, groups, personProperties, groupProperties)
144
175
  try {
145
- const result = await this.computeFlagAndPayloadLocally(
146
- featureFlag,
147
- distinctId,
148
- groups,
149
- personProperties,
150
- groupProperties
151
- )
176
+ const result = await this.computeFlagAndPayloadLocally(featureFlag, evaluationContext)
152
177
  response = result.value
153
178
  this.logMsgIfDebug(() => console.debug(`Successfully computed flag locally: ${key} -> ${response}`))
154
179
  } catch (e) {
@@ -164,10 +189,7 @@ class FeatureFlagsPoller {
164
189
  }
165
190
 
166
191
  async getAllFlagsAndPayloads(
167
- distinctId: string,
168
- groups: Record<string, string> = {},
169
- personProperties: Record<string, any> = {},
170
- groupProperties: Record<string, Record<string, any>> = {},
192
+ evaluationContext: FeatureFlagEvaluationContext,
171
193
  flagKeysToExplicitlyEvaluate?: string[]
172
194
  ): Promise<{
173
195
  response: Record<string, FeatureFlagValue>
@@ -184,20 +206,17 @@ class FeatureFlagsPoller {
184
206
  ? flagKeysToExplicitlyEvaluate.map((key) => this.featureFlagsByKey[key]).filter(Boolean)
185
207
  : this.featureFlags
186
208
 
187
- // Create a shared evaluation cache to prevent memory leaks when processing many flags
188
- const sharedEvaluationCache: Record<string, FeatureFlagValue> = {}
209
+ const sharedEvaluationContext = {
210
+ ...evaluationContext,
211
+ evaluationCache: evaluationContext.evaluationCache ?? {},
212
+ }
189
213
 
190
214
  await Promise.all(
191
215
  flagsToEvaluate.map(async (flag) => {
192
216
  try {
193
217
  const { value: matchValue, payload: matchPayload } = await this.computeFlagAndPayloadLocally(
194
218
  flag,
195
- distinctId,
196
- groups,
197
- personProperties,
198
- groupProperties,
199
- undefined /* matchValue */,
200
- sharedEvaluationCache
219
+ sharedEvaluationContext
201
220
  )
202
221
  response[flag.key] = matchValue
203
222
  if (matchPayload) {
@@ -219,17 +238,14 @@ class FeatureFlagsPoller {
219
238
 
220
239
  async computeFlagAndPayloadLocally(
221
240
  flag: PostHogFeatureFlag,
222
- distinctId: string,
223
- groups: Record<string, string> = {},
224
- personProperties: Record<string, any> = {},
225
- groupProperties: Record<string, Record<string, any>> = {},
226
- matchValue?: FeatureFlagValue,
227
- evaluationCache?: Record<string, FeatureFlagValue>,
228
- skipLoadCheck: boolean = false
241
+ evaluationContext: FeatureFlagEvaluationContext,
242
+ options: ComputeFlagAndPayloadOptions = {}
229
243
  ): Promise<{
230
244
  value: FeatureFlagValue
231
245
  payload: JsonType | null
232
246
  }> {
247
+ const { matchValue, skipLoadCheck = false } = options
248
+
233
249
  // Only load flags if not already loaded and not skipping the check
234
250
  if (!skipLoadCheck) {
235
251
  await this.loadFeatureFlags()
@@ -245,14 +261,7 @@ class FeatureFlagsPoller {
245
261
  if (matchValue !== undefined) {
246
262
  flagValue = matchValue
247
263
  } else {
248
- flagValue = await this.computeFlagValueLocally(
249
- flag,
250
- distinctId,
251
- groups,
252
- personProperties,
253
- groupProperties,
254
- evaluationCache
255
- )
264
+ flagValue = await this.computeFlagValueLocally(flag, evaluationContext)
256
265
  }
257
266
 
258
267
  // Always compute payload based on the final flagValue (whether provided or computed)
@@ -263,12 +272,10 @@ class FeatureFlagsPoller {
263
272
 
264
273
  private async computeFlagValueLocally(
265
274
  flag: PostHogFeatureFlag,
266
- distinctId: string,
267
- groups: Record<string, string> = {},
268
- personProperties: Record<string, any> = {},
269
- groupProperties: Record<string, Record<string, any>> = {},
270
- evaluationCache: Record<string, FeatureFlagValue> = {}
275
+ evaluationContext: FeatureFlagEvaluationContext
271
276
  ): Promise<FeatureFlagValue> {
277
+ const { distinctId, groups, personProperties, groupProperties } = evaluationContext
278
+
272
279
  if (flag.ensure_experience_continuity) {
273
280
  throw new InconclusiveMatchError('Flag has experience continuity enabled')
274
281
  }
@@ -299,32 +306,30 @@ class FeatureFlagsPoller {
299
306
  return false
300
307
  }
301
308
 
309
+ if (
310
+ flag.bucketing_identifier === 'device_id' &&
311
+ (personProperties?.$device_id === undefined ||
312
+ personProperties?.$device_id === null ||
313
+ personProperties?.$device_id === '')
314
+ ) {
315
+ this.logMsgIfDebug(() =>
316
+ console.warn(`[FEATURE FLAGS] Ignoring bucketing_identifier for group flag: ${flag.key}`)
317
+ )
318
+ }
319
+
302
320
  const focusedGroupProperties = groupProperties[groupName]
303
- return await this.matchFeatureFlagProperties(
304
- flag,
305
- groups[groupName],
306
- focusedGroupProperties,
307
- evaluationCache,
308
- distinctId,
309
- groups,
310
- personProperties,
311
- groupProperties
312
- )
321
+ return await this.matchFeatureFlagProperties(flag, groups[groupName], focusedGroupProperties, evaluationContext)
313
322
  } else {
314
323
  const bucketingValue = this.getBucketingValueForFlag(flag, distinctId, personProperties)
315
324
  if (bucketingValue === undefined) {
325
+ this.logMsgIfDebug(() =>
326
+ console.warn(
327
+ `[FEATURE FLAGS] Can't compute feature flag: ${flag.key} without $device_id, falling back to server evaluation`
328
+ )
329
+ )
316
330
  throw new InconclusiveMatchError(`Can't compute feature flag: ${flag.key} without $device_id`)
317
331
  }
318
- return await this.matchFeatureFlagProperties(
319
- flag,
320
- bucketingValue,
321
- personProperties,
322
- evaluationCache,
323
- distinctId,
324
- groups,
325
- personProperties,
326
- groupProperties
327
- )
332
+ return await this.matchFeatureFlagProperties(flag, bucketingValue, personProperties, evaluationContext)
328
333
  }
329
334
  }
330
335
 
@@ -384,13 +389,10 @@ class FeatureFlagsPoller {
384
389
 
385
390
  private async evaluateFlagDependency(
386
391
  property: FlagProperty,
387
- distinctId: string,
388
- groups: Record<string, string>,
389
- personProperties: Record<string, any>,
390
- groupProperties: Record<string, Record<string, any>>,
391
392
  properties: Record<string, any>,
392
- evaluationCache: Record<string, FeatureFlagValue>
393
+ evaluationContext: FeatureFlagEvaluationContext
393
394
  ): Promise<boolean> {
395
+ const { evaluationCache } = evaluationContext
394
396
  const targetFlagKey = property.key
395
397
 
396
398
  if (!this.featureFlagsByKey) {
@@ -434,14 +436,7 @@ class FeatureFlagsPoller {
434
436
  } else {
435
437
  // Reuse full flag evaluation so dependencies respect person vs group bucketing rules.
436
438
  try {
437
- const depResult = await this.computeFlagValueLocally(
438
- depFlag,
439
- distinctId,
440
- groups,
441
- personProperties,
442
- groupProperties,
443
- evaluationCache
444
- )
439
+ const depResult = await this.computeFlagValueLocally(depFlag, evaluationContext)
445
440
  evaluationCache[depFlagKey] = depResult
446
441
  } catch (error) {
447
442
  throw new InconclusiveMatchError(
@@ -486,11 +481,7 @@ class FeatureFlagsPoller {
486
481
  flag: PostHogFeatureFlag,
487
482
  bucketingValue: string,
488
483
  properties: Record<string, any>,
489
- evaluationCache: Record<string, FeatureFlagValue> = {},
490
- distinctId: string = bucketingValue,
491
- groups: Record<string, string> = {},
492
- personProperties: Record<string, any> = {},
493
- groupProperties: Record<string, Record<string, any>> = {}
484
+ evaluationContext: FeatureFlagEvaluationContext
494
485
  ): Promise<FeatureFlagValue> {
495
486
  const flagFilters = flag.filters || {}
496
487
  const flagConditions = flagFilters.groups || []
@@ -499,19 +490,7 @@ class FeatureFlagsPoller {
499
490
 
500
491
  for (const condition of flagConditions) {
501
492
  try {
502
- if (
503
- await this.isConditionMatch(
504
- flag,
505
- bucketingValue,
506
- condition,
507
- properties,
508
- evaluationCache,
509
- distinctId,
510
- groups,
511
- personProperties,
512
- groupProperties
513
- )
514
- ) {
493
+ if (await this.isConditionMatch(flag, bucketingValue, condition, properties, evaluationContext)) {
515
494
  const variantOverride = condition.variant
516
495
  const flagVariants = flagFilters.multivariate?.variants || []
517
496
  if (variantOverride && flagVariants.some((variant) => variant.key === variantOverride)) {
@@ -551,11 +530,7 @@ class FeatureFlagsPoller {
551
530
  bucketingValue: string,
552
531
  condition: FeatureFlagCondition,
553
532
  properties: Record<string, any>,
554
- evaluationCache: Record<string, FeatureFlagValue> = {},
555
- distinctId: string = bucketingValue,
556
- groups: Record<string, string> = {},
557
- personProperties: Record<string, any> = {},
558
- groupProperties: Record<string, Record<string, any>> = {}
533
+ evaluationContext: FeatureFlagEvaluationContext
559
534
  ): Promise<boolean> {
560
535
  const rolloutPercentage = condition.rollout_percentage
561
536
  const warnFunction = (msg: string): void => {
@@ -569,15 +544,7 @@ class FeatureFlagsPoller {
569
544
  if (propertyType === 'cohort') {
570
545
  matches = matchCohort(prop, properties, this.cohorts, this.debugMode)
571
546
  } else if (propertyType === 'flag') {
572
- matches = await this.evaluateFlagDependency(
573
- prop,
574
- distinctId,
575
- groups,
576
- personProperties,
577
- groupProperties,
578
- properties,
579
- evaluationCache
580
- )
547
+ matches = await this.evaluateFlagDependency(prop, properties, evaluationContext)
581
548
  } else {
582
549
  matches = matchProperty(prop, properties, warnFunction)
583
550
  }
@@ -724,6 +691,14 @@ class FeatureFlagsPoller {
724
691
  return (this.loadedSuccessfullyOnce ?? false) && (this.featureFlags?.length ?? 0) > 0
725
692
  }
726
693
 
694
+ /**
695
+ * Returns the timestamp (in milliseconds) when flag definitions were last loaded.
696
+ * Returns undefined if flags have not been loaded yet.
697
+ */
698
+ getFlagDefinitionsLoadedAt(): number | undefined {
699
+ return this.flagDefinitionsLoadedAt
700
+ }
701
+
727
702
  /**
728
703
  * If a client is misconfigured with an invalid or improper API key, the polling interval is doubled each time
729
704
  * until a successful request is made, up to a maximum of 60 seconds.
@@ -885,6 +860,8 @@ class FeatureFlagsPoller {
885
860
  }
886
861
 
887
862
  this.updateFlagState(flagData)
863
+ // Set timestamp to when definitions were actually fetched from server
864
+ this.flagDefinitionsLoadedAt = Date.now()
888
865
  this.clearBackoff()
889
866
 
890
867
  if (this.cacheProvider && shouldFetch) {