@posthog/core 1.11.0 → 1.12.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.
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  PostHogAutocaptureElement,
3
3
  PostHogFlagsResponse,
4
+ PostHogFeatureFlagsResponse,
4
5
  PostHogCoreOptions,
5
6
  PostHogEventProperties,
6
7
  PostHogCaptureOptions,
@@ -15,6 +16,8 @@ import type {
15
16
  Survey,
16
17
  SurveyResponse,
17
18
  PostHogGroupProperties,
19
+ BeforeSendFn,
20
+ CaptureEvent,
18
21
  } from './types'
19
22
  import {
20
23
  createFlagsResponseFromFlagsAndPayloads,
@@ -24,7 +27,7 @@ import {
24
27
  normalizeFlagsResponse,
25
28
  updateFlagValue,
26
29
  } from './featureFlagUtils'
27
- import { Compression, PostHogPersistedProperty } from './types'
30
+ import { Compression, FeatureFlagError, PostHogPersistedProperty } from './types'
28
31
  import { maybeAdd, PostHogCoreStateless, QuotaLimitedFeature } from './posthog-core-stateless'
29
32
  import { uuidv7 } from './vendor/uuidv7'
30
33
  import { isPlainError } from './utils'
@@ -33,9 +36,10 @@ export abstract class PostHogCore extends PostHogCoreStateless {
33
36
  // options
34
37
  private sendFeatureFlagEvent: boolean
35
38
  private flagCallReported: { [key: string]: boolean } = {}
39
+ private _beforeSend?: BeforeSendFn | BeforeSendFn[]
36
40
 
37
41
  // internal
38
- protected _flagsResponsePromise?: Promise<PostHogFlagsResponse | undefined> // TODO: come back to this, fix typing
42
+ protected _flagsResponsePromise?: Promise<PostHogFeatureFlagsResponse | undefined>
39
43
  protected _sessionExpirationTimeSeconds: number
40
44
  private _sessionMaxLengthSeconds: number = 24 * 60 * 60 // 24 hours
41
45
  protected sessionProps: PostHogEventProperties = {}
@@ -51,6 +55,7 @@ export abstract class PostHogCore extends PostHogCoreStateless {
51
55
 
52
56
  this.sendFeatureFlagEvent = options?.sendFeatureFlagEvent ?? true
53
57
  this._sessionExpirationTimeSeconds = options?.sessionExpirationTimeSeconds ?? 1800 // 30 minutes
58
+ this._beforeSend = options?.before_send
54
59
  }
55
60
 
56
61
  protected setupBootstrap(options?: Partial<PostHogCoreOptions>): void {
@@ -453,7 +458,7 @@ export abstract class PostHogCore extends PostHogCoreStateless {
453
458
  protected async flagsAsync(
454
459
  sendAnonDistinctId: boolean = true,
455
460
  fetchConfig: boolean = true
456
- ): Promise<PostHogFlagsResponse | undefined> {
461
+ ): Promise<PostHogFeatureFlagsResponse | undefined> {
457
462
  await this._initPromise
458
463
  if (this._flagsResponsePromise) {
459
464
  return this._flagsResponsePromise
@@ -551,7 +556,7 @@ export abstract class PostHogCore extends PostHogCoreStateless {
551
556
  private async _flagsAsync(
552
557
  sendAnonDistinctId: boolean = true,
553
558
  fetchConfig: boolean = true
554
- ): Promise<PostHogFlagsResponse | undefined> {
559
+ ): Promise<PostHogFeatureFlagsResponse | undefined> {
555
560
  this._flagsResponsePromise = this._initPromise
556
561
  .then(async () => {
557
562
  const distinctId = this.getDistinctId()
@@ -566,7 +571,7 @@ export abstract class PostHogCore extends PostHogCoreStateless {
566
571
  $anon_distinct_id: sendAnonDistinctId ? this.getAnonymousId() : undefined,
567
572
  }
568
573
 
569
- const res = await super.getFlags(
574
+ const result = await super.getFlags(
570
575
  distinctId,
571
576
  groups as PostHogGroupProperties,
572
577
  personProperties,
@@ -574,12 +579,24 @@ export abstract class PostHogCore extends PostHogCoreStateless {
574
579
  extraProperties,
575
580
  fetchConfig
576
581
  )
577
- // Add check for quota limitation on feature flags
582
+
583
+ if (!result.success) {
584
+ this.setKnownFeatureFlagDetails({
585
+ flags: this.getKnownFeatureFlagDetails()?.flags ?? {},
586
+ requestError: result.error,
587
+ })
588
+ return undefined
589
+ }
590
+
591
+ const res = result.response
592
+
578
593
  if (res?.quotaLimited?.includes(QuotaLimitedFeature.FeatureFlags)) {
579
- // Unset all feature flags by setting to null
580
- this.setKnownFeatureFlagDetails(null)
594
+ this.setKnownFeatureFlagDetails({
595
+ flags: this.getKnownFeatureFlagDetails()?.flags ?? {},
596
+ quotaLimited: res.quotaLimited,
597
+ })
581
598
  console.warn(
582
- '[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all flags. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
599
+ '[FEATURE FLAGS] Feature flags quota limit exceeded. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
583
600
  )
584
601
  return res
585
602
  }
@@ -600,7 +617,13 @@ export abstract class PostHogCore extends PostHogCoreStateless {
600
617
  flags: { ...currentFlagDetails?.flags, ...res.flags },
601
618
  }
602
619
  }
603
- this.setKnownFeatureFlagDetails(newFeatureFlagDetails)
620
+ this.setKnownFeatureFlagDetails({
621
+ flags: newFeatureFlagDetails.flags,
622
+ requestId: res.requestId,
623
+ evaluatedAt: res.evaluatedAt,
624
+ errorsWhileComputingFlags: res.errorsWhileComputingFlags,
625
+ quotaLimited: res.quotaLimited,
626
+ })
604
627
  // Mark that we hit the /flags endpoint so we can capture this in the $feature_flag_called event
605
628
  this.setPersistedProperty(PostHogPersistedProperty.FlagsEndpointWasHit, true)
606
629
  this.cacheSessionReplay('flags', res)
@@ -613,7 +636,6 @@ export abstract class PostHogCore extends PostHogCoreStateless {
613
636
  return this._flagsResponsePromise
614
637
  }
615
638
 
616
- // We only store the flags and request id in the feature flag details storage key
617
639
  private setKnownFeatureFlagDetails(flagsResponse: PostHogFlagsStorageFormat | null): void {
618
640
  this.wrap(() => {
619
641
  this.setPersistedProperty<PostHogFlagsStorageFormat>(PostHogPersistedProperty.FeatureFlagDetails, flagsResponse)
@@ -647,20 +669,16 @@ export abstract class PostHogCore extends PostHogCoreStateless {
647
669
  ) as PostHogFeatureFlagDetails
648
670
  }
649
671
 
650
- protected getKnownFeatureFlags(): PostHogFlagsResponse['featureFlags'] | undefined {
651
- const featureFlagDetails = this.getKnownFeatureFlagDetails()
652
- if (!featureFlagDetails) {
653
- return undefined
654
- }
655
- return getFlagValuesFromFlags(featureFlagDetails.flags)
672
+ private getStoredFlagDetails(): PostHogFlagsStorageFormat | undefined {
673
+ return this.getPersistedProperty<PostHogFlagsStorageFormat>(PostHogPersistedProperty.FeatureFlagDetails)
656
674
  }
657
675
 
658
- private getKnownFeatureFlagPayloads(): PostHogFlagsResponse['featureFlagPayloads'] | undefined {
676
+ protected getKnownFeatureFlags(): PostHogFlagsResponse['featureFlags'] | undefined {
659
677
  const featureFlagDetails = this.getKnownFeatureFlagDetails()
660
678
  if (!featureFlagDetails) {
661
679
  return undefined
662
680
  }
663
- return getPayloadsFromFlags(featureFlagDetails.flags)
681
+ return getFlagValuesFromFlags(featureFlagDetails.flags)
664
682
  }
665
683
 
666
684
  private getBootstrappedFeatureFlagDetails(): PostHogFeatureFlagDetails | undefined {
@@ -694,28 +712,58 @@ export abstract class PostHogCore extends PostHogCoreStateless {
694
712
  }
695
713
 
696
714
  getFeatureFlag(key: string): FeatureFlagValue | undefined {
715
+ const storedDetails = this.getStoredFlagDetails()
697
716
  const details = this.getFeatureFlagDetails()
698
-
699
- if (!details) {
700
- // If we haven't loaded flags yet, or errored out, we respond with undefined
701
- return undefined
717
+ const errors: string[] = []
718
+ const isQuotaLimited = storedDetails?.quotaLimited?.includes(QuotaLimitedFeature.FeatureFlags)
719
+
720
+ if (storedDetails?.requestError) {
721
+ const { type, statusCode } = storedDetails.requestError
722
+ if (type === 'timeout') {
723
+ errors.push(FeatureFlagError.TIMEOUT)
724
+ } else if (type === 'api_error' && statusCode !== undefined) {
725
+ errors.push(FeatureFlagError.apiError(statusCode))
726
+ } else if (type === 'connection_error') {
727
+ errors.push(FeatureFlagError.CONNECTION_ERROR)
728
+ } else {
729
+ errors.push(FeatureFlagError.UNKNOWN_ERROR)
730
+ }
731
+ } else if (storedDetails) {
732
+ if (storedDetails.errorsWhileComputingFlags) {
733
+ errors.push(FeatureFlagError.ERRORS_WHILE_COMPUTING)
734
+ }
735
+ if (isQuotaLimited) {
736
+ errors.push(FeatureFlagError.QUOTA_LIMITED)
737
+ }
702
738
  }
703
739
 
704
- const featureFlag = details.flags[key]
740
+ const featureFlag = details?.flags[key]
705
741
 
706
- let response = getFeatureFlagValue(featureFlag)
742
+ let response: FeatureFlagValue | undefined = getFeatureFlagValue(featureFlag)
707
743
 
708
744
  if (response === undefined) {
709
- // For cases where the flag is unknown, return false
710
- response = false
745
+ // Return false for missing flags when we have successfully loaded flags.
746
+ const hasCachedFlags = details && Object.keys(details.flags).length > 0
747
+ if (hasCachedFlags) {
748
+ response = false
749
+ }
750
+
751
+ // Track missing flags only when we had a successful, non-limited request.
752
+ // When quota limited or request failed, we cannot determine if the flag is truly missing.
753
+ if (details && !featureFlag && !storedDetails?.requestError && !isQuotaLimited) {
754
+ errors.push(FeatureFlagError.FLAG_MISSING)
755
+ }
711
756
  }
712
757
 
713
758
  if (this.sendFeatureFlagEvent && !this.flagCallReported[key]) {
714
759
  const bootstrappedResponse = this.getBootstrappedFeatureFlags()?.[key]
715
760
  const bootstrappedPayload = this.getBootstrappedFeatureFlagPayloads()?.[key]
716
761
 
762
+ const featureFlagError = errors.length > 0 ? errors.join(',') : undefined
763
+
717
764
  this.flagCallReported[key] = true
718
- this.capture('$feature_flag_called', {
765
+
766
+ const properties: Record<string, any> = {
719
767
  $feature_flag: key,
720
768
  $feature_flag_response: response,
721
769
  ...maybeAdd('$feature_flag_id', featureFlag?.metadata?.id),
@@ -725,12 +773,14 @@ export abstract class PostHogCore extends PostHogCoreStateless {
725
773
  ...maybeAdd('$feature_flag_bootstrapped_payload', bootstrappedPayload),
726
774
  // If we haven't yet received a response from the /flags endpoint, we must have used the bootstrapped value
727
775
  $used_bootstrap_value: !this.getPersistedProperty(PostHogPersistedProperty.FlagsEndpointWasHit),
728
- ...maybeAdd('$feature_flag_request_id', details.requestId),
729
- ...maybeAdd('$feature_flag_evaluated_at', details.evaluatedAt),
730
- })
776
+ ...maybeAdd('$feature_flag_request_id', details?.requestId),
777
+ ...maybeAdd('$feature_flag_evaluated_at', details?.evaluatedAt),
778
+ ...maybeAdd('$feature_flag_error', featureFlagError),
779
+ }
780
+
781
+ this.capture('$feature_flag_called', properties)
731
782
  }
732
783
 
733
- // If we have flags we either return the value (true or string) or false
734
784
  return response
735
785
  }
736
786
 
@@ -950,4 +1000,90 @@ export abstract class PostHogCore extends PostHogCoreStateless {
950
1000
  $ai_trace_id: String(traceId),
951
1001
  })
952
1002
  }
1003
+
1004
+ /**
1005
+ * Override processBeforeEnqueue to run before_send hooks.
1006
+ * This runs after prepareMessage, giving users full control over the final event.
1007
+ *
1008
+ * The internal message contains many fields (event, distinct_id, properties, type, library,
1009
+ * library_version, timestamp, uuid). CaptureEvent exposes a subset matching the web SDK's
1010
+ * CaptureResult: uuid, event, properties, $set, $set_once, timestamp.
1011
+ * Note: $set/$set_once are extracted from properties.$set and properties.$set_once.
1012
+ */
1013
+ protected processBeforeEnqueue(message: PostHogEventProperties): PostHogEventProperties | null {
1014
+ if (!this._beforeSend) {
1015
+ return message
1016
+ }
1017
+
1018
+ // Convert internal message format to CaptureEvent (user-facing interface matching web SDK's CaptureResult)
1019
+ const timestamp = message.timestamp
1020
+ const props = (message.properties || {}) as PostHogEventProperties
1021
+ const captureEvent: CaptureEvent = {
1022
+ uuid: message.uuid as string,
1023
+ event: message.event as string,
1024
+ properties: props,
1025
+ $set: props.$set as PostHogEventProperties | undefined,
1026
+ $set_once: props.$set_once as PostHogEventProperties | undefined,
1027
+ // Convert timestamp to Date if it's a string (from currentISOTime())
1028
+ timestamp: typeof timestamp === 'string' ? new Date(timestamp) : (timestamp as unknown as Date | undefined),
1029
+ }
1030
+
1031
+ const result = this._runBeforeSend(captureEvent)
1032
+
1033
+ if (!result) {
1034
+ return null
1035
+ }
1036
+
1037
+ // Apply modifications from CaptureEvent back to internal message
1038
+ // Put $set/$set_once back into properties where they belong
1039
+ const resultProps = { ...(result.properties ?? props) } as PostHogEventProperties
1040
+ if (result.$set !== undefined) {
1041
+ resultProps.$set = result.$set as JsonType
1042
+ } else {
1043
+ delete resultProps.$set
1044
+ }
1045
+ if (result.$set_once !== undefined) {
1046
+ resultProps.$set_once = result.$set_once as JsonType
1047
+ } else {
1048
+ delete resultProps.$set_once
1049
+ }
1050
+
1051
+ return {
1052
+ ...message,
1053
+ uuid: result.uuid ?? message.uuid,
1054
+ event: result.event,
1055
+ properties: resultProps,
1056
+ timestamp: result.timestamp as unknown as JsonType,
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Runs the before_send hook(s) on the given capture event.
1062
+ * If any hook returns null, the event is dropped.
1063
+ *
1064
+ * @param captureEvent The event to process
1065
+ * @returns The processed event, or null if the event should be dropped
1066
+ */
1067
+ private _runBeforeSend(captureEvent: CaptureEvent): CaptureEvent | null {
1068
+ const beforeSend = this._beforeSend
1069
+ if (!beforeSend) {
1070
+ return captureEvent
1071
+ }
1072
+ const fns = Array.isArray(beforeSend) ? beforeSend : [beforeSend]
1073
+ let result: CaptureEvent | null = captureEvent
1074
+
1075
+ for (const fn of fns) {
1076
+ try {
1077
+ result = fn(result)
1078
+ if (!result) {
1079
+ this._logger.info(`Event '${captureEvent.event}' was rejected in before_send function`)
1080
+ return null
1081
+ }
1082
+ } catch (e) {
1083
+ this._logger.error(`Error in before_send function for event '${captureEvent.event}':`, e)
1084
+ }
1085
+ }
1086
+
1087
+ return result
1088
+ }
953
1089
  }
@@ -1,11 +1,5 @@
1
1
  import { PostHogCore } from '@/posthog-core'
2
- import type {
3
- JsonType,
4
- PostHogCoreOptions,
5
- PostHogFetchOptions,
6
- PostHogFetchResponse,
7
- PostHogFlagsResponse,
8
- } from '@/types'
2
+ import type { GetFlagsResult, JsonType, PostHogCoreOptions, PostHogFetchOptions, PostHogFetchResponse } from '@/types'
9
3
 
10
4
  const version = '2.0.0-alpha'
11
5
 
@@ -37,7 +31,7 @@ export class PostHogCoreTestClient extends PostHogCore {
37
31
  personProperties: Record<string, string> = {},
38
32
  groupProperties: Record<string, Record<string, string>> = {},
39
33
  extraPayload: Record<string, any> = {}
40
- ): Promise<PostHogFlagsResponse | undefined> {
34
+ ): Promise<GetFlagsResult> {
41
35
  return super.getFlags(distinctId, groups, personProperties, groupProperties, extraPayload)
42
36
  }
43
37
 
package/src/types.ts CHANGED
@@ -71,6 +71,12 @@ export type PostHogCoreOptions = {
71
71
  * @deprecated Use evaluationContexts instead. This property will be removed in a future version.
72
72
  */
73
73
  evaluationEnvironments?: readonly string[]
74
+ /**
75
+ * Allows modification or dropping of events before they're sent to PostHog.
76
+ * If an array is provided, the functions are run in order.
77
+ * If a function returns null, the event will be dropped.
78
+ */
79
+ before_send?: BeforeSendFn | BeforeSendFn[]
74
80
  }
75
81
 
76
82
  export enum PostHogPersistedProperty {
@@ -260,7 +266,12 @@ export type PostHogV2FlagsResponse = Omit<PostHogFlagsResponse, 'featureFlags' |
260
266
  * When we pull flags from persistence, we can normalize them to PostHogFeatureFlagDetails
261
267
  * so that we can support v1 and v2 of the API.
262
268
  */
263
- export type PostHogFlagsStorageFormat = Pick<PostHogFeatureFlagDetails, 'flags'>
269
+ export type PostHogFlagsStorageFormat = Pick<PostHogFeatureFlagDetails, 'flags'> &
270
+ Partial<Pick<PostHogFlagsResponse, 'requestId' | 'evaluatedAt'>> & {
271
+ errorsWhileComputingFlags?: boolean
272
+ quotaLimited?: string[]
273
+ requestError?: FeatureFlagRequestError
274
+ }
264
275
 
265
276
  /**
266
277
  * Models legacy flags and payloads return type for many public methods.
@@ -273,6 +284,51 @@ export type JsonType = string | number | boolean | null | { [key: string]: JsonT
273
284
 
274
285
  export type FetchLike = (url: string, options: PostHogFetchOptions) => Promise<PostHogFetchResponse>
275
286
 
287
+ /**
288
+ * Error type constants for the $feature_flag_error property.
289
+ *
290
+ * These values are sent in analytics events to track flag evaluation failures.
291
+ * They should not be changed without considering impact on existing dashboards
292
+ * and queries that filter on these values.
293
+ *
294
+ * Error values:
295
+ * ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true
296
+ * FLAG_MISSING: Requested flag not in API response
297
+ * QUOTA_LIMITED: Rate/quota limit exceeded
298
+ * TIMEOUT: Request timed out
299
+ * CONNECTION_ERROR: Network connection failed
300
+ * apiError: HTTP error with status code (e.g., api_error_500)
301
+ */
302
+ export const FeatureFlagError = {
303
+ ERRORS_WHILE_COMPUTING: 'errors_while_computing_flags',
304
+ FLAG_MISSING: 'flag_missing',
305
+ QUOTA_LIMITED: 'quota_limited',
306
+ TIMEOUT: 'timeout',
307
+ CONNECTION_ERROR: 'connection_error',
308
+ UNKNOWN_ERROR: 'unknown_error',
309
+ apiError: (status: number): string => `api_error_${status}`,
310
+ } as const
311
+
312
+ export type FeatureFlagErrorType =
313
+ | (typeof FeatureFlagError)[Exclude<keyof typeof FeatureFlagError, 'apiError'>]
314
+ | ReturnType<typeof FeatureFlagError.apiError>
315
+ | string
316
+
317
+ /**
318
+ * Represents an error that occurred during a feature flag request.
319
+ */
320
+ export type FeatureFlagRequestError = {
321
+ type: 'timeout' | 'connection_error' | 'api_error' | 'unknown_error'
322
+ statusCode?: number
323
+ }
324
+
325
+ /**
326
+ * Result type for getFlags that includes either a successful response or error information.
327
+ */
328
+ export type GetFlagsResult =
329
+ | { success: true; response: PostHogFeatureFlagsResponse }
330
+ | { success: false; error: FeatureFlagRequestError }
331
+
276
332
  export type FeatureFlagDetail = {
277
333
  key: string
278
334
  enabled: boolean
@@ -566,3 +622,28 @@ export const knownUnsafeEditableEvent = [
566
622
  * Some features of PostHog rely on receiving 100% of these events
567
623
  */
568
624
  export type KnownUnsafeEditableEvent = (typeof knownUnsafeEditableEvent)[number]
625
+
626
+ /**
627
+ * Represents an event before it's sent to PostHog.
628
+ * This is the interface exposed to the `before_send` hook, matching the web SDK's `CaptureResult`.
629
+ */
630
+ export type CaptureEvent = {
631
+ /** UUID for the event (optional to allow compatibility with Node SDK's EventMessage) */
632
+ uuid?: string
633
+ /** The name of the event */
634
+ event: string
635
+ /** Properties associated with the event (optional to allow compatibility with Node SDK's EventMessage) */
636
+ properties?: PostHogEventProperties
637
+ /** Properties to set on the person (overrides existing values) */
638
+ $set?: PostHogEventProperties
639
+ /** Properties to set on the person only once (does not override existing values) */
640
+ $set_once?: PostHogEventProperties
641
+ /** Timestamp for the event */
642
+ timestamp?: Date
643
+ }
644
+
645
+ /**
646
+ * Function type for the `before_send` hook.
647
+ * Receives an event and can return a modified event or null to drop the event.
648
+ */
649
+ export type BeforeSendFn = (event: CaptureEvent | null) => CaptureEvent | null