@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
@@ -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, PostHogLogsConfig, ResolvedPostHogLogsConfig } from './types'
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 resolveForTest = (partial?: PostHogLogsConfig): ResolvedPostHogLogsConfig => ({
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('is a no-op when config.enabled is false', () => {
209
+ it('captures unconditionally only optedOut, missing body, and beforeSend can drop', () => {
191
210
  const logs = new PostHogLogs(
192
211
  mockInstance,
193
- resolveForTest({ enabled: false }),
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
  })