@posthog/core 1.33.0 → 1.35.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.
@@ -9,7 +9,7 @@ import type {
9
9
  OtlpSeverityEntry,
10
10
  OtlpSeverityText,
11
11
  } from '@posthog/types'
12
- import type { LogSdkContext } from './types'
12
+ import type { LogSdkContext, ResolvedPostHogLogsConfig } from './types'
13
13
  import { isArray, isBoolean, isNull, isUndefined } from '../utils'
14
14
 
15
15
  // ============================================================================
@@ -168,6 +168,35 @@ export function buildOtlpLogRecord(options: CaptureLogOptions, sdkContext: LogSd
168
168
  // OTLP envelope construction
169
169
  // ============================================================================
170
170
 
171
+ /**
172
+ * OTLP resource attributes for every batch, shared by the core flush path and
173
+ * SDK-specific paths that bypass it (e.g. the browser's synchronous sendBeacon
174
+ * drain). Having one builder keeps those paths from drifting.
175
+ *
176
+ * Layout: user `resourceAttributes` spread first, then SDK-controlled keys
177
+ * (`service.name`, `deployment.environment`, `service.version`,
178
+ * `telemetry.sdk.*`) layered on top so a stray user key can't clobber the
179
+ * ingestion-attribution keys. The dedicated `serviceName` / `environment` /
180
+ * `serviceVersion` config fields are the supported way to override the first
181
+ * three; each SDK resolves its own `service.name` default before this point, so
182
+ * the `unknown_service` fallback here only fires if a config slips through with
183
+ * an empty `serviceName`.
184
+ */
185
+ export function buildResourceAttributes(
186
+ config: ResolvedPostHogLogsConfig,
187
+ scopeName: string,
188
+ scopeVersion: string
189
+ ): Record<string, LogAttributeValue> {
190
+ return {
191
+ ...config.resourceAttributes,
192
+ 'service.name': config.serviceName || 'unknown_service',
193
+ ...(config.environment && { 'deployment.environment': config.environment }),
194
+ ...(config.serviceVersion && { 'service.version': config.serviceVersion }),
195
+ 'telemetry.sdk.name': scopeName,
196
+ 'telemetry.sdk.version': scopeVersion,
197
+ }
198
+ }
199
+
171
200
  /**
172
201
  * Wraps a list of records in the OTLP `resourceLogs` envelope.
173
202
  *
package/src/logs/types.ts CHANGED
@@ -11,6 +11,7 @@ export type {
11
11
  OtlpKeyValue,
12
12
  OtlpLogRecord,
13
13
  OtlpLogsPayload,
14
+ BeforeSendLogFn,
14
15
  } from '@posthog/types'
15
16
 
16
17
  /**
@@ -39,44 +40,34 @@ export interface LogSdkContext {
39
40
  import type { Logger as CaptureLoggerType } from '@posthog/types'
40
41
  export type CaptureLogger = CaptureLoggerType
41
42
 
42
- import type { LogAttributeValue, CaptureLogOptions, OtlpLogRecord } from '@posthog/types'
43
+ import type {
44
+ LogAttributeValue,
45
+ CaptureLogOptions,
46
+ OtlpLogRecord,
47
+ OtlpLogsPayload,
48
+ BeforeSendLogFn,
49
+ } from '@posthog/types'
50
+ import type { PostHogPersistedProperty } from '../types'
51
+ import type { SendLogsBatchOutcome } from '../posthog-core-stateless'
43
52
 
44
53
  export interface BufferedLogEntry {
45
54
  record: OtlpLogRecord
46
55
  }
47
56
 
48
57
  /**
49
- * Pre-send filter. Inspect, mutate, or drop a captured record before it
50
- * enters the rate-cap or the queue. Return the (possibly transformed) record
51
- * to keep it; return `null` to drop it.
52
- *
53
- * Configure as a single fn or an array. Arrays form a left-to-right chain:
54
- * each fn receives the previous fn's return value. A `null` from any link
55
- * short-circuits the chain and drops the record.
56
- *
57
- * Runs *before* the rate cap so dropped records don't consume the
58
- * per-interval budget. Throwing fns are logged and skipped — the chain
59
- * continues with the previous return value, so a buggy filter degrades to a
60
- * no-op rather than crashing `captureLog()`.
61
- *
62
- * @example Redact secrets from log bodies
63
- * ```ts
64
- * logs: {
65
- * beforeSend: (record) => ({
66
- * ...record,
67
- * body: record.body.replace(/api_key=\S+/g, 'api_key=[REDACTED]'),
68
- * }),
69
- * }
70
- * ```
71
- *
72
- * @example Drop noisy debug logs in production
73
- * ```ts
74
- * logs: {
75
- * beforeSend: (record) => (record.level === 'debug' ? null : record),
76
- * }
77
- * ```
58
+ * The minimal host surface `PostHogLogs` depends on. `PostHogCoreStateless`
59
+ * satisfies it structurally (mobile/node); the browser supplies an adapter
60
+ * backed by its own persistence and request layer.
78
61
  */
79
- export type BeforeSendLogFn = (record: CaptureLogOptions) => CaptureLogOptions | null
62
+ export interface LogsHost {
63
+ readonly isDisabled: boolean
64
+ readonly optedOut: boolean
65
+ getPersistedProperty<T>(key: PostHogPersistedProperty): T | undefined
66
+ setPersistedProperty<T>(key: PostHogPersistedProperty, value: T | null): void
67
+ _sendLogsBatch(payload: OtlpLogsPayload): Promise<SendLogsBatchOutcome>
68
+ getLibraryId(): string
69
+ getLibraryVersion(): string
70
+ }
80
71
 
81
72
  /**
82
73
  * Configuration for the logs feature on `new PostHog(key, { logs: ... })`.
@@ -87,7 +78,7 @@ export interface PostHogLogsConfig {
87
78
  /**
88
79
  * Service name attached to every record as the OTLP `service.name`
89
80
  * resource attribute. Used by the Logs UI for filtering / grouping.
90
- * Default: `'unknown_service'`.
81
+ * Defaults to `'unknown_service'` when unset.
91
82
  */
92
83
  serviceName?: string
93
84
 
@@ -121,11 +112,11 @@ export interface PostHogLogsConfig {
121
112
  flushIntervalMs?: number
122
113
 
123
114
  /**
124
- * Max records held in memory before the queue evicts the oldest on push
125
- * (FIFO). Bounds memory footprint and on-disk-queue size. When the buffer
126
- * hits this size, an immediate flush is triggered to reclaim space; if
127
- * the flush hasn't completed before the next capture, the oldest record
128
- * is shifted out. Default: 100.
115
+ * Number of buffered records that triggers an immediate flush. Records also
116
+ * flush on the periodic interval and on `shutdown()`. The queue can grow past
117
+ * this while an async flush is in flight (e.g. during a synchronous burst); a
118
+ * separate, larger memory backstop evicts the oldest only once the queue
119
+ * exceeds the per-interval rate cap. Default: 100.
129
120
  */
130
121
  maxBufferSize?: number
131
122
 
@@ -182,6 +173,10 @@ export interface PostHogLogsConfig {
182
173
  // Flat names internally — public API uses `rateCap: { maxLogs, windowMs }`.
183
174
  export interface ResolvedPostHogLogsConfig extends Omit<PostHogLogsConfig, 'rateCap'> {
184
175
  maxBufferSize: number
176
+ // Eviction cap: the queue drops the oldest record once it exceeds this. Separate
177
+ // from `maxBufferSize` (the flush trigger) so a burst is held, not evicted, while
178
+ // the async flush drains. Defaults to `maxBufferSize` when unset.
179
+ maxQueueSize?: number
185
180
  flushIntervalMs: number
186
181
  maxBatchRecordsPerPost: number
187
182
  rateCapWindowMs: number
@@ -53,6 +53,7 @@ interface PendingFlagsRequest extends FlagsAsyncOptions {
53
53
  export abstract class PostHogCore extends PostHogCoreStateless {
54
54
  // options
55
55
  private sendFeatureFlagEvent: boolean
56
+ private disableRemoteFeatureFlags: boolean
56
57
  private flagCallReported: { [key: string]: boolean } = {}
57
58
  private _beforeSend?: BeforeSendFn | BeforeSendFn[]
58
59
 
@@ -82,6 +83,7 @@ export abstract class PostHogCore extends PostHogCoreStateless {
82
83
  super(apiKey, { ...options, disableGeoip: disableGeoipOption, featureFlagsRequestTimeoutMs })
83
84
 
84
85
  this.sendFeatureFlagEvent = options?.sendFeatureFlagEvent ?? true
86
+ this.disableRemoteFeatureFlags = options?.disableRemoteFeatureFlags ?? false
85
87
  this._sessionExpirationTimeSeconds = options?.sessionExpirationTimeSeconds ?? 1800 // 30 minutes
86
88
  this._personProfiles = options?.personProfiles ?? 'identified_only'
87
89
  this._beforeSend = options?.before_send
@@ -178,7 +180,17 @@ export abstract class PostHogCore extends PostHogCoreStateless {
178
180
  }
179
181
  }
180
182
 
181
- this.reloadFeatureFlags()
183
+ if (this.disableRemoteFeatureFlags && !this.disabled) {
184
+ // No reload runs to emit the change, so emit the now-cleared flags directly
185
+ // (drives onFeatureFlags listeners, e.g. session replay re-arm). Callers re-push
186
+ // the new identity's flags via updateFlags(). Skip when the caller kept
187
+ // FeatureFlagDetails — those flags stay as-is, so there's nothing to emit.
188
+ if (!allPropertiesToKeep.includes(PostHogPersistedProperty.FeatureFlagDetails)) {
189
+ this.setKnownFeatureFlagDetails({ flags: {} })
190
+ }
191
+ } else {
192
+ this.reloadFeatureFlags()
193
+ }
182
194
  })
183
195
  }
184
196
 
@@ -585,6 +597,11 @@ export abstract class PostHogCore extends PostHogCoreStateless {
585
597
  if (this.disabled) {
586
598
  return undefined
587
599
  }
600
+ // Config-fetching requests still go out (carrying disable_flags); only pure reloads no-op.
601
+ if (this.disableRemoteFeatureFlags && !fetchConfig) {
602
+ this._logger.info('Feature flags are disabled (disableRemoteFeatureFlags), skipping reload.')
603
+ return undefined
604
+ }
588
605
  if (this._flagsResponsePromise) {
589
606
  // Queue the reload request instead of dropping it
590
607
  // This ensures that requests with $anon_distinct_id (from identify()) are not lost
@@ -696,11 +713,12 @@ export abstract class PostHogCore extends PostHogCoreStateless {
696
713
  // we only dont load flags if the remote config has no feature flags
697
714
  let willLoadFlags = false
698
715
  if (response.hasFeatureFlags === false) {
699
- // resetting flags to empty object
700
- this.setKnownFeatureFlagDetails({ flags: {} })
716
+ if (!this.disableRemoteFeatureFlags) {
717
+ this.setKnownFeatureFlagDetails({ flags: {} })
718
+ }
701
719
 
702
720
  this._logger.warn('Remote config has no feature flags, will not load feature flags.')
703
- } else if (this.preloadFeatureFlags !== false) {
721
+ } else if (this.preloadFeatureFlags !== false && !this.disableRemoteFeatureFlags) {
704
722
  willLoadFlags = true
705
723
  this.flagsAsync({ sendAnonDistinctId: true, fetchConfig: true, triggerOnRemoteConfig: true })
706
724
  }
@@ -746,6 +764,7 @@ export abstract class PostHogCore extends PostHogCoreStateless {
746
764
  $anon_distinct_id: sendAnonDistinctId ? this.getAnonymousId() : undefined,
747
765
  // Only set by the React Native SDK; omitted from JSON when DeviceId is not persisted
748
766
  $device_id: deviceId ?? undefined,
767
+ ...(this.disableRemoteFeatureFlags ? { disable_flags: true } : {}),
749
768
  }
750
769
 
751
770
  const result = await super.getFlags(
@@ -758,20 +777,24 @@ export abstract class PostHogCore extends PostHogCoreStateless {
758
777
  )
759
778
 
760
779
  if (!result.success) {
761
- this.setKnownFeatureFlagDetails({
762
- flags: this.getKnownFeatureFlagDetails()?.flags ?? {},
763
- requestError: result.error,
764
- })
780
+ if (!this.disableRemoteFeatureFlags) {
781
+ this.setKnownFeatureFlagDetails({
782
+ flags: this.getKnownFeatureFlagDetails()?.flags ?? {},
783
+ requestError: result.error,
784
+ })
785
+ }
765
786
  return undefined
766
787
  }
767
788
 
768
789
  const res = result.response
769
790
 
770
791
  if (res?.quotaLimited?.includes(QuotaLimitedFeature.FeatureFlags)) {
771
- this.setKnownFeatureFlagDetails({
772
- flags: this.getKnownFeatureFlagDetails()?.flags ?? {},
773
- quotaLimited: res.quotaLimited,
774
- })
792
+ if (!this.disableRemoteFeatureFlags) {
793
+ this.setKnownFeatureFlagDetails({
794
+ flags: this.getKnownFeatureFlagDetails()?.flags ?? {},
795
+ quotaLimited: res.quotaLimited,
796
+ })
797
+ }
775
798
  this._logger.warn(
776
799
  '[FEATURE FLAGS] Feature flags quota limit exceeded. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
777
800
  )
@@ -779,6 +802,12 @@ export abstract class PostHogCore extends PostHogCoreStateless {
779
802
  return res
780
803
  }
781
804
  if (res?.featureFlags) {
805
+ if (this.disableRemoteFeatureFlags) {
806
+ this.cacheSessionReplay('flags', res)
807
+ this.maybeNotifyRemoteConfig(triggerOnRemoteConfig, res)
808
+ return res
809
+ }
810
+
782
811
  // clear flag call reported if we have new flags since they might have changed
783
812
  if (this.sendFeatureFlagEvent) {
784
813
  this.flagCallReported = {}
@@ -1052,7 +1081,8 @@ export abstract class PostHogCore extends PostHogCoreStateless {
1052
1081
 
1053
1082
  details = details ?? { featureFlags: {}, featureFlagPayloads: {}, flags: {} }
1054
1083
 
1055
- const flags: Record<string, FeatureFlagDetail> = details.flags ?? {}
1084
+ // Copy before applying overrides so reads don't mutate the stored flags in place.
1085
+ const flags: Record<string, FeatureFlagDetail> = { ...(details.flags ?? {}) }
1056
1086
 
1057
1087
  for (const key in overriddenFlags) {
1058
1088
  if (!overriddenFlags[key]) {
@@ -1093,6 +1123,10 @@ export abstract class PostHogCore extends PostHogCoreStateless {
1093
1123
 
1094
1124
  // Used when we want to trigger the reload but we don't care about the result
1095
1125
  reloadFeatureFlags(options?: { cb?: (err?: Error, flags?: PostHogFlagsResponse['featureFlags']) => void }): void {
1126
+ if (this.disableRemoteFeatureFlags && !this.disabled) {
1127
+ this._initPromise.then(() => options?.cb?.(undefined, this.getFeatureFlags()))
1128
+ return
1129
+ }
1096
1130
  this.flagsAsync({ sendAnonDistinctId: true })
1097
1131
  .then((res) => {
1098
1132
  options?.cb?.(undefined, res?.featureFlags)
@@ -1112,6 +1146,10 @@ export abstract class PostHogCore extends PostHogCoreStateless {
1112
1146
  async reloadFeatureFlagsAsync(
1113
1147
  sendAnonDistinctId?: boolean
1114
1148
  ): Promise<PostHogFlagsResponse['featureFlags'] | undefined> {
1149
+ if (this.disableRemoteFeatureFlags && !this.disabled) {
1150
+ await this._initPromise
1151
+ return this.getFeatureFlags()
1152
+ }
1115
1153
  return (await this.flagsAsync({ sendAnonDistinctId: sendAnonDistinctId }))?.featureFlags
1116
1154
  }
1117
1155
 
@@ -1142,6 +1180,75 @@ export abstract class PostHogCore extends PostHogCoreStateless {
1142
1180
  })
1143
1181
  }
1144
1182
 
1183
+ /**
1184
+ * Replaces (or merges into) the stored feature flags and payloads with locally supplied
1185
+ * values, exactly as if they had been returned by the flags endpoint: the values are
1186
+ * persisted, `getFeatureFlag()`/`getFeatureFlagPayload()` read them back, and
1187
+ * `onFeatureFlags` listeners fire. Makes no network request.
1188
+ *
1189
+ * Intended for apps that evaluate flags outside the SDK (e.g. server-side local
1190
+ * evaluation) and push the results in at runtime, typically together with the
1191
+ * `disableRemoteFeatureFlags` option so the SDK never fetches flags itself.
1192
+ *
1193
+ * The values are cleared by `reset()`, so push them again after an identity change.
1194
+ *
1195
+ * @param flags - Flag keys mapped to their values (boolean, or a variant string)
1196
+ * @param payloads - Optional flag keys mapped to their JSON payloads
1197
+ * @param options - Set `merge: true` to merge with the currently stored flags instead of replacing them
1198
+ */
1199
+ updateFlags(
1200
+ flags: Record<string, FeatureFlagValue>,
1201
+ payloads?: Record<string, JsonType>,
1202
+ options?: { merge?: boolean }
1203
+ ): void {
1204
+ this.wrap(() => {
1205
+ // Merge against the raw stored flags, not the override-applied view.
1206
+ const existingDetails = options?.merge ? this.getKnownFeatureFlagDetails()?.flags : undefined
1207
+ const existingFlags = existingDetails ? getFlagValuesFromFlags(existingDetails) : {}
1208
+ const existingPayloads: Record<string, JsonType> = {}
1209
+ for (const key in existingDetails) {
1210
+ const storedPayload = existingDetails[key].metadata?.payload
1211
+ if (storedPayload !== undefined) {
1212
+ existingPayloads[key] = parsePayload(storedPayload)
1213
+ }
1214
+ }
1215
+ const finalFlags = { ...existingFlags, ...flags }
1216
+ const finalPayloads = { ...existingPayloads, ...(payloads ?? {}) }
1217
+
1218
+ // Built by hand, not via createFlagsResponseFromFlagsAndPayloads, which drops false flags.
1219
+ const flagDetails: Record<string, FeatureFlagDetail> = {}
1220
+ for (const [key, value] of Object.entries(finalFlags)) {
1221
+ const payload = finalPayloads[key]
1222
+ let serializedPayload: string | undefined
1223
+ if (payload !== undefined) {
1224
+ try {
1225
+ serializedPayload = JSON.stringify(payload)
1226
+ } catch (e) {
1227
+ this._logger.error(`updateFlags: could not serialize the payload for flag "${key}", dropping it.`, e)
1228
+ }
1229
+ }
1230
+ flagDetails[key] = {
1231
+ key,
1232
+ enabled: getEnabledFromValue(value),
1233
+ variant: getVariantFromValue(value),
1234
+ reason: undefined,
1235
+ // Locally supplied flags have no server-side id/version
1236
+ metadata: {
1237
+ id: undefined,
1238
+ version: undefined,
1239
+ description: undefined,
1240
+ payload: serializedPayload,
1241
+ },
1242
+ }
1243
+ }
1244
+
1245
+ if (this.sendFeatureFlagEvent) {
1246
+ this.flagCallReported = {}
1247
+ }
1248
+ this.setKnownFeatureFlagDetails({ flags: flagDetails })
1249
+ })
1250
+ }
1251
+
1145
1252
  /**
1146
1253
  * Capture a caught exception manually
1147
1254
  *
package/src/types.ts CHANGED
@@ -53,6 +53,25 @@ export type PostHogCoreOptions = {
53
53
  * @default true
54
54
  */
55
55
  preloadFeatureFlags?: boolean
56
+ /**
57
+ * Advanced: whether to disable fetching and evaluating feature flags from PostHog entirely.
58
+ *
59
+ * When set to true, `reloadFeatureFlags()` and the reloads triggered by `identify()`,
60
+ * `group()`, `setPersonPropertiesForFlags()` and `reset()` become no-ops, and any request
61
+ * to the flags endpoint that still goes out (e.g. to fetch remote config or surveys)
62
+ * carries `disable_flags: true` so the server skips flag evaluation. Flag values must be
63
+ * supplied via the `bootstrap` option or `updateFlags()` instead; `getFeatureFlag()` and
64
+ * related methods keep working against those values. Until `updateFlags()` runs, reads
65
+ * return their not-loaded defaults, so use `bootstrap` for any flags needed at startup.
66
+ * Equivalent to the web SDK's `advanced_disable_feature_flags`.
67
+ *
68
+ * Note: surveys gated on feature flags will not evaluate unless the survey targeting
69
+ * flags are also provided via `updateFlags()`. This option cannot be toggled at runtime.
70
+ * `posthog-node` inherits this option but does not implement it (no-op).
71
+ *
72
+ * @default false
73
+ */
74
+ disableRemoteFeatureFlags?: boolean
56
75
  /**
57
76
  * Whether to load remote config when initialized or not
58
77
  * Experimental support