@zuplo/cli 7.7.8 → 7.7.9

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 (143) hide show
  1. package/node_modules/@posthog/core/dist/error-tracking/parsers/base.d.ts.map +1 -1
  2. package/node_modules/@posthog/core/dist/error-tracking/parsers/base.js +19 -2
  3. package/node_modules/@posthog/core/dist/error-tracking/parsers/base.mjs +19 -2
  4. package/node_modules/@posthog/core/dist/index.d.ts +7 -0
  5. package/node_modules/@posthog/core/dist/index.d.ts.map +1 -1
  6. package/node_modules/@posthog/core/dist/index.js +82 -21
  7. package/node_modules/@posthog/core/dist/index.mjs +4 -0
  8. package/node_modules/@posthog/core/dist/logs/index.d.ts +3 -2
  9. package/node_modules/@posthog/core/dist/logs/index.d.ts.map +1 -1
  10. package/node_modules/@posthog/core/dist/logs/index.js +28 -22
  11. package/node_modules/@posthog/core/dist/logs/index.mjs +29 -23
  12. package/node_modules/@posthog/core/dist/logs/logs-utils.js +1 -1
  13. package/node_modules/@posthog/core/dist/logs/logs-utils.mjs +2 -2
  14. package/node_modules/@posthog/core/dist/logs/types.d.ts +2 -2
  15. package/node_modules/@posthog/core/dist/metrics/index.d.ts +5 -2
  16. package/node_modules/@posthog/core/dist/metrics/index.d.ts.map +1 -1
  17. package/node_modules/@posthog/core/dist/metrics/index.js +28 -14
  18. package/node_modules/@posthog/core/dist/metrics/index.mjs +29 -15
  19. package/node_modules/@posthog/core/dist/metrics/metrics-utils.d.ts.map +1 -1
  20. package/node_modules/@posthog/core/dist/metrics/metrics-utils.js +1 -2
  21. package/node_modules/@posthog/core/dist/metrics/metrics-utils.mjs +2 -3
  22. package/node_modules/@posthog/core/dist/metrics/types.d.ts +7 -0
  23. package/node_modules/@posthog/core/dist/metrics/types.d.ts.map +1 -1
  24. package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts +32 -4
  25. package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts.map +1 -1
  26. package/node_modules/@posthog/core/dist/posthog-core-stateless.js +68 -20
  27. package/node_modules/@posthog/core/dist/posthog-core-stateless.mjs +68 -20
  28. package/node_modules/@posthog/core/dist/traces/config.d.ts +13 -0
  29. package/node_modules/@posthog/core/dist/traces/config.d.ts.map +1 -0
  30. package/node_modules/@posthog/core/dist/traces/config.js +101 -0
  31. package/node_modules/@posthog/core/dist/traces/config.mjs +63 -0
  32. package/node_modules/@posthog/core/dist/traces/context.d.ts +18 -0
  33. package/node_modules/@posthog/core/dist/traces/context.d.ts.map +1 -0
  34. package/node_modules/@posthog/core/dist/traces/context.js +53 -0
  35. package/node_modules/@posthog/core/dist/traces/context.mjs +15 -0
  36. package/node_modules/@posthog/core/dist/traces/ids.d.ts +12 -0
  37. package/node_modules/@posthog/core/dist/traces/ids.d.ts.map +1 -0
  38. package/node_modules/@posthog/core/dist/traces/ids.js +92 -0
  39. package/node_modules/@posthog/core/dist/traces/ids.mjs +42 -0
  40. package/node_modules/@posthog/core/dist/traces/index.d.ts +150 -0
  41. package/node_modules/@posthog/core/dist/traces/index.d.ts.map +1 -0
  42. package/node_modules/@posthog/core/dist/traces/index.js +551 -0
  43. package/node_modules/@posthog/core/dist/traces/index.mjs +513 -0
  44. package/node_modules/@posthog/core/dist/traces/otlp.d.ts +24 -0
  45. package/node_modules/@posthog/core/dist/traces/otlp.d.ts.map +1 -0
  46. package/node_modules/@posthog/core/dist/traces/otlp.js +158 -0
  47. package/node_modules/@posthog/core/dist/traces/otlp.mjs +108 -0
  48. package/node_modules/@posthog/core/dist/traces/sanitize.d.ts +32 -0
  49. package/node_modules/@posthog/core/dist/traces/sanitize.d.ts.map +1 -0
  50. package/node_modules/@posthog/core/dist/traces/sanitize.js +93 -0
  51. package/node_modules/@posthog/core/dist/traces/sanitize.mjs +43 -0
  52. package/node_modules/@posthog/core/dist/traces/span.d.ts +189 -0
  53. package/node_modules/@posthog/core/dist/traces/span.d.ts.map +1 -0
  54. package/node_modules/@posthog/core/dist/traces/span.js +533 -0
  55. package/node_modules/@posthog/core/dist/traces/span.mjs +462 -0
  56. package/node_modules/@posthog/core/dist/traces/traceparent.d.ts +51 -0
  57. package/node_modules/@posthog/core/dist/traces/traceparent.d.ts.map +1 -0
  58. package/node_modules/@posthog/core/dist/traces/traceparent.js +116 -0
  59. package/node_modules/@posthog/core/dist/traces/traceparent.mjs +63 -0
  60. package/node_modules/@posthog/core/dist/traces/types.d.ts +114 -0
  61. package/node_modules/@posthog/core/dist/traces/types.d.ts.map +1 -0
  62. package/node_modules/@posthog/core/dist/traces/types.js +18 -0
  63. package/node_modules/@posthog/core/dist/traces/types.mjs +1 -0
  64. package/node_modules/@posthog/core/dist/utils/backoff.d.ts +23 -0
  65. package/node_modules/@posthog/core/dist/utils/backoff.d.ts.map +1 -0
  66. package/node_modules/@posthog/core/dist/utils/backoff.js +65 -0
  67. package/node_modules/@posthog/core/dist/utils/backoff.mjs +14 -0
  68. package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts +26 -0
  69. package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts.map +1 -0
  70. package/node_modules/@posthog/core/dist/utils/flush-timer.js +67 -0
  71. package/node_modules/@posthog/core/dist/utils/flush-timer.mjs +29 -0
  72. package/node_modules/@posthog/core/dist/utils/json-utils.d.ts +7 -0
  73. package/node_modules/@posthog/core/dist/utils/json-utils.d.ts.map +1 -1
  74. package/node_modules/@posthog/core/dist/utils/json-utils.js +27 -0
  75. package/node_modules/@posthog/core/dist/utils/json-utils.mjs +25 -1
  76. package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts +13 -2
  77. package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts.map +1 -1
  78. package/node_modules/@posthog/core/dist/utils/otlp-resource.js +34 -7
  79. package/node_modules/@posthog/core/dist/utils/otlp-resource.mjs +26 -2
  80. package/node_modules/@posthog/core/dist/utils/retry-after.d.ts +55 -0
  81. package/node_modules/@posthog/core/dist/utils/retry-after.d.ts.map +1 -0
  82. package/node_modules/@posthog/core/dist/utils/retry-after.js +95 -0
  83. package/node_modules/@posthog/core/dist/utils/retry-after.mjs +50 -0
  84. package/node_modules/@posthog/core/package.json +3 -3
  85. package/node_modules/@posthog/core/src/error-tracking/parsers/base.spec.ts +77 -7
  86. package/node_modules/@posthog/core/src/error-tracking/parsers/base.ts +42 -19
  87. package/node_modules/@posthog/core/src/index.ts +18 -0
  88. package/node_modules/@posthog/core/src/logs/index.spec.ts +490 -2
  89. package/node_modules/@posthog/core/src/logs/index.ts +63 -31
  90. package/node_modules/@posthog/core/src/logs/logs-utils.ts +2 -2
  91. package/node_modules/@posthog/core/src/logs/types.ts +2 -2
  92. package/node_modules/@posthog/core/src/metrics/index.spec.ts +304 -0
  93. package/node_modules/@posthog/core/src/metrics/index.ts +53 -16
  94. package/node_modules/@posthog/core/src/metrics/metrics-utils.ts +2 -3
  95. package/node_modules/@posthog/core/src/metrics/types.ts +10 -2
  96. package/node_modules/@posthog/core/src/posthog-core-stateless.ts +157 -38
  97. package/node_modules/@posthog/core/src/traces/config.spec.ts +271 -0
  98. package/node_modules/@posthog/core/src/traces/config.ts +142 -0
  99. package/node_modules/@posthog/core/src/traces/context.ts +30 -0
  100. package/node_modules/@posthog/core/src/traces/ids.spec.ts +115 -0
  101. package/node_modules/@posthog/core/src/traces/ids.ts +75 -0
  102. package/node_modules/@posthog/core/src/traces/index.spec.ts +3187 -0
  103. package/node_modules/@posthog/core/src/traces/index.ts +1060 -0
  104. package/node_modules/@posthog/core/src/traces/live-spans.spec.ts +69 -0
  105. package/node_modules/@posthog/core/src/traces/otlp.spec.ts +368 -0
  106. package/node_modules/@posthog/core/src/traces/otlp.ts +191 -0
  107. package/node_modules/@posthog/core/src/traces/sanitize.ts +116 -0
  108. package/node_modules/@posthog/core/src/traces/span.spec.ts +1122 -0
  109. package/node_modules/@posthog/core/src/traces/span.ts +822 -0
  110. package/node_modules/@posthog/core/src/traces/traceparent.spec.ts +184 -0
  111. package/node_modules/@posthog/core/src/traces/traceparent.ts +172 -0
  112. package/node_modules/@posthog/core/src/traces/types.ts +140 -0
  113. package/node_modules/@posthog/core/src/utils/backoff.ts +44 -0
  114. package/node_modules/@posthog/core/src/utils/flush-timer.ts +50 -0
  115. package/node_modules/@posthog/core/src/utils/json-utils.ts +33 -0
  116. package/node_modules/@posthog/core/src/utils/otlp-resource.spec.ts +55 -9
  117. package/node_modules/@posthog/core/src/utils/otlp-resource.ts +45 -4
  118. package/node_modules/@posthog/core/src/utils/retry-after.ts +135 -0
  119. package/node_modules/@posthog/types/dist/index.d.ts +1 -0
  120. package/node_modules/@posthog/types/dist/index.d.ts.map +1 -1
  121. package/node_modules/@posthog/types/dist/posthog-config.d.ts +10 -5
  122. package/node_modules/@posthog/types/dist/posthog-config.d.ts.map +1 -1
  123. package/node_modules/@posthog/types/dist/traces.d.ts +387 -0
  124. package/node_modules/@posthog/types/dist/traces.d.ts.map +1 -0
  125. package/node_modules/@posthog/types/dist/traces.js +18 -0
  126. package/node_modules/@posthog/types/dist/traces.mjs +1 -0
  127. package/node_modules/@posthog/types/package.json +1 -1
  128. package/node_modules/@posthog/types/src/index.ts +19 -0
  129. package/node_modules/@posthog/types/src/posthog-config.ts +10 -5
  130. package/node_modules/@posthog/types/src/traces.ts +429 -0
  131. package/node_modules/@zuplo/core/package.json +1 -1
  132. package/node_modules/@zuplo/graphql/package.json +1 -1
  133. package/node_modules/@zuplo/openapi-tools/package.json +1 -1
  134. package/node_modules/@zuplo/otel/package.json +1 -1
  135. package/node_modules/@zuplo/runtime/out/esm/{chunk-DRQ5CMVC.js → chunk-KNAYKBEE.js} +80 -80
  136. package/node_modules/@zuplo/runtime/out/esm/chunk-KNAYKBEE.js.map +1 -0
  137. package/node_modules/@zuplo/runtime/out/esm/index.js +1 -1
  138. package/node_modules/@zuplo/runtime/out/esm/mcp-gateway/index.js +1 -1
  139. package/node_modules/@zuplo/runtime/out/esm/mocks/index.js +1 -1
  140. package/node_modules/@zuplo/runtime/package.json +1 -1
  141. package/package.json +11 -11
  142. package/node_modules/@zuplo/runtime/out/esm/chunk-DRQ5CMVC.js.map +0 -1
  143. /package/node_modules/@zuplo/runtime/out/esm/{chunk-DRQ5CMVC.js.LEGAL.txt → chunk-KNAYKBEE.js.LEGAL.txt} +0 -0
