@posthog/core 1.27.8 → 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.
- 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 +147 -4
- package/dist/logs/index.mjs +148 -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 +39 -0
- package/dist/posthog-core-stateless.mjs +39 -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 +337 -13
- 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 +64 -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.spec.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import { PostHogPersistedProperty } from '../types'
|
|
2
2
|
import type { Logger } from '../types'
|
|
3
3
|
import { PostHogLogs } from './index'
|
|
4
|
-
import type { BufferedLogEntry,
|
|
4
|
+
import type { BufferedLogEntry, ResolvedPostHogLogsConfig } from './types'
|
|
5
5
|
|
|
6
6
|
// Default resolved config for tests — mirrors what each SDK would build by
|
|
7
7
|
// merging user config onto its own defaults. Test-only fixture; the real
|
|
8
|
-
// defaults live per-SDK.
|
|
8
|
+
// defaults live per-SDK. Takes the resolved (flat) shape directly so tests
|
|
9
|
+
// can override `maxLogsPerInterval` / `rateCapWindowMs` without going through
|
|
10
|
+
// the public `rateCap: { maxLogs, windowMs }` wrapper.
|
|
9
11
|
const DEFAULT_MAX_BUFFER_SIZE = 100
|
|
10
|
-
const
|
|
12
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 10000
|
|
13
|
+
const DEFAULT_MAX_BATCH_RECORDS_PER_POST = 50
|
|
14
|
+
const DEFAULT_RATE_CAP_WINDOW_MS = 10000
|
|
15
|
+
const DEFAULT_BACKGROUND_FLUSH_BUDGET_MS = 25000
|
|
16
|
+
const DEFAULT_TERMINATION_FLUSH_BUDGET_MS = 2000
|
|
17
|
+
const resolveForTest = (partial?: Partial<ResolvedPostHogLogsConfig>): ResolvedPostHogLogsConfig => ({
|
|
11
18
|
...partial,
|
|
12
19
|
maxBufferSize: partial?.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE,
|
|
20
|
+
flushIntervalMs: partial?.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
21
|
+
maxBatchRecordsPerPost: partial?.maxBatchRecordsPerPost ?? DEFAULT_MAX_BATCH_RECORDS_PER_POST,
|
|
22
|
+
rateCapWindowMs: partial?.rateCapWindowMs ?? DEFAULT_RATE_CAP_WINDOW_MS,
|
|
23
|
+
backgroundFlushBudgetMs: DEFAULT_BACKGROUND_FLUSH_BUDGET_MS,
|
|
24
|
+
terminationFlushBudgetMs: DEFAULT_TERMINATION_FLUSH_BUDGET_MS,
|
|
25
|
+
// Uncapped by default so existing tests aren't affected. The rate-limit
|
|
26
|
+
// describe block opts in explicitly via { maxLogsPerInterval: N }.
|
|
27
|
+
maxLogsPerInterval: partial?.maxLogsPerInterval,
|
|
13
28
|
})
|
|
14
29
|
|
|
15
30
|
// Mock PostHog instance exposing the `PostHogCoreStateless` surface PostHogLogs
|
|
@@ -20,6 +35,8 @@ const createMockInstance = (overrides: Record<string, any> = {}): any => {
|
|
|
20
35
|
optedOut: false,
|
|
21
36
|
getDistinctId: jest.fn(() => 'user-123'),
|
|
22
37
|
getSessionId: jest.fn(() => 'sess-456'),
|
|
38
|
+
getLibraryId: jest.fn(() => 'posthog-core-tests'),
|
|
39
|
+
getLibraryVersion: jest.fn(() => '0.0.0-test'),
|
|
23
40
|
getPersistedProperty: jest.fn((key: string) => store[key]),
|
|
24
41
|
setPersistedProperty: jest.fn((key: string, value: any) => {
|
|
25
42
|
if (value === null || value === undefined) {
|
|
@@ -28,6 +45,7 @@ const createMockInstance = (overrides: Record<string, any> = {}): any => {
|
|
|
28
45
|
store[key] = value
|
|
29
46
|
}
|
|
30
47
|
}),
|
|
48
|
+
_sendLogsBatch: jest.fn(() => Promise.resolve({ kind: 'ok' })),
|
|
31
49
|
addPendingPromise: jest.fn(<T>(promise: Promise<T>) => promise),
|
|
32
50
|
_store: store,
|
|
33
51
|
...overrides,
|
|
@@ -41,6 +59,7 @@ const immediateOnReady = (fn: () => void): void => fn()
|
|
|
41
59
|
|
|
42
60
|
const createMockLogger = (): Logger => {
|
|
43
61
|
const logger: any = {
|
|
62
|
+
debug: jest.fn(),
|
|
44
63
|
info: jest.fn(),
|
|
45
64
|
warn: jest.fn(),
|
|
46
65
|
error: jest.fn(),
|
|
@@ -187,22 +206,10 @@ describe('PostHogLogs', () => {
|
|
|
187
206
|
expect(readQueue(instance)).toHaveLength(0)
|
|
188
207
|
})
|
|
189
208
|
|
|
190
|
-
it('
|
|
209
|
+
it('captures unconditionally — only optedOut, missing body, and beforeSend can drop', () => {
|
|
191
210
|
const logs = new PostHogLogs(
|
|
192
211
|
mockInstance,
|
|
193
|
-
resolveForTest(
|
|
194
|
-
logger,
|
|
195
|
-
getContextFor(mockInstance),
|
|
196
|
-
immediateOnReady
|
|
197
|
-
)
|
|
198
|
-
logs.captureLog({ body: 'should be dropped' })
|
|
199
|
-
expect(readQueue(mockInstance)).toHaveLength(0)
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
it('captures when config is provided with enabled undefined (defaults to true)', () => {
|
|
203
|
-
const logs = new PostHogLogs(
|
|
204
|
-
mockInstance,
|
|
205
|
-
resolveForTest({}),
|
|
212
|
+
resolveForTest(),
|
|
206
213
|
logger,
|
|
207
214
|
getContextFor(mockInstance),
|
|
208
215
|
immediateOnReady
|
|
@@ -357,4 +364,871 @@ describe('PostHogLogs', () => {
|
|
|
357
364
|
expect(() => logs.captureLog({ body: 'after-reject-2' })).not.toThrow()
|
|
358
365
|
})
|
|
359
366
|
})
|
|
367
|
+
|
|
368
|
+
describe('flush', () => {
|
|
369
|
+
it('is a no-op when the queue is empty', async () => {
|
|
370
|
+
const logs = new PostHogLogs(
|
|
371
|
+
mockInstance,
|
|
372
|
+
resolveForTest(),
|
|
373
|
+
logger,
|
|
374
|
+
getContextFor(mockInstance),
|
|
375
|
+
immediateOnReady
|
|
376
|
+
)
|
|
377
|
+
await logs.flush()
|
|
378
|
+
expect(mockInstance._sendLogsBatch).not.toHaveBeenCalled()
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('drains the queue and sends an OTLP payload with resource + scope attrs', async () => {
|
|
382
|
+
const logs = new PostHogLogs(
|
|
383
|
+
mockInstance,
|
|
384
|
+
resolveForTest({ serviceName: 'my-service', environment: 'prod', serviceVersion: '1.2.3' }),
|
|
385
|
+
logger,
|
|
386
|
+
getContextFor(mockInstance),
|
|
387
|
+
immediateOnReady
|
|
388
|
+
)
|
|
389
|
+
logs.captureLog({ body: 'one' })
|
|
390
|
+
logs.captureLog({ body: 'two' })
|
|
391
|
+
|
|
392
|
+
await logs.flush()
|
|
393
|
+
|
|
394
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
395
|
+
const payload = mockInstance._sendLogsBatch.mock.calls[0][0]
|
|
396
|
+
const resourceAttrs = Object.fromEntries(
|
|
397
|
+
payload.resourceLogs[0].resource.attributes.map((a: any) => [a.key, a.value])
|
|
398
|
+
)
|
|
399
|
+
expect(resourceAttrs['service.name']).toEqual({ stringValue: 'my-service' })
|
|
400
|
+
expect(resourceAttrs['deployment.environment']).toEqual({ stringValue: 'prod' })
|
|
401
|
+
expect(resourceAttrs['service.version']).toEqual({ stringValue: '1.2.3' })
|
|
402
|
+
// OTLP-standard SDK identification — pulled from the instance's
|
|
403
|
+
// getLibraryId/Version so every SDK self-identifies.
|
|
404
|
+
expect(resourceAttrs['telemetry.sdk.name']).toEqual({ stringValue: 'posthog-core-tests' })
|
|
405
|
+
expect(resourceAttrs['telemetry.sdk.version']).toEqual({ stringValue: '0.0.0-test' })
|
|
406
|
+
|
|
407
|
+
const scope = payload.resourceLogs[0].scopeLogs[0].scope
|
|
408
|
+
expect(scope).toEqual({ name: 'posthog-core-tests', version: '0.0.0-test' })
|
|
409
|
+
|
|
410
|
+
const bodies = payload.resourceLogs[0].scopeLogs[0].logRecords.map((r: any) => r.body.stringValue)
|
|
411
|
+
expect(bodies).toEqual(['one', 'two'])
|
|
412
|
+
|
|
413
|
+
expect(readQueue(mockInstance)).toHaveLength(0)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('defaults service.name to "unknown_service" when not configured', async () => {
|
|
417
|
+
const logs = new PostHogLogs(
|
|
418
|
+
mockInstance,
|
|
419
|
+
resolveForTest(),
|
|
420
|
+
logger,
|
|
421
|
+
getContextFor(mockInstance),
|
|
422
|
+
immediateOnReady
|
|
423
|
+
)
|
|
424
|
+
logs.captureLog({ body: 'hi' })
|
|
425
|
+
await logs.flush()
|
|
426
|
+
|
|
427
|
+
const attrs = Object.fromEntries(
|
|
428
|
+
mockInstance._sendLogsBatch.mock.calls[0][0].resourceLogs[0].resource.attributes.map((a: any) => [
|
|
429
|
+
a.key,
|
|
430
|
+
a.value,
|
|
431
|
+
])
|
|
432
|
+
)
|
|
433
|
+
expect(attrs['service.name']).toEqual({ stringValue: 'unknown_service' })
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('SDK-controlled telemetry.sdk.* and service.name win over user resourceAttributes', async () => {
|
|
437
|
+
// Most logs backends index on these keys for routing, SDK-version
|
|
438
|
+
// dashboards, and bug-correlation. Letting a stray user key clobber
|
|
439
|
+
// them silently breaks ingestion attribution, so the layout puts
|
|
440
|
+
// user attrs first and SDK identity attrs on top.
|
|
441
|
+
const logs = new PostHogLogs(
|
|
442
|
+
mockInstance,
|
|
443
|
+
resolveForTest({
|
|
444
|
+
resourceAttributes: {
|
|
445
|
+
'telemetry.sdk.name': 'my-wrapper',
|
|
446
|
+
'service.name': 'user-supplied-service',
|
|
447
|
+
// Non-protected user keys still pass through.
|
|
448
|
+
'host.name': 'my-host',
|
|
449
|
+
},
|
|
450
|
+
}),
|
|
451
|
+
logger,
|
|
452
|
+
getContextFor(mockInstance),
|
|
453
|
+
immediateOnReady
|
|
454
|
+
)
|
|
455
|
+
logs.captureLog({ body: 'hi' })
|
|
456
|
+
await logs.flush()
|
|
457
|
+
|
|
458
|
+
const attrs = Object.fromEntries(
|
|
459
|
+
mockInstance._sendLogsBatch.mock.calls[0][0].resourceLogs[0].resource.attributes.map((a: any) => [
|
|
460
|
+
a.key,
|
|
461
|
+
a.value,
|
|
462
|
+
])
|
|
463
|
+
)
|
|
464
|
+
expect(attrs['telemetry.sdk.name']).toEqual({ stringValue: 'posthog-core-tests' })
|
|
465
|
+
expect(attrs['telemetry.sdk.version']).toEqual({ stringValue: '0.0.0-test' })
|
|
466
|
+
expect(attrs['service.name']).toEqual({ stringValue: 'unknown_service' })
|
|
467
|
+
expect(attrs['host.name']).toEqual({ stringValue: 'my-host' })
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('splits a large queue into multiple batches of maxBatchRecordsPerPost and persists after each', async () => {
|
|
471
|
+
const sendOrder: number[] = []
|
|
472
|
+
let persistCallsBeforeSecondSend = 0
|
|
473
|
+
mockInstance._sendLogsBatch = jest.fn(async (payload: any) => {
|
|
474
|
+
// Record the persist count *at the start of* send #2. The first send
|
|
475
|
+
// must have already persisted its queue advance by then — otherwise a
|
|
476
|
+
// crash between sends could double-send the first batch.
|
|
477
|
+
if (sendOrder.length === 1) {
|
|
478
|
+
persistCallsBeforeSecondSend = mockInstance.setPersistedProperty.mock.calls.length
|
|
479
|
+
}
|
|
480
|
+
sendOrder.push(payload.resourceLogs[0].scopeLogs[0].logRecords.length)
|
|
481
|
+
return { kind: 'ok' }
|
|
482
|
+
})
|
|
483
|
+
const logs = new PostHogLogs(
|
|
484
|
+
mockInstance,
|
|
485
|
+
resolveForTest({ maxBatchRecordsPerPost: 2, maxBufferSize: 10 }),
|
|
486
|
+
logger,
|
|
487
|
+
getContextFor(mockInstance),
|
|
488
|
+
immediateOnReady
|
|
489
|
+
)
|
|
490
|
+
for (let i = 0; i < 5; i++) {
|
|
491
|
+
logs.captureLog({ body: `msg-${i}` })
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
await logs.flush()
|
|
495
|
+
|
|
496
|
+
// 2 + 2 + 1 = 5 records across 3 POSTs
|
|
497
|
+
expect(sendOrder).toEqual([2, 2, 1])
|
|
498
|
+
// After the first send, the queue must have been persisted before the second send —
|
|
499
|
+
// otherwise a crash between sends could double-send the first batch.
|
|
500
|
+
expect(persistCallsBeforeSecondSend).toBeGreaterThan(5 /* enqueue writes */)
|
|
501
|
+
expect(readQueue(mockInstance)).toHaveLength(0)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('halves maxBatchRecordsPerPost and retries the same records on too-large outcome', async () => {
|
|
505
|
+
const sendSizes: number[] = []
|
|
506
|
+
mockInstance._sendLogsBatch = jest.fn(async (payload: any) => {
|
|
507
|
+
const size = payload.resourceLogs[0].scopeLogs[0].logRecords.length
|
|
508
|
+
sendSizes.push(size)
|
|
509
|
+
if (sendSizes.length === 1) {
|
|
510
|
+
return { kind: 'too-large' }
|
|
511
|
+
}
|
|
512
|
+
return { kind: 'ok' }
|
|
513
|
+
})
|
|
514
|
+
const logs = new PostHogLogs(
|
|
515
|
+
mockInstance,
|
|
516
|
+
resolveForTest({ maxBatchRecordsPerPost: 4, maxBufferSize: 10 }),
|
|
517
|
+
logger,
|
|
518
|
+
getContextFor(mockInstance),
|
|
519
|
+
immediateOnReady
|
|
520
|
+
)
|
|
521
|
+
for (let i = 0; i < 4; i++) {
|
|
522
|
+
logs.captureLog({ body: `msg-${i}` })
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
await logs.flush()
|
|
526
|
+
|
|
527
|
+
// First POST: 4 records → too-large. Retry with halved cap = 2, so: 2 + 2.
|
|
528
|
+
expect(sendSizes).toEqual([4, 2, 2])
|
|
529
|
+
expect(readQueue(mockInstance)).toHaveLength(0)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it('ramps maxBatchRecordsPerPost back toward the configured cap after a healthy streak', async () => {
|
|
533
|
+
// Reproduces the Greptile P1 concern: a one-off oversized payload
|
|
534
|
+
// should not permanently degrade throughput. After a 413 halves the
|
|
535
|
+
// cap, each healthy send grows it back by 1 until the configured
|
|
536
|
+
// maximum is reached.
|
|
537
|
+
const sendSizes: number[] = []
|
|
538
|
+
mockInstance._sendLogsBatch = jest.fn(async (payload: any) => {
|
|
539
|
+
const size = payload.resourceLogs[0].scopeLogs[0].logRecords.length
|
|
540
|
+
sendSizes.push(size)
|
|
541
|
+
// First POST is rejected as too-large; everything else succeeds.
|
|
542
|
+
if (sendSizes.length === 1) {
|
|
543
|
+
return { kind: 'too-large' }
|
|
544
|
+
}
|
|
545
|
+
return { kind: 'ok' }
|
|
546
|
+
})
|
|
547
|
+
const logs = new PostHogLogs(
|
|
548
|
+
mockInstance,
|
|
549
|
+
resolveForTest({ maxBatchRecordsPerPost: 4, maxBufferSize: 100 }),
|
|
550
|
+
logger,
|
|
551
|
+
getContextFor(mockInstance),
|
|
552
|
+
immediateOnReady
|
|
553
|
+
)
|
|
554
|
+
// Enqueue plenty so the recovery has room to ramp.
|
|
555
|
+
for (let i = 0; i < 16; i++) {
|
|
556
|
+
logs.captureLog({ body: `msg-${i}` })
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
await logs.flush()
|
|
560
|
+
|
|
561
|
+
// First POST: 4 records → too-large. Cap halves to 2. From there each
|
|
562
|
+
// healthy send grows the cap by 1 toward the configured 4:
|
|
563
|
+
// sizes: [4 (413), 2, 3, 4, 4, ...] (the trailing 3 drains the
|
|
564
|
+
// remainder of the 16-record queue).
|
|
565
|
+
expect(sendSizes).toEqual([4, 2, 3, 4, 4, 3])
|
|
566
|
+
expect(readQueue(mockInstance)).toHaveLength(0)
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('drops the only record when too-large arrives on a batch of size 1', async () => {
|
|
570
|
+
mockInstance._sendLogsBatch = jest.fn(() => Promise.resolve({ kind: 'too-large' }))
|
|
571
|
+
const logs = new PostHogLogs(
|
|
572
|
+
mockInstance,
|
|
573
|
+
resolveForTest({ maxBatchRecordsPerPost: 1 }),
|
|
574
|
+
logger,
|
|
575
|
+
getContextFor(mockInstance),
|
|
576
|
+
immediateOnReady
|
|
577
|
+
)
|
|
578
|
+
logs.captureLog({ body: 'too-big' })
|
|
579
|
+
|
|
580
|
+
await logs.flush()
|
|
581
|
+
|
|
582
|
+
// Batch of 1 that's rejected as too-large is permanent — drop it rather
|
|
583
|
+
// than spin on the same record forever.
|
|
584
|
+
expect(readQueue(mockInstance)).toHaveLength(0)
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('warns explicitly when dropping a size-1 413 (visibility for the lost record)', async () => {
|
|
588
|
+
mockInstance._sendLogsBatch = jest.fn(() => Promise.resolve({ kind: 'too-large' }))
|
|
589
|
+
const logs = new PostHogLogs(
|
|
590
|
+
mockInstance,
|
|
591
|
+
resolveForTest({ maxBatchRecordsPerPost: 1 }),
|
|
592
|
+
logger,
|
|
593
|
+
getContextFor(mockInstance),
|
|
594
|
+
immediateOnReady
|
|
595
|
+
)
|
|
596
|
+
logs.captureLog({ body: 'oversized' })
|
|
597
|
+
await logs.flush()
|
|
598
|
+
|
|
599
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
600
|
+
expect.stringContaining('Dropping a single log record after 413 with batch size 1')
|
|
601
|
+
)
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('keeps draining the queue after a size-1 413 drop (one bad record does not stall the pipeline)', async () => {
|
|
605
|
+
// First record returns too-large with size 1 (drops and warns), then
|
|
606
|
+
// the rest of the queue should continue flushing normally.
|
|
607
|
+
let callCount = 0
|
|
608
|
+
mockInstance._sendLogsBatch = jest.fn(() => {
|
|
609
|
+
callCount++
|
|
610
|
+
return Promise.resolve(callCount === 1 ? { kind: 'too-large' } : { kind: 'ok' })
|
|
611
|
+
})
|
|
612
|
+
const logs = new PostHogLogs(
|
|
613
|
+
mockInstance,
|
|
614
|
+
resolveForTest({ maxBatchRecordsPerPost: 1, maxBufferSize: 10 }),
|
|
615
|
+
logger,
|
|
616
|
+
getContextFor(mockInstance),
|
|
617
|
+
immediateOnReady
|
|
618
|
+
)
|
|
619
|
+
logs.captureLog({ body: 'oversized' })
|
|
620
|
+
logs.captureLog({ body: 'ok-1' })
|
|
621
|
+
logs.captureLog({ body: 'ok-2' })
|
|
622
|
+
|
|
623
|
+
await logs.flush()
|
|
624
|
+
|
|
625
|
+
// Three sends: oversized (dropped), ok-1, ok-2. Queue is empty.
|
|
626
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(3)
|
|
627
|
+
expect(readQueue(mockInstance)).toHaveLength(0)
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('size-1 413 retry-shrink path: starts at maxBatchRecordsPerPost, halves to 1, drops at 1', async () => {
|
|
631
|
+
// Realistic flow: batch=N gets too-large, halves to N/2, halves to 1,
|
|
632
|
+
// then 413 on size 1 is the permanent drop. Verifies the cap actually
|
|
633
|
+
// shrinks all the way down before the size-1 drop fires.
|
|
634
|
+
const sendSizes: number[] = []
|
|
635
|
+
mockInstance._sendLogsBatch = jest.fn(async (payload: any) => {
|
|
636
|
+
const size = payload.resourceLogs[0].scopeLogs[0].logRecords.length
|
|
637
|
+
sendSizes.push(size)
|
|
638
|
+
return { kind: 'too-large' }
|
|
639
|
+
})
|
|
640
|
+
const logs = new PostHogLogs(
|
|
641
|
+
mockInstance,
|
|
642
|
+
resolveForTest({ maxBatchRecordsPerPost: 4, maxBufferSize: 10 }),
|
|
643
|
+
logger,
|
|
644
|
+
getContextFor(mockInstance),
|
|
645
|
+
immediateOnReady
|
|
646
|
+
)
|
|
647
|
+
// Single oversized record. With maxBatchRecordsPerPost=4 but only 1 record
|
|
648
|
+
// in the queue, the first send is size 1 — going straight to the drop path.
|
|
649
|
+
logs.captureLog({ body: 'huge' })
|
|
650
|
+
|
|
651
|
+
await logs.flush()
|
|
652
|
+
|
|
653
|
+
// Single send of size 1, dropped immediately (no halving rounds because
|
|
654
|
+
// batch was already at 1).
|
|
655
|
+
expect(sendSizes).toEqual([1])
|
|
656
|
+
expect(readQueue(mockInstance)).toHaveLength(0)
|
|
657
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
658
|
+
expect.stringContaining('Dropping a single log record after 413 with batch size 1')
|
|
659
|
+
)
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('keeps records in the queue on retry-later outcome and re-throws the carried error', async () => {
|
|
663
|
+
const netErr = new Error('offline')
|
|
664
|
+
mockInstance._sendLogsBatch = jest.fn(() => Promise.resolve({ kind: 'retry-later', error: netErr }))
|
|
665
|
+
const logs = new PostHogLogs(
|
|
666
|
+
mockInstance,
|
|
667
|
+
resolveForTest(),
|
|
668
|
+
logger,
|
|
669
|
+
getContextFor(mockInstance),
|
|
670
|
+
immediateOnReady
|
|
671
|
+
)
|
|
672
|
+
logs.captureLog({ body: 'queued' })
|
|
673
|
+
|
|
674
|
+
await expect(logs.flush()).rejects.toBe(netErr)
|
|
675
|
+
|
|
676
|
+
expect(readQueue(mockInstance)).toHaveLength(1)
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('drops the batch on fatal outcome and re-throws the carried error', async () => {
|
|
680
|
+
const bogus = new Error('malformed')
|
|
681
|
+
mockInstance._sendLogsBatch = jest.fn(() => Promise.resolve({ kind: 'fatal', error: bogus }))
|
|
682
|
+
const logs = new PostHogLogs(
|
|
683
|
+
mockInstance,
|
|
684
|
+
resolveForTest(),
|
|
685
|
+
logger,
|
|
686
|
+
getContextFor(mockInstance),
|
|
687
|
+
immediateOnReady
|
|
688
|
+
)
|
|
689
|
+
logs.captureLog({ body: 'doomed' })
|
|
690
|
+
|
|
691
|
+
await expect(logs.flush()).rejects.toBe(bogus)
|
|
692
|
+
|
|
693
|
+
expect(readQueue(mockInstance)).toHaveLength(0)
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
it('awaits _waitForStoragePersist between batches so a crash can’t replay records', async () => {
|
|
697
|
+
const sequence: string[] = []
|
|
698
|
+
mockInstance._sendLogsBatch = jest.fn(async (payload: any) => {
|
|
699
|
+
sequence.push(`send:${payload.resourceLogs[0].scopeLogs[0].logRecords.length}`)
|
|
700
|
+
return { kind: 'ok' }
|
|
701
|
+
})
|
|
702
|
+
const waitForStoragePersist = jest.fn(async () => {
|
|
703
|
+
sequence.push('waitForPersist')
|
|
704
|
+
})
|
|
705
|
+
const logs = new PostHogLogs(
|
|
706
|
+
mockInstance,
|
|
707
|
+
resolveForTest({ maxBatchRecordsPerPost: 2, maxBufferSize: 10 }),
|
|
708
|
+
logger,
|
|
709
|
+
getContextFor(mockInstance),
|
|
710
|
+
immediateOnReady,
|
|
711
|
+
waitForStoragePersist
|
|
712
|
+
)
|
|
713
|
+
for (let i = 0; i < 3; i++) {
|
|
714
|
+
logs.captureLog({ body: `msg-${i}` })
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
await logs.flush()
|
|
718
|
+
|
|
719
|
+
// Send 2 → waitForPersist → send 1 → waitForPersist. If the wait
|
|
720
|
+
// landed out-of-order (e.g. before send), a crash mid-batch could
|
|
721
|
+
// replay records on the next startup.
|
|
722
|
+
expect(sequence).toEqual(['send:2', 'waitForPersist', 'send:1', 'waitForPersist'])
|
|
723
|
+
expect(waitForStoragePersist).toHaveBeenCalledTimes(2)
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('serializes concurrent flush calls rather than racing them', async () => {
|
|
727
|
+
let resolveFirst: (v: any) => void = () => {}
|
|
728
|
+
mockInstance._sendLogsBatch = jest.fn(
|
|
729
|
+
() =>
|
|
730
|
+
new Promise((r) => {
|
|
731
|
+
resolveFirst = r
|
|
732
|
+
})
|
|
733
|
+
)
|
|
734
|
+
const logs = new PostHogLogs(
|
|
735
|
+
mockInstance,
|
|
736
|
+
resolveForTest(),
|
|
737
|
+
logger,
|
|
738
|
+
getContextFor(mockInstance),
|
|
739
|
+
immediateOnReady
|
|
740
|
+
)
|
|
741
|
+
logs.captureLog({ body: 'a' })
|
|
742
|
+
|
|
743
|
+
const first = logs.flush()
|
|
744
|
+
const second = logs.flush()
|
|
745
|
+
|
|
746
|
+
// Both callers observe the same in-flight promise, so only one POST happens.
|
|
747
|
+
resolveFirst({ kind: 'ok' })
|
|
748
|
+
await Promise.all([first, second])
|
|
749
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
750
|
+
})
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
describe('flush triggers', () => {
|
|
754
|
+
beforeEach(() => jest.useFakeTimers())
|
|
755
|
+
afterEach(() => jest.useRealTimers())
|
|
756
|
+
|
|
757
|
+
it('fires a flush when the buffer hits maxBufferSize', () => {
|
|
758
|
+
const logs = new PostHogLogs(
|
|
759
|
+
mockInstance,
|
|
760
|
+
resolveForTest({ maxBufferSize: 3 }),
|
|
761
|
+
logger,
|
|
762
|
+
getContextFor(mockInstance),
|
|
763
|
+
immediateOnReady
|
|
764
|
+
)
|
|
765
|
+
logs.captureLog({ body: 'a' })
|
|
766
|
+
logs.captureLog({ body: 'b' })
|
|
767
|
+
expect(mockInstance._sendLogsBatch).not.toHaveBeenCalled()
|
|
768
|
+
|
|
769
|
+
logs.captureLog({ body: 'c' })
|
|
770
|
+
// Threshold trigger fires `flush()` fire-and-forget; the call happens
|
|
771
|
+
// synchronously on the hot path.
|
|
772
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
it('schedules one timer per idle window and fires flush on expiry', () => {
|
|
776
|
+
const logs = new PostHogLogs(
|
|
777
|
+
mockInstance,
|
|
778
|
+
resolveForTest({ flushIntervalMs: 5000 }),
|
|
779
|
+
logger,
|
|
780
|
+
getContextFor(mockInstance),
|
|
781
|
+
immediateOnReady
|
|
782
|
+
)
|
|
783
|
+
logs.captureLog({ body: 'first' })
|
|
784
|
+
logs.captureLog({ body: 'second' })
|
|
785
|
+
logs.captureLog({ body: 'third' })
|
|
786
|
+
|
|
787
|
+
// Only one timer armed, not three — subsequent enqueues inside the
|
|
788
|
+
// window must not push the flush out.
|
|
789
|
+
expect(mockInstance._sendLogsBatch).not.toHaveBeenCalled()
|
|
790
|
+
jest.advanceTimersByTime(4999)
|
|
791
|
+
expect(mockInstance._sendLogsBatch).not.toHaveBeenCalled()
|
|
792
|
+
jest.advanceTimersByTime(1)
|
|
793
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('does not schedule a timer for the threshold-triggered path', () => {
|
|
797
|
+
const logs = new PostHogLogs(
|
|
798
|
+
mockInstance,
|
|
799
|
+
resolveForTest({ maxBufferSize: 2, flushIntervalMs: 5000 }),
|
|
800
|
+
logger,
|
|
801
|
+
getContextFor(mockInstance),
|
|
802
|
+
immediateOnReady
|
|
803
|
+
)
|
|
804
|
+
logs.captureLog({ body: 'a' })
|
|
805
|
+
logs.captureLog({ body: 'b' })
|
|
806
|
+
// Threshold path flushed already; advancing time must not trigger a second send.
|
|
807
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
808
|
+
jest.advanceTimersByTime(5000)
|
|
809
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
810
|
+
})
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
describe('shutdown', () => {
|
|
814
|
+
beforeEach(() => jest.useFakeTimers())
|
|
815
|
+
afterEach(() => jest.useRealTimers())
|
|
816
|
+
|
|
817
|
+
it('drains the queue and clears any armed flush timer', async () => {
|
|
818
|
+
const logs = new PostHogLogs(
|
|
819
|
+
mockInstance,
|
|
820
|
+
resolveForTest({ flushIntervalMs: 5000 }),
|
|
821
|
+
logger,
|
|
822
|
+
getContextFor(mockInstance),
|
|
823
|
+
immediateOnReady
|
|
824
|
+
)
|
|
825
|
+
logs.captureLog({ body: 'a' })
|
|
826
|
+
// A timer is now armed — shutdown must cancel it so the process can
|
|
827
|
+
// exit cleanly even if the final flush triggers a duplicate send.
|
|
828
|
+
|
|
829
|
+
await logs.shutdown()
|
|
830
|
+
|
|
831
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
832
|
+
// Advancing past the original interval must not produce a second flush.
|
|
833
|
+
jest.advanceTimersByTime(10000)
|
|
834
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
it('swallows flush errors so shutdown can complete', async () => {
|
|
838
|
+
mockInstance._sendLogsBatch = jest.fn(() => Promise.resolve({ kind: 'fatal', error: new Error('boom') }))
|
|
839
|
+
const logs = new PostHogLogs(
|
|
840
|
+
mockInstance,
|
|
841
|
+
resolveForTest(),
|
|
842
|
+
logger,
|
|
843
|
+
getContextFor(mockInstance),
|
|
844
|
+
immediateOnReady
|
|
845
|
+
)
|
|
846
|
+
logs.captureLog({ body: 'doomed' })
|
|
847
|
+
|
|
848
|
+
await expect(logs.shutdown()).resolves.toBeUndefined()
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
it('is a no-op when the queue is empty and no timer is armed', async () => {
|
|
852
|
+
const logs = new PostHogLogs(
|
|
853
|
+
mockInstance,
|
|
854
|
+
resolveForTest(),
|
|
855
|
+
logger,
|
|
856
|
+
getContextFor(mockInstance),
|
|
857
|
+
immediateOnReady
|
|
858
|
+
)
|
|
859
|
+
await logs.shutdown()
|
|
860
|
+
expect(mockInstance._sendLogsBatch).not.toHaveBeenCalled()
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
it('called twice is idempotent (second call is a no-op once queue drains)', async () => {
|
|
864
|
+
const logs = new PostHogLogs(
|
|
865
|
+
mockInstance,
|
|
866
|
+
resolveForTest(),
|
|
867
|
+
logger,
|
|
868
|
+
getContextFor(mockInstance),
|
|
869
|
+
immediateOnReady
|
|
870
|
+
)
|
|
871
|
+
logs.captureLog({ body: 'x' })
|
|
872
|
+
await logs.shutdown()
|
|
873
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
874
|
+
|
|
875
|
+
// Queue is empty now — a second shutdown shouldn't re-send.
|
|
876
|
+
await logs.shutdown()
|
|
877
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it('while a flush is in flight, the shared promise coordinates a single drain', async () => {
|
|
881
|
+
let resolveFirst: (v: any) => void = () => {}
|
|
882
|
+
mockInstance._sendLogsBatch = jest.fn(
|
|
883
|
+
() =>
|
|
884
|
+
new Promise((r) => {
|
|
885
|
+
resolveFirst = r
|
|
886
|
+
})
|
|
887
|
+
)
|
|
888
|
+
const logs = new PostHogLogs(
|
|
889
|
+
mockInstance,
|
|
890
|
+
resolveForTest(),
|
|
891
|
+
logger,
|
|
892
|
+
getContextFor(mockInstance),
|
|
893
|
+
immediateOnReady
|
|
894
|
+
)
|
|
895
|
+
logs.captureLog({ body: 'a' })
|
|
896
|
+
|
|
897
|
+
// Real timers only here — shutdown(timeoutMs) path uses safeSetTimeout,
|
|
898
|
+
// which is incompatible with the default `jest.useFakeTimers()`.
|
|
899
|
+
jest.useRealTimers()
|
|
900
|
+
|
|
901
|
+
const flushP = logs.flush()
|
|
902
|
+
const shutdownP = logs.shutdown()
|
|
903
|
+
|
|
904
|
+
resolveFirst({ kind: 'ok' })
|
|
905
|
+
await Promise.all([flushP, shutdownP])
|
|
906
|
+
|
|
907
|
+
// Both callers joined the same in-flight flush — no double-send.
|
|
908
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
it('races the final flush against timeoutMs so a stalled send does not hang shutdown', async () => {
|
|
912
|
+
jest.useRealTimers()
|
|
913
|
+
// _sendLogsBatch never resolves — the budget must force shutdown to return.
|
|
914
|
+
mockInstance._sendLogsBatch = jest.fn(() => new Promise(() => {}))
|
|
915
|
+
const logs = new PostHogLogs(
|
|
916
|
+
mockInstance,
|
|
917
|
+
resolveForTest(),
|
|
918
|
+
logger,
|
|
919
|
+
getContextFor(mockInstance),
|
|
920
|
+
immediateOnReady
|
|
921
|
+
)
|
|
922
|
+
logs.captureLog({ body: 'stuck' })
|
|
923
|
+
|
|
924
|
+
const start = Date.now()
|
|
925
|
+
await logs.shutdown(30)
|
|
926
|
+
const elapsed = Date.now() - start
|
|
927
|
+
|
|
928
|
+
// Loose upper bound — just prove we didn't wait forever.
|
|
929
|
+
expect(elapsed).toBeLessThan(500)
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
it('propagates a _waitForStoragePersist rejection out of flush (so callers can react)', async () => {
|
|
933
|
+
const persistErr = new Error('disk is gone')
|
|
934
|
+
const logs = new PostHogLogs(
|
|
935
|
+
mockInstance,
|
|
936
|
+
resolveForTest(),
|
|
937
|
+
logger,
|
|
938
|
+
getContextFor(mockInstance),
|
|
939
|
+
immediateOnReady,
|
|
940
|
+
// Persist fails AFTER the HTTP send succeeds — records were sent but
|
|
941
|
+
// the queue-advance didn't reach disk. Surface the error so the
|
|
942
|
+
// caller knows a retry on restart may re-send.
|
|
943
|
+
() => Promise.reject(persistErr)
|
|
944
|
+
)
|
|
945
|
+
logs.captureLog({ body: 'sent-but-not-persisted' })
|
|
946
|
+
|
|
947
|
+
await expect(logs.flush()).rejects.toBe(persistErr)
|
|
948
|
+
})
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
describe('beforeSend hook', () => {
|
|
952
|
+
// Helper that hides the constructor boilerplate so the table-driven
|
|
953
|
+
// cases below can be a single line of setup each.
|
|
954
|
+
const makeLogs = (beforeSend: PostHogLogsConfig['beforeSend']): PostHogLogs =>
|
|
955
|
+
new PostHogLogs(
|
|
956
|
+
mockInstance,
|
|
957
|
+
resolveForTest({ beforeSend }),
|
|
958
|
+
logger,
|
|
959
|
+
getContextFor(mockInstance),
|
|
960
|
+
immediateOnReady
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
// Cases that share a "captureLog → assert queue body" shape. Bespoke
|
|
964
|
+
// assertions (logger expectations, throw-doesn't-crash, post-chain
|
|
965
|
+
// continuation after throw) live in their own `it` blocks below — those
|
|
966
|
+
// were warping the table when forced into it.
|
|
967
|
+
type Case = {
|
|
968
|
+
name: string
|
|
969
|
+
beforeSend: PostHogLogsConfig['beforeSend']
|
|
970
|
+
input: string
|
|
971
|
+
expectedQueueLen: number
|
|
972
|
+
expectedBody?: string
|
|
973
|
+
}
|
|
974
|
+
const cases: Case[] = [
|
|
975
|
+
{
|
|
976
|
+
name: 'transforms body when fn returns mutated value',
|
|
977
|
+
beforeSend: (r) => ({ ...r, body: r.body.toUpperCase() }),
|
|
978
|
+
input: 'hello',
|
|
979
|
+
expectedQueueLen: 1,
|
|
980
|
+
expectedBody: 'HELLO',
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
name: 'drops the record when fn returns null',
|
|
984
|
+
beforeSend: () => null,
|
|
985
|
+
input: 'silent',
|
|
986
|
+
expectedQueueLen: 0,
|
|
987
|
+
},
|
|
988
|
+
{
|
|
989
|
+
name: 'chains an array left-to-right (each fn sees previous result)',
|
|
990
|
+
beforeSend: [
|
|
991
|
+
(r) => ({ ...r, body: `${r.body}-1` }),
|
|
992
|
+
(r) => ({ ...r, body: `${r.body}-2` }),
|
|
993
|
+
(r) => ({ ...r, body: `${r.body}-3` }),
|
|
994
|
+
],
|
|
995
|
+
input: 'x',
|
|
996
|
+
expectedQueueLen: 1,
|
|
997
|
+
expectedBody: 'x-1-2-3',
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
name: 'short-circuits the chain when any fn returns null',
|
|
1001
|
+
beforeSend: [(r) => r, () => null, (r) => r],
|
|
1002
|
+
input: 'dropped',
|
|
1003
|
+
expectedQueueLen: 0,
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
name: 'treats an empty body returned by beforeSend as a drop',
|
|
1007
|
+
beforeSend: (r) => ({ ...r, body: '' }),
|
|
1008
|
+
input: 'will-be-emptied',
|
|
1009
|
+
expectedQueueLen: 0,
|
|
1010
|
+
},
|
|
1011
|
+
]
|
|
1012
|
+
|
|
1013
|
+
it.each(cases)('$name', ({ beforeSend, input, expectedQueueLen, expectedBody }) => {
|
|
1014
|
+
const logs = makeLogs(beforeSend)
|
|
1015
|
+
logs.captureLog({ body: input })
|
|
1016
|
+
|
|
1017
|
+
const queue = readQueue(mockInstance)
|
|
1018
|
+
expect(queue).toHaveLength(expectedQueueLen)
|
|
1019
|
+
if (expectedBody !== undefined) {
|
|
1020
|
+
expect(queue[0].record.body.stringValue).toBe(expectedBody)
|
|
1021
|
+
}
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
it('logs an info line when a fn returns null', () => {
|
|
1025
|
+
// Carved out because the table only asserts queue shape; this
|
|
1026
|
+
// verifies the diagnostic path that warns the user a record was
|
|
1027
|
+
// dropped by their filter (no other knob to surface that).
|
|
1028
|
+
const logs = makeLogs(() => null)
|
|
1029
|
+
logs.captureLog({ body: 'silent' })
|
|
1030
|
+
expect(logger.info).toHaveBeenCalledWith('Log was rejected in beforeSend function')
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('never crashes the caller when a fn throws — the chain continues with the prior result', () => {
|
|
1034
|
+
// Bespoke: needs to verify (a) no throw escapes captureLog, (b) the
|
|
1035
|
+
// chain continues with the previous result so a buggy filter degrades
|
|
1036
|
+
// to a no-op, and (c) the failure is logged. Doesn't fit the table.
|
|
1037
|
+
const thrower = jest.fn(() => {
|
|
1038
|
+
throw new Error('bad filter')
|
|
1039
|
+
})
|
|
1040
|
+
const after = jest.fn((r: any) => ({ ...r, body: `${r.body}!` }))
|
|
1041
|
+
const logs = new PostHogLogs(
|
|
1042
|
+
mockInstance,
|
|
1043
|
+
resolveForTest({ beforeSend: [thrower, after] }),
|
|
1044
|
+
logger,
|
|
1045
|
+
getContextFor(mockInstance),
|
|
1046
|
+
immediateOnReady
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
expect(() => logs.captureLog({ body: 'hi' })).not.toThrow()
|
|
1050
|
+
expect(readQueue(mockInstance)[0].record.body.stringValue).toBe('hi!')
|
|
1051
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
1052
|
+
expect.stringContaining('Error in beforeSend function for log:'),
|
|
1053
|
+
expect.any(Error)
|
|
1054
|
+
)
|
|
1055
|
+
})
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
describe('rate limiting', () => {
|
|
1059
|
+
beforeEach(() => jest.useFakeTimers({ now: 0 }))
|
|
1060
|
+
afterEach(() => jest.useRealTimers())
|
|
1061
|
+
|
|
1062
|
+
// Tabular form for the simple in-window cap cases. Bespoke ones
|
|
1063
|
+
// (warn-once, window-roll reset, clock-jump backward, beforeSend
|
|
1064
|
+
// accounting) keep their own `it` blocks because they assert
|
|
1065
|
+
// multi-window or interleaving behavior.
|
|
1066
|
+
type CapCase = {
|
|
1067
|
+
name: string
|
|
1068
|
+
maxLogsPerInterval: number | undefined
|
|
1069
|
+
capturesInWindow: number
|
|
1070
|
+
expectedQueueLen: number
|
|
1071
|
+
}
|
|
1072
|
+
const capCases: CapCase[] = [
|
|
1073
|
+
{
|
|
1074
|
+
name: 'is uncapped when maxLogsPerInterval is undefined (default)',
|
|
1075
|
+
maxLogsPerInterval: undefined,
|
|
1076
|
+
capturesInWindow: 50,
|
|
1077
|
+
expectedQueueLen: 50,
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
name: 'drops captures beyond maxLogsPerInterval within the window',
|
|
1081
|
+
maxLogsPerInterval: 3,
|
|
1082
|
+
capturesInWindow: 5,
|
|
1083
|
+
expectedQueueLen: 3,
|
|
1084
|
+
},
|
|
1085
|
+
]
|
|
1086
|
+
|
|
1087
|
+
it.each(capCases)('$name', ({ maxLogsPerInterval, capturesInWindow, expectedQueueLen }) => {
|
|
1088
|
+
const logs = new PostHogLogs(
|
|
1089
|
+
mockInstance,
|
|
1090
|
+
resolveForTest({ maxLogsPerInterval, rateCapWindowMs: 1000 }),
|
|
1091
|
+
logger,
|
|
1092
|
+
getContextFor(mockInstance),
|
|
1093
|
+
immediateOnReady
|
|
1094
|
+
)
|
|
1095
|
+
for (let i = 0; i < capturesInWindow; i++) {
|
|
1096
|
+
logs.captureLog({ body: `msg-${i}` })
|
|
1097
|
+
}
|
|
1098
|
+
expect(readQueue(mockInstance)).toHaveLength(expectedQueueLen)
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
it('warns exactly once per window when dropping, regardless of how many drops', () => {
|
|
1102
|
+
const logs = new PostHogLogs(
|
|
1103
|
+
mockInstance,
|
|
1104
|
+
resolveForTest({ maxLogsPerInterval: 2, rateCapWindowMs: 1000 }),
|
|
1105
|
+
logger,
|
|
1106
|
+
getContextFor(mockInstance),
|
|
1107
|
+
immediateOnReady
|
|
1108
|
+
)
|
|
1109
|
+
for (let i = 0; i < 10; i++) {
|
|
1110
|
+
logs.captureLog({ body: `msg-${i}` })
|
|
1111
|
+
}
|
|
1112
|
+
expect(logger.warn).toHaveBeenCalledTimes(1)
|
|
1113
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('captureLog dropping logs'))
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
it('resets the counter when the window rolls (and warns again on next overflow)', () => {
|
|
1117
|
+
const logs = new PostHogLogs(
|
|
1118
|
+
mockInstance,
|
|
1119
|
+
resolveForTest({ maxLogsPerInterval: 1, rateCapWindowMs: 1000 }),
|
|
1120
|
+
logger,
|
|
1121
|
+
getContextFor(mockInstance),
|
|
1122
|
+
immediateOnReady
|
|
1123
|
+
)
|
|
1124
|
+
logs.captureLog({ body: 'window-1-kept' })
|
|
1125
|
+
logs.captureLog({ body: 'window-1-dropped' })
|
|
1126
|
+
expect(readQueue(mockInstance)).toHaveLength(1)
|
|
1127
|
+
expect(logger.warn).toHaveBeenCalledTimes(1)
|
|
1128
|
+
|
|
1129
|
+
jest.setSystemTime(1001)
|
|
1130
|
+
logs.captureLog({ body: 'window-2-kept' })
|
|
1131
|
+
logs.captureLog({ body: 'window-2-dropped' })
|
|
1132
|
+
expect(readQueue(mockInstance)).toHaveLength(2)
|
|
1133
|
+
expect(logger.warn).toHaveBeenCalledTimes(2)
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
it('resets the window when the clock jumps backward (NTP correction / manual clock change)', () => {
|
|
1137
|
+
const logs = new PostHogLogs(
|
|
1138
|
+
mockInstance,
|
|
1139
|
+
resolveForTest({ maxLogsPerInterval: 2, rateCapWindowMs: 1000 }),
|
|
1140
|
+
logger,
|
|
1141
|
+
getContextFor(mockInstance),
|
|
1142
|
+
immediateOnReady
|
|
1143
|
+
)
|
|
1144
|
+
// Seed the window at t=5000, fill the budget.
|
|
1145
|
+
jest.setSystemTime(5000)
|
|
1146
|
+
logs.captureLog({ body: 'a' })
|
|
1147
|
+
logs.captureLog({ body: 'b' })
|
|
1148
|
+
logs.captureLog({ body: 'dropped-pre-jump' })
|
|
1149
|
+
expect(readQueue(mockInstance)).toHaveLength(2)
|
|
1150
|
+
|
|
1151
|
+
// Clock jumps backward by 1 hour (e.g. user reset device time).
|
|
1152
|
+
// Without the `elapsed < 0` guard, the rate cap would stay "stuck"
|
|
1153
|
+
// until `now` exceeds the old window-start again — potentially
|
|
1154
|
+
// dropping every log for the duration of the backward jump.
|
|
1155
|
+
jest.setSystemTime(5000 - 60 * 60 * 1000)
|
|
1156
|
+
logs.captureLog({ body: 'accepted-post-jump' })
|
|
1157
|
+
|
|
1158
|
+
expect(readQueue(mockInstance)).toHaveLength(3)
|
|
1159
|
+
expect(readQueue(mockInstance)[2].record.body.stringValue).toBe('accepted-post-jump')
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
it('beforeSend-rejected records do not consume the per-interval budget', () => {
|
|
1163
|
+
// beforeSend drops the first record; rate cap is 1 per window. The
|
|
1164
|
+
// SECOND capture should still succeed — if beforeSend consumed the
|
|
1165
|
+
// budget, it'd be dropped.
|
|
1166
|
+
const beforeSend = jest
|
|
1167
|
+
.fn()
|
|
1168
|
+
.mockReturnValueOnce(null)
|
|
1169
|
+
.mockImplementation((r: any) => r)
|
|
1170
|
+
const logs = new PostHogLogs(
|
|
1171
|
+
mockInstance,
|
|
1172
|
+
resolveForTest({ maxLogsPerInterval: 1, rateCapWindowMs: 1000, beforeSend }),
|
|
1173
|
+
logger,
|
|
1174
|
+
getContextFor(mockInstance),
|
|
1175
|
+
immediateOnReady
|
|
1176
|
+
)
|
|
1177
|
+
logs.captureLog({ body: 'pre-filtered-out' })
|
|
1178
|
+
logs.captureLog({ body: 'should-still-fit' })
|
|
1179
|
+
|
|
1180
|
+
expect(readQueue(mockInstance)).toHaveLength(1)
|
|
1181
|
+
expect(readQueue(mockInstance)[0].record.body.stringValue).toBe('should-still-fit')
|
|
1182
|
+
})
|
|
1183
|
+
})
|
|
1184
|
+
|
|
1185
|
+
describe('concurrent capture during flush', () => {
|
|
1186
|
+
it('mid-flush captures land in the queue for the next cycle — not lost, not double-sent', async () => {
|
|
1187
|
+
let resolveSend: (v: any) => void = () => {}
|
|
1188
|
+
let captureDuringSend: (() => void) | null = null
|
|
1189
|
+
|
|
1190
|
+
mockInstance._sendLogsBatch = jest.fn(
|
|
1191
|
+
() =>
|
|
1192
|
+
new Promise((r) => {
|
|
1193
|
+
if (captureDuringSend) {
|
|
1194
|
+
captureDuringSend()
|
|
1195
|
+
captureDuringSend = null
|
|
1196
|
+
}
|
|
1197
|
+
resolveSend = (v) => r(v)
|
|
1198
|
+
})
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
const logs = new PostHogLogs(
|
|
1202
|
+
mockInstance,
|
|
1203
|
+
resolveForTest({ maxBatchRecordsPerPost: 1, maxBufferSize: 10 }),
|
|
1204
|
+
logger,
|
|
1205
|
+
getContextFor(mockInstance),
|
|
1206
|
+
immediateOnReady
|
|
1207
|
+
)
|
|
1208
|
+
logs.captureLog({ body: 'first' })
|
|
1209
|
+
captureDuringSend = (): void => {
|
|
1210
|
+
logs.captureLog({ body: 'mid-flight' })
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const flushP = logs.flush()
|
|
1214
|
+
await new Promise((r) => setImmediate(r))
|
|
1215
|
+
resolveSend({ kind: 'ok' })
|
|
1216
|
+
await flushP
|
|
1217
|
+
|
|
1218
|
+
// flush() uses `originalQueueLength` at entry, so a mid-flight capture
|
|
1219
|
+
// is intentionally left for the NEXT flush (matches events semantics).
|
|
1220
|
+
// The invariant we care about: not lost, not double-sent.
|
|
1221
|
+
expect(readQueue(mockInstance)).toHaveLength(1)
|
|
1222
|
+
expect(readQueue(mockInstance)[0].record.body.stringValue).toBe('mid-flight')
|
|
1223
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
1224
|
+
|
|
1225
|
+
// A subsequent flush picks it up — no data lost.
|
|
1226
|
+
const flushP2 = logs.flush()
|
|
1227
|
+
await new Promise((r) => setImmediate(r))
|
|
1228
|
+
resolveSend({ kind: 'ok' })
|
|
1229
|
+
await flushP2
|
|
1230
|
+
expect(readQueue(mockInstance)).toHaveLength(0)
|
|
1231
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(2)
|
|
1232
|
+
})
|
|
1233
|
+
})
|
|
360
1234
|
})
|