@posthog/core 1.27.9 → 1.28.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.
Files changed (51) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/logs/index.d.ts +94 -4
  4. package/dist/logs/index.d.ts.map +1 -1
  5. package/dist/logs/index.js +147 -4
  6. package/dist/logs/index.mjs +148 -5
  7. package/dist/logs/logs-utils.d.ts +2 -1
  8. package/dist/logs/logs-utils.d.ts.map +1 -1
  9. package/dist/logs/types.d.ts +140 -8
  10. package/dist/logs/types.d.ts.map +1 -1
  11. package/dist/posthog-core-stateless.d.ts +34 -0
  12. package/dist/posthog-core-stateless.d.ts.map +1 -1
  13. package/dist/posthog-core-stateless.js +39 -0
  14. package/dist/posthog-core-stateless.mjs +39 -0
  15. package/dist/surveys/events.d.ts +22 -0
  16. package/dist/surveys/events.d.ts.map +1 -0
  17. package/dist/surveys/events.js +95 -0
  18. package/dist/surveys/events.mjs +43 -0
  19. package/dist/surveys/index.d.ts +4 -0
  20. package/dist/surveys/index.d.ts.map +1 -0
  21. package/dist/surveys/index.js +83 -0
  22. package/dist/surveys/index.mjs +4 -0
  23. package/dist/surveys/translations.d.ts +38 -0
  24. package/dist/surveys/translations.d.ts.map +1 -0
  25. package/dist/surveys/translations.js +207 -0
  26. package/dist/surveys/translations.mjs +158 -0
  27. package/dist/testing/test-utils.d.ts.map +1 -1
  28. package/dist/testing/test-utils.js +1 -0
  29. package/dist/testing/test-utils.mjs +1 -0
  30. package/dist/types.d.ts +31 -2
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/utils/logger.d.ts +1 -1
  33. package/dist/utils/logger.d.ts.map +1 -1
  34. package/dist/utils/logger.js +3 -0
  35. package/dist/utils/logger.mjs +3 -0
  36. package/package.json +26 -2
  37. package/src/index.ts +12 -1
  38. package/src/logs/index.spec.ts +891 -17
  39. package/src/logs/index.ts +337 -13
  40. package/src/logs/logs-utils.spec.ts +2 -1
  41. package/src/logs/logs-utils.ts +1 -1
  42. package/src/logs/types.ts +150 -25
  43. package/src/posthog-core-stateless.ts +64 -0
  44. package/src/surveys/events.spec.ts +52 -0
  45. package/src/surveys/events.ts +80 -0
  46. package/src/surveys/index.ts +18 -0
  47. package/src/surveys/translations.spec.ts +205 -0
  48. package/src/surveys/translations.ts +244 -0
  49. package/src/testing/test-utils.ts +1 -0
  50. package/src/types.ts +38 -2
  51. package/src/utils/logger.ts +6 -2
package/src/logs/index.ts CHANGED
@@ -1,28 +1,59 @@
1
- import type { LogSdkContext } from '@posthog/types'
2
- import { buildOtlpLogRecord } from './logs-utils'
1
+ import type { LogAttributeValue } from '@posthog/types'
2
+ import { buildOtlpLogRecord, buildOtlpLogsPayload } from './logs-utils'
3
3
  import { Logger, PostHogPersistedProperty } from '../types'
4
4
  import type { PostHogCoreStateless } from '../posthog-core-stateless'
5
- import type { BufferedLogEntry, CaptureLogOptions, ResolvedPostHogLogsConfig } from './types'
5
+ import { isArray, safeSetTimeout } from '../utils'
6
+ import type {
7
+ BeforeSendLogFn,
8
+ BufferedLogEntry,
9
+ CaptureLogOptions,
10
+ LogSdkContext,
11
+ ResolvedPostHogLogsConfig,
12
+ } from './types'
6
13
 
