@zuplo/cli 7.7.8 → 7.7.10

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 (154) 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 +552 -0
  43. package/node_modules/@posthog/core/dist/traces/index.mjs +514 -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 +198 -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 +538 -0
  55. package/node_modules/@posthog/core/dist/traces/span.mjs +467 -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/index.d.ts +1 -0
  73. package/node_modules/@posthog/core/dist/utils/index.d.ts.map +1 -1
  74. package/node_modules/@posthog/core/dist/utils/index.js +22 -2
  75. package/node_modules/@posthog/core/dist/utils/index.mjs +8 -1
  76. package/node_modules/@posthog/core/dist/utils/json-utils.d.ts +7 -0
  77. package/node_modules/@posthog/core/dist/utils/json-utils.d.ts.map +1 -1
  78. package/node_modules/@posthog/core/dist/utils/json-utils.js +27 -0
  79. package/node_modules/@posthog/core/dist/utils/json-utils.mjs +25 -1
  80. package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts +13 -2
  81. package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts.map +1 -1
  82. package/node_modules/@posthog/core/dist/utils/otlp-resource.js +34 -7
  83. package/node_modules/@posthog/core/dist/utils/otlp-resource.mjs +26 -2
  84. package/node_modules/@posthog/core/dist/utils/retry-after.d.ts +55 -0
  85. package/node_modules/@posthog/core/dist/utils/retry-after.d.ts.map +1 -0
  86. package/node_modules/@posthog/core/dist/utils/retry-after.js +95 -0
  87. package/node_modules/@posthog/core/dist/utils/retry-after.mjs +50 -0
  88. package/node_modules/@posthog/core/package.json +3 -3
  89. package/node_modules/@posthog/core/src/error-tracking/parsers/base.spec.ts +77 -7
  90. package/node_modules/@posthog/core/src/error-tracking/parsers/base.ts +42 -19
  91. package/node_modules/@posthog/core/src/index.ts +18 -0
  92. package/node_modules/@posthog/core/src/logs/index.spec.ts +490 -2
  93. package/node_modules/@posthog/core/src/logs/index.ts +63 -31
  94. package/node_modules/@posthog/core/src/logs/logs-utils.ts +2 -2
  95. package/node_modules/@posthog/core/src/logs/types.ts +2 -2
  96. package/node_modules/@posthog/core/src/metrics/index.spec.ts +304 -0
  97. package/node_modules/@posthog/core/src/metrics/index.ts +53 -16
  98. package/node_modules/@posthog/core/src/metrics/metrics-utils.ts +2 -3
  99. package/node_modules/@posthog/core/src/metrics/types.ts +10 -2
  100. package/node_modules/@posthog/core/src/posthog-core-stateless.ts +157 -38
  101. package/node_modules/@posthog/core/src/traces/config.spec.ts +271 -0
  102. package/node_modules/@posthog/core/src/traces/config.ts +142 -0
  103. package/node_modules/@posthog/core/src/traces/context.ts +30 -0
  104. package/node_modules/@posthog/core/src/traces/ids.spec.ts +115 -0
  105. package/node_modules/@posthog/core/src/traces/ids.ts +75 -0
  106. package/node_modules/@posthog/core/src/traces/index.spec.ts +3269 -0
  107. package/node_modules/@posthog/core/src/traces/index.ts +1063 -0
  108. package/node_modules/@posthog/core/src/traces/live-spans.spec.ts +69 -0
  109. package/node_modules/@posthog/core/src/traces/otlp.spec.ts +368 -0
  110. package/node_modules/@posthog/core/src/traces/otlp.ts +191 -0
  111. package/node_modules/@posthog/core/src/traces/sanitize.ts +116 -0
  112. package/node_modules/@posthog/core/src/traces/span.spec.ts +1123 -0
  113. package/node_modules/@posthog/core/src/traces/span.ts +844 -0
  114. package/node_modules/@posthog/core/src/traces/traceparent.spec.ts +184 -0
  115. package/node_modules/@posthog/core/src/traces/traceparent.ts +172 -0
  116. package/node_modules/@posthog/core/src/traces/types.ts +140 -0
  117. package/node_modules/@posthog/core/src/utils/backoff.ts +44 -0
  118. package/node_modules/@posthog/core/src/utils/flush-timer.ts +50 -0
  119. package/node_modules/@posthog/core/src/utils/index.ts +8 -0
  120. package/node_modules/@posthog/core/src/utils/json-utils.ts +33 -0
  121. package/node_modules/@posthog/core/src/utils/otlp-resource.spec.ts +55 -9
  122. package/node_modules/@posthog/core/src/utils/otlp-resource.ts +45 -4
  123. package/node_modules/@posthog/core/src/utils/retry-after.ts +135 -0
  124. package/node_modules/@posthog/types/dist/index.d.ts +1 -0
  125. package/node_modules/@posthog/types/dist/index.d.ts.map +1 -1
  126. package/node_modules/@posthog/types/dist/posthog-config.d.ts +29 -9
  127. package/node_modules/@posthog/types/dist/posthog-config.d.ts.map +1 -1
  128. package/node_modules/@posthog/types/dist/traces.d.ts +387 -0
  129. package/node_modules/@posthog/types/dist/traces.d.ts.map +1 -0
  130. package/node_modules/@posthog/types/dist/traces.js +18 -0
  131. package/node_modules/@posthog/types/dist/traces.mjs +1 -0
  132. package/node_modules/@posthog/types/package.json +1 -1
  133. package/node_modules/@posthog/types/src/index.ts +19 -0
  134. package/node_modules/@posthog/types/src/posthog-config.ts +29 -9
  135. package/node_modules/@posthog/types/src/traces.ts +429 -0
  136. package/node_modules/@zuplo/core/package.json +1 -1
  137. package/node_modules/@zuplo/graphql/package.json +1 -1
  138. package/node_modules/@zuplo/openapi-tools/package.json +1 -1
  139. package/node_modules/@zuplo/otel/package.json +1 -1
  140. package/node_modules/@zuplo/runtime/out/esm/{chunk-7ZX347IG.js → chunk-JTYJUJYO.js} +3 -3
  141. package/node_modules/@zuplo/runtime/out/esm/chunk-JTYJUJYO.js.map +1 -0
  142. package/node_modules/@zuplo/runtime/out/esm/chunk-M3VFT3HF.js +410 -0
  143. package/node_modules/@zuplo/runtime/out/esm/chunk-M3VFT3HF.js.map +1 -0
  144. package/node_modules/@zuplo/runtime/out/esm/index.js +1 -1
  145. package/node_modules/@zuplo/runtime/out/esm/mcp-gateway/index.js +3 -3
  146. package/node_modules/@zuplo/runtime/out/esm/mocks/index.js +1 -1
  147. package/node_modules/@zuplo/runtime/out/esm/sdks/aws/index.js +1 -1
  148. package/node_modules/@zuplo/runtime/out/types/index.d.ts +20 -1
  149. package/node_modules/@zuplo/runtime/package.json +1 -1
  150. package/package.json +11 -11
  151. package/node_modules/@zuplo/runtime/out/esm/chunk-7ZX347IG.js.map +0 -1
  152. package/node_modules/@zuplo/runtime/out/esm/chunk-DRQ5CMVC.js +0 -410
  153. package/node_modules/@zuplo/runtime/out/esm/chunk-DRQ5CMVC.js.map +0 -1
  154. /package/node_modules/@zuplo/runtime/out/esm/{chunk-DRQ5CMVC.js.LEGAL.txt → chunk-M3VFT3HF.js.LEGAL.txt} +0 -0
