@posthog/core 1.27.9 → 1.28.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/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/logs/index.d.ts +94 -4
- package/dist/logs/index.d.ts.map +1 -1
- package/dist/logs/index.js +149 -4
- package/dist/logs/index.mjs +150 -5
- package/dist/logs/logs-utils.d.ts +2 -1
- package/dist/logs/logs-utils.d.ts.map +1 -1
- package/dist/logs/types.d.ts +140 -8
- package/dist/logs/types.d.ts.map +1 -1
- package/dist/posthog-core-stateless.d.ts +34 -0
- package/dist/posthog-core-stateless.d.ts.map +1 -1
- package/dist/posthog-core-stateless.js +46 -0
- package/dist/posthog-core-stateless.mjs +46 -0
- package/dist/posthog-core.d.ts.map +1 -1
- package/dist/posthog-core.js +2 -0
- package/dist/posthog-core.mjs +2 -0
- package/dist/surveys/events.d.ts +22 -0
- package/dist/surveys/events.d.ts.map +1 -0
- package/dist/surveys/events.js +95 -0
- package/dist/surveys/events.mjs +43 -0
- package/dist/surveys/index.d.ts +4 -0
- package/dist/surveys/index.d.ts.map +1 -0
- package/dist/surveys/index.js +83 -0
- package/dist/surveys/index.mjs +4 -0
- package/dist/surveys/translations.d.ts +38 -0
- package/dist/surveys/translations.d.ts.map +1 -0
- package/dist/surveys/translations.js +207 -0
- package/dist/surveys/translations.mjs +158 -0
- package/dist/testing/test-utils.d.ts.map +1 -1
- package/dist/testing/test-utils.js +1 -0
- package/dist/testing/test-utils.mjs +1 -0
- package/dist/types.d.ts +31 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/logger.d.ts +1 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +3 -0
- package/dist/utils/logger.mjs +3 -0
- package/package.json +26 -2
- package/src/index.ts +12 -1
- package/src/logs/index.spec.ts +891 -17
- package/src/logs/index.ts +341 -11
- package/src/logs/logs-utils.spec.ts +2 -1
- package/src/logs/logs-utils.ts +1 -1
- package/src/logs/types.ts +150 -25
- package/src/posthog-core-stateless.ts +80 -0
- package/src/posthog-core.ts +6 -0
- package/src/surveys/events.spec.ts +52 -0
- package/src/surveys/events.ts +80 -0
- package/src/surveys/index.ts +18 -0
- package/src/surveys/translations.spec.ts +205 -0
- package/src/surveys/translations.ts +244 -0
- package/src/testing/test-utils.ts +1 -0
- package/src/types.ts +38 -2
- package/src/utils/logger.ts +6 -2
package/src/logs/index.ts
CHANGED
|
@@ -1,26 +1,60 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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 (
|
|
57
|
+
if (this._instance.isDisabled) {
|
|
24
58
|
return
|
|
25
59
|
}
|
|
26
60
|
if (this._instance.optedOut) {
|
|
@@ -30,19 +64,230 @@ export class PostHogLogs {
|
|
|
30
64
|
return
|
|
31
65
|
}
|
|
32
66
|
|
|
67
|
+
// Ordering: beforeSend → rate cap → OTLP build. beforeSend runs first so
|
|
68
|
+
// user-dropped records don't consume the per-interval budget.
|
|
69
|
+
const filtered = this._runBeforeSend(options)
|
|
70
|
+
if (filtered === null) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
// beforeSend could return a record with empty body — treat as drop.
|
|
74
|
+
if (!filtered.body) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!this._checkRateLimit()) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
33
82
|
// Build before deferring so attributes reflect state at capture time, not
|
|
34
83
|
// at drain time (identity/session changes between capture and drain must
|
|
35
84
|
// not corrupt recorded attributes).
|
|
36
|
-
const record = buildOtlpLogRecord(
|
|
85
|
+
const record = buildOtlpLogRecord(filtered, this._getContext())
|
|
37
86
|
const entry: BufferedLogEntry = { record }
|
|
38
87
|
|
|
39
88
|
this._onReady(() => this._enqueue(entry))
|
|
40
89
|
}
|
|
41
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Runs the configured `beforeSend` hook(s) on a capture record:
|
|
93
|
+
* - single fn OR array of fns (chain, left-to-right)
|
|
94
|
+
* - returning `null` drops the record (logged at info)
|
|
95
|
+
* - a thrown error is logged and the chain *continues* with the previous
|
|
96
|
+
* result — a buggy user filter must never crash the caller's
|
|
97
|
+
* `captureLog()` call
|
|
98
|
+
*/
|
|
99
|
+
private _runBeforeSend(options: CaptureLogOptions): CaptureLogOptions | null {
|
|
100
|
+
const beforeSend = this._config.beforeSend
|
|
101
|
+
if (!beforeSend) {
|
|
102
|
+
return options
|
|
103
|
+
}
|
|
104
|
+
const fns = isArray(beforeSend) ? beforeSend : [beforeSend]
|
|
105
|
+
let result: CaptureLogOptions = options
|
|
106
|
+
for (const fn of fns) {
|
|
107
|
+
try {
|
|
108
|
+
const next = fn(result)
|
|
109
|
+
if (!next) {
|
|
110
|
+
this._logger.info(`Log was rejected in beforeSend function`)
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
result = next
|
|
114
|
+
} catch (e) {
|
|
115
|
+
// Swallow the throw — the chain continues with `result` unchanged so
|
|
116
|
+
// a buggy filter degrades to a no-op rather than crashing the app.
|
|
117
|
+
this._logger.error(`Error in beforeSend function for log:`, e)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Returns `true` if this capture fits within the current rate-cap window,
|
|
125
|
+
* `false` if it should be dropped.
|
|
126
|
+
*
|
|
127
|
+
* Fixed (tumbling) window: the counter resets the first time `captureLog`
|
|
128
|
+
* fires after `rateCapWindowMs` has elapsed — no timer needed.
|
|
129
|
+
* `maxLogsPerInterval === undefined` means unbounded.
|
|
130
|
+
*
|
|
131
|
+
* Wall-clock safety: if `Date.now()` jumps backward (manual device-clock
|
|
132
|
+
* change, big NTP correction), `elapsed` goes negative. We treat that the
|
|
133
|
+
* same as "window expired" and reset — otherwise the rate cap would be
|
|
134
|
+
* stuck until the clock caught up to the old window start, potentially
|
|
135
|
+
* dropping logs for hours.
|
|
136
|
+
*
|
|
137
|
+
* Pre-init note: the counter increments here, before `_onReady` defers
|
|
138
|
+
* `_enqueue` to the init promise. If init resolves slowly and the user is
|
|
139
|
+
* later opted out, the counter has already consumed budget for records
|
|
140
|
+
* that won't enqueue. Cosmetic — no record is "lost" beyond what's
|
|
141
|
+
* already gated, and the window rolls on its own.
|
|
142
|
+
*/
|
|
143
|
+
private _checkRateLimit(): boolean {
|
|
144
|
+
if (this._maxLogsPerInterval === undefined) {
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
147
|
+
const now = Date.now()
|
|
148
|
+
const elapsed = now - this._intervalWindowStart
|
|
149
|
+
if (elapsed >= this._rateCapWindowMs || elapsed < 0) {
|
|
150
|
+
this._intervalWindowStart = now
|
|
151
|
+
this._intervalLogCount = 0
|
|
152
|
+
this._droppedWarned = false
|
|
153
|
+
}
|
|
154
|
+
if (this._intervalLogCount >= this._maxLogsPerInterval) {
|
|
155
|
+
if (!this._droppedWarned) {
|
|
156
|
+
this._logger.warn(
|
|
157
|
+
`captureLog dropping logs: exceeded ${this._maxLogsPerInterval} logs per ${this._rateCapWindowMs}ms`
|
|
158
|
+
)
|
|
159
|
+
this._droppedWarned = true
|
|
160
|
+
}
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
this._intervalLogCount++
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Drains `LogsQueue` in `maxBatchRecordsPerPost` slices, POSTing each as an
|
|
169
|
+
* OTLP payload.
|
|
170
|
+
* - Network error → keep items in queue, re-throw (caller retries later)
|
|
171
|
+
* - 413 → halve batch size, retry same records (do not advance)
|
|
172
|
+
* - Any other error → drop the batch (avoid infinite loop on malformed data),
|
|
173
|
+
* re-throw so callers can log/report
|
|
174
|
+
* Concurrent calls are serialized through `_flushPromise` so records at the
|
|
175
|
+
* head of the queue can't be sent twice.
|
|
176
|
+
*/
|
|
177
|
+
async flush(): Promise<void> {
|
|
178
|
+
if (this._instance.isDisabled) {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (this._flushPromise) {
|
|
182
|
+
return this._flushPromise
|
|
183
|
+
}
|
|
184
|
+
this._flushPromise = this._flushInner().finally(() => {
|
|
185
|
+
this._flushPromise = null
|
|
186
|
+
})
|
|
187
|
+
return this._flushPromise
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async _flushInner(): Promise<void> {
|
|
191
|
+
this._clearFlushTimer()
|
|
192
|
+
|
|
193
|
+
let queue = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue) ?? []
|
|
194
|
+
if (queue.length === 0) {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const originalQueueLength = queue.length
|
|
199
|
+
let sentCount = 0
|
|
200
|
+
|
|
201
|
+
while (queue.length > 0 && sentCount < originalQueueLength) {
|
|
202
|
+
const batchSize = Math.min(queue.length, this._maxBatchRecordsPerPost)
|
|
203
|
+
const batch = queue.slice(0, batchSize)
|
|
204
|
+
const records = batch.map((e) => e.record)
|
|
205
|
+
|
|
206
|
+
const payload = buildOtlpLogsPayload(
|
|
207
|
+
records,
|
|
208
|
+
this._buildResourceAttributes(),
|
|
209
|
+
this._instance.getLibraryId(),
|
|
210
|
+
this._instance.getLibraryVersion()
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const outcome = await this._instance._sendLogsBatch(payload)
|
|
214
|
+
|
|
215
|
+
if (outcome.kind === 'too-large' && batch.length > 1) {
|
|
216
|
+
this._maxBatchRecordsPerPost = Math.max(1, Math.floor(batch.length / 2))
|
|
217
|
+
this._logger.warn(
|
|
218
|
+
`Received 413 when sending logs batch of size ${batch.length}, reducing batch size to ${this._maxBatchRecordsPerPost}`
|
|
219
|
+
)
|
|
220
|
+
// Don't advance the queue — retry the same records with the smaller cap.
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (outcome.kind === 'retry-later') {
|
|
225
|
+
// Network error: keep records in the queue for the next flush cycle
|
|
226
|
+
// and surface the error so the caller can log/react.
|
|
227
|
+
throw outcome.error
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ok | fatal | too-large-with-batch-of-1 → records are leaving the
|
|
231
|
+
// queue. 'fatal' and size-1 413s are dropped so we don't spin on the
|
|
232
|
+
// same record forever. Surface the size-1 413 explicitly so a single
|
|
233
|
+
// oversized record (e.g. a giant body field) is visible in logs
|
|
234
|
+
// instead of silently disappearing.
|
|
235
|
+
if (outcome.kind === 'too-large') {
|
|
236
|
+
this._logger.warn(
|
|
237
|
+
'Dropping a single log record after 413 with batch size 1 — the record is larger than the server cap and cannot be split further.'
|
|
238
|
+
)
|
|
239
|
+
} else if (outcome.kind === 'ok' && this._maxBatchRecordsPerPost < this._config.maxBatchRecordsPerPost) {
|
|
240
|
+
// Linear recovery: each healthy send pushes the cap back up by 1
|
|
241
|
+
// toward the configured maximum. Linear (not doubling) so we don't
|
|
242
|
+
// immediately retrigger a 413 if the previous shrink was justified.
|
|
243
|
+
this._maxBatchRecordsPerPost = Math.min(this._config.maxBatchRecordsPerPost, this._maxBatchRecordsPerPost + 1)
|
|
244
|
+
}
|
|
245
|
+
await this._persistQueueAdvance(batch.length)
|
|
246
|
+
queue = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue) ?? []
|
|
247
|
+
sentCount += batch.length
|
|
248
|
+
|
|
249
|
+
if (outcome.kind === 'fatal') {
|
|
250
|
+
throw outcome.error
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async _persistQueueAdvance(consumed: number): Promise<void> {
|
|
256
|
+
// Re-read the queue in case captures landed mid-flush, then drop the head.
|
|
257
|
+
const refreshed = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue) ?? []
|
|
258
|
+
this._instance.setPersistedProperty(PostHogPersistedProperty.LogsQueue, refreshed.slice(consumed))
|
|
259
|
+
// Wait for the advance to hit disk before the next batch sends, matching
|
|
260
|
+
// events' `flushStorage()` contract. Prevents duplicates if the app crashes
|
|
261
|
+
// after the HTTP success but before the queue-advance persists.
|
|
262
|
+
await this._waitForStoragePersist()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* OTLP resource attributes for every batch.
|
|
267
|
+
*
|
|
268
|
+
* Layout: user `resourceAttributes` spread first, then SDK-controlled
|
|
269
|
+
* keys layered on top so users cannot accidentally clobber them. Most logs
|
|
270
|
+
* backends index on `service.name` and `telemetry.sdk.*` for routing,
|
|
271
|
+
* SDK-version dashboards, and bug-correlation; letting a stray user key
|
|
272
|
+
* overwrite them silently breaks ingestion attribution. The dedicated
|
|
273
|
+
* `serviceName` / `environment` / `serviceVersion` config fields are the
|
|
274
|
+
* supported way to override `service.name` / `deployment.environment` /
|
|
275
|
+
* `service.version`.
|
|
276
|
+
*/
|
|
277
|
+
private _buildResourceAttributes(): Record<string, LogAttributeValue> {
|
|
278
|
+
return {
|
|
279
|
+
...this._config.resourceAttributes,
|
|
280
|
+
'service.name': this._config.serviceName || 'unknown_service',
|
|
281
|
+
...(this._config.environment && { 'deployment.environment': this._config.environment }),
|
|
282
|
+
...(this._config.serviceVersion && { 'service.version': this._config.serviceVersion }),
|
|
283
|
+
'telemetry.sdk.name': this._instance.getLibraryId(),
|
|
284
|
+
'telemetry.sdk.version': this._instance.getLibraryVersion(),
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
42
288
|
private _enqueue(entry: BufferedLogEntry): void {
|
|
43
|
-
// Re-check
|
|
44
|
-
//
|
|
45
|
-
// while this fn was deferred.
|
|
289
|
+
// Re-check optedOut: preload may have hydrated the real persisted value,
|
|
290
|
+
// or optIn/optOut may have fired while this fn was deferred.
|
|
46
291
|
if (this._instance.optedOut) {
|
|
47
292
|
return
|
|
48
293
|
}
|
|
@@ -54,5 +299,90 @@ export class PostHogLogs {
|
|
|
54
299
|
}
|
|
55
300
|
queue.push(entry)
|
|
56
301
|
this._instance.setPersistedProperty(PostHogPersistedProperty.LogsQueue, queue)
|
|
302
|
+
|
|
303
|
+
// Threshold trigger: at-capacity means flushing now reclaims space before
|
|
304
|
+
// the next capture has to shift something out.
|
|
305
|
+
if (queue.length >= this._maxBufferSize) {
|
|
306
|
+
this._flushInBackground()
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Timer trigger: only arm one timer at a time. A subsequent enqueue within
|
|
311
|
+
// the window shouldn't reschedule — that would keep pushing the flush out.
|
|
312
|
+
if (!this._flushTimer) {
|
|
313
|
+
this._flushTimer = safeSetTimeout(() => {
|
|
314
|
+
this._flushTimer = undefined
|
|
315
|
+
this._flushInBackground()
|
|
316
|
+
}, this._flushIntervalMs)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Stops the timer-based flush and sends anything still in the queue.
|
|
322
|
+
* Intended for process-teardown paths (RN `_shutdown` override). Swallows
|
|
323
|
+
* errors so a failing final flush can't block the broader shutdown.
|
|
324
|
+
*
|
|
325
|
+
* If `timeoutMs` is provided, the final flush races against that budget so
|
|
326
|
+
* a slow network/storage can't hold up shutdown indefinitely. Without it,
|
|
327
|
+
* flush time is bounded only by `fetchRetryCount * (requestTimeout +
|
|
328
|
+
* fetchRetryDelay)`, which can exceed the caller's shutdown SLA.
|
|
329
|
+
*/
|
|
330
|
+
async shutdown(timeoutMs?: number): Promise<void> {
|
|
331
|
+
this._clearFlushTimer()
|
|
332
|
+
const flushPromise = this.flush().catch(() => {
|
|
333
|
+
// Best-effort: a logs-flush failure during shutdown is not actionable
|
|
334
|
+
// and must not prevent the rest of shutdown from running. Errors are
|
|
335
|
+
// still surfaced from the regular `flush()` path in normal operation.
|
|
336
|
+
})
|
|
337
|
+
if (timeoutMs === undefined) {
|
|
338
|
+
await flushPromise
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
await Promise.race([flushPromise, new Promise<void>((resolve) => safeSetTimeout(resolve, timeoutMs))])
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Time-bounded flush for transient lifecycle events (e.g. RN
|
|
346
|
+
* foreground→background) that must complete inside an OS-imposed window.
|
|
347
|
+
* Unlike `shutdown`, this leaves the periodic flush timer in place so the
|
|
348
|
+
* pipeline keeps draining if the process is resumed instead of suspended.
|
|
349
|
+
*
|
|
350
|
+
* Errors propagate so the host SDK can route them through its standard
|
|
351
|
+
* lifecycle error handler (e.g. RN's `logFlushError`). If the timer wins
|
|
352
|
+
* the race, a late rejection from the in-flight flush is silenced via a
|
|
353
|
+
* no-op handler attached after the race settles, to avoid noisy
|
|
354
|
+
* unhandled-rejection logs — the next regular flush cycle will retry.
|
|
355
|
+
*/
|
|
356
|
+
async flushWithTimeout(timeoutMs: number): Promise<void> {
|
|
357
|
+
let timedOut = false
|
|
358
|
+
const flushPromise = this.flush()
|
|
359
|
+
const timerPromise = new Promise<void>((resolve) =>
|
|
360
|
+
safeSetTimeout(() => {
|
|
361
|
+
timedOut = true
|
|
362
|
+
resolve()
|
|
363
|
+
}, timeoutMs)
|
|
364
|
+
)
|
|
365
|
+
try {
|
|
366
|
+
await Promise.race([flushPromise, timerPromise])
|
|
367
|
+
} finally {
|
|
368
|
+
if (timedOut) {
|
|
369
|
+
// Race lost — flush is still in flight. Attach a no-op rejection
|
|
370
|
+
// handler so a late failure isn't logged as unhandled.
|
|
371
|
+
void flushPromise.catch(() => {})
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private _flushInBackground(): void {
|
|
377
|
+
void this.flush().catch((err) => {
|
|
378
|
+
this._logger.error('PostHog logs flush failed:', err)
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private _clearFlushTimer(): void {
|
|
383
|
+
if (this._flushTimer) {
|
|
384
|
+
clearTimeout(this._flushTimer)
|
|
385
|
+
this._flushTimer = undefined
|
|
386
|
+
}
|
|
57
387
|
}
|
|
58
388
|
}
|
package/src/logs/logs-utils.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
62
|
-
// defaults
|
|
63
|
-
//
|
|
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
|
}
|