7
14
  export class PostHogLogs {
8
- private _localEnabled: boolean
9
15
  private _maxBufferSize: number
16
+ private _flushIntervalMs: number
17
+ // Mutable — halved on 413 to shrink the next POST, and ramped back up by
18
+ // one record after each successful send so a one-off oversized payload
19
+ // (e.g. a giant stack trace) doesn't permanently degrade throughput.
20
+ private _maxBatchRecordsPerPost: number
21
+ private _flushTimer?: ReturnType<typeof safeSetTimeout>
22
+ // Serializes concurrent flushes — the second caller awaits the first rather
23
+ // than racing it and double-sending the same head-of-queue records.
24
+ private _flushPromise: Promise<void> | null = null
25
+
26
+ // Fixed-window rate cap. Tumbling (not sliding) for cheap arithmetic on the
27
+ // hot path. Window rolls the first time `captureLog` fires after the window
28
+ // expires — no background timer needed. `_droppedWarned` keeps the log noise
29
+ // to one line per window regardless of how many records got dropped.
30
+ private _rateCapWindowMs: number
31
+ private _maxLogsPerInterval?: number
32
+ private _intervalWindowStart = 0
33
+ private _intervalLogCount = 0
34
+ private _droppedWarned = false
10
35
 
11
36
  constructor(
12
37
  private readonly _instance: PostHogCoreStateless,
13
38
  private readonly _config: ResolvedPostHogLogsConfig,
14
39
  private readonly _logger: Logger,
15
40
  private readonly _getContext: () => LogSdkContext,
16
- private readonly _onReady: (fn: () => void) => void
41
+ private readonly _onReady: (fn: () => void) => void,
42
+ // Waits for the logs-storage persist to hit disk. Called between batches
43
+ // so a crash after a successful HTTP send but before the queue-advance
44
+ // reaches disk can't cause duplicate records on next startup. SDKs with
45
+ // synchronous storage (or no async persist layer) can pass a no-op. RN
46
+ // wires this to its dedicated `_logsStorage.waitForPersist()`.
47
+ private readonly _waitForStoragePersist: () => Promise<void> = () => Promise.resolve()
17
48
  ) {
18
- this._localEnabled = _config.enabled !== false
19
49
  this._maxBufferSize = _config.maxBufferSize
50
+ this._flushIntervalMs = _config.flushIntervalMs
51
+ this._maxBatchRecordsPerPost = _config.maxBatchRecordsPerPost
52
+ this._rateCapWindowMs = _config.rateCapWindowMs
53
+ this._maxLogsPerInterval = _config.maxLogsPerInterval
20
54
  }
21
55
 
22
56
  captureLog(options: CaptureLogOptions): void {
23
- if (!this._localEnabled) {
24
- return
25
- }
26
57
  if (this._instance.optedOut) {
27
58
  return
28
59
  }
@@ -30,19 +61,227 @@ export class PostHogLogs {
30
61
  return
31
62
  }
32
63
 
64
+ // Ordering: beforeSend → rate cap → OTLP build. beforeSend runs first so
65
+ // user-dropped records don't consume the per-interval budget.
66
+ const filtered = this._runBeforeSend(options)
67
+ if (filtered === null) {
68
+ return
69
+ }
70
+ // beforeSend could return a record with empty body — treat as drop.
71
+ if (!filtered.body) {
72
+ return
73
+ }
74
+
75
+ if (!this._checkRateLimit()) {
76
+ return
77
+ }
78
+
33
79
  // Build before deferring so attributes reflect state at capture time, not
34
80
  // at drain time (identity/session changes between capture and drain must
35
81
  // not corrupt recorded attributes).
36
- const record = buildOtlpLogRecord(options, this._getContext())
82
+ const record = buildOtlpLogRecord(filtered, this._getContext())
37
83
  const entry: BufferedLogEntry = { record }
38
84
 
39
85
  this._onReady(() => this._enqueue(entry))
40
86
  }
41
87
 
88
+ /**
89
+ * Runs the configured `beforeSend` hook(s) on a capture record:
90
+ * - single fn OR array of fns (chain, left-to-right)
91
+ * - returning `null` drops the record (logged at info)
92
+ * - a thrown error is logged and the chain *continues* with the previous
93
+ * result — a buggy user filter must never crash the caller's
94
+ * `captureLog()` call
95
+ */
96
+ private _runBeforeSend(options: CaptureLogOptions): CaptureLogOptions | null {
97
+ const beforeSend = this._config.beforeSend
98
+ if (!beforeSend) {
99
+ return options
100
+ }
101
+ const fns = isArray(beforeSend) ? beforeSend : [beforeSend]
102
+ let result: CaptureLogOptions = options
103
+ for (const fn of fns) {
104
+ try {
105
+ const next = fn(result)
106
+ if (!next) {
107
+ this._logger.info(`Log was rejected in beforeSend function`)
108
+ return null
109
+ }
110
+ result = next
111
+ } catch (e) {
112
+ // Swallow the throw — the chain continues with `result` unchanged so
113
+ // a buggy filter degrades to a no-op rather than crashing the app.
114
+ this._logger.error(`Error in beforeSend function for log:`, e)
115
+ }
116
+ }
117
+ return result
118
+ }
119
+
120
+ /**
121
+ * Returns `true` if this capture fits within the current rate-cap window,
122
+ * `false` if it should be dropped.
123
+ *
124
+ * Fixed (tumbling) window: the counter resets the first time `captureLog`
125
+ * fires after `rateCapWindowMs` has elapsed — no timer needed.
126
+ * `maxLogsPerInterval === undefined` means unbounded.
127
+ *
128
+ * Wall-clock safety: if `Date.now()` jumps backward (manual device-clock
129
+ * change, big NTP correction), `elapsed` goes negative. We treat that the
130
+ * same as "window expired" and reset — otherwise the rate cap would be
131
+ * stuck until the clock caught up to the old window start, potentially
132
+ * dropping logs for hours.
133
+ *
134
+ * Pre-init note: the counter increments here, before `_onReady` defers
135
+ * `_enqueue` to the init promise. If init resolves slowly and the user is
136
+ * later opted out, the counter has already consumed budget for records
137
+ * that won't enqueue. Cosmetic — no record is "lost" beyond what's
138
+ * already gated, and the window rolls on its own.
139
+ */
140
+ private _checkRateLimit(): boolean {
141
+ if (this._maxLogsPerInterval === undefined) {
142
+ return true
143
+ }
144
+ const now = Date.now()
145
+ const elapsed = now - this._intervalWindowStart
146
+ if (elapsed >= this._rateCapWindowMs || elapsed < 0) {
147
+ this._intervalWindowStart = now
148
+ this._intervalLogCount = 0
149
+ this._droppedWarned = false
150
+ }
151
+ if (this._intervalLogCount >= this._maxLogsPerInterval) {
152
+ if (!this._droppedWarned) {
153
+ this._logger.warn(
154
+ `captureLog dropping logs: exceeded ${this._maxLogsPerInterval} logs per ${this._rateCapWindowMs}ms`
155
+ )
156
+ this._droppedWarned = true
157
+ }
158
+ return false
159
+ }
160
+ this._intervalLogCount++
161
+ return true
162
+ }
163
+
164
+ /**
165
+ * Drains `LogsQueue` in `maxBatchRecordsPerPost` slices, POSTing each as an
166
+ * OTLP payload.
167
+ * - Network error → keep items in queue, re-throw (caller retries later)
168
+ * - 413 → halve batch size, retry same records (do not advance)
169
+ * - Any other error → drop the batch (avoid infinite loop on malformed data),
170
+ * re-throw so callers can log/report
171
+ * Concurrent calls are serialized through `_flushPromise` so records at the
172
+ * head of the queue can't be sent twice.
173
+ */
174
+ async flush(): Promise<void> {
175
+ if (this._flushPromise) {
176
+ return this._flushPromise
177
+ }
178
+ this._flushPromise = this._flushInner().finally(() => {
179
+ this._flushPromise = null
180
+ })
181
+ return this._flushPromise
182
+ }
183
+
184
+ private async _flushInner(): Promise<void> {
185
+ this._clearFlushTimer()
186
+
187
+ let queue = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue) ?? []
188
+ if (queue.length === 0) {
189
+ return
190
+ }
191
+
192
+ const originalQueueLength = queue.length
193
+ let sentCount = 0
194
+
195
+ while (queue.length > 0 && sentCount < originalQueueLength) {
196
+ const batchSize = Math.min(queue.length, this._maxBatchRecordsPerPost)
197
+ const batch = queue.slice(0, batchSize)
198
+ const records = batch.map((e) => e.record)
199
+
200
+ const payload = buildOtlpLogsPayload(
201
+ records,
202
+ this._buildResourceAttributes(),
203
+ this._instance.getLibraryId(),
204
+ this._instance.getLibraryVersion()
205
+ )
206
+
207
+ const outcome = await this._instance._sendLogsBatch(payload)
208
+
209
+ if (outcome.kind === 'too-large' && batch.length > 1) {
210
+ this._maxBatchRecordsPerPost = Math.max(1, Math.floor(batch.length / 2))
211
+ this._logger.warn(
212
+ `Received 413 when sending logs batch of size ${batch.length}, reducing batch size to ${this._maxBatchRecordsPerPost}`
213
+ )
214
+ // Don't advance the queue — retry the same records with the smaller cap.
215
+ continue
216
+ }
217
+
218
+ if (outcome.kind === 'retry-later') {
219
+ // Network error: keep records in the queue for the next flush cycle
220
+ // and surface the error so the caller can log/react.
221
+ throw outcome.error
222
+ }
223
+
224
+ // ok | fatal | too-large-with-batch-of-1 → records are leaving the
225
+ // queue. 'fatal' and size-1 413s are dropped so we don't spin on the
226
+ // same record forever. Surface the size-1 413 explicitly so a single
227
+ // oversized record (e.g. a giant body field) is visible in logs
228
+ // instead of silently disappearing.
229
+ if (outcome.kind === 'too-large') {
230
+ this._logger.warn(
231
+ 'Dropping a single log record after 413 with batch size 1 — the record is larger than the server cap and cannot be split further.'
232
+ )
233
+ } else if (outcome.kind === 'ok' && this._maxBatchRecordsPerPost < this._config.maxBatchRecordsPerPost) {
234
+ // Linear recovery: each healthy send pushes the cap back up by 1
235
+ // toward the configured maximum. Linear (not doubling) so we don't
236
+ // immediately retrigger a 413 if the previous shrink was justified.
237
+ this._maxBatchRecordsPerPost = Math.min(this._config.maxBatchRecordsPerPost, this._maxBatchRecordsPerPost + 1)
238
+ }
239
+ await this._persistQueueAdvance(batch.length)
240
+ queue = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue) ?? []
241
+ sentCount += batch.length
242
+
243
+ if (outcome.kind === 'fatal') {
244
+ throw outcome.error
245
+ }
246
+ }
247
+ }
248
+
249
+ private async _persistQueueAdvance(consumed: number): Promise<void> {
250
+ // Re-read the queue in case captures landed mid-flush, then drop the head.
251
+ const refreshed = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue) ?? []
252
+ this._instance.setPersistedProperty(PostHogPersistedProperty.LogsQueue, refreshed.slice(consumed))
253
+ // Wait for the advance to hit disk before the next batch sends, matching
254
+ // events' `flushStorage()` contract. Prevents duplicates if the app crashes
255
+ // after the HTTP success but before the queue-advance persists.
256
+ await this._waitForStoragePersist()
257
+ }
258
+
259
+ /**
260
+ * OTLP resource attributes for every batch.
261
+ *
262
+ * Layout: user `resourceAttributes` spread first, then SDK-controlled
263
+ * keys layered on top so users cannot accidentally clobber them. Most logs
264
+ * backends index on `service.name` and `telemetry.sdk.*` for routing,
265
+ * SDK-version dashboards, and bug-correlation; letting a stray user key
266
+ * overwrite them silently breaks ingestion attribution. The dedicated
267
+ * `serviceName` / `environment` / `serviceVersion` config fields are the
268
+ * supported way to override `service.name` / `deployment.environment` /
269
+ * `service.version`.
270
+ */
271
+ private _buildResourceAttributes(): Record<string, LogAttributeValue> {
272
+ return {
273
+ ...this._config.resourceAttributes,
274
+ 'service.name': this._config.serviceName || 'unknown_service',
275
+ ...(this._config.environment && { 'deployment.environment': this._config.environment }),
276
+ ...(this._config.serviceVersion && { 'service.version': this._config.serviceVersion }),
277
+ 'telemetry.sdk.name': this._instance.getLibraryId(),
278
+ 'telemetry.sdk.version': this._instance.getLibraryVersion(),
279
+ }
280
+ }
281
+
42
282
  private _enqueue(entry: BufferedLogEntry): void {
43
- // Re-check: optedOut can flip between captureLog and here preload may
44
- // have hydrated the real persisted value, or optIn/optOut may have fired
45
- // while this fn was deferred.
283
+ // Re-check optedOut: preload may have hydrated the real persisted value,
284
+ // or optIn/optOut may have fired while this fn was deferred.
46
285
  if (this._instance.optedOut) {
47
286
  return
48
287
  }
@@ -54,5 +293,90 @@ export class PostHogLogs {
54
293
  }
55
294
  queue.push(entry)
56
295
  this._instance.setPersistedProperty(PostHogPersistedProperty.LogsQueue, queue)
296
+
297
+ // Threshold trigger: at-capacity means flushing now reclaims space before
298
+ // the next capture has to shift something out.
299
+ if (queue.length >= this._maxBufferSize) {
300
+ this._flushInBackground()
301
+ return
302
+ }
303
+
304
+ // Timer trigger: only arm one timer at a time. A subsequent enqueue within
305
+ // the window shouldn't reschedule — that would keep pushing the flush out.
306
+ if (!this._flushTimer) {
307
+ this._flushTimer = safeSetTimeout(() => {
308
+ this._flushTimer = undefined
309
+ this._flushInBackground()
310
+ }, this._flushIntervalMs)
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Stops the timer-based flush and sends anything still in the queue.
316
+ * Intended for process-teardown paths (RN `_shutdown` override). Swallows
317
+ * errors so a failing final flush can't block the broader shutdown.
318
+ *
319
+ * If `timeoutMs` is provided, the final flush races against that budget so
320
+ * a slow network/storage can't hold up shutdown indefinitely. Without it,
321
+ * flush time is bounded only by `fetchRetryCount * (requestTimeout +
322
+ * fetchRetryDelay)`, which can exceed the caller's shutdown SLA.
323
+ */
324
+ async shutdown(timeoutMs?: number): Promise<void> {
325
+ this._clearFlushTimer()
326
+ const flushPromise = this.flush().catch(() => {
327
+ // Best-effort: a logs-flush failure during shutdown is not actionable
328
+ // and must not prevent the rest of shutdown from running. Errors are
329
+ // still surfaced from the regular `flush()` path in normal operation.
330
+ })
331
+ if (timeoutMs === undefined) {
332
+ await flushPromise
333
+ return
334
+ }
335
+ await Promise.race([flushPromise, new Promise<void>((resolve) => safeSetTimeout(resolve, timeoutMs))])
336
+ }
337
+
338
+ /**
339
+ * Time-bounded flush for transient lifecycle events (e.g. RN
340
+ * foreground→background) that must complete inside an OS-imposed window.
341
+ * Unlike `shutdown`, this leaves the periodic flush timer in place so the
342
+ * pipeline keeps draining if the process is resumed instead of suspended.
343
+ *
344
+ * Errors propagate so the host SDK can route them through its standard
345
+ * lifecycle error handler (e.g. RN's `logFlushError`). If the timer wins
346
+ * the race, a late rejection from the in-flight flush is silenced via a
347
+ * no-op handler attached after the race settles, to avoid noisy
348
+ * unhandled-rejection logs — the next regular flush cycle will retry.
349
+ */
350
+ async flushWithTimeout(timeoutMs: number): Promise<void> {
351
+ let timedOut = false
352
+ const flushPromise = this.flush()
353
+ const timerPromise = new Promise<void>((resolve) =>
354
+ safeSetTimeout(() => {
355
+ timedOut = true
356
+ resolve()
357
+ }, timeoutMs)
358
+ )
359
+ try {
360
+ await Promise.race([flushPromise, timerPromise])
361
+ } finally {
362
+ if (timedOut) {
363
+ // Race lost — flush is still in flight. Attach a no-op rejection
364
+ // handler so a late failure isn't logged as unhandled.
365
+ void flushPromise.catch(() => {})
366
+ }
367
+ }
368
+ }
369
+
370
+ private _flushInBackground(): void {
371
+ void this.flush().catch((err) => {
372
+ this._logger.error('PostHog logs flush failed:', err)
373
+ })
374
+ }
375
+
376
+ private _clearFlushTimer(): void {
377
+ if (this._flushTimer) {
378
+ clearTimeout(this._flushTimer)
379
+ this._flushTimer = undefined
380
+ }
57
381
  }
58
382
  }
@@ -1,4 +1,5 @@
1
- import type { CaptureLogOptions, LogSdkContext, LogSeverityLevel } from '@posthog/types'
1
+ import type { CaptureLogOptions, LogSeverityLevel } from '@posthog/types'
2
+ import type { LogSdkContext } from './types'
2
3
  import {
3
4
  buildOtlpLogRecord,
4
5
  buildOtlpLogsPayload,
@@ -1,7 +1,6 @@
1
1
  import type {
2
2
  CaptureLogOptions,
3
3
  LogAttributeValue,
4
- LogSdkContext,
5
4
  LogSeverityLevel,
6
5
  OtlpAnyValue,
7
6
  OtlpKeyValue,
@@ -10,6 +9,7 @@ import type {
10
9
  OtlpSeverityEntry,
11
10
  OtlpSeverityText,
12
11
  } from '@posthog/types'
12
+ import type { LogSdkContext } from './types'
13
13
  import { isArray, isBoolean, isNull, isUndefined } from '../utils'
14
14
 
15
15
  // ============================================================================
package/src/logs/types.ts CHANGED
@@ -11,9 +11,28 @@ export type {
11
11
  OtlpKeyValue,
12
12
  OtlpLogRecord,
13
13
  OtlpLogsPayload,
14
- LogSdkContext,
15
14
  } from '@posthog/types'
16
15
 
16
+ /**
17
+ * SDK-internal context the host SDK passes to `buildOtlpLogRecord` at capture
18
+ * time. Each SDK populates the fields that apply to it: browser fills
19
+ * `currentUrl`, mobile fills `screenName` / `appState`. Missing fields are
20
+ * omitted from the OTLP record (no stray attributes).
21
+ *
22
+ * Internal to `@posthog/core` — customers don't see this in autocomplete.
23
+ */
24
+ export interface LogSdkContext {
25
+ distinctId?: string
26
+ sessionId?: string
27
+ /** Web-only — current page URL */
28
+ currentUrl?: string
29
+ /** Mobile-only — current screen / view name */
30
+ screenName?: string
31
+ /** Mobile-only — app foreground/background state at capture time */
32
+ appState?: 'foreground' | 'background'
33
+ activeFeatureFlags?: string[]
34
+ }
35
+
17
36
  // The public capture-logger interface lives in @posthog/types as `Logger`. Core
18
37
  // also exports a `Logger` (the SDK's internal warn/info/error logger). Alias the
19
38
  // public one to avoid the name collision inside this package.
@@ -22,45 +41,151 @@ export type CaptureLogger = CaptureLoggerType
22
41
 
23
42
  import type { LogAttributeValue, CaptureLogOptions, OtlpLogRecord } from '@posthog/types'
24
43
 
25
- // Wrapper around OtlpLogRecord for queue entries. Parallels events' queue
26
- // item shape (`{ message }`) — future additions like `retryCount` or
27
- // `enqueuedAt` can be added without migrating the queue format.
28
44
  export interface BufferedLogEntry {
29
45
  record: OtlpLogRecord
30
46
  }
31
47
 
32
- // Public configuration for the logs module. Per-SDK defaults diverge (mobile
33
- // cellular radio cost, browser tab suspension, node process lifecycle).
34
- export interface PostHogLogsConfig {
35
- // Master switch. Default: true when a config object is provided.
36
- enabled?: boolean
48
+ /**
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
+ * ```
78
+ */
79
+ export type BeforeSendLogFn = (record: CaptureLogOptions) => CaptureLogOptions | null
37
80
 
38
- // Resource attributes
81
+ /**
82
+ * Configuration for the logs feature on `new PostHog(key, { logs: ... })`.
83
+ * All fields are optional; per-SDK defaults apply (mobile vs browser tune
84
+ * differently for cellular cost vs tab-suspension behavior).
85
+ */
86
+ export interface PostHogLogsConfig {
87
+ /**
88
+ * Service name attached to every record as the OTLP `service.name`
89
+ * resource attribute. Used by the Logs UI for filtering / grouping.
90
+ * Default: `'unknown_service'`.
91
+ */
39
92
  serviceName?: string
93
+
94
+ /**
95
+ * Service version attached as OTLP `service.version`. Useful for
96
+ * correlating regressions to specific app releases.
97
+ */
40
98
  serviceVersion?: string
99
+
100
+ /**
101
+ * Deployment environment attached as OTLP `deployment.environment`
102
+ * (e.g. `'production'`, `'staging'`, `'dev'`).
103
+ */
41
104
  environment?: string
105
+
106
+ /**
107
+ * Extra OTLP resource attributes attached to every record. Spread first;
108
+ * SDK-controlled keys (`service.name`, `telemetry.sdk.*`, RN's `os.*`)
109
+ * are layered on top so users cannot accidentally clobber them. Use the
110
+ * dedicated `serviceName` / `environment` / `serviceVersion` fields to
111
+ * override those keys.
112
+ */
42
113
  resourceAttributes?: Record<string, LogAttributeValue>
43
114
 
44
- // Buffering
115
+ /**
116
+ * How often the periodic background flush fires (ms). Records also flush
117
+ * eagerly when the buffer fills, on AppState changes (RN), and on
118
+ * `shutdown()`. Lower values trade battery/bandwidth for fresher data.
119
+ * Default: 10000 (RN) / 3000 (browser).
120
+ */
45
121
  flushIntervalMs?: number
46
- rateCapWindowMs?: number // separate from flushIntervalMs so flush cadence does not move the rate-cap window
122
+
123
+ /**
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.
129
+ */
47
130
  maxBufferSize?: number
48
- maxLogsPerInterval?: number
49
- maxBatchRecordsPerPost?: number // keeps each POST under the 2 MB server cap
50
131
 
51
- // Shutdown — separate budgets because foreground→background and app-terminate
52
- // have different OS-imposed windows.
53
- backgroundFlushBudgetMs?: number
54
- terminationFlushBudgetMs?: number
132
+ /**
133
+ * Max records per outbound POST. Keeps each request under the server's
134
+ * 2 MB cap. On a 413 response, the SDK halves this value, retries the
135
+ * same records, then ramps back up by 1 per healthy send. A 413 on a
136
+ * single-record batch drops the record (it's larger than the server can
137
+ * accept regardless of batch size). Default: 50 (RN) / 100 (browser).
138
+ */
139
+ maxBatchRecordsPerPost?: number
55
140
 
56
- // Filtering. Runs synchronously before the rate cap so beforeSend-dropped
57
- // records do not consume the per-interval budget.
58
- beforeSend?: (record: CaptureLogOptions) => CaptureLogOptions | null
141
+ /**
142
+ * Tumbling-window rate cap. Bounds how many records can be captured
143
+ * within a sliding (technically tumbling) time window. Records exceeding
144
+ * the cap are dropped synchronously at `captureLog()` (they never enter
145
+ * the buffer or consume bandwidth). A single warn line is logged per
146
+ * window when the cap is hit.
147
+ *
148
+ * Defaults are per-SDK; on RN the default is `{ maxLogs: 500, windowMs:
149
+ * 10000 }` (≈50 logs/sec ceiling, tuned for cellular bandwidth).
150
+ *
151
+ * @example Allow brief bursts up to 1000/min
152
+ * ```ts
153
+ * logs: { rateCap: { maxLogs: 1000, windowMs: 60000 } }
154
+ * ```
155
+ *
156
+ * @example Disable the cap entirely (unbounded)
157
+ * ```ts
158
+ * logs: { rateCap: { maxLogs: undefined } }
159
+ * ```
160
+ */
161
+ rateCap?: {
162
+ /**
163
+ * Max records accepted per `windowMs` window. `undefined` = unbounded.
164
+ */
165
+ maxLogs?: number
166
+ /**
167
+ * Window length in ms. Tumbling, not sliding — the counter resets the
168
+ * first time a capture fires after the window expires.
169
+ */
170
+ windowMs?: number
171
+ }
172
+
173
+ /**
174
+ * Pre-send filter. See {@link BeforeSendLogFn} for shape and examples.
175
+ * Configure as a single function or a chain.
176
+ */
177
+ beforeSend?: BeforeSendLogFn | BeforeSendLogFn[]
59
178
  }
60
179
 
61
- // Fields PostHogLogs needs resolved at runtime. Each SDK supplies its own
62
- // defaults (mobile, browser, node have different right answers) and hands the
63
- // filled-in config to the PostHogLogs constructor.
64
- export interface ResolvedPostHogLogsConfig extends PostHogLogsConfig {
180
+ // Fields PostHogLogs needs resolved at runtime. The host SDK fills in its
181
+ // defaults and hands the resolved config to the PostHogLogs constructor.
182
+ // Flat names internally public API uses `rateCap: { maxLogs, windowMs }`.
183
+ export interface ResolvedPostHogLogsConfig extends Omit<PostHogLogsConfig, 'rateCap'> {
65
184
  maxBufferSize: number
185
+ flushIntervalMs: number
186
+ maxBatchRecordsPerPost: number
187
+ rateCapWindowMs: number
188
+ maxLogsPerInterval?: number
189
+ backgroundFlushBudgetMs: number
190
+ terminationFlushBudgetMs: number
66
191
  }