@zuplo/cli 7.7.8 → 7.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@posthog/core/dist/error-tracking/parsers/base.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/error-tracking/parsers/base.js +19 -2
- package/node_modules/@posthog/core/dist/error-tracking/parsers/base.mjs +19 -2
- package/node_modules/@posthog/core/dist/index.d.ts +7 -0
- package/node_modules/@posthog/core/dist/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/index.js +82 -21
- package/node_modules/@posthog/core/dist/index.mjs +4 -0
- package/node_modules/@posthog/core/dist/logs/index.d.ts +3 -2
- package/node_modules/@posthog/core/dist/logs/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/logs/index.js +28 -22
- package/node_modules/@posthog/core/dist/logs/index.mjs +29 -23
- package/node_modules/@posthog/core/dist/logs/logs-utils.js +1 -1
- package/node_modules/@posthog/core/dist/logs/logs-utils.mjs +2 -2
- package/node_modules/@posthog/core/dist/logs/types.d.ts +2 -2
- package/node_modules/@posthog/core/dist/metrics/index.d.ts +5 -2
- package/node_modules/@posthog/core/dist/metrics/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/metrics/index.js +28 -14
- package/node_modules/@posthog/core/dist/metrics/index.mjs +29 -15
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.js +1 -2
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.mjs +2 -3
- package/node_modules/@posthog/core/dist/metrics/types.d.ts +7 -0
- package/node_modules/@posthog/core/dist/metrics/types.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts +32 -4
- package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/posthog-core-stateless.js +68 -20
- package/node_modules/@posthog/core/dist/posthog-core-stateless.mjs +68 -20
- package/node_modules/@posthog/core/dist/traces/config.d.ts +13 -0
- package/node_modules/@posthog/core/dist/traces/config.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/config.js +101 -0
- package/node_modules/@posthog/core/dist/traces/config.mjs +63 -0
- package/node_modules/@posthog/core/dist/traces/context.d.ts +18 -0
- package/node_modules/@posthog/core/dist/traces/context.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/context.js +53 -0
- package/node_modules/@posthog/core/dist/traces/context.mjs +15 -0
- package/node_modules/@posthog/core/dist/traces/ids.d.ts +12 -0
- package/node_modules/@posthog/core/dist/traces/ids.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/ids.js +92 -0
- package/node_modules/@posthog/core/dist/traces/ids.mjs +42 -0
- package/node_modules/@posthog/core/dist/traces/index.d.ts +150 -0
- package/node_modules/@posthog/core/dist/traces/index.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/index.js +551 -0
- package/node_modules/@posthog/core/dist/traces/index.mjs +513 -0
- package/node_modules/@posthog/core/dist/traces/otlp.d.ts +24 -0
- package/node_modules/@posthog/core/dist/traces/otlp.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/otlp.js +158 -0
- package/node_modules/@posthog/core/dist/traces/otlp.mjs +108 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.d.ts +32 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.js +93 -0
- package/node_modules/@posthog/core/dist/traces/sanitize.mjs +43 -0
- package/node_modules/@posthog/core/dist/traces/span.d.ts +189 -0
- package/node_modules/@posthog/core/dist/traces/span.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/span.js +533 -0
- package/node_modules/@posthog/core/dist/traces/span.mjs +462 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.d.ts +51 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.js +116 -0
- package/node_modules/@posthog/core/dist/traces/traceparent.mjs +63 -0
- package/node_modules/@posthog/core/dist/traces/types.d.ts +114 -0
- package/node_modules/@posthog/core/dist/traces/types.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/traces/types.js +18 -0
- package/node_modules/@posthog/core/dist/traces/types.mjs +1 -0
- package/node_modules/@posthog/core/dist/utils/backoff.d.ts +23 -0
- package/node_modules/@posthog/core/dist/utils/backoff.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/utils/backoff.js +65 -0
- package/node_modules/@posthog/core/dist/utils/backoff.mjs +14 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts +26 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.js +67 -0
- package/node_modules/@posthog/core/dist/utils/flush-timer.mjs +29 -0
- package/node_modules/@posthog/core/dist/utils/json-utils.d.ts +7 -0
- package/node_modules/@posthog/core/dist/utils/json-utils.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/utils/json-utils.js +27 -0
- package/node_modules/@posthog/core/dist/utils/json-utils.mjs +25 -1
- package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts +13 -2
- package/node_modules/@posthog/core/dist/utils/otlp-resource.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/utils/otlp-resource.js +34 -7
- package/node_modules/@posthog/core/dist/utils/otlp-resource.mjs +26 -2
- package/node_modules/@posthog/core/dist/utils/retry-after.d.ts +55 -0
- package/node_modules/@posthog/core/dist/utils/retry-after.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/utils/retry-after.js +95 -0
- package/node_modules/@posthog/core/dist/utils/retry-after.mjs +50 -0
- package/node_modules/@posthog/core/package.json +3 -3
- package/node_modules/@posthog/core/src/error-tracking/parsers/base.spec.ts +77 -7
- package/node_modules/@posthog/core/src/error-tracking/parsers/base.ts +42 -19
- package/node_modules/@posthog/core/src/index.ts +18 -0
- package/node_modules/@posthog/core/src/logs/index.spec.ts +490 -2
- package/node_modules/@posthog/core/src/logs/index.ts +63 -31
- package/node_modules/@posthog/core/src/logs/logs-utils.ts +2 -2
- package/node_modules/@posthog/core/src/logs/types.ts +2 -2
- package/node_modules/@posthog/core/src/metrics/index.spec.ts +304 -0
- package/node_modules/@posthog/core/src/metrics/index.ts +53 -16
- package/node_modules/@posthog/core/src/metrics/metrics-utils.ts +2 -3
- package/node_modules/@posthog/core/src/metrics/types.ts +10 -2
- package/node_modules/@posthog/core/src/posthog-core-stateless.ts +157 -38
- package/node_modules/@posthog/core/src/traces/config.spec.ts +271 -0
- package/node_modules/@posthog/core/src/traces/config.ts +142 -0
- package/node_modules/@posthog/core/src/traces/context.ts +30 -0
- package/node_modules/@posthog/core/src/traces/ids.spec.ts +115 -0
- package/node_modules/@posthog/core/src/traces/ids.ts +75 -0
- package/node_modules/@posthog/core/src/traces/index.spec.ts +3187 -0
- package/node_modules/@posthog/core/src/traces/index.ts +1060 -0
- package/node_modules/@posthog/core/src/traces/live-spans.spec.ts +69 -0
- package/node_modules/@posthog/core/src/traces/otlp.spec.ts +368 -0
- package/node_modules/@posthog/core/src/traces/otlp.ts +191 -0
- package/node_modules/@posthog/core/src/traces/sanitize.ts +116 -0
- package/node_modules/@posthog/core/src/traces/span.spec.ts +1122 -0
- package/node_modules/@posthog/core/src/traces/span.ts +822 -0
- package/node_modules/@posthog/core/src/traces/traceparent.spec.ts +184 -0
- package/node_modules/@posthog/core/src/traces/traceparent.ts +172 -0
- package/node_modules/@posthog/core/src/traces/types.ts +140 -0
- package/node_modules/@posthog/core/src/utils/backoff.ts +44 -0
- package/node_modules/@posthog/core/src/utils/flush-timer.ts +50 -0
- package/node_modules/@posthog/core/src/utils/json-utils.ts +33 -0
- package/node_modules/@posthog/core/src/utils/otlp-resource.spec.ts +55 -9
- package/node_modules/@posthog/core/src/utils/otlp-resource.ts +45 -4
- package/node_modules/@posthog/core/src/utils/retry-after.ts +135 -0
- package/node_modules/@posthog/types/dist/index.d.ts +1 -0
- package/node_modules/@posthog/types/dist/index.d.ts.map +1 -1
- package/node_modules/@posthog/types/dist/posthog-config.d.ts +10 -5
- package/node_modules/@posthog/types/dist/posthog-config.d.ts.map +1 -1
- package/node_modules/@posthog/types/dist/traces.d.ts +387 -0
- package/node_modules/@posthog/types/dist/traces.d.ts.map +1 -0
- package/node_modules/@posthog/types/dist/traces.js +18 -0
- package/node_modules/@posthog/types/dist/traces.mjs +1 -0
- package/node_modules/@posthog/types/package.json +1 -1
- package/node_modules/@posthog/types/src/index.ts +19 -0
- package/node_modules/@posthog/types/src/posthog-config.ts +10 -5
- package/node_modules/@posthog/types/src/traces.ts +429 -0
- package/node_modules/@zuplo/core/package.json +1 -1
- package/node_modules/@zuplo/graphql/package.json +1 -1
- package/node_modules/@zuplo/openapi-tools/package.json +1 -1
- package/node_modules/@zuplo/otel/package.json +1 -1
- package/node_modules/@zuplo/runtime/out/esm/{chunk-DRQ5CMVC.js → chunk-KNAYKBEE.js} +80 -80
- package/node_modules/@zuplo/runtime/out/esm/chunk-KNAYKBEE.js.map +1 -0
- package/node_modules/@zuplo/runtime/out/esm/index.js +1 -1
- package/node_modules/@zuplo/runtime/out/esm/mcp-gateway/index.js +1 -1
- package/node_modules/@zuplo/runtime/out/esm/mocks/index.js +1 -1
- package/node_modules/@zuplo/runtime/package.json +1 -1
- package/package.json +11 -11
- package/node_modules/@zuplo/runtime/out/esm/chunk-DRQ5CMVC.js.map +0 -1
- /package/node_modules/@zuplo/runtime/out/esm/{chunk-DRQ5CMVC.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
|
+
}
|