@@ -1,11 +1,13 @@
1
1
  import type { LogAttributeValue } from '@posthog/types'
2
2
  import { buildOtlpLogRecord, buildOtlpLogsPayload, buildResourceAttributes } from './logs-utils'
3
3
  import { Logger, PostHogPersistedProperty } from '../types'
4
- import { isArray, raceWithTimeout, safeSetTimeout } from '../utils'
4
+ import { isArray, raceWithTimeout } from '../utils'
5
+ import { FlushTimer } from '../utils/flush-timer'
6
+ import { RetryAfterWindow } from '../utils/retry-after'
7
+ import { MAX_FLUSH_BACKOFF_MS, NO_JITTER, backoffDelayMs, drawJitter } from '../utils/backoff'
5
8
  import type { BufferedLogEntry, CaptureLogOptions, LogSdkContext, LogsHost, ResolvedPostHogLogsConfig } from './types'
6
9
 
7
10
  // Caps the retry backoff at 2^6 = 64× the flush interval.
8
- const MAX_FLUSH_BACKOFF_EXPONENT = 6
9
11
 
10
12
  export class PostHogLogs {
11
13
  private _maxBufferSize: number
@@ -17,7 +19,7 @@ export class PostHogLogs {
17
19
  // one record after each successful send so a one-off oversized payload
18
20
  // (e.g. a giant stack trace) doesn't permanently degrade throughput.
19
21
  private _maxBatchRecordsPerPost: number
20
- private _flushTimer?: ReturnType<typeof safeSetTimeout>
22
+ private readonly _flushTimer = new FlushTimer(() => this._flushInBackground())
21
23
  // Serializes concurrent flushes — the second caller awaits the first rather
22
24
  // than racing it and double-sending the same head-of-queue records.
23
25
  private _flushPromise: Promise<void> | null = null
@@ -27,9 +29,12 @@ export class PostHogLogs {
27
29
  // A batch captures this when it is assembled, so it can tell that the records it is
28
30
  // holding no longer correspond to anything queued.
29
31
  private _queueGeneration = 0
32
+ // Every path that can start a send checks this, not just the retry timer.
33
+ private _retryAfter = new RetryAfterWindow()
30
34
  // Consecutive failed flushes; drives exponential backoff on the retry timer.
31
35
  // A successful flush resets it to 0.
32
36
  private _consecutiveFlushFailures = 0
37
+ private _flushJitter = NO_JITTER
33
38
 
34
39
  // Fixed-window rate cap. Tumbling (not sliding) for cheap arithmetic on the
35
40
  // hot path. Window rolls the first time `captureLog` fires after the window
@@ -83,7 +88,7 @@ export class PostHogLogs {
83
88
  * and clears it separately (the browser empties its in-memory store).
84
89
  */
85
90
  reset(): void {
86
- this._clearFlushTimer()
91
+ this._flushTimer.clear()
87
92
  // `_flushPromise` is deliberately left alone: clearing it would let a second flush
88
93
  // run alongside the in-flight one. Retiring that flush is `clearQueue`'s job,
89
94
  // because the queue it holds belongs to the host, not to this state.
@@ -91,14 +96,28 @@ export class PostHogLogs {
91
96
  this._intervalLogCount = 0
92
97
  this._droppedWarned = false
93
98
  this._consecutiveFlushFailures = 0
99
+ this._flushJitter = NO_JITTER
100
+ this._retryAfter.reset()
94
101
  this._maxBatchRecordsPerPost = this._config.maxBatchRecordsPerPost
95
102
  }
96
103
 
97
104
  // Call when connectivity is restored: clear the failure backoff and flush now,
98
105
  // so records don't wait out a (possibly minutes-long) backoff delay after the
99
106
  // network returns. The host owns connectivity detection (web: `online` event).
107
+ // A `Retry-After` window survives this: the network coming back says nothing
108
+ // about the rate limit the endpoint set, and browsers fire `online` on every
109
+ // network handover.
100
110
  onReconnect(): void {
101
111
  this._consecutiveFlushFailures = 0
112
+ this._flushJitter = NO_JITTER
113
+ if (this._retryAfter.isOpen()) {
114
+ // The wait outlives the reconnect, but something still has to schedule
115
+ // the retry: an explicit `flush()` leaves no timer behind.
116
+ if (this._hasQueuedRecords()) {
117
+ this._armFlushTimer()
118
+ }
119
+ return
120
+ }
102
121
  this._flushInBackground()
103
122
  }
104
123
 
@@ -248,7 +267,7 @@ export class PostHogLogs {
248
267
  }
249
268
 
250
269
  private async _flushInner(): Promise<void> {
251
- this._clearFlushTimer()
270
+ this._flushTimer.clear()
252
271
 
253
272
  let queue = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue) ?? []
254
273
  if (queue.length === 0) {
@@ -292,12 +311,22 @@ export class PostHogLogs {
292
311
  if (outcome.kind === 'too-large' && batch.length > 1) {
293
312
  this._maxBatchRecordsPerPost = Math.max(1, Math.floor(batch.length / 2))
294
313
  this._logger.warn(
295
- `Received 413 when sending logs batch of size ${batch.length}, reducing batch size to ${this._maxBatchRecordsPerPost}`
314
+ `Logs batch of size ${batch.length} was too large for the ingestion endpoint, reducing batch size to ${this._maxBatchRecordsPerPost}`
296
315
  )
297
316
  // Don't advance the queue — retry the same records with the smaller cap.
298
317
  continue
299
318
  }
300
319
 
320
+ // Not on the background wrapper: every lifecycle hook takes `flush()`,
321
+ // which does not go through it.
322
+ this._retryAfter.record(outcome)
323
+
324
+ // Outright, not through the ratchet: a timer a mid-flight capture armed
325
+ // is measured against a window this outcome may just have closed.
326
+ if (this._flushTimer.pending) {
327
+ this._flushTimer.arm(Math.max(this._flushIntervalMs, this._retryAfter.remainingMs()))
328
+ }
329
+
301
330
  if (outcome.kind === 'retry-later') {
302
331
  // Transient failure: keep records in the queue for the next flush cycle
303
332
  // and surface the error so the caller can log/react.
@@ -305,13 +334,15 @@ export class PostHogLogs {
305
334
  }
306
335
 
307
336
  // ok | fatal | too-large-with-batch-of-1 → records are leaving the
308
- // queue. 'fatal' and size-1 413s are dropped so we don't spin on the
309
- // same record forever. Surface the size-1 413 explicitly so a single
337
+ // queue. 'fatal' and size-1 refusals are dropped so we don't spin on the
338
+ // same record forever. Surface the size-1 refusal explicitly so a single
310
339
  // oversized record (e.g. a giant body field) is visible in logs
311
340
  // instead of silently disappearing.
312
341
  if (outcome.kind === 'too-large') {
342
+ // Reached either from a 413 or from the size the SDK measured before
343
+ // sending, so the message names neither.
313
344
  this._logger.warn(
314
- 'Dropping a single log record after 413 with batch size 1 — the record is larger than the server cap and cannot be split further.'
345
+ 'Dropping a single log record with batch size 1 — the record is larger than the server cap and cannot be split further.'
315
346
  )
316
347
  } else if (outcome.kind === 'ok' && this._maxBatchRecordsPerPost < this._config.maxBatchRecordsPerPost) {
317
348
  // Linear recovery: each healthy send pushes the cap back up by 1
@@ -364,34 +395,40 @@ export class PostHogLogs {
364
395
  this._instance.setPersistedProperty(PostHogPersistedProperty.LogsQueue, queue)
365
396
 
366
397
  // Flush trigger: drain now rather than waiting for the timer. The queue may
367
- // grow past this up to the eviction cap while the flush is in flight.
368
- if (queue.length >= this._maxBufferSize) {
398
+ // grow past this up to the eviction cap while the flush is in flight. Not
399
+ // while the endpoint has asked us to wait: the size trigger is the dominant
400
+ // one on a busy host, so sending here would ignore the window entirely.
401
+ if (queue.length >= this._maxBufferSize && !this._retryAfter.isOpen()) {
369
402
  this._flushInBackground()
370
403
  return
371
404
  }
372
405
 
373
- // Arm one timer at a time; re-arming within the window would push the flush out.
406
+ // Arm one timer at a time; re-arming on every enqueue would push the flush out.
374
407
  this._armFlushTimer()
375
408
  }
376
409
 
377
410
  // Arms the flush timer if none is pending. One-shot: the callback clears the
378
411
  // handle so the next enqueue (or a flush that left records) schedules again.
379
- private _armFlushTimer(delayMs: number = this._flushIntervalMs): void {
380
- if (this._flushTimer) {
412
+ private _armFlushTimer(): void {
413
+ if (this._flushTimer.pending) {
381
414
  return
382
415
  }
383
- this._flushTimer = safeSetTimeout(() => {
384
- this._flushTimer = undefined
385
- this._flushInBackground()
386
- }, delayMs)
416
+ // Floored by any open window: `flush()` leaves no timer behind, so a
417
+ // capture after one arrives here.
418
+ this._flushTimer.arm(Math.max(this._flushIntervalMs, this._retryAfter.remainingMs()))
387
419
  }
388
420
 
389
421
  // Retry delay after a flush that left records: the first retry is at the base
390
- // interval, then exponential backoff (capped) so a sustained outage isn't
391
- // retried every interval.
422
+ // interval, then exponential backoff so a sustained outage isn't retried every
423
+ // interval. Jitter is drawn once per failure and reused, so two delays taken
424
+ // for the same failure cannot disagree.
392
425
  private _nextFlushDelay(): number {
393
- const exponent = Math.min(Math.max(0, this._consecutiveFlushFailures - 1), MAX_FLUSH_BACKOFF_EXPONENT)
394
- return this._flushIntervalMs * 2 ** exponent
426
+ // A floor, not a replacement: the header never retries us sooner than our
427
+ // own backoff would have.
428
+ return Math.max(
429
+ backoffDelayMs(this._flushIntervalMs, this._consecutiveFlushFailures, this._flushJitter, MAX_FLUSH_BACKOFF_MS),
430
+ this._retryAfter.remainingMs()
431
+ )
395
432
  }
396
433
 
397
434
  private _hasQueuedRecords(): boolean {
@@ -410,7 +447,7 @@ export class PostHogLogs {
410
447
  * fetchRetryDelay)`, which can exceed the caller's shutdown SLA.
411
448
  */
412
449
  async shutdown(timeoutMs?: number): Promise<void> {
413
- this._clearFlushTimer()
450
+ this._flushTimer.clear()
414
451
  const flushPromise = this.flush().catch(() => {
415
452
  // Best-effort: a logs-flush failure during shutdown is not actionable
416
453
  // and must not prevent the rest of shutdown from running. Errors are
@@ -449,9 +486,11 @@ export class PostHogLogs {
449
486
  .then(
450
487
  () => {
451
488
  this._consecutiveFlushFailures = 0
489
+ this._flushJitter = NO_JITTER
452
490
  },
453
491
  (err) => {
454
492
  this._consecutiveFlushFailures++
493
+ this._flushJitter = drawJitter()
455
494
  this._logger.error('PostHog logs flush failed:', err)
456
495
  }
457
496
  )
@@ -460,15 +499,8 @@ export class PostHogLogs {
460
499
  // sit undelivered on a quiet page; re-arm so the timer retries them, backing
461
500
  // off on consecutive failures.
462
501
  if (!this._instance.isDisabled && this._hasQueuedRecords()) {
463
- this._armFlushTimer(this._nextFlushDelay())
502
+ this._flushTimer.armNoEarlierThan(this._nextFlushDelay())
464
503
  }
465
504
  })
466
505
  }
467
-
468
- private _clearFlushTimer(): void {
469
- if (this._flushTimer) {
470
- clearTimeout(this._flushTimer)
471
- this._flushTimer = undefined
472
- }
473
- }
474
506
  }
@@ -12,7 +12,7 @@ import type { LogSdkContext, ResolvedPostHogLogsConfig } from './types'
12
12
  import { isNullish, isNumber, isUndefined } from '../utils'
13
13
  import { sanitizeString, UNSERIALIZABLE_VALUE } from '../utils/json-utils'
14
14
  import { toOtlpKeyValueList } from '../utils/otlp-any-value'
15
- import { buildOtlpResourceAttributes } from '../utils/otlp-resource'
15
+ import { buildOtlpResourceAttributes, toOtlpResourceKeyValueList } from '../utils/otlp-resource'
16
16
 
17
17
  // ============================================================================
18
18
  // Severity mapping
@@ -206,7 +206,7 @@ export function buildOtlpLogsPayload(
206
206
  return {
207
207
  resourceLogs: [
208
208
  {
209
- resource: { attributes: toOtlpKeyValueList(resourceAttributes) },
209
+ resource: { attributes: toOtlpResourceKeyValueList(resourceAttributes) },
210
210
  scopeLogs: [
211
211
  {
212
212
  scope: { name: scopeName, version: scopeVersion },
@@ -128,8 +128,8 @@ export interface PostHogLogsConfig {
128
128
 
129
129
  /**
130
130
  * Max records per outbound POST. Keeps each request under the server's
131
- * 2 MB cap. On a 413 response, the SDK halves this value, retries the
132
- * same records, then ramps back up by 1 per healthy send. A 413 on a
131
+ * request body cap. On a 413 response, the SDK halves this value, retries
132
+ * the same records, then ramps back up by 1 per healthy send. A 413 on a
133
133
  * single-record batch drops the record (it's larger than the server can
134
134
  * accept regardless of batch size). Default: 50 (RN) / 100 (browser).
135
135
  */
@@ -50,6 +50,9 @@ describe('PostHogMetrics', () => {
50
50
  vi.useFakeTimers()
51
51
  mockInstance = createMockInstance()
52
52
  logger = createMockLogger()
53
+ // Retry delays carry jitter; pinned to its midpoint so every timing
54
+ // assertion here measures the backoff itself and cannot flake.
55
+ vi.spyOn(Math, 'random').mockReturnValue(0.5)
53
56
  })
54
57
 
55
58
  afterEach(() => {
@@ -243,6 +246,263 @@ describe('PostHogMetrics', () => {
243
246
  expect(mockInstance._sendMetricsBatch).toHaveBeenCalledTimes(1)
244
247
  })
245
248
 
249
+ it('keeps flushing on the interval while captures keep arriving', async () => {
250
+ // Every capture arms the timer, and metrics have no size trigger to fall
251
+ // back on: re-arming a pending one would stop the window ever being sent.
252
+ const metrics = createMetrics({ flushIntervalMs: 10_000 })
253
+
254
+ for (let i = 0; i < 60; i++) {
255
+ metrics.count('orders_created', 1)
256
+ await vi.advanceTimersByTimeAsync(1000)
257
+ }
258
+
259
+ expect(mockInstance._sendMetricsBatch).toHaveBeenCalled()
260
+ })
261
+
262
+ it('waits out Retry-After even when a flush timer is already pending', async () => {
263
+ // The capture arms a timer at the flush interval; the failed flush then asks
264
+ // for far longer. The pending timer must not fire first.
265
+ const instance = createMockInstance({
266
+ _sendMetricsBatch: vi.fn((): Promise<SendMetricsBatchOutcome> =>
267
+ Promise.resolve({ kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 })
268
+ ),
269
+ })
270
+ const metrics = createMetrics({ flushIntervalMs: 10_000 }, instance)
271
+ metrics.count('orders_created', 1)
272
+ await metrics.flush()
273
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(1)
274
+
275
+ await vi.advanceTimersByTimeAsync(11_000)
276
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(1)
277
+
278
+ await vi.advanceTimersByTimeAsync(300_000)
279
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(2)
280
+ })
281
+
282
+ it('clears Retry-After on an outcome that is not a retry', async () => {
283
+ // Only a retriable outcome carries a wait. A stale one left set here would
284
+ // pin every later flush at the server's old window forever.
285
+ const outcomes: SendMetricsBatchOutcome[] = [
286
+ { kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 },
287
+ { kind: 'fatal', error: new Error('400') },
288
+ ]
289
+ const instance = createMockInstance({
290
+ _sendMetricsBatch: vi.fn(() => Promise.resolve(outcomes.shift() ?? { kind: 'ok' })),
291
+ })
292
+ const metrics = createMetrics({ flushIntervalMs: 10_000 }, instance)
293
+ metrics.count('orders_created', 1)
294
+ await metrics.flush()
295
+
296
+ // The wait elapses, the retry lands a 400, and that ends the wait.
297
+ await vi.advanceTimersByTimeAsync(300_000)
298
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(2)
299
+
300
+ metrics.count('orders_created', 1)
301
+ await vi.advanceTimersByTimeAsync(10_000)
302
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(3)
303
+ })
304
+
305
+ it('keeps the Retry-After window when a batch is refused for size', async () => {
306
+ // `too-large` is a verdict on the body's size — the SDK's own or a 413 —
307
+ // so it says nothing about the endpoint's rate limit. Ending the wait on
308
+ // it lets the next refusal install a fresh window, pushing the retry out
309
+ // past the deadline the endpoint actually named — observed here as the
310
+ // flush landing at 300s rather than at 310s.
311
+ const outcomes: SendMetricsBatchOutcome[] = [
312
+ { kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 },
313
+ { kind: 'too-large' },
314
+ { kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 },
315
+ ]
316
+ const instance = createMockInstance({
317
+ _sendMetricsBatch: vi.fn(() => Promise.resolve(outcomes.shift() ?? { kind: 'ok' })),
318
+ })
319
+ const metrics = createMetrics({ flushIntervalMs: 1000 }, instance)
320
+ metrics.count('orders_created', 1)
321
+ await metrics.flush()
322
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(1)
323
+
324
+ await vi.advanceTimersByTimeAsync(10_000)
325
+ await metrics.flush()
326
+ metrics.count('orders_created', 1)
327
+ await metrics.flush()
328
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(3)
329
+
330
+ await vi.advanceTimersByTimeAsync(295_000)
331
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(4)
332
+ })
333
+
334
+ it('closes the window at the ceiling for a host out-pacing it', async () => {
335
+ // Each refusal sliding the deadline would keep `_nextFlushDelay` pinned at
336
+ // the full window, so the flush cadence would never recover.
337
+ const instance = createMockInstance({
338
+ _sendMetricsBatch: vi.fn((): Promise<SendMetricsBatchOutcome> =>
339
+ Promise.resolve({ kind: 'retry-later', error: new Error('429'), retryAfterMs: 30_000 })
340
+ ),
341
+ })
342
+ const metrics = createMetrics({ flushIntervalMs: 60_000 }, instance)
343
+ metrics.count('orders_created', 1)
344
+ await metrics.flush()
345
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(1)
346
+
347
+ // Sampled: whether a given moment falls inside a window is timing
348
+ // dependent, but it must fall outside one sometimes.
349
+ let sawWindowClosed = false
350
+ for (let i = 0; i < 70; i++) {
351
+ await vi.advanceTimersByTimeAsync(5000)
352
+ // Sampled before the flush: a flush that finds the window closed opens
353
+ // a fresh one, so sampling after it would always look open.
354
+ if ((metrics as any)._retryAfter.remainingMs() === 0) {
355
+ sawWindowClosed = true
356
+ }
357
+ metrics.count('orders_created', 1)
358
+ await metrics.flush()
359
+ }
360
+ expect(sawWindowClosed).toBe(true)
361
+ })
362
+
363
+ it('does not install a Retry-After that lands after reset', async () => {
364
+ let settle: ((outcome: SendMetricsBatchOutcome) => void) | undefined
365
+ const instance = createMockInstance({
366
+ _sendMetricsBatch: vi.fn(
367
+ (): Promise<SendMetricsBatchOutcome> =>
368
+ new Promise((resolve) => {
369
+ settle = resolve
370
+ })
371
+ ),
372
+ })
373
+ const metrics = createMetrics({ flushIntervalMs: 1000 }, instance)
374
+ metrics.count('orders_created', 1)
375
+ await vi.advanceTimersByTimeAsync(1000)
376
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(1)
377
+
378
+ metrics.reset()
379
+
380
+ // Settle first: the capture that arms the next timer must not find a
381
+ // window belonging to the client that was just torn down.
382
+ settle?.({ kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 })
383
+ await vi.advanceTimersByTimeAsync(0)
384
+
385
+ metrics.count('orders_created', 1)
386
+ await vi.advanceTimersByTimeAsync(1000)
387
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(2)
388
+ })
389
+
390
+ it('drops a Retry-After wait on reset', async () => {
391
+ const instance = createMockInstance({
392
+ _sendMetricsBatch: vi.fn((): Promise<SendMetricsBatchOutcome> =>
393
+ Promise.resolve({ kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 })
394
+ ),
395
+ })
396
+ const metrics = createMetrics({ flushIntervalMs: 10_000 }, instance)
397
+ metrics.count('orders_created', 1)
398
+ await metrics.flush()
399
+
400
+ metrics.reset()
401
+ metrics.count('orders_created', 1)
402
+ await vi.advanceTimersByTimeAsync(10_000)
403
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(2)
404
+ })
405
+
406
+ it('does not restart a served-out wait for a sample captured mid-retry', async () => {
407
+ // A deadline, not a duration. The retry's timer has already fired, so the
408
+ // capture below is the one that arms the next timer — and it sees the
409
+ // wait still set, because the send it belongs to has not settled. Holding
410
+ // a duration here re-arms for the whole window a second time and leaves
411
+ // the sample 300s behind on an endpoint that has already recovered.
412
+ let settleRetry: ((outcome: SendMetricsBatchOutcome) => void) | undefined
413
+ let call = 0
414
+ const instance = createMockInstance({
415
+ _sendMetricsBatch: vi.fn((): Promise<SendMetricsBatchOutcome> => {
416
+ call++
417
+ if (call === 1) {
418
+ return Promise.resolve({ kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 })
419
+ }
420
+ return new Promise<SendMetricsBatchOutcome>((resolve) => {
421
+ settleRetry = resolve
422
+ })
423
+ }),
424
+ })
425
+ const metrics = createMetrics({ flushIntervalMs: 10_000 }, instance)
426
+ metrics.count('orders_created', 1)
427
+ await vi.advanceTimersByTimeAsync(10_000)
428
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(1)
429
+
430
+ // The wait elapses and the retry goes out, but hangs.
431
+ await vi.advanceTimersByTimeAsync(300_000)
432
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(2)
433
+
434
+ metrics.count('orders_created', 1)
435
+ settleRetry?.({ kind: 'ok' })
436
+ await vi.advanceTimersByTimeAsync(0)
437
+
438
+ // One interval, not another window.
439
+ await vi.advanceTimersByTimeAsync(10_000)
440
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(3)
441
+ })
442
+
443
+ it('keeps its own interval when the endpoint asks for less', async () => {
444
+ const instance = createMockInstance({
445
+ _sendMetricsBatch: vi.fn((): Promise<SendMetricsBatchOutcome> =>
446
+ Promise.resolve({ kind: 'retry-later', error: new Error('503'), retryAfterMs: 10 })
447
+ ),
448
+ })
449
+ const metrics = createMetrics({ flushIntervalMs: 10_000 }, instance)
450
+ metrics.count('orders_created', 1)
451
+ await vi.advanceTimersByTimeAsync(10_000)
452
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(1)
453
+
454
+ await vi.advanceTimersByTimeAsync(1000)
455
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(1)
456
+
457
+ await vi.advanceTimersByTimeAsync(10_000)
458
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(2)
459
+ })
460
+
461
+ it('sends on an explicit flush inside the window', async () => {
462
+ // Lifecycle drains (RN background, shutdown) must not become no-ops for
463
+ // the length of a window; metrics has no `force` escape hatch.
464
+ const outcomes: SendMetricsBatchOutcome[] = [
465
+ { kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 },
466
+ ]
467
+ const instance = createMockInstance({
468
+ _sendMetricsBatch: vi.fn((): Promise<SendMetricsBatchOutcome> =>
469
+ Promise.resolve(outcomes.shift() ?? { kind: 'ok' })
470
+ ),
471
+ })
472
+ const metrics = createMetrics({ flushIntervalMs: 10_000 }, instance)
473
+ metrics.count('orders_created', 1)
474
+ await vi.advanceTimersByTimeAsync(10_000)
475
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(1)
476
+
477
+ metrics.count('orders_created', 1)
478
+ await metrics.flush()
479
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(2)
480
+ })
481
+
482
+ it('ends the wait when a later failure names none', async () => {
483
+ const outcomes: SendMetricsBatchOutcome[] = [
484
+ { kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 },
485
+ { kind: 'retry-later', error: new Error('503') },
486
+ ]
487
+ const instance = createMockInstance({
488
+ _sendMetricsBatch: vi.fn((): Promise<SendMetricsBatchOutcome> =>
489
+ Promise.resolve(outcomes.shift() ?? { kind: 'ok' })
490
+ ),
491
+ })
492
+ const metrics = createMetrics({ flushIntervalMs: 10_000 }, instance)
493
+ metrics.count('orders_created', 1)
494
+ await vi.advanceTimersByTimeAsync(10_000)
495
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(1)
496
+
497
+ await vi.advanceTimersByTimeAsync(300_000)
498
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(2)
499
+
500
+ // Off the 300s window and back on our own backoff: two consecutive
501
+ // failures, so one doubling of the interval rather than another 300s.
502
+ await vi.advanceTimersByTimeAsync(20_000)
503
+ expect(instance._sendMetricsBatch).toHaveBeenCalledTimes(3)
504
+ })
505
+
246
506
  it('does not send when the window is empty', async () => {
247
507
  createMetrics({ flushIntervalMs: 5000 })
248
508
  await vi.advanceTimersByTimeAsync(15000)
@@ -519,4 +779,48 @@ describe('PostHogMetrics', () => {
519
779
  expect(optedOutInstance._sendMetricsBatch).not.toHaveBeenCalled()
520
780
  })
521
781
  })
782
+
783
+ it('does not hold a later capture at a window a successful flush already closed', async () => {
784
+ let sends = 0
785
+ mockInstance._sendMetricsBatch = vi.fn(async (): Promise<SendMetricsBatchOutcome> => {
786
+ sends += 1
787
+ return sends === 1 ? { kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 } : { kind: 'ok' }
788
+ })
789
+ const metrics = createMetrics({ flushIntervalMs: 5000 })
790
+
791
+ metrics.count('a', 1)
792
+ await vi.advanceTimersByTimeAsync(5000)
793
+ await metrics.flush()
794
+ const closedAt = Date.now()
795
+
796
+ metrics.count('b', 1)
797
+ await vi.advanceTimersByTimeAsync(5000)
798
+
799
+ expect(mockInstance._sendMetricsBatch).toHaveBeenCalledTimes(3)
800
+ expect(Date.now() - closedAt).toBeLessThanOrEqual(5000)
801
+ })
802
+
803
+ it('releases a series captured mid-flush once that flush closes the window', async () => {
804
+ const metrics = createMetrics({ flushIntervalMs: 10_000 })
805
+ let sends = 0
806
+ mockInstance._sendMetricsBatch = vi.fn(async (): Promise<SendMetricsBatchOutcome> => {
807
+ sends += 1
808
+ if (sends === 1) {
809
+ return { kind: 'retry-later', error: new Error('429'), retryAfterMs: 300_000 }
810
+ }
811
+ await Promise.resolve()
812
+ if (sends === 2) {
813
+ metrics.count('mid', 1)
814
+ }
815
+ return { kind: 'ok' }
816
+ })
817
+
818
+ metrics.count('a', 1)
819
+ await vi.advanceTimersByTimeAsync(10_000)
820
+ await vi.advanceTimersByTimeAsync(10_000)
821
+ await metrics.flush()
822
+
823
+ await vi.advanceTimersByTimeAsync(10_000)
824
+ expect(mockInstance._sendMetricsBatch).toHaveBeenCalledTimes(3)
825
+ })
522
826
  })