@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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. 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
+ }