@posthog/core 1.34.0 → 1.35.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/logs/index.ts CHANGED
@@ -1,18 +1,17 @@
1
1
  import type { LogAttributeValue } from '@posthog/types'
2
- import { buildOtlpLogRecord, buildOtlpLogsPayload } from './logs-utils'
2
+ import { buildOtlpLogRecord, buildOtlpLogsPayload, buildResourceAttributes } from './logs-utils'
3
3
  import { Logger, PostHogPersistedProperty } from '../types'
4
- import type { PostHogCoreStateless } from '../posthog-core-stateless'
5
4
  import { isArray, safeSetTimeout } from '../utils'
6
- import type {
7
- BeforeSendLogFn,
8
- BufferedLogEntry,
9
- CaptureLogOptions,
10
- LogSdkContext,
11
- ResolvedPostHogLogsConfig,
12
- } from './types'
5
+ import type { BufferedLogEntry, CaptureLogOptions, LogSdkContext, LogsHost, ResolvedPostHogLogsConfig } from './types'
6
+
7
+ // Caps the retry backoff at 2^6 = 64× the flush interval.
8
+ const MAX_FLUSH_BACKOFF_EXPONENT = 6
13
9
 
14
10
  export class PostHogLogs {
15
11
  private _maxBufferSize: number
12
+ // Eviction cap; `_maxBufferSize` only triggers a flush. Collapses to
13
+ // `_maxBufferSize` when the host sets no separate value.
14
+ private _maxQueueSize: number
16
15
  private _flushIntervalMs: number
17
16
  // Mutable — halved on 413 to shrink the next POST, and ramped back up by
18
17
  // one record after each successful send so a one-off oversized payload
@@ -22,6 +21,12 @@ export class PostHogLogs {
22
21
  // Serializes concurrent flushes — the second caller awaits the first rather
23
22
  // than racing it and double-sending the same head-of-queue records.
24
23
  private _flushPromise: Promise<void> | null = null
24
+ // Head records evicted (FIFO) while a batch is in flight; the queue-advance
25
+ // subtracts these so it drops the sent records, not ones captured mid-send.
26
+ private _evictedSinceAdvance = 0
27
+ // Consecutive failed flushes; drives exponential backoff on the retry timer.
28
+ // A successful flush resets it to 0.
29
+ private _consecutiveFlushFailures = 0
25
30
 
26
31
  // Fixed-window rate cap. Tumbling (not sliding) for cheap arithmetic on the
27
32
  // hot path. Window rolls the first time `captureLog` fires after the window
@@ -34,7 +39,7 @@ export class PostHogLogs {
34
39
  private _droppedWarned = false
35
40
 
36
41
  constructor(
37
- private readonly _instance: PostHogCoreStateless,
42
+ private readonly _instance: LogsHost,
38
43
  private readonly _config: ResolvedPostHogLogsConfig,
39
44
  private readonly _logger: Logger,
40
45
  private readonly _getContext: () => LogSdkContext,
@@ -47,12 +52,38 @@ export class PostHogLogs {
47
52
  private readonly _waitForStoragePersist: () => Promise<void> = () => Promise.resolve()
48
53
  ) {
49
54
  this._maxBufferSize = _config.maxBufferSize
55
+ // Never below the flush trigger: a smaller eviction cap would stop the
56
+ // size-based flush from ever firing. Collapses to `maxBufferSize` when unset.
57
+ this._maxQueueSize = Math.max(_config.maxQueueSize ?? _config.maxBufferSize, _config.maxBufferSize)
50
58
  this._flushIntervalMs = _config.flushIntervalMs
51
59
  this._maxBatchRecordsPerPost = _config.maxBatchRecordsPerPost
52
60
  this._rateCapWindowMs = _config.rateCapWindowMs
53
61
  this._maxLogsPerInterval = _config.maxLogsPerInterval
54
62
  }
55
63
 
64
+ /**
65
+ * Clears the flush timer and rate-cap state. The host owns the record queue
66
+ * and clears it separately (the browser empties its in-memory store).
67
+ */
68
+ reset(): void {
69
+ this._clearFlushTimer()
70
+ this._flushPromise = null
71
+ this._intervalWindowStart = 0
72
+ this._intervalLogCount = 0
73
+ this._droppedWarned = false
74
+ this._evictedSinceAdvance = 0
75
+ this._consecutiveFlushFailures = 0
76
+ this._maxBatchRecordsPerPost = this._config.maxBatchRecordsPerPost
77
+ }
78
+
79
+ // Call when connectivity is restored: clear the failure backoff and flush now,
80
+ // so records don't wait out a (possibly minutes-long) backoff delay after the
81
+ // network returns. The host owns connectivity detection (web: `online` event).
82
+ onReconnect(): void {
83
+ this._consecutiveFlushFailures = 0
84
+ this._flushInBackground()
85
+ }
86
+
56
87
  captureLog(options: CaptureLogOptions): void {
57
88
  if (this._instance.isDisabled) {
58
89
  return
@@ -70,8 +101,10 @@ export class PostHogLogs {
70
101
  if (filtered === null) {
71
102
  return
72
103
  }
73
- // beforeSend could return a record with empty body — treat as drop.
104
+ // beforeSend could return a record with empty body — drop it, mirroring the
105
+ // null-return path so both beforeSend drops surface the same diagnostic.
74
106
  if (!filtered.body) {
107
+ this._logger.info(`Log was rejected in beforeSend function`)
75
108
  return
76
109
  }
77
110
 
@@ -196,6 +229,12 @@ export class PostHogLogs {
196
229
  let sentCount = 0
197
230
 
198
231
  while (queue.length > 0 && sentCount < originalQueueLength) {
232
+ // Reset per batch so the advance below counts only evictions during THIS
233
+ // batch's send. Evictions during the persist await or between iterations
234
+ // are already reflected in the next iteration's queue read, so they must
235
+ // not carry into the next batch's drop math.
236
+ this._evictedSinceAdvance = 0
237
+
199
238
  const batchSize = Math.min(queue.length, this._maxBatchRecordsPerPost)
200
239
  const batch = queue.slice(0, batchSize)
201
240
  const records = batch.map((e) => e.record)
@@ -250,36 +289,19 @@ export class PostHogLogs {
250
289
  }
251
290
 
252
291
  private async _persistQueueAdvance(consumed: number): Promise<void> {
253
- // Re-read the queue in case captures landed mid-flush, then drop the head.
292
+ // Re-read in case captures landed mid-flush. Subtract head records the FIFO cap
293
+ // evicted during the send (already-sent records that left the queue), so we drop
294
+ // only the still-present sent records, not ones captured during the send.
295
+ const evicted = this._evictedSinceAdvance
296
+ const drop = Math.max(0, consumed - evicted)
254
297
  const refreshed = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue) ?? []
255
- this._instance.setPersistedProperty(PostHogPersistedProperty.LogsQueue, refreshed.slice(consumed))
256
- // Wait for the advance to hit disk before the next batch sends, matching
257
- // events' `flushStorage()` contract. Prevents duplicates if the app crashes
258
- // after the HTTP success but before the queue-advance persists.
298
+ this._instance.setPersistedProperty(PostHogPersistedProperty.LogsQueue, refreshed.slice(drop))
299
+ // Persist before the next batch sends so a crash after HTTP success can't dupe.
259
300
  await this._waitForStoragePersist()
260
301
  }
261
302
 
262
- /**
263
- * OTLP resource attributes for every batch.
264
- *
265
- * Layout: user `resourceAttributes` spread first, then SDK-controlled
266
- * keys layered on top so users cannot accidentally clobber them. Most logs
267
- * backends index on `service.name` and `telemetry.sdk.*` for routing,
268
- * SDK-version dashboards, and bug-correlation; letting a stray user key
269
- * overwrite them silently breaks ingestion attribution. The dedicated
270
- * `serviceName` / `environment` / `serviceVersion` config fields are the
271
- * supported way to override `service.name` / `deployment.environment` /
272
- * `service.version`.
273
- */
274
303
  private _buildResourceAttributes(): Record<string, LogAttributeValue> {
275
- return {
276
- ...this._config.resourceAttributes,
277
- 'service.name': this._config.serviceName || 'unknown_service',
278
- ...(this._config.environment && { 'deployment.environment': this._config.environment }),
279
- ...(this._config.serviceVersion && { 'service.version': this._config.serviceVersion }),
280
- 'telemetry.sdk.name': this._instance.getLibraryId(),
281
- 'telemetry.sdk.version': this._instance.getLibraryVersion(),
282
- }
304
+ return buildResourceAttributes(this._config, this._instance.getLibraryId(), this._instance.getLibraryVersion())
283
305
  }
284
306
 
285
307
  private _enqueue(entry: BufferedLogEntry): void {
@@ -290,28 +312,50 @@ export class PostHogLogs {
290
312
  }
291
313
 
292
314
  const queue = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue) ?? []
293
- if (queue.length >= this._maxBufferSize) {
315
+ // Drop the oldest only above the eviction cap, not at the flush trigger, so a
316
+ // burst is held while the async flush drains.
317
+ if (queue.length >= this._maxQueueSize) {
294
318
  queue.shift()
319
+ this._evictedSinceAdvance++
295
320
  this._logger.info('Logs queue is full, dropping oldest record.')
296
321
  }
297
322
  queue.push(entry)
298
323
  this._instance.setPersistedProperty(PostHogPersistedProperty.LogsQueue, queue)
299
324
 
300
- // Threshold trigger: at-capacity means flushing now reclaims space before
301
- // the next capture has to shift something out.
325
+ // Flush trigger: drain now rather than waiting for the timer. The queue may
326
+ // grow past this up to the eviction cap while the flush is in flight.
302
327
  if (queue.length >= this._maxBufferSize) {
303
328
  this._flushInBackground()
304
329
  return
305
330
  }
306
331
 
307
- // Timer trigger: only arm one timer at a time. A subsequent enqueue within
308
- // the window shouldn't reschedule — that would keep pushing the flush out.
309
- if (!this._flushTimer) {
310
- this._flushTimer = safeSetTimeout(() => {
311
- this._flushTimer = undefined
312
- this._flushInBackground()
313
- }, this._flushIntervalMs)
332
+ // Arm one timer at a time; re-arming within the window would push the flush out.
333
+ this._armFlushTimer()
334
+ }
335
+
336
+ // Arms the flush timer if none is pending. One-shot: the callback clears the
337
+ // handle so the next enqueue (or a flush that left records) schedules again.
338
+ private _armFlushTimer(delayMs: number = this._flushIntervalMs): void {
339
+ if (this._flushTimer) {
340
+ return
314
341
  }
342
+ this._flushTimer = safeSetTimeout(() => {
343
+ this._flushTimer = undefined
344
+ this._flushInBackground()
345
+ }, delayMs)
346
+ }
347
+
348
+ // Retry delay after a flush that left records: the first retry is at the base
349
+ // interval, then exponential backoff (capped) so a sustained outage isn't
350
+ // retried every interval.
351
+ private _nextFlushDelay(): number {
352
+ const exponent = Math.min(Math.max(0, this._consecutiveFlushFailures - 1), MAX_FLUSH_BACKOFF_EXPONENT)
353
+ return this._flushIntervalMs * 2 ** exponent
354
+ }
355
+
356
+ private _hasQueuedRecords(): boolean {
357
+ const queue = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue)
358
+ return !!queue && queue.length > 0
315
359
  }
316
360
 
317
361
  /**
@@ -371,9 +415,24 @@ export class PostHogLogs {
371
415
  }
372
416
 
373
417
  private _flushInBackground(): void {
374
- void this.flush().catch((err) => {
375
- this._logger.error('PostHog logs flush failed:', err)
376
- })
418
+ void this.flush()
419
+ .then(
420
+ () => {
421
+ this._consecutiveFlushFailures = 0
422
+ },
423
+ (err) => {
424
+ this._consecutiveFlushFailures++
425
+ this._logger.error('PostHog logs flush failed:', err)
426
+ }
427
+ )
428
+ .finally(() => {
429
+ // Records remaining (transient failure or mid-flush captures) would otherwise
430
+ // sit undelivered on a quiet page; re-arm so the timer retries them, backing
431
+ // off on consecutive failures.
432
+ if (!this._instance.isDisabled && this._hasQueuedRecords()) {
433
+ this._armFlushTimer(this._nextFlushDelay())
434
+ }
435
+ })
377
436
  }
378
437
 
379
438
  private _clearFlushTimer(): void {
@@ -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
@@ -1052,6 +1052,9 @@ export abstract class PostHogCore extends PostHogCoreStateless {
1052
1052
  return result?.variant ?? result?.enabled
1053
1053
  }
1054
1054
 
1055
+ /**
1056
+ * @deprecated Use `getFeatureFlagResult()` instead, which returns the flag value and payload from a single evaluation.
1057
+ */
1055
1058
  getFeatureFlagPayload(key: string): JsonType | undefined {
1056
1059
  const result = this._getFeatureFlagResult(key, { missingFlagBehavior: 'getFeatureFlagPayload', sendEvent: false })
1057
1060
  return result?.payload