@mandujs/core 0.41.2 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
|
@@ -1,694 +1,694 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 18.θ — OpenTelemetry-compatible request tracing.
|
|
3
|
-
*
|
|
4
|
-
* Hand-rolled W3C Trace Context + OTLP/HTTP encoder. Zero runtime
|
|
5
|
-
* dependencies (AsyncLocalStorage comes from `node:async_hooks`, which
|
|
6
|
-
* Bun ships natively).
|
|
7
|
-
*
|
|
8
|
-
* Public API:
|
|
9
|
-
* - {@link Tracer} — factory for root + child spans
|
|
10
|
-
* - {@link Span} — lifecycle + attribute API (`end`, `setAttribute`, `setStatus`)
|
|
11
|
-
* - {@link parseTraceparent} / {@link formatTraceparent} — W3C wire format
|
|
12
|
-
* - {@link getActiveSpan} — AsyncLocalStorage accessor
|
|
13
|
-
* - {@link runWithSpan} — bind a span to the async context of a callback
|
|
14
|
-
* - {@link injectTraceContext} — stamp `traceparent` on outgoing `Headers`
|
|
15
|
-
* - {@link ConsoleSpanExporter} / {@link OtlpHttpSpanExporter}
|
|
16
|
-
*
|
|
17
|
-
* Design:
|
|
18
|
-
*
|
|
19
|
-
* 1. Tracing is **off by default**. An explicit
|
|
20
|
-
* `{ enabled: true }` on the config (or `MANDU_OTEL_ENDPOINT` env)
|
|
21
|
-
* flips it on; otherwise `startSpan()` returns a no-op span and the
|
|
22
|
-
* hot path is branch-free.
|
|
23
|
-
*
|
|
24
|
-
* 2. Span ids / trace ids are generated via `crypto.getRandomValues`
|
|
25
|
-
* per the W3C Trace Context spec — 16 bytes for trace-id (hex 32
|
|
26
|
-
* chars), 8 bytes for span-id (hex 16 chars). The "all zero" case
|
|
27
|
-
* forbidden by the spec is retried (probability ≈ 2^-128).
|
|
28
|
-
*
|
|
29
|
-
* 3. Parent context comes from the incoming `traceparent` header.
|
|
30
|
-
* If missing / malformed, a new root trace-id is minted. On
|
|
31
|
-
* outgoing fetches, {@link injectTraceContext} re-emits the current
|
|
32
|
-
* context so downstream services see the same trace-id.
|
|
33
|
-
*
|
|
34
|
-
* 4. AsyncLocalStorage keeps the active span in scope across
|
|
35
|
-
* `await`s, timers, and nested callbacks — without threading an
|
|
36
|
-
* explicit `ctx` parameter through every function. Works in Bun
|
|
37
|
-
* 1.1+ and Node ≥ 16.
|
|
38
|
-
*
|
|
39
|
-
* 5. OTLP/HTTP exporter serialises spans to the JSON encoding of the
|
|
40
|
-
* `ExportTraceServiceRequest` protobuf (OTLP v1). Honeycomb,
|
|
41
|
-
* Grafana Tempo, and the OTel Collector all accept this over HTTP
|
|
42
|
-
* with `Content-Type: application/json`.
|
|
43
|
-
*/
|
|
44
|
-
|
|
45
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
46
|
-
|
|
47
|
-
// ─── W3C Trace Context ──────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Parsed W3C `traceparent` header. The spec defines exactly four dash-
|
|
51
|
-
* separated fields; anything else yields `null`.
|
|
52
|
-
*
|
|
53
|
-
* @see https://www.w3.org/TR/trace-context/#traceparent-header
|
|
54
|
-
*/
|
|
55
|
-
export interface TraceparentFields {
|
|
56
|
-
/** 2-hex-char version. Always `"00"` for v1. */
|
|
57
|
-
version: string;
|
|
58
|
-
/** 32-hex-char trace-id (128-bit). */
|
|
59
|
-
traceId: string;
|
|
60
|
-
/** 16-hex-char span-id (64-bit) of the *parent* span. */
|
|
61
|
-
parentId: string;
|
|
62
|
-
/** 2-hex-char flags byte. Bit 0 = sampled. */
|
|
63
|
-
flags: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const TRACEPARENT_REGEX =
|
|
67
|
-
/^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Parse a `traceparent` header. Returns `null` for any header that does
|
|
71
|
-
* not match the v1 grammar, including the all-zero trace-id / span-id
|
|
72
|
-
* cases forbidden by the spec.
|
|
73
|
-
*/
|
|
74
|
-
export function parseTraceparent(value: string | null | undefined): TraceparentFields | null {
|
|
75
|
-
if (!value) return null;
|
|
76
|
-
const m = TRACEPARENT_REGEX.exec(value.trim());
|
|
77
|
-
if (!m) return null;
|
|
78
|
-
const [, version, traceId, parentId, flags] = m;
|
|
79
|
-
if (version === "ff") return null; // reserved
|
|
80
|
-
if (/^0+$/.test(traceId)) return null;
|
|
81
|
-
if (/^0+$/.test(parentId)) return null;
|
|
82
|
-
return { version, traceId, parentId, flags };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Format a `traceparent` header for downstream propagation.
|
|
87
|
-
*
|
|
88
|
-
* `flags` defaults to `"01"` (sampled) so the entire trace survives
|
|
89
|
-
* middle hops. Pass `"00"` to mark the span as un-sampled.
|
|
90
|
-
*/
|
|
91
|
-
export function formatTraceparent(traceId: string, spanId: string, flags: string = "01"): string {
|
|
92
|
-
return `00-${traceId}-${spanId}-${flags}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ─── ID generation ──────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
function randomHex(bytes: number): string {
|
|
98
|
-
const buf = new Uint8Array(bytes);
|
|
99
|
-
crypto.getRandomValues(buf);
|
|
100
|
-
let out = "";
|
|
101
|
-
for (let i = 0; i < buf.length; i++) {
|
|
102
|
-
out += buf[i].toString(16).padStart(2, "0");
|
|
103
|
-
}
|
|
104
|
-
return out;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Mint a fresh 128-bit W3C-compliant trace-id. Never all-zero. */
|
|
108
|
-
export function newTraceId(): string {
|
|
109
|
-
for (;;) {
|
|
110
|
-
const id = randomHex(16);
|
|
111
|
-
if (!/^0+$/.test(id)) return id;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Mint a fresh 64-bit W3C-compliant span-id. Never all-zero. */
|
|
116
|
-
export function newSpanId(): string {
|
|
117
|
-
for (;;) {
|
|
118
|
-
const id = randomHex(8);
|
|
119
|
-
if (!/^0+$/.test(id)) return id;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ─── Span / Tracer ──────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
export type SpanStatus = "unset" | "ok" | "error";
|
|
126
|
-
|
|
127
|
-
export type SpanKind = "internal" | "server" | "client" | "producer" | "consumer";
|
|
128
|
-
|
|
129
|
-
export interface SpanAttributes {
|
|
130
|
-
[key: string]: string | number | boolean | null | undefined;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export interface SpanOptions {
|
|
134
|
-
/** Optional span kind. Default: `"internal"`. Root spans use `"server"`. */
|
|
135
|
-
kind?: SpanKind;
|
|
136
|
-
/** Attributes set at span start. Additional attributes can be set via {@link Span.setAttribute}. */
|
|
137
|
-
attributes?: SpanAttributes;
|
|
138
|
-
/**
|
|
139
|
-
* Override the parent span-id. Default: the currently-active span
|
|
140
|
-
* (from AsyncLocalStorage). Use `null` to force a root span.
|
|
141
|
-
*/
|
|
142
|
-
parent?: Span | null;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* A single span in the trace tree. Always lifecycle-matched with
|
|
147
|
-
* exactly one {@link Span.end} call — the Tracer's exporter flushes
|
|
148
|
-
* the span only after `end()` returns.
|
|
149
|
-
*/
|
|
150
|
-
export interface Span {
|
|
151
|
-
readonly traceId: string;
|
|
152
|
-
readonly spanId: string;
|
|
153
|
-
readonly parentSpanId: string | undefined;
|
|
154
|
-
readonly name: string;
|
|
155
|
-
readonly kind: SpanKind;
|
|
156
|
-
readonly startTimeMs: number;
|
|
157
|
-
readonly attributes: SpanAttributes;
|
|
158
|
-
/** `-1` until {@link end} is called. */
|
|
159
|
-
readonly endTimeMs: number;
|
|
160
|
-
readonly status: SpanStatus;
|
|
161
|
-
readonly errorMessage: string | undefined;
|
|
162
|
-
/** `false` for the no-op tracer (feature disabled). */
|
|
163
|
-
readonly recording: boolean;
|
|
164
|
-
|
|
165
|
-
setAttribute(key: string, value: string | number | boolean | null | undefined): void;
|
|
166
|
-
setAttributes(attrs: SpanAttributes): void;
|
|
167
|
-
setStatus(status: "ok" | "error", errorMessage?: string): void;
|
|
168
|
-
end(): void;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Internal mutable state for a recording span. */
|
|
172
|
-
class RecordingSpan implements Span {
|
|
173
|
-
readonly traceId: string;
|
|
174
|
-
readonly spanId: string;
|
|
175
|
-
readonly parentSpanId: string | undefined;
|
|
176
|
-
readonly name: string;
|
|
177
|
-
readonly kind: SpanKind;
|
|
178
|
-
readonly startTimeMs: number;
|
|
179
|
-
readonly attributes: SpanAttributes;
|
|
180
|
-
endTimeMs: number = -1;
|
|
181
|
-
status: SpanStatus = "unset";
|
|
182
|
-
errorMessage: string | undefined;
|
|
183
|
-
readonly recording = true;
|
|
184
|
-
private ended = false;
|
|
185
|
-
|
|
186
|
-
constructor(
|
|
187
|
-
private readonly tracer: Tracer,
|
|
188
|
-
opts: {
|
|
189
|
-
traceId: string;
|
|
190
|
-
spanId: string;
|
|
191
|
-
parentSpanId: string | undefined;
|
|
192
|
-
name: string;
|
|
193
|
-
kind: SpanKind;
|
|
194
|
-
attributes: SpanAttributes;
|
|
195
|
-
}
|
|
196
|
-
) {
|
|
197
|
-
this.traceId = opts.traceId;
|
|
198
|
-
this.spanId = opts.spanId;
|
|
199
|
-
this.parentSpanId = opts.parentSpanId;
|
|
200
|
-
this.name = opts.name;
|
|
201
|
-
this.kind = opts.kind;
|
|
202
|
-
this.startTimeMs = nowMs();
|
|
203
|
-
this.attributes = { ...opts.attributes };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
setAttribute(key: string, value: string | number | boolean | null | undefined): void {
|
|
207
|
-
if (this.ended) return;
|
|
208
|
-
this.attributes[key] = value;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
setAttributes(attrs: SpanAttributes): void {
|
|
212
|
-
if (this.ended) return;
|
|
213
|
-
Object.assign(this.attributes, attrs);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
setStatus(status: "ok" | "error", errorMessage?: string): void {
|
|
217
|
-
if (this.ended) return;
|
|
218
|
-
this.status = status;
|
|
219
|
-
if (status === "error" && errorMessage) this.errorMessage = errorMessage;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
end(): void {
|
|
223
|
-
if (this.ended) return;
|
|
224
|
-
this.ended = true;
|
|
225
|
-
this.endTimeMs = nowMs();
|
|
226
|
-
this.tracer._onSpanEnd(this);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** No-op span when tracing is disabled. Zero allocations on hot path. */
|
|
231
|
-
const NOOP_SPAN: Span = Object.freeze({
|
|
232
|
-
traceId: "00000000000000000000000000000000",
|
|
233
|
-
spanId: "0000000000000000",
|
|
234
|
-
parentSpanId: undefined,
|
|
235
|
-
name: "",
|
|
236
|
-
kind: "internal" as SpanKind,
|
|
237
|
-
startTimeMs: 0,
|
|
238
|
-
attributes: {} as SpanAttributes,
|
|
239
|
-
endTimeMs: 0,
|
|
240
|
-
status: "unset" as SpanStatus,
|
|
241
|
-
errorMessage: undefined,
|
|
242
|
-
recording: false,
|
|
243
|
-
setAttribute: () => {},
|
|
244
|
-
setAttributes: () => {},
|
|
245
|
-
setStatus: () => {},
|
|
246
|
-
end: () => {},
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
function nowMs(): number {
|
|
250
|
-
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
251
|
-
// performance.timeOrigin anchors the high-resolution clock to epoch ms,
|
|
252
|
-
// so exporters emit wall-clock timestamps rather than process-relative ones.
|
|
253
|
-
return performance.timeOrigin + performance.now();
|
|
254
|
-
}
|
|
255
|
-
return Date.now();
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ─── Exporters ──────────────────────────────────────────────────────────
|
|
259
|
-
|
|
260
|
-
export interface SpanExporter {
|
|
261
|
-
export(spans: Span[]): void | Promise<void>;
|
|
262
|
-
shutdown?(): void | Promise<void>;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Console exporter — pretty-prints each span to stderr on `end()`.
|
|
267
|
-
* Indentation reflects depth (via parent-span lookup in a short-lived
|
|
268
|
-
* Map). Used as the default in dev mode when no OTLP endpoint is set.
|
|
269
|
-
*/
|
|
270
|
-
export class ConsoleSpanExporter implements SpanExporter {
|
|
271
|
-
private readonly stream: { write: (chunk: string) => void };
|
|
272
|
-
private readonly depthByParent = new Map<string, number>();
|
|
273
|
-
private readonly depthBySpan = new Map<string, number>();
|
|
274
|
-
|
|
275
|
-
constructor(stream?: { write: (chunk: string) => void }) {
|
|
276
|
-
// process.stderr guarantees a `write(string)` signature in Bun + Node.
|
|
277
|
-
// Accepting an override keeps the class testable without capturing
|
|
278
|
-
// real TTY output.
|
|
279
|
-
this.stream =
|
|
280
|
-
stream ??
|
|
281
|
-
(typeof process !== "undefined" && process.stderr
|
|
282
|
-
? (process.stderr as unknown as { write: (chunk: string) => void })
|
|
283
|
-
: { write: (chunk) => console.error(chunk.trimEnd()) });
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
export(spans: Span[]): void {
|
|
287
|
-
for (const span of spans) {
|
|
288
|
-
const depth = this.resolveDepth(span);
|
|
289
|
-
const indent = " ".repeat(depth);
|
|
290
|
-
const durationMs = Math.max(0, span.endTimeMs - span.startTimeMs).toFixed(2);
|
|
291
|
-
const statusIcon =
|
|
292
|
-
span.status === "error" ? "✗" : span.status === "ok" ? "✓" : "·";
|
|
293
|
-
const idTag = `${span.traceId.slice(0, 8)}/${span.spanId.slice(0, 8)}`;
|
|
294
|
-
const attrSummary = formatAttrsShort(span.attributes);
|
|
295
|
-
const err = span.errorMessage ? ` error=${JSON.stringify(span.errorMessage)}` : "";
|
|
296
|
-
this.stream.write(
|
|
297
|
-
`[trace ${idTag}] ${indent}${statusIcon} ${span.name} ${durationMs}ms${attrSummary}${err}\n`
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Compute a nesting depth for the span by walking the parent chain we
|
|
304
|
-
* have already seen. The first span we see for a given trace starts at
|
|
305
|
-
* depth 0; each child adds one level of indent. Entries age out when
|
|
306
|
-
* the root span ends so the map stays bounded.
|
|
307
|
-
*/
|
|
308
|
-
private resolveDepth(span: Span): number {
|
|
309
|
-
const parent = span.parentSpanId;
|
|
310
|
-
if (!parent) {
|
|
311
|
-
this.depthBySpan.set(span.spanId, 0);
|
|
312
|
-
return 0;
|
|
313
|
-
}
|
|
314
|
-
const parentDepth = this.depthBySpan.get(parent) ?? 0;
|
|
315
|
-
const depth = parentDepth + 1;
|
|
316
|
-
this.depthBySpan.set(span.spanId, depth);
|
|
317
|
-
// Housekeeping: when a root span (depth 0) ends, evict its subtree.
|
|
318
|
-
if (parentDepth === 0 && this.depthBySpan.size > 512) {
|
|
319
|
-
this.depthBySpan.clear();
|
|
320
|
-
this.depthByParent.clear();
|
|
321
|
-
}
|
|
322
|
-
return depth;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function formatAttrsShort(attrs: SpanAttributes): string {
|
|
327
|
-
const keys = Object.keys(attrs);
|
|
328
|
-
if (keys.length === 0) return "";
|
|
329
|
-
const parts: string[] = [];
|
|
330
|
-
for (const key of keys) {
|
|
331
|
-
const v = attrs[key];
|
|
332
|
-
if (v === undefined || v === null) continue;
|
|
333
|
-
parts.push(`${key}=${typeof v === "string" ? JSON.stringify(v) : String(v)}`);
|
|
334
|
-
}
|
|
335
|
-
return parts.length ? ` [${parts.join(" ")}]` : "";
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* OTLP/HTTP JSON exporter. Produces an `ExportTraceServiceRequest`
|
|
340
|
-
* body and POSTs it to `<endpoint>/v1/traces`. Honeycomb, Grafana
|
|
341
|
-
* Tempo, AWS X-Ray (via OTel Collector), and the OpenTelemetry
|
|
342
|
-
* Collector all accept this wire format.
|
|
343
|
-
*
|
|
344
|
-
* Errors during export are logged to stderr but never throw — tracing
|
|
345
|
-
* must never break a request.
|
|
346
|
-
*/
|
|
347
|
-
export class OtlpHttpSpanExporter implements SpanExporter {
|
|
348
|
-
constructor(
|
|
349
|
-
private readonly endpoint: string,
|
|
350
|
-
private readonly serviceName: string,
|
|
351
|
-
private readonly headers: Record<string, string> = {}
|
|
352
|
-
) {}
|
|
353
|
-
|
|
354
|
-
async export(spans: Span[]): Promise<void> {
|
|
355
|
-
if (spans.length === 0) return;
|
|
356
|
-
const body = encodeOtlpJson(spans, this.serviceName);
|
|
357
|
-
const url = this.endpoint.endsWith("/v1/traces")
|
|
358
|
-
? this.endpoint
|
|
359
|
-
: `${this.endpoint.replace(/\/$/, "")}/v1/traces`;
|
|
360
|
-
try {
|
|
361
|
-
const res = await fetch(url, {
|
|
362
|
-
method: "POST",
|
|
363
|
-
headers: {
|
|
364
|
-
"Content-Type": "application/json",
|
|
365
|
-
...this.headers,
|
|
366
|
-
},
|
|
367
|
-
body: JSON.stringify(body),
|
|
368
|
-
});
|
|
369
|
-
if (!res.ok) {
|
|
370
|
-
if (typeof process !== "undefined" && process.stderr) {
|
|
371
|
-
process.stderr.write(
|
|
372
|
-
`[mandu/tracing] OTLP export failed: ${res.status} ${res.statusText}\n`
|
|
373
|
-
);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
} catch (err) {
|
|
377
|
-
if (typeof process !== "undefined" && process.stderr) {
|
|
378
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
379
|
-
process.stderr.write(`[mandu/tracing] OTLP export error: ${msg}\n`);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Encode an array of spans as the JSON shape of OTLP
|
|
387
|
-
* `ExportTraceServiceRequest`. Externally-visible for tests and for
|
|
388
|
-
* adapter authors who want to POST spans through a custom transport.
|
|
389
|
-
*/
|
|
390
|
-
export function encodeOtlpJson(spans: Span[], serviceName: string): Record<string, unknown> {
|
|
391
|
-
// Times in OTLP are unix-nano unsigned ints encoded as decimal strings.
|
|
392
|
-
const toNano = (ms: number): string => {
|
|
393
|
-
const nanos = BigInt(Math.max(0, Math.floor(ms * 1_000_000)));
|
|
394
|
-
return nanos.toString();
|
|
395
|
-
};
|
|
396
|
-
|
|
397
|
-
const statusCodeOtlp = (s: SpanStatus): number => {
|
|
398
|
-
// OTel canonical codes: 0 = UNSET, 1 = OK, 2 = ERROR.
|
|
399
|
-
if (s === "ok") return 1;
|
|
400
|
-
if (s === "error") return 2;
|
|
401
|
-
return 0;
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
const spanKindOtlp = (k: SpanKind): number => {
|
|
405
|
-
// OTel canonical kinds: 1 = INTERNAL, 2 = SERVER, 3 = CLIENT, 4 = PRODUCER, 5 = CONSUMER.
|
|
406
|
-
switch (k) {
|
|
407
|
-
case "server":
|
|
408
|
-
return 2;
|
|
409
|
-
case "client":
|
|
410
|
-
return 3;
|
|
411
|
-
case "producer":
|
|
412
|
-
return 4;
|
|
413
|
-
case "consumer":
|
|
414
|
-
return 5;
|
|
415
|
-
default:
|
|
416
|
-
return 1;
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
const otlpAttr = (key: string, value: string | number | boolean | null | undefined) => {
|
|
421
|
-
if (value === null || value === undefined) return null;
|
|
422
|
-
if (typeof value === "string") return { key, value: { stringValue: value } };
|
|
423
|
-
if (typeof value === "boolean") return { key, value: { boolValue: value } };
|
|
424
|
-
if (Number.isInteger(value)) return { key, value: { intValue: String(value) } };
|
|
425
|
-
return { key, value: { doubleValue: value } };
|
|
426
|
-
};
|
|
427
|
-
|
|
428
|
-
const otlpSpans = spans.map((s) => ({
|
|
429
|
-
traceId: s.traceId,
|
|
430
|
-
spanId: s.spanId,
|
|
431
|
-
parentSpanId: s.parentSpanId ?? "",
|
|
432
|
-
name: s.name,
|
|
433
|
-
kind: spanKindOtlp(s.kind),
|
|
434
|
-
startTimeUnixNano: toNano(s.startTimeMs),
|
|
435
|
-
endTimeUnixNano: toNano(s.endTimeMs > 0 ? s.endTimeMs : s.startTimeMs),
|
|
436
|
-
attributes: Object.entries(s.attributes)
|
|
437
|
-
.map(([k, v]) => otlpAttr(k, v))
|
|
438
|
-
.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
|
|
439
|
-
status: {
|
|
440
|
-
code: statusCodeOtlp(s.status),
|
|
441
|
-
...(s.errorMessage ? { message: s.errorMessage } : {}),
|
|
442
|
-
},
|
|
443
|
-
}));
|
|
444
|
-
|
|
445
|
-
return {
|
|
446
|
-
resourceSpans: [
|
|
447
|
-
{
|
|
448
|
-
resource: {
|
|
449
|
-
attributes: [
|
|
450
|
-
{ key: "service.name", value: { stringValue: serviceName } },
|
|
451
|
-
],
|
|
452
|
-
},
|
|
453
|
-
scopeSpans: [
|
|
454
|
-
{
|
|
455
|
-
scope: { name: "@mandujs/core", version: "1" },
|
|
456
|
-
spans: otlpSpans,
|
|
457
|
-
},
|
|
458
|
-
],
|
|
459
|
-
},
|
|
460
|
-
],
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// ─── AsyncLocalStorage context propagation ──────────────────────────────
|
|
465
|
-
|
|
466
|
-
const spanStorage = new AsyncLocalStorage<Span>();
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* Return the currently-active span, or `undefined` outside any
|
|
470
|
-
* `runWithSpan()` scope. Works across `await`s, timers, and nested
|
|
471
|
-
* callbacks.
|
|
472
|
-
*/
|
|
473
|
-
export function getActiveSpan(): Span | undefined {
|
|
474
|
-
return spanStorage.getStore();
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Bind a span to the AsyncLocalStorage context of `fn`. Inside (and
|
|
479
|
-
* downstream of) `fn`, {@link getActiveSpan} returns `span`. The span
|
|
480
|
-
* is NOT auto-ended — callers own lifecycle.
|
|
481
|
-
*/
|
|
482
|
-
export function runWithSpan<T>(span: Span, fn: () => T): T {
|
|
483
|
-
return spanStorage.run(span, fn);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Stamp the active span's trace context onto `headers` as a
|
|
488
|
-
* `traceparent` header so downstream services can join the trace.
|
|
489
|
-
* No-op when no active span OR the active span is the no-op span.
|
|
490
|
-
*/
|
|
491
|
-
export function injectTraceContext(headers: Headers, span: Span | undefined = getActiveSpan()): void {
|
|
492
|
-
if (!span || !span.recording) return;
|
|
493
|
-
headers.set("traceparent", formatTraceparent(span.traceId, span.spanId));
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// ─── Tracer ─────────────────────────────────────────────────────────────
|
|
497
|
-
|
|
498
|
-
export interface TracerConfig {
|
|
499
|
-
/** Off → `startSpan` returns no-op. Default: `false`. */
|
|
500
|
-
enabled?: boolean;
|
|
501
|
-
/** `"console"` (default) or `"otlp"`. */
|
|
502
|
-
exporter?: "console" | "otlp";
|
|
503
|
-
/** OTLP collector endpoint (e.g. `https://api.honeycomb.io`). Required for `"otlp"`. */
|
|
504
|
-
endpoint?: string;
|
|
505
|
-
/** OTLP headers (e.g. `{ 'x-honeycomb-team': 'KEY' }`). */
|
|
506
|
-
headers?: Record<string, string>;
|
|
507
|
-
/** `resource.service.name`. Default: `"mandu"`. */
|
|
508
|
-
serviceName?: string;
|
|
509
|
-
/** Inject a custom exporter (overrides `exporter` / `endpoint`). Used in tests. */
|
|
510
|
-
customExporter?: SpanExporter;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
export class Tracer {
|
|
514
|
-
readonly config: Required<
|
|
515
|
-
Omit<TracerConfig, "customExporter" | "endpoint" | "headers">
|
|
516
|
-
> & {
|
|
517
|
-
endpoint?: string;
|
|
518
|
-
headers: Record<string, string>;
|
|
519
|
-
customExporter?: SpanExporter;
|
|
520
|
-
};
|
|
521
|
-
private readonly exporter: SpanExporter | null;
|
|
522
|
-
|
|
523
|
-
constructor(config: TracerConfig = {}) {
|
|
524
|
-
this.config = {
|
|
525
|
-
enabled: config.enabled ?? false,
|
|
526
|
-
exporter: config.exporter ?? "console",
|
|
527
|
-
endpoint: config.endpoint,
|
|
528
|
-
headers: config.headers ?? {},
|
|
529
|
-
serviceName: config.serviceName ?? "mandu",
|
|
530
|
-
customExporter: config.customExporter,
|
|
531
|
-
};
|
|
532
|
-
if (!this.config.enabled) {
|
|
533
|
-
this.exporter = null;
|
|
534
|
-
} else if (config.customExporter) {
|
|
535
|
-
this.exporter = config.customExporter;
|
|
536
|
-
} else if (this.config.exporter === "otlp") {
|
|
537
|
-
if (!this.config.endpoint) {
|
|
538
|
-
// Misconfigured → fall back to console so dev still gets output.
|
|
539
|
-
this.exporter = new ConsoleSpanExporter();
|
|
540
|
-
} else {
|
|
541
|
-
this.exporter = new OtlpHttpSpanExporter(
|
|
542
|
-
this.config.endpoint,
|
|
543
|
-
this.config.serviceName,
|
|
544
|
-
this.config.headers
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
} else {
|
|
548
|
-
this.exporter = new ConsoleSpanExporter();
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/** Whether this tracer will record spans. */
|
|
553
|
-
get enabled(): boolean {
|
|
554
|
-
return this.config.enabled;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Start a new span. When tracing is disabled, returns the shared
|
|
559
|
-
* no-op span (zero allocations).
|
|
560
|
-
*
|
|
561
|
-
* Parent resolution order:
|
|
562
|
-
* 1. `opts.parent` if provided (use `null` to force a root span)
|
|
563
|
-
* 2. The active span from AsyncLocalStorage
|
|
564
|
-
* 3. None (root span)
|
|
565
|
-
*/
|
|
566
|
-
startSpan(name: string, opts: SpanOptions = {}): Span {
|
|
567
|
-
if (!this.config.enabled) return NOOP_SPAN;
|
|
568
|
-
const parent =
|
|
569
|
-
opts.parent === null
|
|
570
|
-
? undefined
|
|
571
|
-
: opts.parent ?? getActiveSpan();
|
|
572
|
-
const parentRecording = parent && parent.recording ? parent : undefined;
|
|
573
|
-
const traceId = parentRecording ? parentRecording.traceId : newTraceId();
|
|
574
|
-
return new RecordingSpan(this, {
|
|
575
|
-
traceId,
|
|
576
|
-
spanId: newSpanId(),
|
|
577
|
-
parentSpanId: parentRecording?.spanId,
|
|
578
|
-
name,
|
|
579
|
-
kind: opts.kind ?? (parentRecording ? "internal" : "server"),
|
|
580
|
-
attributes: opts.attributes ?? {},
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
/**
|
|
585
|
-
* Start a span from an incoming request's `traceparent` header. If
|
|
586
|
-
* the header is missing / malformed, a new root span is minted.
|
|
587
|
-
*/
|
|
588
|
-
startSpanFromRequest(name: string, req: Request, opts: Omit<SpanOptions, "parent"> = {}): Span {
|
|
589
|
-
if (!this.config.enabled) return NOOP_SPAN;
|
|
590
|
-
const tp = parseTraceparent(req.headers.get("traceparent"));
|
|
591
|
-
if (!tp) {
|
|
592
|
-
return this.startSpan(name, { ...opts, parent: null, kind: opts.kind ?? "server" });
|
|
593
|
-
}
|
|
594
|
-
return new RecordingSpan(this, {
|
|
595
|
-
traceId: tp.traceId,
|
|
596
|
-
spanId: newSpanId(),
|
|
597
|
-
parentSpanId: tp.parentId,
|
|
598
|
-
name,
|
|
599
|
-
kind: opts.kind ?? "server",
|
|
600
|
-
attributes: opts.attributes ?? {},
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
/**
|
|
605
|
-
* Run `fn` with `span` as the active span. Equivalent to
|
|
606
|
-
* {@link runWithSpan} but colocated with the tracer for DX.
|
|
607
|
-
*/
|
|
608
|
-
withSpan<T>(span: Span, fn: () => T): T {
|
|
609
|
-
return runWithSpan(span, fn);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/**
|
|
613
|
-
* Open a child span, invoke `fn`, and auto-end the span when the
|
|
614
|
-
* returned promise resolves (or rejects, with `status=error`).
|
|
615
|
-
*/
|
|
616
|
-
async span<T>(name: string, fn: (span: Span) => Promise<T> | T, opts: SpanOptions = {}): Promise<T> {
|
|
617
|
-
const span = this.startSpan(name, opts);
|
|
618
|
-
try {
|
|
619
|
-
const result = await runWithSpan(span, () => fn(span));
|
|
620
|
-
if (span.status === "unset") span.setStatus("ok");
|
|
621
|
-
return result;
|
|
622
|
-
} catch (err) {
|
|
623
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
624
|
-
span.setStatus("error", msg);
|
|
625
|
-
throw err;
|
|
626
|
-
} finally {
|
|
627
|
-
span.end();
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
/** @internal Called by {@link RecordingSpan.end}. */
|
|
632
|
-
_onSpanEnd(span: Span): void {
|
|
633
|
-
if (!this.exporter) return;
|
|
634
|
-
try {
|
|
635
|
-
void this.exporter.export([span]);
|
|
636
|
-
} catch {
|
|
637
|
-
// Exporter failures must never surface — swallow.
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/** Flush + release the underlying exporter. No-op if disabled. */
|
|
642
|
-
async shutdown(): Promise<void> {
|
|
643
|
-
if (this.exporter && typeof this.exporter.shutdown === "function") {
|
|
644
|
-
await this.exporter.shutdown();
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// ─── Default tracer ──────────────────────────────────────────────────────
|
|
650
|
-
|
|
651
|
-
let defaultTracer: Tracer = new Tracer({ enabled: false });
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Return the process-global tracer. Starts disabled; the runtime
|
|
655
|
-
* installs a configured tracer during `startServer()` when
|
|
656
|
-
* `observability.tracing.enabled` is true.
|
|
657
|
-
*/
|
|
658
|
-
export function getTracer(): Tracer {
|
|
659
|
-
return defaultTracer;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
/**
|
|
663
|
-
* Install a tracer as the process-global. Idempotent — a subsequent
|
|
664
|
-
* call replaces the previous tracer (the runtime calls this once at
|
|
665
|
-
* boot; tests use it to swap in a capturing exporter).
|
|
666
|
-
*/
|
|
667
|
-
export function setTracer(tracer: Tracer): void {
|
|
668
|
-
defaultTracer = tracer;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
* Reset the process-global tracer back to the disabled no-op tracer.
|
|
673
|
-
* Intended for tests; production code should not need this.
|
|
674
|
-
*/
|
|
675
|
-
export function resetTracer(): void {
|
|
676
|
-
defaultTracer = new Tracer({ enabled: false });
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* Build a tracer from a {@link TracerConfig}-shaped object, honouring
|
|
681
|
-
* the `MANDU_OTEL_ENDPOINT` env var as an override. Used by the
|
|
682
|
-
* runtime at `startServer()` time.
|
|
683
|
-
*/
|
|
684
|
-
export function createTracerFromConfig(cfg: TracerConfig | undefined): Tracer {
|
|
685
|
-
const envEndpoint =
|
|
686
|
-
typeof process !== "undefined" ? process.env?.MANDU_OTEL_ENDPOINT : undefined;
|
|
687
|
-
const resolved: TracerConfig = { ...(cfg ?? {}) };
|
|
688
|
-
if (envEndpoint) {
|
|
689
|
-
resolved.enabled = true;
|
|
690
|
-
resolved.exporter = "otlp";
|
|
691
|
-
resolved.endpoint = envEndpoint;
|
|
692
|
-
}
|
|
693
|
-
return new Tracer(resolved);
|
|
694
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Phase 18.θ — OpenTelemetry-compatible request tracing.
|
|
3
|
+
*
|
|
4
|
+
* Hand-rolled W3C Trace Context + OTLP/HTTP encoder. Zero runtime
|
|
5
|
+
* dependencies (AsyncLocalStorage comes from `node:async_hooks`, which
|
|
6
|
+
* Bun ships natively).
|
|
7
|
+
*
|
|
8
|
+
* Public API:
|
|
9
|
+
* - {@link Tracer} — factory for root + child spans
|
|
10
|
+
* - {@link Span} — lifecycle + attribute API (`end`, `setAttribute`, `setStatus`)
|
|
11
|
+
* - {@link parseTraceparent} / {@link formatTraceparent} — W3C wire format
|
|
12
|
+
* - {@link getActiveSpan} — AsyncLocalStorage accessor
|
|
13
|
+
* - {@link runWithSpan} — bind a span to the async context of a callback
|
|
14
|
+
* - {@link injectTraceContext} — stamp `traceparent` on outgoing `Headers`
|
|
15
|
+
* - {@link ConsoleSpanExporter} / {@link OtlpHttpSpanExporter}
|
|
16
|
+
*
|
|
17
|
+
* Design:
|
|
18
|
+
*
|
|
19
|
+
* 1. Tracing is **off by default**. An explicit
|
|
20
|
+
* `{ enabled: true }` on the config (or `MANDU_OTEL_ENDPOINT` env)
|
|
21
|
+
* flips it on; otherwise `startSpan()` returns a no-op span and the
|
|
22
|
+
* hot path is branch-free.
|
|
23
|
+
*
|
|
24
|
+
* 2. Span ids / trace ids are generated via `crypto.getRandomValues`
|
|
25
|
+
* per the W3C Trace Context spec — 16 bytes for trace-id (hex 32
|
|
26
|
+
* chars), 8 bytes for span-id (hex 16 chars). The "all zero" case
|
|
27
|
+
* forbidden by the spec is retried (probability ≈ 2^-128).
|
|
28
|
+
*
|
|
29
|
+
* 3. Parent context comes from the incoming `traceparent` header.
|
|
30
|
+
* If missing / malformed, a new root trace-id is minted. On
|
|
31
|
+
* outgoing fetches, {@link injectTraceContext} re-emits the current
|
|
32
|
+
* context so downstream services see the same trace-id.
|
|
33
|
+
*
|
|
34
|
+
* 4. AsyncLocalStorage keeps the active span in scope across
|
|
35
|
+
* `await`s, timers, and nested callbacks — without threading an
|
|
36
|
+
* explicit `ctx` parameter through every function. Works in Bun
|
|
37
|
+
* 1.1+ and Node ≥ 16.
|
|
38
|
+
*
|
|
39
|
+
* 5. OTLP/HTTP exporter serialises spans to the JSON encoding of the
|
|
40
|
+
* `ExportTraceServiceRequest` protobuf (OTLP v1). Honeycomb,
|
|
41
|
+
* Grafana Tempo, and the OTel Collector all accept this over HTTP
|
|
42
|
+
* with `Content-Type: application/json`.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
46
|
+
|
|
47
|
+
// ─── W3C Trace Context ──────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parsed W3C `traceparent` header. The spec defines exactly four dash-
|
|
51
|
+
* separated fields; anything else yields `null`.
|
|
52
|
+
*
|
|
53
|
+
* @see https://www.w3.org/TR/trace-context/#traceparent-header
|
|
54
|
+
*/
|
|
55
|
+
export interface TraceparentFields {
|
|
56
|
+
/** 2-hex-char version. Always `"00"` for v1. */
|
|
57
|
+
version: string;
|
|
58
|
+
/** 32-hex-char trace-id (128-bit). */
|
|
59
|
+
traceId: string;
|
|
60
|
+
/** 16-hex-char span-id (64-bit) of the *parent* span. */
|
|
61
|
+
parentId: string;
|
|
62
|
+
/** 2-hex-char flags byte. Bit 0 = sampled. */
|
|
63
|
+
flags: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TRACEPARENT_REGEX =
|
|
67
|
+
/^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse a `traceparent` header. Returns `null` for any header that does
|
|
71
|
+
* not match the v1 grammar, including the all-zero trace-id / span-id
|
|
72
|
+
* cases forbidden by the spec.
|
|
73
|
+
*/
|
|
74
|
+
export function parseTraceparent(value: string | null | undefined): TraceparentFields | null {
|
|
75
|
+
if (!value) return null;
|
|
76
|
+
const m = TRACEPARENT_REGEX.exec(value.trim());
|
|
77
|
+
if (!m) return null;
|
|
78
|
+
const [, version, traceId, parentId, flags] = m;
|
|
79
|
+
if (version === "ff") return null; // reserved
|
|
80
|
+
if (/^0+$/.test(traceId)) return null;
|
|
81
|
+
if (/^0+$/.test(parentId)) return null;
|
|
82
|
+
return { version, traceId, parentId, flags };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format a `traceparent` header for downstream propagation.
|
|
87
|
+
*
|
|
88
|
+
* `flags` defaults to `"01"` (sampled) so the entire trace survives
|
|
89
|
+
* middle hops. Pass `"00"` to mark the span as un-sampled.
|
|
90
|
+
*/
|
|
91
|
+
export function formatTraceparent(traceId: string, spanId: string, flags: string = "01"): string {
|
|
92
|
+
return `00-${traceId}-${spanId}-${flags}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── ID generation ──────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function randomHex(bytes: number): string {
|
|
98
|
+
const buf = new Uint8Array(bytes);
|
|
99
|
+
crypto.getRandomValues(buf);
|
|
100
|
+
let out = "";
|
|
101
|
+
for (let i = 0; i < buf.length; i++) {
|
|
102
|
+
out += buf[i].toString(16).padStart(2, "0");
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Mint a fresh 128-bit W3C-compliant trace-id. Never all-zero. */
|
|
108
|
+
export function newTraceId(): string {
|
|
109
|
+
for (;;) {
|
|
110
|
+
const id = randomHex(16);
|
|
111
|
+
if (!/^0+$/.test(id)) return id;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Mint a fresh 64-bit W3C-compliant span-id. Never all-zero. */
|
|
116
|
+
export function newSpanId(): string {
|
|
117
|
+
for (;;) {
|
|
118
|
+
const id = randomHex(8);
|
|
119
|
+
if (!/^0+$/.test(id)) return id;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Span / Tracer ──────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export type SpanStatus = "unset" | "ok" | "error";
|
|
126
|
+
|
|
127
|
+
export type SpanKind = "internal" | "server" | "client" | "producer" | "consumer";
|
|
128
|
+
|
|
129
|
+
export interface SpanAttributes {
|
|
130
|
+
[key: string]: string | number | boolean | null | undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SpanOptions {
|
|
134
|
+
/** Optional span kind. Default: `"internal"`. Root spans use `"server"`. */
|
|
135
|
+
kind?: SpanKind;
|
|
136
|
+
/** Attributes set at span start. Additional attributes can be set via {@link Span.setAttribute}. */
|
|
137
|
+
attributes?: SpanAttributes;
|
|
138
|
+
/**
|
|
139
|
+
* Override the parent span-id. Default: the currently-active span
|
|
140
|
+
* (from AsyncLocalStorage). Use `null` to force a root span.
|
|
141
|
+
*/
|
|
142
|
+
parent?: Span | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A single span in the trace tree. Always lifecycle-matched with
|
|
147
|
+
* exactly one {@link Span.end} call — the Tracer's exporter flushes
|
|
148
|
+
* the span only after `end()` returns.
|
|
149
|
+
*/
|
|
150
|
+
export interface Span {
|
|
151
|
+
readonly traceId: string;
|
|
152
|
+
readonly spanId: string;
|
|
153
|
+
readonly parentSpanId: string | undefined;
|
|
154
|
+
readonly name: string;
|
|
155
|
+
readonly kind: SpanKind;
|
|
156
|
+
readonly startTimeMs: number;
|
|
157
|
+
readonly attributes: SpanAttributes;
|
|
158
|
+
/** `-1` until {@link end} is called. */
|
|
159
|
+
readonly endTimeMs: number;
|
|
160
|
+
readonly status: SpanStatus;
|
|
161
|
+
readonly errorMessage: string | undefined;
|
|
162
|
+
/** `false` for the no-op tracer (feature disabled). */
|
|
163
|
+
readonly recording: boolean;
|
|
164
|
+
|
|
165
|
+
setAttribute(key: string, value: string | number | boolean | null | undefined): void;
|
|
166
|
+
setAttributes(attrs: SpanAttributes): void;
|
|
167
|
+
setStatus(status: "ok" | "error", errorMessage?: string): void;
|
|
168
|
+
end(): void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Internal mutable state for a recording span. */
|
|
172
|
+
class RecordingSpan implements Span {
|
|
173
|
+
readonly traceId: string;
|
|
174
|
+
readonly spanId: string;
|
|
175
|
+
readonly parentSpanId: string | undefined;
|
|
176
|
+
readonly name: string;
|
|
177
|
+
readonly kind: SpanKind;
|
|
178
|
+
readonly startTimeMs: number;
|
|
179
|
+
readonly attributes: SpanAttributes;
|
|
180
|
+
endTimeMs: number = -1;
|
|
181
|
+
status: SpanStatus = "unset";
|
|
182
|
+
errorMessage: string | undefined;
|
|
183
|
+
readonly recording = true;
|
|
184
|
+
private ended = false;
|
|
185
|
+
|
|
186
|
+
constructor(
|
|
187
|
+
private readonly tracer: Tracer,
|
|
188
|
+
opts: {
|
|
189
|
+
traceId: string;
|
|
190
|
+
spanId: string;
|
|
191
|
+
parentSpanId: string | undefined;
|
|
192
|
+
name: string;
|
|
193
|
+
kind: SpanKind;
|
|
194
|
+
attributes: SpanAttributes;
|
|
195
|
+
}
|
|
196
|
+
) {
|
|
197
|
+
this.traceId = opts.traceId;
|
|
198
|
+
this.spanId = opts.spanId;
|
|
199
|
+
this.parentSpanId = opts.parentSpanId;
|
|
200
|
+
this.name = opts.name;
|
|
201
|
+
this.kind = opts.kind;
|
|
202
|
+
this.startTimeMs = nowMs();
|
|
203
|
+
this.attributes = { ...opts.attributes };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
setAttribute(key: string, value: string | number | boolean | null | undefined): void {
|
|
207
|
+
if (this.ended) return;
|
|
208
|
+
this.attributes[key] = value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
setAttributes(attrs: SpanAttributes): void {
|
|
212
|
+
if (this.ended) return;
|
|
213
|
+
Object.assign(this.attributes, attrs);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
setStatus(status: "ok" | "error", errorMessage?: string): void {
|
|
217
|
+
if (this.ended) return;
|
|
218
|
+
this.status = status;
|
|
219
|
+
if (status === "error" && errorMessage) this.errorMessage = errorMessage;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
end(): void {
|
|
223
|
+
if (this.ended) return;
|
|
224
|
+
this.ended = true;
|
|
225
|
+
this.endTimeMs = nowMs();
|
|
226
|
+
this.tracer._onSpanEnd(this);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** No-op span when tracing is disabled. Zero allocations on hot path. */
|
|
231
|
+
const NOOP_SPAN: Span = Object.freeze({
|
|
232
|
+
traceId: "00000000000000000000000000000000",
|
|
233
|
+
spanId: "0000000000000000",
|
|
234
|
+
parentSpanId: undefined,
|
|
235
|
+
name: "",
|
|
236
|
+
kind: "internal" as SpanKind,
|
|
237
|
+
startTimeMs: 0,
|
|
238
|
+
attributes: {} as SpanAttributes,
|
|
239
|
+
endTimeMs: 0,
|
|
240
|
+
status: "unset" as SpanStatus,
|
|
241
|
+
errorMessage: undefined,
|
|
242
|
+
recording: false,
|
|
243
|
+
setAttribute: () => {},
|
|
244
|
+
setAttributes: () => {},
|
|
245
|
+
setStatus: () => {},
|
|
246
|
+
end: () => {},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
function nowMs(): number {
|
|
250
|
+
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
251
|
+
// performance.timeOrigin anchors the high-resolution clock to epoch ms,
|
|
252
|
+
// so exporters emit wall-clock timestamps rather than process-relative ones.
|
|
253
|
+
return performance.timeOrigin + performance.now();
|
|
254
|
+
}
|
|
255
|
+
return Date.now();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Exporters ──────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export interface SpanExporter {
|
|
261
|
+
export(spans: Span[]): void | Promise<void>;
|
|
262
|
+
shutdown?(): void | Promise<void>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Console exporter — pretty-prints each span to stderr on `end()`.
|
|
267
|
+
* Indentation reflects depth (via parent-span lookup in a short-lived
|
|
268
|
+
* Map). Used as the default in dev mode when no OTLP endpoint is set.
|
|
269
|
+
*/
|
|
270
|
+
export class ConsoleSpanExporter implements SpanExporter {
|
|
271
|
+
private readonly stream: { write: (chunk: string) => void };
|
|
272
|
+
private readonly depthByParent = new Map<string, number>();
|
|
273
|
+
private readonly depthBySpan = new Map<string, number>();
|
|
274
|
+
|
|
275
|
+
constructor(stream?: { write: (chunk: string) => void }) {
|
|
276
|
+
// process.stderr guarantees a `write(string)` signature in Bun + Node.
|
|
277
|
+
// Accepting an override keeps the class testable without capturing
|
|
278
|
+
// real TTY output.
|
|
279
|
+
this.stream =
|
|
280
|
+
stream ??
|
|
281
|
+
(typeof process !== "undefined" && process.stderr
|
|
282
|
+
? (process.stderr as unknown as { write: (chunk: string) => void })
|
|
283
|
+
: { write: (chunk) => console.error(chunk.trimEnd()) });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export(spans: Span[]): void {
|
|
287
|
+
for (const span of spans) {
|
|
288
|
+
const depth = this.resolveDepth(span);
|
|
289
|
+
const indent = " ".repeat(depth);
|
|
290
|
+
const durationMs = Math.max(0, span.endTimeMs - span.startTimeMs).toFixed(2);
|
|
291
|
+
const statusIcon =
|
|
292
|
+
span.status === "error" ? "✗" : span.status === "ok" ? "✓" : "·";
|
|
293
|
+
const idTag = `${span.traceId.slice(0, 8)}/${span.spanId.slice(0, 8)}`;
|
|
294
|
+
const attrSummary = formatAttrsShort(span.attributes);
|
|
295
|
+
const err = span.errorMessage ? ` error=${JSON.stringify(span.errorMessage)}` : "";
|
|
296
|
+
this.stream.write(
|
|
297
|
+
`[trace ${idTag}] ${indent}${statusIcon} ${span.name} ${durationMs}ms${attrSummary}${err}\n`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Compute a nesting depth for the span by walking the parent chain we
|
|
304
|
+
* have already seen. The first span we see for a given trace starts at
|
|
305
|
+
* depth 0; each child adds one level of indent. Entries age out when
|
|
306
|
+
* the root span ends so the map stays bounded.
|
|
307
|
+
*/
|
|
308
|
+
private resolveDepth(span: Span): number {
|
|
309
|
+
const parent = span.parentSpanId;
|
|
310
|
+
if (!parent) {
|
|
311
|
+
this.depthBySpan.set(span.spanId, 0);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
const parentDepth = this.depthBySpan.get(parent) ?? 0;
|
|
315
|
+
const depth = parentDepth + 1;
|
|
316
|
+
this.depthBySpan.set(span.spanId, depth);
|
|
317
|
+
// Housekeeping: when a root span (depth 0) ends, evict its subtree.
|
|
318
|
+
if (parentDepth === 0 && this.depthBySpan.size > 512) {
|
|
319
|
+
this.depthBySpan.clear();
|
|
320
|
+
this.depthByParent.clear();
|
|
321
|
+
}
|
|
322
|
+
return depth;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function formatAttrsShort(attrs: SpanAttributes): string {
|
|
327
|
+
const keys = Object.keys(attrs);
|
|
328
|
+
if (keys.length === 0) return "";
|
|
329
|
+
const parts: string[] = [];
|
|
330
|
+
for (const key of keys) {
|
|
331
|
+
const v = attrs[key];
|
|
332
|
+
if (v === undefined || v === null) continue;
|
|
333
|
+
parts.push(`${key}=${typeof v === "string" ? JSON.stringify(v) : String(v)}`);
|
|
334
|
+
}
|
|
335
|
+
return parts.length ? ` [${parts.join(" ")}]` : "";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* OTLP/HTTP JSON exporter. Produces an `ExportTraceServiceRequest`
|
|
340
|
+
* body and POSTs it to `<endpoint>/v1/traces`. Honeycomb, Grafana
|
|
341
|
+
* Tempo, AWS X-Ray (via OTel Collector), and the OpenTelemetry
|
|
342
|
+
* Collector all accept this wire format.
|
|
343
|
+
*
|
|
344
|
+
* Errors during export are logged to stderr but never throw — tracing
|
|
345
|
+
* must never break a request.
|
|
346
|
+
*/
|
|
347
|
+
export class OtlpHttpSpanExporter implements SpanExporter {
|
|
348
|
+
constructor(
|
|
349
|
+
private readonly endpoint: string,
|
|
350
|
+
private readonly serviceName: string,
|
|
351
|
+
private readonly headers: Record<string, string> = {}
|
|
352
|
+
) {}
|
|
353
|
+
|
|
354
|
+
async export(spans: Span[]): Promise<void> {
|
|
355
|
+
if (spans.length === 0) return;
|
|
356
|
+
const body = encodeOtlpJson(spans, this.serviceName);
|
|
357
|
+
const url = this.endpoint.endsWith("/v1/traces")
|
|
358
|
+
? this.endpoint
|
|
359
|
+
: `${this.endpoint.replace(/\/$/, "")}/v1/traces`;
|
|
360
|
+
try {
|
|
361
|
+
const res = await fetch(url, {
|
|
362
|
+
method: "POST",
|
|
363
|
+
headers: {
|
|
364
|
+
"Content-Type": "application/json",
|
|
365
|
+
...this.headers,
|
|
366
|
+
},
|
|
367
|
+
body: JSON.stringify(body),
|
|
368
|
+
});
|
|
369
|
+
if (!res.ok) {
|
|
370
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
371
|
+
process.stderr.write(
|
|
372
|
+
`[mandu/tracing] OTLP export failed: ${res.status} ${res.statusText}\n`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
378
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
379
|
+
process.stderr.write(`[mandu/tracing] OTLP export error: ${msg}\n`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Encode an array of spans as the JSON shape of OTLP
|
|
387
|
+
* `ExportTraceServiceRequest`. Externally-visible for tests and for
|
|
388
|
+
* adapter authors who want to POST spans through a custom transport.
|
|
389
|
+
*/
|
|
390
|
+
export function encodeOtlpJson(spans: Span[], serviceName: string): Record<string, unknown> {
|
|
391
|
+
// Times in OTLP are unix-nano unsigned ints encoded as decimal strings.
|
|
392
|
+
const toNano = (ms: number): string => {
|
|
393
|
+
const nanos = BigInt(Math.max(0, Math.floor(ms * 1_000_000)));
|
|
394
|
+
return nanos.toString();
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const statusCodeOtlp = (s: SpanStatus): number => {
|
|
398
|
+
// OTel canonical codes: 0 = UNSET, 1 = OK, 2 = ERROR.
|
|
399
|
+
if (s === "ok") return 1;
|
|
400
|
+
if (s === "error") return 2;
|
|
401
|
+
return 0;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const spanKindOtlp = (k: SpanKind): number => {
|
|
405
|
+
// OTel canonical kinds: 1 = INTERNAL, 2 = SERVER, 3 = CLIENT, 4 = PRODUCER, 5 = CONSUMER.
|
|
406
|
+
switch (k) {
|
|
407
|
+
case "server":
|
|
408
|
+
return 2;
|
|
409
|
+
case "client":
|
|
410
|
+
return 3;
|
|
411
|
+
case "producer":
|
|
412
|
+
return 4;
|
|
413
|
+
case "consumer":
|
|
414
|
+
return 5;
|
|
415
|
+
default:
|
|
416
|
+
return 1;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const otlpAttr = (key: string, value: string | number | boolean | null | undefined) => {
|
|
421
|
+
if (value === null || value === undefined) return null;
|
|
422
|
+
if (typeof value === "string") return { key, value: { stringValue: value } };
|
|
423
|
+
if (typeof value === "boolean") return { key, value: { boolValue: value } };
|
|
424
|
+
if (Number.isInteger(value)) return { key, value: { intValue: String(value) } };
|
|
425
|
+
return { key, value: { doubleValue: value } };
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const otlpSpans = spans.map((s) => ({
|
|
429
|
+
traceId: s.traceId,
|
|
430
|
+
spanId: s.spanId,
|
|
431
|
+
parentSpanId: s.parentSpanId ?? "",
|
|
432
|
+
name: s.name,
|
|
433
|
+
kind: spanKindOtlp(s.kind),
|
|
434
|
+
startTimeUnixNano: toNano(s.startTimeMs),
|
|
435
|
+
endTimeUnixNano: toNano(s.endTimeMs > 0 ? s.endTimeMs : s.startTimeMs),
|
|
436
|
+
attributes: Object.entries(s.attributes)
|
|
437
|
+
.map(([k, v]) => otlpAttr(k, v))
|
|
438
|
+
.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
|
|
439
|
+
status: {
|
|
440
|
+
code: statusCodeOtlp(s.status),
|
|
441
|
+
...(s.errorMessage ? { message: s.errorMessage } : {}),
|
|
442
|
+
},
|
|
443
|
+
}));
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
resourceSpans: [
|
|
447
|
+
{
|
|
448
|
+
resource: {
|
|
449
|
+
attributes: [
|
|
450
|
+
{ key: "service.name", value: { stringValue: serviceName } },
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
scopeSpans: [
|
|
454
|
+
{
|
|
455
|
+
scope: { name: "@mandujs/core", version: "1" },
|
|
456
|
+
spans: otlpSpans,
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
},
|
|
460
|
+
],
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ─── AsyncLocalStorage context propagation ──────────────────────────────
|
|
465
|
+
|
|
466
|
+
const spanStorage = new AsyncLocalStorage<Span>();
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Return the currently-active span, or `undefined` outside any
|
|
470
|
+
* `runWithSpan()` scope. Works across `await`s, timers, and nested
|
|
471
|
+
* callbacks.
|
|
472
|
+
*/
|
|
473
|
+
export function getActiveSpan(): Span | undefined {
|
|
474
|
+
return spanStorage.getStore();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Bind a span to the AsyncLocalStorage context of `fn`. Inside (and
|
|
479
|
+
* downstream of) `fn`, {@link getActiveSpan} returns `span`. The span
|
|
480
|
+
* is NOT auto-ended — callers own lifecycle.
|
|
481
|
+
*/
|
|
482
|
+
export function runWithSpan<T>(span: Span, fn: () => T): T {
|
|
483
|
+
return spanStorage.run(span, fn);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Stamp the active span's trace context onto `headers` as a
|
|
488
|
+
* `traceparent` header so downstream services can join the trace.
|
|
489
|
+
* No-op when no active span OR the active span is the no-op span.
|
|
490
|
+
*/
|
|
491
|
+
export function injectTraceContext(headers: Headers, span: Span | undefined = getActiveSpan()): void {
|
|
492
|
+
if (!span || !span.recording) return;
|
|
493
|
+
headers.set("traceparent", formatTraceparent(span.traceId, span.spanId));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ─── Tracer ─────────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
export interface TracerConfig {
|
|
499
|
+
/** Off → `startSpan` returns no-op. Default: `false`. */
|
|
500
|
+
enabled?: boolean;
|
|
501
|
+
/** `"console"` (default) or `"otlp"`. */
|
|
502
|
+
exporter?: "console" | "otlp";
|
|
503
|
+
/** OTLP collector endpoint (e.g. `https://api.honeycomb.io`). Required for `"otlp"`. */
|
|
504
|
+
endpoint?: string;
|
|
505
|
+
/** OTLP headers (e.g. `{ 'x-honeycomb-team': 'KEY' }`). */
|
|
506
|
+
headers?: Record<string, string>;
|
|
507
|
+
/** `resource.service.name`. Default: `"mandu"`. */
|
|
508
|
+
serviceName?: string;
|
|
509
|
+
/** Inject a custom exporter (overrides `exporter` / `endpoint`). Used in tests. */
|
|
510
|
+
customExporter?: SpanExporter;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export class Tracer {
|
|
514
|
+
readonly config: Required<
|
|
515
|
+
Omit<TracerConfig, "customExporter" | "endpoint" | "headers">
|
|
516
|
+
> & {
|
|
517
|
+
endpoint?: string;
|
|
518
|
+
headers: Record<string, string>;
|
|
519
|
+
customExporter?: SpanExporter;
|
|
520
|
+
};
|
|
521
|
+
private readonly exporter: SpanExporter | null;
|
|
522
|
+
|
|
523
|
+
constructor(config: TracerConfig = {}) {
|
|
524
|
+
this.config = {
|
|
525
|
+
enabled: config.enabled ?? false,
|
|
526
|
+
exporter: config.exporter ?? "console",
|
|
527
|
+
endpoint: config.endpoint,
|
|
528
|
+
headers: config.headers ?? {},
|
|
529
|
+
serviceName: config.serviceName ?? "mandu",
|
|
530
|
+
customExporter: config.customExporter,
|
|
531
|
+
};
|
|
532
|
+
if (!this.config.enabled) {
|
|
533
|
+
this.exporter = null;
|
|
534
|
+
} else if (config.customExporter) {
|
|
535
|
+
this.exporter = config.customExporter;
|
|
536
|
+
} else if (this.config.exporter === "otlp") {
|
|
537
|
+
if (!this.config.endpoint) {
|
|
538
|
+
// Misconfigured → fall back to console so dev still gets output.
|
|
539
|
+
this.exporter = new ConsoleSpanExporter();
|
|
540
|
+
} else {
|
|
541
|
+
this.exporter = new OtlpHttpSpanExporter(
|
|
542
|
+
this.config.endpoint,
|
|
543
|
+
this.config.serviceName,
|
|
544
|
+
this.config.headers
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
this.exporter = new ConsoleSpanExporter();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Whether this tracer will record spans. */
|
|
553
|
+
get enabled(): boolean {
|
|
554
|
+
return this.config.enabled;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Start a new span. When tracing is disabled, returns the shared
|
|
559
|
+
* no-op span (zero allocations).
|
|
560
|
+
*
|
|
561
|
+
* Parent resolution order:
|
|
562
|
+
* 1. `opts.parent` if provided (use `null` to force a root span)
|
|
563
|
+
* 2. The active span from AsyncLocalStorage
|
|
564
|
+
* 3. None (root span)
|
|
565
|
+
*/
|
|
566
|
+
startSpan(name: string, opts: SpanOptions = {}): Span {
|
|
567
|
+
if (!this.config.enabled) return NOOP_SPAN;
|
|
568
|
+
const parent =
|
|
569
|
+
opts.parent === null
|
|
570
|
+
? undefined
|
|
571
|
+
: opts.parent ?? getActiveSpan();
|
|
572
|
+
const parentRecording = parent && parent.recording ? parent : undefined;
|
|
573
|
+
const traceId = parentRecording ? parentRecording.traceId : newTraceId();
|
|
574
|
+
return new RecordingSpan(this, {
|
|
575
|
+
traceId,
|
|
576
|
+
spanId: newSpanId(),
|
|
577
|
+
parentSpanId: parentRecording?.spanId,
|
|
578
|
+
name,
|
|
579
|
+
kind: opts.kind ?? (parentRecording ? "internal" : "server"),
|
|
580
|
+
attributes: opts.attributes ?? {},
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Start a span from an incoming request's `traceparent` header. If
|
|
586
|
+
* the header is missing / malformed, a new root span is minted.
|
|
587
|
+
*/
|
|
588
|
+
startSpanFromRequest(name: string, req: Request, opts: Omit<SpanOptions, "parent"> = {}): Span {
|
|
589
|
+
if (!this.config.enabled) return NOOP_SPAN;
|
|
590
|
+
const tp = parseTraceparent(req.headers.get("traceparent"));
|
|
591
|
+
if (!tp) {
|
|
592
|
+
return this.startSpan(name, { ...opts, parent: null, kind: opts.kind ?? "server" });
|
|
593
|
+
}
|
|
594
|
+
return new RecordingSpan(this, {
|
|
595
|
+
traceId: tp.traceId,
|
|
596
|
+
spanId: newSpanId(),
|
|
597
|
+
parentSpanId: tp.parentId,
|
|
598
|
+
name,
|
|
599
|
+
kind: opts.kind ?? "server",
|
|
600
|
+
attributes: opts.attributes ?? {},
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Run `fn` with `span` as the active span. Equivalent to
|
|
606
|
+
* {@link runWithSpan} but colocated with the tracer for DX.
|
|
607
|
+
*/
|
|
608
|
+
withSpan<T>(span: Span, fn: () => T): T {
|
|
609
|
+
return runWithSpan(span, fn);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Open a child span, invoke `fn`, and auto-end the span when the
|
|
614
|
+
* returned promise resolves (or rejects, with `status=error`).
|
|
615
|
+
*/
|
|
616
|
+
async span<T>(name: string, fn: (span: Span) => Promise<T> | T, opts: SpanOptions = {}): Promise<T> {
|
|
617
|
+
const span = this.startSpan(name, opts);
|
|
618
|
+
try {
|
|
619
|
+
const result = await runWithSpan(span, () => fn(span));
|
|
620
|
+
if (span.status === "unset") span.setStatus("ok");
|
|
621
|
+
return result;
|
|
622
|
+
} catch (err) {
|
|
623
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
624
|
+
span.setStatus("error", msg);
|
|
625
|
+
throw err;
|
|
626
|
+
} finally {
|
|
627
|
+
span.end();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** @internal Called by {@link RecordingSpan.end}. */
|
|
632
|
+
_onSpanEnd(span: Span): void {
|
|
633
|
+
if (!this.exporter) return;
|
|
634
|
+
try {
|
|
635
|
+
void this.exporter.export([span]);
|
|
636
|
+
} catch {
|
|
637
|
+
// Exporter failures must never surface — swallow.
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Flush + release the underlying exporter. No-op if disabled. */
|
|
642
|
+
async shutdown(): Promise<void> {
|
|
643
|
+
if (this.exporter && typeof this.exporter.shutdown === "function") {
|
|
644
|
+
await this.exporter.shutdown();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ─── Default tracer ──────────────────────────────────────────────────────
|
|
650
|
+
|
|
651
|
+
let defaultTracer: Tracer = new Tracer({ enabled: false });
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Return the process-global tracer. Starts disabled; the runtime
|
|
655
|
+
* installs a configured tracer during `startServer()` when
|
|
656
|
+
* `observability.tracing.enabled` is true.
|
|
657
|
+
*/
|
|
658
|
+
export function getTracer(): Tracer {
|
|
659
|
+
return defaultTracer;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Install a tracer as the process-global. Idempotent — a subsequent
|
|
664
|
+
* call replaces the previous tracer (the runtime calls this once at
|
|
665
|
+
* boot; tests use it to swap in a capturing exporter).
|
|
666
|
+
*/
|
|
667
|
+
export function setTracer(tracer: Tracer): void {
|
|
668
|
+
defaultTracer = tracer;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Reset the process-global tracer back to the disabled no-op tracer.
|
|
673
|
+
* Intended for tests; production code should not need this.
|
|
674
|
+
*/
|
|
675
|
+
export function resetTracer(): void {
|
|
676
|
+
defaultTracer = new Tracer({ enabled: false });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Build a tracer from a {@link TracerConfig}-shaped object, honouring
|
|
681
|
+
* the `MANDU_OTEL_ENDPOINT` env var as an override. Used by the
|
|
682
|
+
* runtime at `startServer()` time.
|
|
683
|
+
*/
|
|
684
|
+
export function createTracerFromConfig(cfg: TracerConfig | undefined): Tracer {
|
|
685
|
+
const envEndpoint =
|
|
686
|
+
typeof process !== "undefined" ? process.env?.MANDU_OTEL_ENDPOINT : undefined;
|
|
687
|
+
const resolved: TracerConfig = { ...(cfg ?? {}) };
|
|
688
|
+
if (envEndpoint) {
|
|
689
|
+
resolved.enabled = true;
|
|
690
|
+
resolved.exporter = "otlp";
|
|
691
|
+
resolved.endpoint = envEndpoint;
|
|
692
|
+
}
|
|
693
|
+
return new Tracer(resolved);
|
|
694
|
+
}
|