@@ -0,0 +1,1063 @@
1
+ import type { Span, SpanAttributes, SpanRecord as HookSpanRecord, StartSpanOptions } from '@posthog/types'
2
+ import type { Logger } from '../types'
3
+ import type {
4
+ OtlpSpan,
5
+ ResolvedTracesConfig,
6
+ SpanContextManager,
7
+ SpanEventRecord,
8
+ SpanRecord,
9
+ TraceSdkContext,
10
+ TracesHost,
11
+ } from './types'
12
+ import {
13
+ PassThroughSpan,
14
+ PostHogSpan,
15
+ applySpanLimits,
16
+ describeError,
17
+ inertSpan,
18
+ monotonicNow,
19
+ runWithActiveSpan,
20
+ truncateAttributes,
21
+ } from './span'
22
+ import type { ClockAnchor } from './span'
23
+ import { newSpanId, newTraceId } from './ids'
24
+ import { parseTraceparent, sanitizeTracestate, traceparentHeader } from './traceparent'
25
+ import { clampEndTime, resolveStartTime, resolveSuppliedTime, sanitizeName, toEpochMs } from './sanitize'
26
+ import { assignUserAttributes } from '../utils/json-utils'
27
+ import { buildOtlpSpan, buildOtlpTracesPayload, buildTracesResourceAttributes } from './otlp'
28
+ import { isPromise } from '../utils'
29
+ import { FlushTimer } from '../utils/flush-timer'
30
+ import { RetryAfterWindow } from '../utils/retry-after'
31
+ import { MAX_FLUSH_BACKOFF_MS, NO_JITTER, backoffDelayMs, drawJitter } from '../utils/backoff'
32
+
33
+ // Retriable failures on the same head batch before it is dropped, so a stuck
34
+ // batch cannot pin the queue while fresher spans are refused at the cap. The
35
+ // budget counts backoff windows rather than attempts: a host that drains on
36
+ // every request would otherwise retire a batch in milliseconds, spending on
37
+ // its own call rate what the timer path spends over minutes.
38
+ const MAX_RETRIES_PER_BATCH = 8
39
+
40
+ type SpanCallback<T> = (span: Span) => T
41
+
42
+ /** Monotonic where the platform has one, wall clock otherwise. Both are ms, and a platform never switches. */
43
+ function clockNow(): number {
44
+ return monotonicNow() ?? Date.now()
45
+ }
46
+
47
+ /** `instanceof` and property access both throw on a hostile proxy; `startSpan` must not. */
48
+ function isOwnSpan(value: unknown): value is PostHogSpan {
49
+ try {
50
+ return value instanceof PostHogSpan
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+
56
+ /** A `traceparent` header as the parent context it describes, or nothing if it is malformed. */
57
+ function remoteContext(header: string, tracestate: string | undefined): ParentContext | undefined {
58
+ const remote = parseTraceparent(header)
59
+ if (!remote) {
60
+ return undefined
61
+ }
62
+ return {
63
+ traceId: remote.traceId,
64
+ parentSpanId: remote.spanId,
65
+ traceState: sanitizeTracestate(tracestate),
66
+ traceFlags: remote.flags,
67
+ isRemote: true,
68
+ }
69
+ }
70
+
71
+ function looksLikeSpan(value: unknown): boolean {
72
+ try {
73
+ return typeof (value as Span).traceparent === 'function'
74
+ } catch {
75
+ return false
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The rebuilt record with every field named, optional ones included. A field
81
+ * added to either half of `SpanRecord` is a compile error at the rebuild until
82
+ * it says whether a hook may set that field or the span keeps its own value.
83
+ */
84
+ type RebuiltSpanRecord = { [K in keyof Required<SpanRecord>]: SpanRecord[K] }
85
+
86
+ interface SpanIdentity {
87
+ traceId: string
88
+ spanId: string
89
+ parentSpanId?: string
90
+ traceState?: string
91
+ }
92
+
93
+ /**
94
+ * Whether a `beforeSpanSend` return value still carries every field the public
95
+ * `SpanRecord` declares as required. An array is rejected for `attributes`: it
96
+ * would encode as `{ "0": ... }` rather than fail.
97
+ *
98
+ * Presence, not usability: a field that is there but holds the wrong type is a
99
+ * hook editing a real record badly, and the sanitising below is what answers
100
+ * that. A field that is absent means the hook returned something that was never
101
+ * a span record, and the fallbacks would dress it up as one.
102
+ */
103
+ function isSpanRecordShape(record: SpanRecord): boolean {
104
+ return (
105
+ !!record.attributes &&
106
+ typeof record.attributes === 'object' &&
107
+ !Array.isArray(record.attributes) &&
108
+ Array.isArray(record.events) &&
109
+ record.name !== undefined &&
110
+ record.kind !== undefined &&
111
+ record.startTime !== undefined &&
112
+ record.endTime !== undefined
113
+ )
114
+ }
115
+
116
+ /** Writes `value` onto `record` only when it isn't already there. */
117
+ function restoreField<K extends keyof SpanIdentity>(record: SpanIdentity, field: K, value: SpanIdentity[K]): void {
118
+ if (record[field] !== value) {
119
+ record[field] = value
120
+ }
121
+ }
122
+
123
+ /**
124
+ * A stand-in for a record whose identity could not be written back, carrying the
125
+ * original ids and everything else the hook returned.
126
+ *
127
+ * Built from the descriptors rather than spread so a class instance keeps its
128
+ * prototype — `instanceof` and a field exposed as a prototype getter both still
129
+ * answer — and so `Object.keys` reads what it read before. Only the four
130
+ * identity descriptors are replaced, which is what makes the copy writable where
131
+ * the original was frozen.
132
+ */
133
+ function withRestoredIdentity(hooked: HookSpanRecord, original: SpanIdentity): HookSpanRecord {
134
+ try {
135
+ const descriptors = Object.getOwnPropertyDescriptors(hooked) as Record<string, PropertyDescriptor>
136
+ for (const field of ['traceId', 'spanId', 'parentSpanId', 'traceState'] as const) {
137
+ descriptors[field] = {
138
+ value: original[field],
139
+ enumerable: true,
140
+ writable: true,
141
+ configurable: true,
142
+ }
143
+ }
144
+ return Object.create(Object.getPrototypeOf(hooked) as object | null, descriptors) as HookSpanRecord
145
+ } catch {
146
+ // A hostile descriptor read. The export still uses the snapshot, so this
147
+ // costs the next hook a correct id rather than the span.
148
+ return hooked
149
+ }
150
+ }
151
+
152
+ interface ParentContext {
153
+ traceId: string
154
+ parentSpanId?: string
155
+ traceState?: string
156
+ traceFlags?: string
157
+ /** True when the parent arrived as a `traceparent` header. */
158
+ isRemote?: boolean
159
+ clockAnchor?: ClockAnchor
160
+ }
161
+
162
+ /**
163
+ * The traces pipeline: span creation, active-span parenting, and OTLP export.
164
+ * Separate from the analytics-events pipeline — own queue, endpoint and flush
165
+ * cycle — mirroring logs and metrics.
166
+ *
167
+ * @internal Exposed for cross-package use within this SDK; not part of the stable public API.
168
+ */
169
+ export class PostHogTraces {
170
+ private _queue: SpanRecord[] = []
171
+ private readonly _flushTimer = new FlushTimer(() => this._flushInBackground())
172
+ // Serializes flushes: a second caller joins the first instead of double-sending the head.
173
+ private _flushPromise: Promise<number> | null = null
174
+ // A trigger no-ops while a background drain is already pending.
175
+ private _backgroundFlush?: Promise<void>
176
+ private _maxExportBatchSize: number
177
+ // Reset when the warning is emitted, so each warning reports its own window.
178
+ private _droppedSinceWarning = 0
179
+ private _lastDropWarningAt = 0
180
+ private _dropReasons = new Set<string>()
181
+ private _consecutiveFlushFailures = 0
182
+ private _flushJitter = NO_JITTER
183
+ private _retryAfter = new RetryAfterWindow()
184
+ // Separate from the backoff counter: this one belongs to whatever batch is at
185
+ // the head, and resets whenever that batch is removed or shrunk.
186
+ private _headBatchFailures = 0
187
+ // Read only while a budget is in flight, so the head cannot grow to sweep in
188
+ // fresh spans and drop them on a budget they never spent.
189
+ private _headBatchSize = 0
190
+ // When the head batch may next be charged, on `clockNow`'s basis: one failure
191
+ // per backoff window, whoever drove the attempt.
192
+ private _headBatchChargeableAt = 0
193
+ // Bumped by reset(); a pass whose generation is stale abandons the queue.
194
+ private _generation = 0
195
+ // Live-span accounting: span id -> monotonic start. Ids and numbers only,
196
+ // never the span itself, so a handle the caller drops is still collectable
197
+ // and the bound can be generous. Insertion order is start order, so the
198
+ // oldest entries are at the front and eviction stops at the first live one.
199
+ private _liveSpans = new Map<string, number>()
200
+
201
+ constructor(
202
+ private readonly _instance: TracesHost,
203
+ private readonly _config: ResolvedTracesConfig,
204
+ private readonly _logger: Logger,
205
+ private readonly _getContext: () => TraceSdkContext,
206
+ private readonly _contextManager: SpanContextManager,
207
+ /** Told when a span joins the queue, so a serverless host can keep the invocation alive. */
208
+ private readonly _onSpanQueued?: () => void
209
+ ) {
210
+ this._maxExportBatchSize = _config.maxExportBatchSize
211
+ }
212
+
213
+ /**
214
+ * Starts a span without making it active. Always returns a handle — an inert
215
+ * one when tracing cannot run — so calling code never branches.
216
+ */
217
+ startSpan(name: string, options?: StartSpanOptions): Span {
218
+ if (this._instance.isDisabled || this._instance.optedOut) {
219
+ return inertSpan(options, this._contextManager.active())
220
+ }
221
+
222
+ const explicitParent = traceparentHeader(options?.parent)
223
+ if (explicitParent && typeof explicitParent !== 'string' && !isOwnSpan(explicitParent)) {
224
+ if (looksLikeSpan(explicitParent)) {
225
+ // Inert like its parent, never an orphan with invented ids — but a
226
+ // pass-through parent's inbound context carries to the child rather than
227
+ // the trace ending here.
228
+ this._logger.debug('Span parent is not a span from this SDK; returning an inert span')
229
+ return inertSpan(options, this._contextManager.active())
230
+ }
231
+ // No `traceparent()` to read: a span from another tracer exposes
232
+ // `spanContext()` instead, and `headersDistinct.traceparent` is a `string[]`
233
+ // holding more than one inbound value. Ignored: falls back to the active
234
+ // span, or to a new trace.
235
+ this._logger.debug('Ignoring an unusable span parent')
236
+ }
237
+
238
+ const parent = this._resolveParent(explicitParent, options)
239
+
240
+ // Swept before the bound is read, so a process that has leaked its way to
241
+ // the bound recovers on the first `startSpan` after the leaks age out.
242
+ this._evictAgedSpans()
243
+ if (this._liveSpans.size >= this._config.maxLiveSpans) {
244
+ this._recordDrop(
245
+ 1,
246
+ `the live-span limit (${this._config.maxLiveSpans}) was reached — spans are being started and never ended`
247
+ )
248
+ return inertSpan(options, this._contextManager.active())
249
+ }
250
+
251
+ const now = Date.now()
252
+ const startTime = resolveStartTime(options?.startTime, now, this._logger)
253
+ const spanId = newSpanId()
254
+ // Read here rather than from the span: age is elapsed time since this call,
255
+ // so a backdated `startTime` neither ages a span early nor exempts it.
256
+ this._liveSpans.set(spanId, clockNow())
257
+
258
+ const autoAttributes = this._autoContextAttributes()
259
+
260
+ return new PostHogSpan(
261
+ {
262
+ traceId: parent?.traceId ?? newTraceId(),
263
+ spanId,
264
+ parentSpanId: parent?.parentSpanId,
265
+ traceState: parent?.traceState,
266
+ traceFlags: parent?.traceFlags,
267
+ parentIsRemote: parent?.isRemote,
268
+ name: sanitizeName(name, 'Span name', this._config.maxAttributeValueLength, this._logger),
269
+ kind: options?.kind ?? 'internal',
270
+ // Auto-context first so user-supplied attributes win on collision.
271
+ attributes: assignUserAttributes({ ...autoAttributes }, options?.attributes),
272
+ autoAttributeKeys: Object.keys(autoAttributes),
273
+ maxAttributes: this._config.maxAttributesPerSpan,
274
+ maxEvents: this._config.maxEventsPerSpan,
275
+ maxAttributesPerEvent: this._config.maxAttributesPerEvent,
276
+ maxAttributeValueLength: this._config.maxAttributeValueLength,
277
+ startTime,
278
+ backdated: startTime !== now,
279
+ clockAnchor: toEpochMs(options?.startTime) === undefined ? parent?.clockAnchor : undefined,
280
+ },
281
+ (record, autoKeys) => this._onSpanEnd(record, autoKeys),
282
+ this._logger
283
+ )
284
+ }
285
+
286
+ /**
287
+ * Runs a callback with a span active for its duration and guarantees the span
288
+ * ends — at return for a sync callback, at settle for an async one.
289
+ *
290
+ * A throw or rejection is recorded on the span and rethrown unmodified: the
291
+ * SDK never swallows application control flow.
292
+ */
293
+ withSpan<T>(name: string, fn: SpanCallback<T>): T
294
+ withSpan<T>(name: string, options: StartSpanOptions, fn: SpanCallback<T>): T
295
+ withSpan<T>(name: string, optionsOrFn: StartSpanOptions | SpanCallback<T>, maybeFn?: SpanCallback<T>): T {
296
+ const options = typeof optionsOrFn === 'function' ? undefined : optionsOrFn
297
+ const fn = (typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn) as SpanCallback<T>
298
+
299
+ const span = this.startSpan(name, options)
300
+
301
+ try {
302
+ const result = runWithActiveSpan(this._contextManager, span, fn)
303
+
304
+ if (isPromise(result)) {
305
+ return result.then(
306
+ (value: unknown) => {
307
+ span.end()
308
+ return value
309
+ },
310
+ (error: unknown) => {
311
+ this._recordCallbackError(span, error)
312
+ span.end()
313
+ throw error
314
+ }
315
+ ) as T
316
+ }
317
+
318
+ span.end()
319
+ return result
320
+ } catch (error) {
321
+ this._recordCallbackError(span, error)
322
+ span.end()
323
+ throw error
324
+ }
325
+ }
326
+
327
+ /** The active span, or `null` outside any `withSpan` callback. */
328
+ getActiveSpan(): Span | null {
329
+ return this._contextManager.active() ?? null
330
+ }
331
+
332
+ /**
333
+ * Drains the span queue in repeated passes: joining a single in-flight pass
334
+ * would leave spans enqueued after its watermark behind.
335
+ *
336
+ * A pass reports spans removed — queue length can't stand in, since a send
337
+ * concurrent with an arrival leaves it unchanged.
338
+ *
339
+ * An open `Retry-After` window does not stop this: an explicit flush, or one a
340
+ * host runs to keep a request alive, sends whatever is queued — a serverless
341
+ * isolate may not be around for the armed timer to fire. The window is honoured
342
+ * by not charging such an attempt against the head batch's retry budget, so the
343
+ * wait costs a request rather than the spans. The periodic flush does wait it
344
+ * out.
345
+ */
346
+ /**
347
+ * Whether the endpoint has asked this queue to wait. An automatic flush skips
348
+ * it; the traces flush timer already drains once the window closes.
349
+ */
350
+ get throttled(): boolean {
351
+ return this._retryAfter.isOpen()
352
+ }
353
+
354
+ async flush(): Promise<void> {
355
+ for (;;) {
356
+ if (!this._queue.length) {
357
+ return
358
+ }
359
+
360
+ const inFlight = this._flushPromise
361
+ const removed = await (inFlight ?? this._startFlush())
362
+
363
+ // No progress means a retriable failure, an abandoned pass, or spans
364
+ // arriving as fast as we send them. Either way, stop rather than spin.
365
+ if (!removed) {
366
+ return
367
+ }
368
+ }
369
+ }
370
+
371
+ private _startFlush(): Promise<number> {
372
+ this._flushTimer.clear()
373
+ // Deferred by a microtask so the slot below is installed before the pass
374
+ // reads anything: `_flushInner` runs synchronously as far as its first
375
+ // await, and a resource-attribute getter or `toJSON` that ends a span in
376
+ // that window would otherwise re-enter here, find no pass in flight, and
377
+ // send the same head batch again — without bound.
378
+ // Sampled before the microtask, not inside `_flushInner`: a `reset()` landing
379
+ // in the window would otherwise be invisible to this pass, which would then
380
+ // drain the post-reset queue alongside the pass `reset()` started.
381
+ const startedAtGeneration = this._generation
382
+ const promise = Promise.resolve()
383
+ .then(() => (startedAtGeneration === this._generation ? this._flushInner() : 0))
384
+ .finally(() => {
385
+ // Only clear the slot this call installed: a `reset()` mid-flight may
386
+ // already have installed a newer one.
387
+ if (this._flushPromise === promise) {
388
+ this._flushPromise = null
389
+ }
390
+ this._armFlushTimerIfQueuedNoEarlierThan()
391
+ })
392
+ this._flushPromise = promise
393
+ return promise
394
+ }
395
+
396
+ /** Clears the queue and timer. Used on shutdown and between tests. */
397
+ reset(): void {
398
+ this._flushTimer.clear()
399
+ if (this._queue.length) {
400
+ // Critical, and said here rather than counted: this is the last chance to
401
+ // say anything about these spans, the drop warning is gated behind `debug`
402
+ // on some hosts, and the only other line the caller sees is the export
403
+ // failure promising a retry on a flush that will never come.
404
+ this._logger.critical(
405
+ `Discarding ${this._queue.length} span(s) that were still queued when tracing was shut down. ` +
406
+ 'Raise the shutdown timeout or flush earlier if they matter.'
407
+ )
408
+ }
409
+ this._queue = []
410
+ this._liveSpans.clear()
411
+ this._flushPromise = null
412
+ // Abandons any in-flight pass, which would otherwise splice out spans it never sent.
413
+ this._generation++
414
+ this._maxExportBatchSize = this._config.maxExportBatchSize
415
+ this._droppedSinceWarning = 0
416
+ this._dropReasons.clear()
417
+ this._lastDropWarningAt = 0
418
+ this._consecutiveFlushFailures = 0
419
+ this._flushJitter = NO_JITTER
420
+ this._retryAfter.reset()
421
+ this._resetHeadBatchBudget()
422
+ }
423
+
424
+ /** Called wherever the head batch leaves or changes shape, so its budget goes with it. */
425
+ private _resetHeadBatchBudget(): void {
426
+ this._headBatchFailures = 0
427
+ this._headBatchChargeableAt = 0
428
+ }
429
+
430
+ /**
431
+ * Resolves a span's parent: an explicit `parent`, then the active span, then a
432
+ * fresh root. A no-op explicit parent is rejected earlier, in `startSpan`.
433
+ */
434
+ private _resolveParent(explicit: unknown, options?: StartSpanOptions): ParentContext | undefined {
435
+ if (typeof explicit === 'string') {
436
+ const remote = remoteContext(explicit, options?.tracestate)
437
+ if (!remote) {
438
+ this._logger.debug('Ignoring malformed traceparent; starting a new trace')
439
+ }
440
+ return remote
441
+ }
442
+
443
+ if (isOwnSpan(explicit)) {
444
+ // `tracestate` is ignored for handle parents — the child inherits the
445
+ // parent span's tracestate instead.
446
+ return explicit.childContext()
447
+ }
448
+
449
+ const active = this._contextManager.active()
450
+ if (isOwnSpan(active)) {
451
+ return active.childContext()
452
+ }
453
+ // A pass-through handle is active when an earlier span in this trace could
454
+ // not be recorded. Its context still parents this one, so the inbound trace
455
+ // survives a span the SDK declined rather than ending there.
456
+ if (active instanceof PassThroughSpan) {
457
+ return remoteContext(active.traceparent(), active.tracestate() ?? undefined)
458
+ }
459
+ return undefined
460
+ }
461
+
462
+ /**
463
+ * PostHog context snapshotted at span start. These are the product's join
464
+ * keys — they're what makes a span reachable from a person or a session.
465
+ */
466
+ private _autoContextAttributes(): SpanAttributes {
467
+ let context: TraceSdkContext
468
+ try {
469
+ context = this._getContext()
470
+ } catch (error) {
471
+ this._logger.debug('Failed to read tracing context; span will carry no PostHog attributes', error)
472
+ return {}
473
+ }
474
+
475
+ const attributes: SpanAttributes = {}
476
+ if (context.distinctId) {
477
+ attributes.posthogDistinctId = context.distinctId
478
+ }
479
+ if (context.sessionId) {
480
+ attributes.sessionId = context.sessionId
481
+ }
482
+ if (context.currentUrl) {
483
+ attributes['url.full'] = context.currentUrl
484
+ }
485
+ if (context.screenName) {
486
+ attributes['screen.name'] = context.screenName
487
+ }
488
+ if (context.appState) {
489
+ attributes['app.state'] = context.appState
490
+ }
491
+ return attributes
492
+ }
493
+
494
+ /**
495
+ * Records a callback failure on the span: an `exception` event always, plus
496
+ * status `error` unless the callback explicitly marked the span `ok`.
497
+ */
498
+ private _recordCallbackError(span: Span, error: unknown): void {
499
+ if (!(span instanceof PostHogSpan)) {
500
+ return
501
+ }
502
+ const { type, message, stack } = describeError(error)
503
+ span.addEvent('exception', {
504
+ 'exception.type': type,
505
+ 'exception.message': message,
506
+ ...(stack && { 'exception.stacktrace': stack }),
507
+ })
508
+ if (!span.statusIsExplicitlyOk) {
509
+ span.setStatus('error', message)
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Drops live accounting for spans older than `maxSpanAgeMs`. An evicted span
515
+ * is never exported — its `end()` finds no entry — so one leak returns its
516
+ * slot instead of disabling tracing for the rest of the process.
517
+ */
518
+ private _evictAgedSpans(): void {
519
+ const cutoff = clockNow() - this._config.maxSpanAgeMs
520
+ let evicted = 0
521
+ for (const [spanId, startedAt] of this._liveSpans) {
522
+ // Insertion order is start order, so the first entry inside the bound ends the sweep.
523
+ if (startedAt > cutoff) {
524
+ break
525
+ }
526
+ this._liveSpans.delete(spanId)
527
+ evicted++
528
+ }
529
+ if (evicted) {
530
+ this._recordDrop(evicted, `they were still live after ${this._config.maxSpanAgeMs}ms`)
531
+ }
532
+ }
533
+
534
+ private _onSpanEnd(incoming: SpanRecord, autoKeys: ReadonlySet<string>): void {
535
+ // Deleted before any other gate, so a span dropped later still returns its slot.
536
+ if (!this._liveSpans.delete(incoming.spanId)) {
537
+ // Evicted for age while live: never exported, and already counted as a drop.
538
+ return
539
+ }
540
+
541
+ // Re-checked at end: opting out mid-trace must stop the span exporting.
542
+ if (this._instance.isDisabled || this._instance.optedOut) {
543
+ this._recordDrop(1, 'the user has opted out')
544
+ return
545
+ }
546
+
547
+ const record = this._runBeforeSpanSend(incoming, autoKeys)
548
+ if (!record) {
549
+ return
550
+ }
551
+ this._reportLimitDrops(record)
552
+
553
+ if (this._queue.length >= this._config.maxQueueSize) {
554
+ // Drop the incoming span, not queued ones: those are completed parents whose
555
+ // children may already have shipped.
556
+ this._recordDrop(
557
+ 1,
558
+ `the queue is full (${this._config.maxQueueSize}) — raise the flush frequency or reduce span volume`
559
+ )
560
+ return
561
+ }
562
+
563
+ this._queue.push(record)
564
+ try {
565
+ this._onSpanQueued?.()
566
+ } catch (error) {
567
+ this._logger.debug('Span queue notification failed', error)
568
+ }
569
+
570
+ // Not while a flush is failing or the endpoint has asked us to wait: the
571
+ // queue stays above the batch size for the whole outage, so every further
572
+ // span end would re-POST immediately and the retry backoff would never apply.
573
+ if (
574
+ this._queue.length >= this._maxExportBatchSize &&
575
+ !this._consecutiveFlushFailures &&
576
+ !this._retryAfter.isOpen()
577
+ ) {
578
+ this._flushInBackground()
579
+ } else {
580
+ this._armFlushTimerIfQueued()
581
+ }
582
+ }
583
+
584
+ /**
585
+ * One diagnostic per span when its limits discarded anything, which is what
586
+ * OTel asks for. Counted after the post-hook pass, so drops a `beforeSpanSend`
587
+ * hook caused are included.
588
+ */
589
+ private _reportLimitDrops(record: SpanRecord): void {
590
+ const attributes = record.droppedAttributesCount ?? 0
591
+ const events = record.droppedEventsCount ?? 0
592
+ let eventAttributes = 0
593
+ for (const event of record.events) {
594
+ eventAttributes += event.droppedAttributesCount ?? 0
595
+ }
596
+ if (attributes || events || eventAttributes) {
597
+ this._logger.debug(
598
+ `Span limits discarded data from "${record.name}": ` +
599
+ `${attributes} attributes, ${events} events, ${eventAttributes} event attributes`
600
+ )
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Runs the `beforeSpanSend` chain, returning the span to enqueue or `null` to
606
+ * drop it.
607
+ *
608
+ * A throwing hook drops the span: the hook is the documented scrubbing point,
609
+ * so a broken scrubber must not let the unscrubbed record through. Identity
610
+ * fields are restored afterwards, since rewriting them orphans shipped children.
611
+ */
612
+ private _runBeforeSpanSend(record: SpanRecord, autoKeys: ReadonlySet<string>): SpanRecord | null {
613
+ if (!this._config.beforeSpanSend.length) {
614
+ return record
615
+ }
616
+
617
+ // Snapshotted before any hook runs: a hook that mutates in place would
618
+ // otherwise leave nothing to restore from.
619
+ const identity = {
620
+ traceId: record.traceId,
621
+ spanId: record.spanId,
622
+ parentSpanId: record.parentSpanId,
623
+ traceState: record.traceState,
624
+ }
625
+ const originalTimes = { startTime: record.startTime, endTime: record.endTime }
626
+ // Snapshotted with the rest: the hook mutates the record in place, so reading
627
+ // these back afterwards reads whatever the hook left there.
628
+ const originalDropped = {
629
+ attributes: record.droppedAttributesCount,
630
+ events: record.droppedEventsCount,
631
+ }
632
+ // The order the span itself wrote them in, so the caps below can keep the
633
+ // earliest-set entries even when a hook adds an integer-like key.
634
+ const keysBeforeHook = Object.keys(record.attributes)
635
+ // Read here rather than restored onto the hook's return value: writing them
636
+ // back would throw on a frozen record, and neither is on the record a hook
637
+ // is handed, so a rebuilding hook always arrives without them.
638
+ const originalPropagation = {
639
+ traceFlags: record.traceFlags,
640
+ parentIsRemote: record.parentIsRemote,
641
+ }
642
+ // Copied, not referenced: the hook is documented as mutating the record in
643
+ // place, and a reference would restore the mutation onto itself.
644
+ const originalStatus = record.status && { ...record.status }
645
+ let hooked: HookSpanRecord = record
646
+ let current = record
647
+ try {
648
+ for (const hook of this._config.beforeSpanSend) {
649
+ const result = hook(hooked)
650
+ if (!result) {
651
+ this._recordDrop(1, 'beforeSpanSend dropped it')
652
+ return null
653
+ }
654
+ hooked = this._keepSpanIdentity(result, identity)
655
+ }
656
+
657
+ // Rebuilt field by field before anything below writes to it. The hook's
658
+ // return value may be frozen, where every write here would throw, or a
659
+ // class instance whose fields are prototype getters a spread would miss.
660
+ // Naming them also bounds what can reach the wire.
661
+ const rebuilt: RebuiltSpanRecord = {
662
+ // All four from the snapshot, never from the hook's return value. A hook
663
+ // that forges an id has it ignored, which is the documented behaviour,
664
+ // and one that also freezes what it returns keeps its span: writing the
665
+ // id back onto a frozen object throws, and a throw here drops the span.
666
+ traceId: identity.traceId,
667
+ spanId: identity.spanId,
668
+ parentSpanId: identity.parentSpanId,
669
+ traceState: identity.traceState,
670
+ name: hooked.name,
671
+ kind: hooked.kind,
672
+ status: hooked.status,
673
+ attributes: hooked.attributes,
674
+ events: hooked.events,
675
+ startTime: hooked.startTime,
676
+ endTime: hooked.endTime,
677
+ // Taken from the span for the same reason as the dropped counts: no
678
+ // public type declares them, so a rebuilding hook returns without them
679
+ // and a `?? fallback` here would export a sampled-out trace as sampled.
680
+ traceFlags: originalPropagation.traceFlags,
681
+ parentIsRemote: originalPropagation.parentIsRemote,
682
+ // Taken from the span, not from the hook's return value: these are SDK
683
+ // bookkeeping that no public type declares, so a hook overwriting them
684
+ // must not erase what the span actually dropped.
685
+ droppedAttributesCount: originalDropped.attributes,
686
+ droppedEventsCount: originalDropped.events,
687
+ }
688
+ current = rebuilt
689
+ // A value missing a required field is not a span record — an `async` hook
690
+ // returns a Promise, truthy and `undefined` for every field. Filling the
691
+ // gaps in would export a span named `unknown` at a fallback time carrying
692
+ // no person or session, joinable to nothing and silent about it.
693
+ if (!isSpanRecordShape(current)) {
694
+ this._logger.debug('beforeSpanSend did not return a span record; dropping the span')
695
+ this._recordDrop(1, 'beforeSpanSend returned an unusable record')
696
+ return null
697
+ }
698
+
699
+ // Re-applied to whatever the hook returned: one undecodable timestamp 400s
700
+ // the whole request, taking unrelated spans with it.
701
+ current.name = sanitizeName(current.name, 'Span name', this._config.maxAttributeValueLength, this._logger)
702
+ // A status the hook rewrote never went through `setStatus`. An unknown code
703
+ // encodes as an empty status object, which loses an error the span really had.
704
+ if (current.status && current.status.code !== 'ok' && current.status.code !== 'error') {
705
+ this._logger.debug('beforeSpanSend set an unknown span status; keeping the original')
706
+ current.status = originalStatus
707
+ }
708
+ current.startTime = toEpochMs(current.startTime) ?? originalTimes.startTime
709
+ current.endTime = clampEndTime(toEpochMs(current.endTime) ?? originalTimes.endTime, current.startTime)
710
+ // Events a hook pushed never went through `addEvent`, so they carry no
711
+ // sanitised name or timestamp; an unvalidated one encodes as `NaN000NaN`
712
+ // and the ingestion service refuses the whole batch.
713
+ const sanitizedEvents: SpanEventRecord[] = []
714
+ for (const event of current.events) {
715
+ try {
716
+ sanitizedEvents.push({
717
+ ...event,
718
+ name: sanitizeName(event.name, 'Span event name', this._config.maxAttributeValueLength, this._logger),
719
+ timestamp: resolveSuppliedTime(event.timestamp, current.startTime, 'event timestamp', this._logger),
720
+ })
721
+ } catch {
722
+ // A hook can leave a `null` in the array or a throwing accessor on an
723
+ // event. That costs the event; the rest of the span still ships.
724
+ this._logger.debug('beforeSpanSend left an unreadable span event; dropping it')
725
+ }
726
+ }
727
+ current.events = sanitizedEvents
728
+ applySpanLimits(
729
+ current,
730
+ autoKeys,
731
+ this._config.maxAttributesPerSpan,
732
+ this._config.maxEventsPerSpan,
733
+ this._config.maxAttributesPerEvent,
734
+ this._config.maxAttributeValueLength,
735
+ keysBeforeHook
736
+ )
737
+ return current
738
+ } catch (error) {
739
+ // Covers the hook and everything done to its return value: a frozen or
740
+ // hostile record must not throw out of `end()` into application code.
741
+ this._logger.debug('beforeSpanSend failed; dropping the span rather than exporting it unscrubbed', error)
742
+ this._recordDrop(1, 'beforeSpanSend failed')
743
+ return null
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Restores the fields a hook must not change. Runs per hook so a later hook in
749
+ * the chain cannot sample on an id an earlier one forged.
750
+ */
751
+ private _keepSpanIdentity(hooked: HookSpanRecord, original: SpanIdentity): HookSpanRecord {
752
+ if (
753
+ hooked.traceId !== original.traceId ||
754
+ hooked.spanId !== original.spanId ||
755
+ hooked.parentSpanId !== original.parentSpanId
756
+ ) {
757
+ this._logger.debug('beforeSpanSend changed a span identity field; keeping the original ids')
758
+ }
759
+ // Only the fields that actually differ are written back: assigning to a
760
+ // frozen property throws even when the value is the one already there, and
761
+ // a hook that freezes what it returns would otherwise drop every span. The
762
+ // record this builds is for the next hook in the chain, not for the export.
763
+ try {
764
+ restoreField(hooked, 'traceId', original.traceId)
765
+ restoreField(hooked, 'spanId', original.spanId)
766
+ restoreField(hooked, 'parentSpanId', original.parentSpanId)
767
+ // A hook that rebuilds the record instead of spreading it would otherwise
768
+ // drop tracestate, which is not part of the record the hook is handed.
769
+ restoreField(hooked, 'traceState', original.traceState)
770
+ } catch {
771
+ // Frozen, so the writes above were refused and this record still carries
772
+ // whatever identity the hook forged. The export reads the snapshot either
773
+ // way, but the next hook in the chain reads this — and would sample or
774
+ // route on a forged id, which identity immutability exists to prevent.
775
+ return withRestoredIdentity(hooked, original)
776
+ }
777
+ return hooked
778
+ }
779
+
780
+ private _recordDrop(count: number, reason: string): void {
781
+ this._droppedSinceWarning += count
782
+ this._dropReasons.add(reason)
783
+ // Drops also happen with no flush in sight — a full queue during an outage —
784
+ // so the warning is paced by the clock rather than by the flush loop.
785
+ if (Date.now() - this._lastDropWarningAt >= this._config.flushIntervalMs) {
786
+ this._warnAboutDrops()
787
+ }
788
+ }
789
+
790
+ /** At most one warning per flush pass, naming the total and every reason behind it. */
791
+ private _warnAboutDrops(): void {
792
+ if (!this._droppedSinceWarning) {
793
+ return
794
+ }
795
+ this._lastDropWarningAt = Date.now()
796
+ this._logger.warn(`Dropping ${this._droppedSinceWarning} span(s): ${[...this._dropReasons].join('; ')}`)
797
+ this._droppedSinceWarning = 0
798
+ this._dropReasons.clear()
799
+ }
800
+
801
+ /**
802
+ * Encodes a batch, dropping any span whose attributes can't be encoded.
803
+ * An unguarded throw here would leave the queue unspliced, so every later
804
+ * flush would die on the same span.
805
+ */
806
+ private _encodeBatch(batch: SpanRecord[]): OtlpSpan[] {
807
+ const encoded: OtlpSpan[] = []
808
+ for (const record of batch) {
809
+ try {
810
+ encoded.push(buildOtlpSpan(record, this._logger))
811
+ } catch (error) {
812
+ this._logger.debug('Failed to encode a span; dropping it', error)
813
+ this._recordDrop(1, 'its attributes could not be encoded')
814
+ }
815
+ }
816
+ return encoded
817
+ }
818
+
819
+ /**
820
+ * Discards the queue when consent has been withdrawn, returning how many spans
821
+ * it dropped. Spans carry `posthogDistinctId` and `sessionId`, so anything still
822
+ * queued when the user opts out must not be exported.
823
+ */
824
+ private _discardQueueIfConsentWithdrawn(): number {
825
+ if (!this._instance.isDisabled && !this._instance.optedOut) {
826
+ return 0
827
+ }
828
+ const discarded = this._queue.length
829
+ this._queue = []
830
+ // The head batch left with the queue, so its budget goes too — anything
831
+ // queued after consent returns is a different batch.
832
+ this._resetHeadBatchBudget()
833
+ this._recordDrop(discarded, 'the user has opted out')
834
+ this._warnAboutDrops()
835
+ return discarded
836
+ }
837
+
838
+ /** Returns how many spans it removed from the queue, sent or dropped. */
839
+ private async _flushInner(): Promise<number> {
840
+ if (!this._queue.length) {
841
+ return 0
842
+ }
843
+
844
+ const discardedBeforeDrain = this._discardQueueIfConsentWithdrawn()
845
+ if (discardedBeforeDrain) {
846
+ return discardedBeforeDrain
847
+ }
848
+
849
+ // Bounded like span attributes: resource attributes are caller-supplied too,
850
+ // and they ride on every batch rather than on one span.
851
+ const resourceAttributes = truncateAttributes(
852
+ buildTracesResourceAttributes(this._config, this._instance.getLibraryId(), this._instance.getLibraryVersion()),
853
+ this._config.maxAttributeValueLength
854
+ )
855
+ const scopeName = this._instance.getLibraryId()
856
+ const scopeVersion = this._instance.getLibraryVersion()
857
+
858
+ // Bounded by queue depth at flush start, so mid-drain arrivals ride the next flush.
859
+ let remaining = this._queue.length
860
+ let removed = 0
861
+ // Splits this drain only. A batch the SDK measured as too large says nothing
862
+ // about the ones after the oversized span is gone, so the cap kept between
863
+ // drains stays where it is and the next one starts at full size.
864
+ let localCap = Number.POSITIVE_INFINITY
865
+ const generation = this._generation
866
+
867
+ try {
868
+ while (remaining > 0 && this._queue.length > 0) {
869
+ // Re-checked per batch: a send suspends, so the user can opt out while one
870
+ // batch is in flight and the batches behind it would still export.
871
+ const discardedMidDrain = this._discardQueueIfConsentWithdrawn()
872
+ if (discardedMidDrain) {
873
+ return removed + discardedMidDrain
874
+ }
875
+
876
+ // Floor at one, or a non-positive batch size loops forever on an empty batch.
877
+ const cap =
878
+ this._headBatchFailures > 0
879
+ ? Math.min(this._maxExportBatchSize, this._headBatchSize)
880
+ : this._maxExportBatchSize
881
+ const size = Math.max(1, Math.min(cap, localCap, remaining, this._queue.length))
882
+ const batch = this._queue.slice(0, size)
883
+ const spans = this._encodeBatch(batch)
884
+
885
+ if (!spans.length) {
886
+ // Nothing survived encoding; drop the batch rather than re-encoding it forever.
887
+ this._queue.splice(0, size)
888
+ remaining -= size
889
+ removed += size
890
+ this._resetHeadBatchBudget()
891
+ continue
892
+ }
893
+
894
+ // Read before the send, so the budget below charges this attempt against
895
+ // the window it was actually made under. A send inside an open
896
+ // `Retry-After` window is caller-driven and exempt from the wait; a later
897
+ // refusal can extend that window past the charge point, so both are read.
898
+ const chargeable = clockNow() >= this._headBatchChargeableAt && !this._retryAfter.isOpen()
899
+
900
+ const outcome = await this._instance._sendTracesBatch(
901
+ buildOtlpTracesPayload(spans, resourceAttributes, scopeName, scopeVersion, this._logger)
902
+ )
903
+
904
+ if (generation !== this._generation) {
905
+ // reset() ran mid-send: this pass no longer owns the queue.
906
+ return removed
907
+ }
908
+
909
+ this._retryAfter.record(outcome)
910
+
911
+ if (outcome.kind === 'ok') {
912
+ this._consecutiveFlushFailures = 0
913
+ this._flushJitter = NO_JITTER
914
+ this._resetHeadBatchBudget()
915
+ this._queue.splice(0, size)
916
+ remaining -= size
917
+ removed += size
918
+ // Ramp back toward the configured max after a 413 shrink.
919
+ if (this._maxExportBatchSize < this._config.maxExportBatchSize) {
920
+ this._maxExportBatchSize++
921
+ }
922
+ continue
923
+ }
924
+
925
+ if (outcome.kind === 'too-large') {
926
+ if (size === 1) {
927
+ // A single span the server won't accept at any size; drop it or it wedges the queue.
928
+ this._queue.splice(0, 1)
929
+ remaining -= 1
930
+ removed += 1
931
+ this._recordDrop(1, 'it is too large for the ingestion endpoint')
932
+ this._consecutiveFlushFailures = 0
933
+ this._flushJitter = NO_JITTER
934
+ this._resetHeadBatchBudget()
935
+ continue
936
+ }
937
+ // Halve the batch that was refused, not the configured maximum: when the
938
+ // queue is shallower than the maximum, shrinking it resends an identical body.
939
+ const halved = Math.max(1, Math.floor(size / 2))
940
+ if (outcome.measuredLocally) {
941
+ localCap = halved
942
+ } else {
943
+ this._maxExportBatchSize = halved
944
+ }
945
+ // A different batch from here on, so its budget starts fresh.
946
+ this._resetHeadBatchBudget()
947
+ this._logger.debug(`Batch too large; retrying the same spans in batches of ${halved}`)
948
+ continue
949
+ }
950
+
951
+ if (outcome.kind === 'retry-later') {
952
+ this._consecutiveFlushFailures++
953
+ this._flushJitter = drawJitter()
954
+ // One charge per backoff window: a refusal arriving before the window
955
+ // the last charge bought has elapsed is the same refusal seen again,
956
+ // not new evidence against the batch.
957
+ this._headBatchSize = size
958
+ if (chargeable) {
959
+ this._headBatchFailures++
960
+ this._headBatchChargeableAt = clockNow() + this._nextFlushDelay()
961
+ }
962
+ if (this._headBatchFailures < MAX_RETRIES_PER_BATCH) {
963
+ // Keep the spans queued; the flush timer picks them up again.
964
+ this._logger.debug('Span export failed; retrying on the next flush', outcome.error)
965
+ return removed
966
+ }
967
+ // Out of retries. Drop this batch and start clean on the next one, so a
968
+ // permanently failing head cannot hold the queue against fresher spans.
969
+ this._queue.splice(0, size)
970
+ remaining -= size
971
+ removed += size
972
+ this._consecutiveFlushFailures = 0
973
+ this._flushJitter = NO_JITTER
974
+ this._resetHeadBatchBudget()
975
+ this._recordDrop(size, `the ingestion endpoint failed ${MAX_RETRIES_PER_BATCH} times in a row`)
976
+ if (this._retryAfter.isOpen()) {
977
+ // Retiring the batch cleared the failure counters, so this drain
978
+ // would carry straight on to the next batch inside the endpoint's
979
+ // wait. Ending the pass here costs one send rather than the rest of
980
+ // the queue: `flush()` sees a non-zero count and loops, so the next
981
+ // batch still goes out, one pass later.
982
+ return removed
983
+ }
984
+ continue
985
+ }
986
+
987
+ // Non-retriable (poison batch or bad key); drop it so it can't wedge the queue.
988
+ this._logger.debug('Dropping a span batch the ingestion endpoint rejected', outcome.error)
989
+ this._queue.splice(0, size)
990
+ remaining -= size
991
+ removed += size
992
+ this._consecutiveFlushFailures = 0
993
+ this._flushJitter = NO_JITTER
994
+ this._resetHeadBatchBudget()
995
+ this._recordDrop(size, 'the ingestion endpoint rejected the batch')
996
+ }
997
+
998
+ return removed
999
+ } finally {
1000
+ // Every exit path, so a queue-full drop during an outage still surfaces —
1001
+ // the retriable branch returns early.
1002
+ this._warnAboutDrops()
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * One background drain at a time. `flush()` is a multi-pass loop that keeps
1008
+ * going while the queue stays above the batch size, so a trigger per span end
1009
+ * would stack a loop per span on a busy service — each retaining its frames.
1010
+ */
1011
+ private _flushInBackground(): void {
1012
+ if (this._backgroundFlush) {
1013
+ return
1014
+ }
1015
+ this._backgroundFlush = this.flush()
1016
+ .catch((error) => {
1017
+ // Background flushes have no caller to surface to; an explicit flush()
1018
+ // still rejects.
1019
+ this._logger.debug('Background span flush failed', error)
1020
+ })
1021
+ .finally(() => {
1022
+ this._backgroundFlush = undefined
1023
+ // A trigger that arrived while this drain was finishing found the guard
1024
+ // set and the queue empty, so neither path armed a timer.
1025
+ this._armFlushTimerIfQueuedNoEarlierThan()
1026
+ })
1027
+ }
1028
+
1029
+ // Every span end can reach this, so a pending timer is left alone rather than
1030
+ // pushing the flush out.
1031
+ private _armFlushTimerIfQueued(): void {
1032
+ if (this._flushTimer.pending || !this._queue.length) {
1033
+ return
1034
+ }
1035
+ this._flushTimer.arm(this._nextFlushDelay())
1036
+ }
1037
+
1038
+ // Both floors, so a timer a span end armed at the plain interval gives way to
1039
+ // a longer one.
1040
+ private _armFlushTimerIfQueuedNoEarlierThan(): void {
1041
+ if (!this._queue.length) {
1042
+ return
1043
+ }
1044
+ this._flushTimer.armNoEarlierThan(this._nextFlushDelay())
1045
+ }
1046
+
1047
+ // Retry delay: base interval, doubling, capped at 30s — never below an interval
1048
+ // a host configured above the cap. The jitter is drawn once per failure, so the
1049
+ // timer and the retry-budget charge point are measured against the same delay.
1050
+ private _nextFlushDelay(): number {
1051
+ // A floor, not a replacement: the header never retries us sooner than our
1052
+ // own backoff would have.
1053
+ return Math.max(
1054
+ backoffDelayMs(
1055
+ this._config.flushIntervalMs,
1056
+ this._consecutiveFlushFailures,
1057
+ this._flushJitter,
1058
+ MAX_FLUSH_BACKOFF_MS
1059
+ ),
1060
+ this._retryAfter.remainingMs()
1061
+ )
1062
+ }
1063
+ }