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