@nifrajs/otel 1.3.0 → 1.4.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/README.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  Distributed tracing for nifra. The `tracing()` plugin continues (or starts) a **W3C trace** per
4
4
  request, opens an **OpenTelemetry-semantic-convention** span, and exposes `c.trace` so you can
5
- forward the trace to downstream services. Spans go to a **pluggable exporter** — bridge to the
6
- OpenTelemetry SDK, or log them directly. No SDK bundled; edge-safe.
5
+ forward the trace to downstream services. One fail-open lifecycle owns parentage, identity, timing,
6
+ errors, final status, and exactly-once completion. Pluggable adapters project that observation into
7
+ the OpenTelemetry SDK, DevTools, private backends, or structured logs. No SDK bundled; edge-safe.
7
8
 
8
9
  ```ts
9
10
  import { tracing, traceHeaders, consoleSpanExporter } from "@nifrajs/otel"
@@ -26,20 +27,25 @@ const app = server()
26
27
  (`error` for 5xx, `ok` otherwise).
27
28
  - **Exposes `c.trace`** (`{ traceId, spanId, parentSpanId?, sampled, traceparent }`) — spread
28
29
  `traceHeaders(c.trace)` into any downstream `fetch`/`ctx.api` call to continue the trace.
30
+ - **Exposes `c.observation`** — integrations can start correctly-parented child observations or
31
+ attach an adapter without rebuilding request lifecycle state.
29
32
  - `responseHeader: true` also sets `traceparent` on the response (browser/client correlation).
30
33
 
31
- ## Exporters
34
+ ## Adapters
32
35
 
33
- Implement `SpanExporter` to send spans wherever you collect them:
36
+ Implement `ObservationAdapter` to send spans wherever you collect them. `SpanExporter` remains as a
37
+ backwards-compatible type alias:
34
38
 
35
39
  ```ts
36
- interface SpanExporter {
40
+ interface ObservationAdapter {
37
41
  onStart?(span: NifraSpan): void
38
42
  onEnd(span: NifraSpan): void
39
43
  }
40
44
  ```
41
45
 
42
46
  - `consoleSpanExporter()` — logs each completed span as one structured line (dev / starting point).
47
+ - `tracing({ adapters: [devtoolsAdapter, privateAdapter] })` — fan out the same lifecycle; adapter
48
+ failures are isolated and never alter the response.
43
49
  - **OpenTelemetry SDK bridge** — a ~10-line adapter maps `NifraSpan` onto a real OTel `Span` from a
44
50
  `Tracer` (the attribute names already follow OTel conventions, so they pass straight through). Your
45
51
  app depends on `@opentelemetry/*`; `@nifrajs/otel` does not.
@@ -50,6 +56,9 @@ Use `traceparent` and the built-in semantic attributes in every request span, th
50
56
  your own collector through an exporter. Keep the package edge-safe by installing the OpenTelemetry
51
57
  SDK only in apps that need that exporter.
52
58
 
59
+ For non-HTTP work, `createObservationLifecycle()` exposes the same state machine directly. Prefer
60
+ it over hand-rolling traceparent parsing, clocks, error status, or completion guards.
61
+
53
62
  ## For AI agents
54
63
 
55
64
  Start with [`LLM.md`](./LLM.md) — this package's contract card (the exports you call + its footguns),
package/dist/index.d.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  * import { tracing, traceHeaders, consoleSpanExporter } from "@nifrajs/otel"
9
9
  * app.use(tracing({ exporter: consoleSpanExporter(), serviceName: "orders-api" }))
10
10
  */
11
- export { type AttributeValue, consoleSpanExporter, type NifraSpan, type SpanExporter, type SpanStatus, } from "./span.js";
11
+ export { type ActiveObservation, createObservationLifecycle, type EndObservation, type ObservationClock, type ObservationContext, type ObservationLifecycle, type ObservationLifecycleOptions, type ObservationParent, type StartObservation, } from "./lifecycle.js";
12
+ export { type AttributeValue, combineObservationAdapters, consoleSpanExporter, type NifraSpan, type ObservationAdapter, type SpanExporter, type SpanStatus, } from "./span.js";
12
13
  export { formatTraceparent, generateSpanId, generateTraceId, type ParsedTraceparent, parseTraceparent, } from "./traceparent.js";
13
14
  export { type TraceContext, type TracingOptions, traceHeaders, tracing } from "./tracing.js";
14
15
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EACL,KAAK,cAAc,EACnB,mBAAmB,EACnB,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,KAAK,UAAU,GAChB,MAAM,WAAW,CAAA;AAClB,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,eAAe,EACf,KAAK,iBAAiB,EACtB,gBAAgB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,cAAc,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EACL,KAAK,iBAAiB,EACtB,0BAA0B,EAC1B,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,EACzB,KAAK,2BAA2B,EAChC,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,GACtB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACL,KAAK,cAAc,EACnB,0BAA0B,EAC1B,mBAAmB,EACnB,KAAK,SAAS,EACd,KAAK,kBAAkB,EACvB,KAAK,YAAY,EACjB,KAAK,UAAU,GAChB,MAAM,WAAW,CAAA;AAClB,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,eAAe,EACf,KAAK,iBAAiB,EACtB,gBAAgB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,cAAc,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA"}
package/dist/index.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * import { tracing, traceHeaders, consoleSpanExporter } from "@nifrajs/otel"
9
9
  * app.use(tracing({ exporter: consoleSpanExporter(), serviceName: "orders-api" }))
10
10
  */
11
- export { consoleSpanExporter, } from "./span.js";
11
+ export { createObservationLifecycle, } from "./lifecycle.js";
12
+ export { combineObservationAdapters, consoleSpanExporter, } from "./span.js";
12
13
  export { formatTraceparent, generateSpanId, generateTraceId, parseTraceparent, } from "./traceparent.js";
13
14
  export { traceHeaders, tracing } from "./tracing.js";
14
15
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAEL,mBAAmB,GAIpB,MAAM,WAAW,CAAA;AAClB,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,eAAe,EAEf,gBAAgB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAA0C,YAAY,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAEL,0BAA0B,GAQ3B,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAEL,0BAA0B,EAC1B,mBAAmB,GAKpB,MAAM,WAAW,CAAA;AAClB,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,eAAe,EAEf,gBAAgB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAA0C,YAAY,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA"}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The deep request-observation lifecycle. This module alone owns trace parent selection, identity,
3
+ * timing, error recording, final status classification, and exactly-once completion. Integrations
4
+ * should adapt completed spans; they should not reimplement this state machine.
5
+ */
6
+ import type { AttributeValue, NifraSpan, ObservationAdapter, SpanStatus } from "./span.js";
7
+ export interface ObservationContext {
8
+ readonly traceId: string;
9
+ readonly spanId: string;
10
+ readonly parentSpanId?: string;
11
+ readonly sampled: boolean;
12
+ /** This span's outbound W3C trace context. */
13
+ readonly traceparent: string;
14
+ }
15
+ export interface ObservationParent {
16
+ readonly traceId: string;
17
+ readonly spanId: string;
18
+ readonly sampled: boolean;
19
+ }
20
+ export interface StartObservation {
21
+ readonly name: string;
22
+ /** Explicit parent. `null` forces a root span; `undefined` falls back to `traceparent`. */
23
+ readonly parent?: ObservationParent | null;
24
+ /** Inbound W3C header used when `parent` is undefined. */
25
+ readonly traceparent?: string | null;
26
+ readonly attributes?: Readonly<Record<string, AttributeValue>>;
27
+ }
28
+ export interface EndObservation {
29
+ readonly statusCode?: number;
30
+ /** Explicit status wins over status-code and recorded-error classification. */
31
+ readonly status?: Exclude<SpanStatus, "unset">;
32
+ readonly attributes?: Readonly<Record<string, AttributeValue>>;
33
+ }
34
+ export interface ActiveObservation {
35
+ readonly span: NifraSpan;
36
+ readonly context: ObservationContext;
37
+ /** Adds a sink to this in-flight span. Re-adding the same adapter is idempotent. */
38
+ addAdapter(adapter: ObservationAdapter): void;
39
+ /** Starts a child that inherits this span's trace identity and sampling decision. */
40
+ startChild(input: Omit<StartObservation, "parent" | "traceparent">, additionalAdapters?: readonly ObservationAdapter[]): ActiveObservation;
41
+ /** Records failure evidence without deciding the final status until the response is known. */
42
+ recordError(error: unknown): void;
43
+ /** Ends and exports once. Repeated calls return the same completed span without notifying again. */
44
+ end(input?: EndObservation): NifraSpan;
45
+ }
46
+ export interface ObservationLifecycle {
47
+ start(input: StartObservation, additionalAdapters?: readonly ObservationAdapter[]): ActiveObservation;
48
+ }
49
+ export interface ObservationClock {
50
+ /** Epoch milliseconds for interoperable start/end timestamps. */
51
+ wallTime(): number;
52
+ /** Monotonic milliseconds for durations. */
53
+ monotonicTime(): number;
54
+ }
55
+ export interface ObservationLifecycleOptions {
56
+ readonly adapters?: readonly ObservationAdapter[];
57
+ /** Injectable seams are primarily useful for deterministic tests and constrained runtimes. */
58
+ readonly clock?: ObservationClock;
59
+ readonly generateTraceId?: () => string;
60
+ readonly generateSpanId?: () => string;
61
+ }
62
+ /** Creates an independent lifecycle factory. Adapters are always called fail-open. */
63
+ export declare function createObservationLifecycle(options?: ObservationLifecycleOptions): ObservationLifecycle;
64
+ //# sourceMappingURL=lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AAQ1F,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8CAA8C;IAC9C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,2FAA2F;IAC3F,QAAQ,CAAC,MAAM,CAAC,EAAE,iBAAiB,GAAG,IAAI,CAAA;IAC1C,0DAA0D;IAC1D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpC,QAAQ,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAA;CAC/D;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,CAAA;IAC9C,QAAQ,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAA;CAC/D;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAA;IACpC,oFAAoF;IACpF,UAAU,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAAA;IAC7C,qFAAqF;IACrF,UAAU,CACR,KAAK,EAAE,IAAI,CAAC,gBAAgB,EAAE,QAAQ,GAAG,aAAa,CAAC,EACvD,kBAAkB,CAAC,EAAE,SAAS,kBAAkB,EAAE,GACjD,iBAAiB,CAAA;IACpB,8FAA8F;IAC9F,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAA;IACjC,oGAAoG;IACpG,GAAG,CAAC,KAAK,CAAC,EAAE,cAAc,GAAG,SAAS,CAAA;CACvC;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,CACH,KAAK,EAAE,gBAAgB,EACvB,kBAAkB,CAAC,EAAE,SAAS,kBAAkB,EAAE,GACjD,iBAAiB,CAAA;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,iEAAiE;IACjE,QAAQ,IAAI,MAAM,CAAA;IAClB,4CAA4C;IAC5C,aAAa,IAAI,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,kBAAkB,EAAE,CAAA;IACjD,8FAA8F;IAC9F,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAA;IACjC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,MAAM,CAAA;IACvC,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,MAAM,CAAA;CACvC;AAUD,sFAAsF;AACtF,wBAAgB,0BAA0B,CACxC,OAAO,GAAE,2BAAgC,GACxC,oBAAoB,CAkHtB"}
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The deep request-observation lifecycle. This module alone owns trace parent selection, identity,
3
+ * timing, error recording, final status classification, and exactly-once completion. Integrations
4
+ * should adapt completed spans; they should not reimplement this state machine.
5
+ */
6
+ import { formatTraceparent, generateSpanId, generateTraceId, parseTraceparent, } from "./traceparent.js";
7
+ const defaultClock = {
8
+ wallTime: () => Date.now(),
9
+ monotonicTime: () => (typeof performance === "undefined" ? Date.now() : performance.now()),
10
+ };
11
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
12
+ /** Creates an independent lifecycle factory. Adapters are always called fail-open. */
13
+ export function createObservationLifecycle(options = {}) {
14
+ const adapters = options.adapters ?? [];
15
+ const clock = options.clock ?? defaultClock;
16
+ const newTraceId = options.generateTraceId ?? generateTraceId;
17
+ const newSpanId = options.generateSpanId ?? generateSpanId;
18
+ const notifyStart = (span, spanAdapters) => {
19
+ for (const adapter of spanAdapters) {
20
+ try {
21
+ adapter.onStart?.(span);
22
+ }
23
+ catch {
24
+ // Observation must never change request behavior.
25
+ }
26
+ }
27
+ };
28
+ const notifyEnd = (span, spanAdapters) => {
29
+ for (const adapter of spanAdapters) {
30
+ try {
31
+ adapter.onEnd(span);
32
+ }
33
+ catch {
34
+ // Observation must never change request behavior.
35
+ }
36
+ }
37
+ };
38
+ const start = (input, additionalAdapters = []) => {
39
+ const spanAdapters = new Set([...adapters, ...additionalAdapters]);
40
+ const parent = input.parent === undefined ? parseTraceparent(input.traceparent ?? null) : input.parent;
41
+ const traceId = parent?.traceId ?? newTraceId();
42
+ const spanId = newSpanId();
43
+ const sampled = parent?.sampled ?? true;
44
+ const startTime = clock.wallTime();
45
+ const monotonicStart = clock.monotonicTime();
46
+ const span = {
47
+ traceId,
48
+ spanId,
49
+ ...(parent === null ? {} : { parentSpanId: parent.spanId }),
50
+ sampled,
51
+ name: input.name,
52
+ startTime,
53
+ status: "unset",
54
+ attributes: { ...input.attributes },
55
+ };
56
+ const context = {
57
+ traceId,
58
+ spanId,
59
+ ...(parent === null ? {} : { parentSpanId: parent.spanId }),
60
+ sampled,
61
+ traceparent: formatTraceparent(traceId, spanId, sampled),
62
+ };
63
+ let recordedError = false;
64
+ let ended = false;
65
+ const active = {
66
+ span,
67
+ context,
68
+ addAdapter(adapter) {
69
+ if (ended || spanAdapters.has(adapter))
70
+ return;
71
+ spanAdapters.add(adapter);
72
+ try {
73
+ adapter.onStart?.(span);
74
+ }
75
+ catch {
76
+ // Dynamic adapters have the same fail-open guarantee as initial adapters.
77
+ }
78
+ },
79
+ startChild(child, childAdapters) {
80
+ return start({
81
+ ...child,
82
+ parent: context,
83
+ }, childAdapters);
84
+ },
85
+ recordError(error) {
86
+ if (ended)
87
+ return;
88
+ recordedError = true;
89
+ span.attributes["error.message"] = errorMessage(error);
90
+ },
91
+ end(endInput = {}) {
92
+ if (ended)
93
+ return span;
94
+ ended = true;
95
+ Object.assign(span.attributes, endInput.attributes);
96
+ span.endTime = clock.wallTime();
97
+ span.durationMs = Math.max(0, clock.monotonicTime() - monotonicStart);
98
+ span.status =
99
+ endInput.status ??
100
+ (endInput.statusCode === undefined
101
+ ? recordedError
102
+ ? "error"
103
+ : "ok"
104
+ : endInput.statusCode >= 500
105
+ ? "error"
106
+ : "ok");
107
+ // Publish an immutable completed span: a misbehaving adapter can no longer mutate the shared
108
+ // attributes bag and corrupt what other adapters (or the caller) observe. Adapters are fail-open,
109
+ // so a stray write throws into their own try/catch rather than changing request behavior.
110
+ Object.freeze(span.attributes);
111
+ Object.freeze(span);
112
+ notifyEnd(span, [...spanAdapters]);
113
+ return span;
114
+ },
115
+ };
116
+ notifyStart(span, [...spanAdapters]);
117
+ return active;
118
+ };
119
+ return { start };
120
+ }
121
+ //# sourceMappingURL=lifecycle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.js","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"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"}
package/dist/span.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The span model + exporter seam. Attribute names follow OpenTelemetry HTTP semantic conventions
3
3
  * (`http.request.method`, `url.path`, `http.response.status_code`, …) so a span maps cleanly onto an
4
- * OTel `Span` when bridged — but nothing here depends on the OTel SDK. You supply a {@link SpanExporter}
4
+ * OTel `Span` when bridged — but nothing here depends on the OTel SDK. You supply an
5
+ * {@link ObservationAdapter}
5
6
  * (a ~10-line adapter to `@opentelemetry/api`, or the bundled {@link consoleSpanExporter}).
6
7
  */
7
8
  export type SpanStatus = "unset" | "ok" | "error";
@@ -33,10 +34,17 @@ export interface NifraSpan {
33
34
  * real `Span` from a `Tracer`), ship to a collector, or just log. `onStart` is optional (most
34
35
  * backends only need the completed span).
35
36
  */
36
- export interface SpanExporter {
37
+ export interface ObservationAdapter {
37
38
  onStart?(span: NifraSpan): void;
38
39
  onEnd(span: NifraSpan): void;
39
40
  }
41
+ /** Backwards-compatible name for an observation sink. */
42
+ export type SpanExporter = ObservationAdapter;
43
+ /**
44
+ * Fan out lifecycle notifications to several adapters. Each adapter is isolated: an exception in
45
+ * one sink cannot prevent the remaining sinks from observing the span.
46
+ */
47
+ export declare function combineObservationAdapters(adapters: readonly ObservationAdapter[]): ObservationAdapter;
40
48
  /** A no-frills exporter that logs each completed span as one structured line. Useful in dev or as a
41
49
  * starting point before wiring a real backend. */
42
50
  export declare function consoleSpanExporter(log?: (line: string) => void): SpanExporter;
@@ -1 +1 @@
1
- {"version":3,"file":"span.d.ts","sourceRoot":"","sources":["../src/span.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,IAAI,GAAG,OAAO,CAAA;AACjD,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAEtD,8DAA8D;AAC9D,MAAM,WAAW,SAAS;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,8BAA8B;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,0EAA0E;IAC1E,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;IAC9B,mDAAmD;IACnD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,uCAAuC;IACvC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,mCAAmC;IACnC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,kCAAkC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,UAAU,CAAA;IAClB,2CAA2C;IAC3C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAA;CACpD;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAA;IAC/B,KAAK,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAA;CAC7B;AAED;kDACkD;AAClD,wBAAgB,mBAAmB,CACjC,GAAG,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAEtB,GACA,YAAY,CAgBd"}
1
+ {"version":3,"file":"span.d.ts","sourceRoot":"","sources":["../src/span.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,IAAI,GAAG,OAAO,CAAA;AACjD,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAEtD,8DAA8D;AAC9D,MAAM,WAAW,SAAS;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,8BAA8B;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,0EAA0E;IAC1E,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;IAC9B,mDAAmD;IACnD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,uCAAuC;IACvC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,mCAAmC;IACnC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,kCAAkC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,UAAU,CAAA;IAClB,2CAA2C;IAC3C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAA;CACpD;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAA;IAC/B,KAAK,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAA;CAC7B;AAED,yDAAyD;AACzD,MAAM,MAAM,YAAY,GAAG,kBAAkB,CAAA;AAE7C;;;GAGG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,SAAS,kBAAkB,EAAE,GACtC,kBAAkB,CAqBpB;AAED;kDACkD;AAClD,wBAAgB,mBAAmB,CACjC,GAAG,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAEtB,GACA,YAAY,CAgBd"}
package/dist/span.js CHANGED
@@ -1,9 +1,38 @@
1
1
  /**
2
2
  * The span model + exporter seam. Attribute names follow OpenTelemetry HTTP semantic conventions
3
3
  * (`http.request.method`, `url.path`, `http.response.status_code`, …) so a span maps cleanly onto an
4
- * OTel `Span` when bridged — but nothing here depends on the OTel SDK. You supply a {@link SpanExporter}
4
+ * OTel `Span` when bridged — but nothing here depends on the OTel SDK. You supply an
5
+ * {@link ObservationAdapter}
5
6
  * (a ~10-line adapter to `@opentelemetry/api`, or the bundled {@link consoleSpanExporter}).
6
7
  */
8
+ /**
9
+ * Fan out lifecycle notifications to several adapters. Each adapter is isolated: an exception in
10
+ * one sink cannot prevent the remaining sinks from observing the span.
11
+ */
12
+ export function combineObservationAdapters(adapters) {
13
+ return {
14
+ onStart(span) {
15
+ for (const adapter of adapters) {
16
+ try {
17
+ adapter.onStart?.(span);
18
+ }
19
+ catch {
20
+ // Observation is fail-open by contract.
21
+ }
22
+ }
23
+ },
24
+ onEnd(span) {
25
+ for (const adapter of adapters) {
26
+ try {
27
+ adapter.onEnd(span);
28
+ }
29
+ catch {
30
+ // Observation is fail-open by contract.
31
+ }
32
+ }
33
+ },
34
+ };
35
+ }
7
36
  /** A no-frills exporter that logs each completed span as one structured line. Useful in dev or as a
8
37
  * starting point before wiring a real backend. */
9
38
  export function consoleSpanExporter(log = (l) => {
package/dist/span.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"span.js","sourceRoot":"","sources":["../src/span.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAsCH;kDACkD;AAClD,MAAM,UAAU,mBAAmB,CACjC,MAA8B,CAAC,CAAC,EAAE,EAAE;IAClC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;AAChB,CAAC;IAED,OAAO;QACL,KAAK,CAAC,IAAI;YACR,GAAG,CACD,IAAI,CAAC,SAAS,CAAC;gBACb,IAAI,EAAE,IAAI,CAAC,IAAI;gBACf,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,YAAY,EAAE,IAAI,CAAC,YAAY;gBAC/B,UAAU,EAAE,IAAI,CAAC,UAAU;gBAC3B,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,GAAG,IAAI,CAAC,UAAU;aACnB,CAAC,CACH,CAAA;QACH,CAAC;KACF,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"span.js","sourceRoot":"","sources":["../src/span.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAyCH;;;GAGG;AACH,MAAM,UAAU,0BAA0B,CACxC,QAAuC;IAEvC,OAAO;QACL,OAAO,CAAC,IAAI;YACV,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;gBAC/B,IAAI,CAAC;oBACH,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAA;gBACzB,CAAC;gBAAC,MAAM,CAAC;oBACP,wCAAwC;gBAC1C,CAAC;YACH,CAAC;QACH,CAAC;QACD,KAAK,CAAC,IAAI;YACR,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;gBAC/B,IAAI,CAAC;oBACH,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;gBACrB,CAAC;gBAAC,MAAM,CAAC;oBACP,wCAAwC;gBAC1C,CAAC;YACH,CAAC;QACH,CAAC;KACF,CAAA;AACH,CAAC;AAED;kDACkD;AAClD,MAAM,UAAU,mBAAmB,CACjC,MAA8B,CAAC,CAAC,EAAE,EAAE;IAClC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;AAChB,CAAC;IAED,OAAO;QACL,KAAK,CAAC,IAAI;YACR,GAAG,CACD,IAAI,CAAC,SAAS,CAAC;gBACb,IAAI,EAAE,IAAI,CAAC,IAAI;gBACf,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,YAAY,EAAE,IAAI,CAAC,YAAY;gBAC/B,UAAU,EAAE,IAAI,CAAC,UAAU;gBAC3B,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,GAAG,IAAI,CAAC,UAAU;aACnB,CAAC,CACH,CAAA;QACH,CAAC;KACF,CAAA;AACH,CAAC"}
package/dist/tracing.d.ts CHANGED
@@ -4,19 +4,15 @@
4
4
  * (traceparent) and attribute names (HTTP semantic conventions); the span itself is exported through
5
5
  * your {@link SpanExporter} (bridge to the OTel SDK, or log via `consoleSpanExporter`).
6
6
  */
7
- import { type SpanExporter } from "./span.js";
7
+ import { type ObservationContext } from "./lifecycle.js";
8
+ import { type ObservationAdapter, type SpanExporter } from "./span.js";
8
9
  /** The trace context exposed on the handler `c.trace` (typed, threaded via `derive`). */
9
- export interface TraceContext {
10
- readonly traceId: string;
11
- readonly spanId: string;
12
- readonly parentSpanId?: string;
13
- readonly sampled: boolean;
14
- /** This request's outbound `traceparent` — forward it on downstream calls to continue the trace. */
15
- readonly traceparent: string;
16
- }
10
+ export type TraceContext = ObservationContext;
17
11
  export interface TracingOptions {
18
12
  /** Where spans are sent. Default: {@link consoleSpanExporter}. */
19
13
  readonly exporter?: SpanExporter;
14
+ /** Additional observation adapters (DevTools, a private redacting backend, metrics, …). */
15
+ readonly adapters?: readonly ObservationAdapter[];
20
16
  /** Sets the `service.name` attribute on every span. */
21
17
  readonly serviceName?: string;
22
18
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"tracing.d.ts","sourceRoot":"","sources":["../src/tracing.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAIL,KAAK,YAAY,EAClB,MAAM,WAAW,CAAA;AAQlB,yFAAyF;AACzF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,oGAAoG;IACpG,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,kEAAkE;IAClE,QAAQ,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAA;IAChC,uDAAuD;IACvD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAA;CAClC;AAED;uDACuD;AACvD,wBAAgB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG;IAAE,WAAW,EAAE,MAAM,CAAA;CAAE,CAEzE;AAUD;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,OAAO,GAAE,cAAmB,oHA2DnD"}
1
+ {"version":3,"file":"tracing.d.ts","sourceRoot":"","sources":["../src/tracing.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAuB,KAAK,kBAAkB,EAAE,KAAK,YAAY,EAAE,MAAM,WAAW,CAAA;AAE3F,yFAAyF;AACzF,MAAM,MAAM,YAAY,GAAG,kBAAkB,CAAA;AAE7C,MAAM,WAAW,cAAc;IAC7B,kEAAkE;IAClE,QAAQ,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAA;IAChC,2FAA2F;IAC3F,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,kBAAkB,EAAE,CAAA;IACjD,uDAAuD;IACvD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAA;CAClC;AAED;uDACuD;AACvD,wBAAgB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG;IAAE,WAAW,EAAE,MAAM,CAAA;CAAE,CAEzE;AAUD;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,OAAO,GAAE,cAAmB,oHAuDnD"}
package/dist/tracing.js CHANGED
@@ -5,8 +5,8 @@
5
5
  * your {@link SpanExporter} (bridge to the OTel SDK, or log via `consoleSpanExporter`).
6
6
  */
7
7
  import { definePlugin } from "@nifrajs/core";
8
- import { consoleSpanExporter, } from "./span.js";
9
- import { formatTraceparent, generateSpanId, generateTraceId, parseTraceparent, } from "./traceparent.js";
8
+ import { createObservationLifecycle, } from "./lifecycle.js";
9
+ import { consoleSpanExporter } from "./span.js";
10
10
  /** Spread into an outgoing `fetch`/`ctx.api` call's headers to continue the trace downstream:
11
11
  * `fetch(url, { headers: traceHeaders(c.trace) })`. */
12
12
  export function traceHeaders(trace) {
@@ -30,61 +30,56 @@ const pathOf = (url) => {
30
30
  * ```
31
31
  */
32
32
  export function tracing(options = {}) {
33
- const exporter = options.exporter ?? consoleSpanExporter();
33
+ const adapters = [
34
+ ...(options.exporter === undefined && options.adapters === undefined
35
+ ? [consoleSpanExporter()]
36
+ : options.exporter === undefined
37
+ ? []
38
+ : [options.exporter]),
39
+ ...(options.adapters ?? []),
40
+ ];
41
+ const lifecycle = createObservationLifecycle({ adapters });
34
42
  const serviceName = options.serviceName;
35
- // Span per in-flight request, keyed by the Request — ended in onResponse (mirrors @nifrajs/middleware
36
- // timing). A WeakMap so an abandoned request can't leak a span.
43
+ // A WeakMap so an abandoned request cannot leak its active observation.
37
44
  const inFlight = new WeakMap();
38
45
  return definePlugin("tracing", (app) => app
39
46
  .derive((c) => {
40
- const parent = parseTraceparent(c.req.headers.get("traceparent"));
41
- const traceId = parent?.traceId ?? generateTraceId();
42
- const spanId = generateSpanId();
43
- const sampled = parent?.sampled ?? true;
44
47
  const path = pathOf(c.req.url);
45
- const attributes = {
46
- "http.request.method": c.req.method,
47
- "url.path": path,
48
- };
49
- if (serviceName !== undefined)
50
- attributes["service.name"] = serviceName;
51
- const span = {
52
- traceId,
53
- spanId,
54
- ...(parent !== null ? { parentSpanId: parent.spanId } : {}),
55
- sampled,
48
+ const observation = lifecycle.start({
56
49
  name: `${c.req.method} ${path}`,
57
- startTime: Date.now(),
58
- status: "unset",
59
- attributes,
60
- };
61
- inFlight.set(c.req, span);
62
- exporter.onStart?.(span);
63
- const traceparent = formatTraceparent(traceId, spanId, sampled);
50
+ traceparent: c.req.headers.get("traceparent"),
51
+ attributes: {
52
+ "http.request.method": c.req.method,
53
+ "url.path": path,
54
+ ...(serviceName === undefined ? {} : { "service.name": serviceName }),
55
+ },
56
+ });
57
+ inFlight.set(c.req, observation);
64
58
  if (options.responseHeader)
65
- c.set.headers.traceparent = traceparent;
66
- const trace = {
67
- traceId,
68
- spanId,
69
- ...(parent !== null ? { parentSpanId: parent.spanId } : {}),
70
- sampled,
71
- traceparent,
72
- };
73
- return { trace };
59
+ c.set.headers.traceparent = observation.context.traceparent;
60
+ return { trace: observation.context, observation };
74
61
  })
75
62
  .use({
76
63
  name: "tracing-end",
64
+ onError: (error, context) => {
65
+ inFlight.get(context.request)?.recordError(error);
66
+ return undefined;
67
+ },
77
68
  onResponse: (res, req) => {
78
- const span = inFlight.get(req);
79
- if (span === undefined)
69
+ const observation = inFlight.get(req);
70
+ if (observation === undefined)
80
71
  return res;
81
72
  inFlight.delete(req);
82
- span.endTime = Date.now();
83
- span.durationMs = span.endTime - span.startTime;
84
- span.attributes["http.response.status_code"] = res.status;
85
- // 5xx is a server error; everything else (incl. handled 4xx) is a normal outcome.
86
- span.status = res.status >= 500 ? "error" : "ok";
87
- exporter.onEnd(span);
73
+ const bodySize = Number(res.headers.get("content-length") ?? "0") || 0;
74
+ const isrStatus = res.headers.get("x-nifra-isr");
75
+ observation.end({
76
+ statusCode: res.status,
77
+ attributes: {
78
+ "http.response.status_code": res.status,
79
+ "http.response.body.size": bodySize,
80
+ ...(isrStatus === null ? {} : { "nifra.isr.status": isrStatus }),
81
+ },
82
+ });
88
83
  return res;
89
84
  },
90
85
  }));
@@ -1 +1 @@
1
- {"version":3,"file":"tracing.js","sourceRoot":"","sources":["../src/tracing.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tracing.js","sourceRoot":"","sources":["../src/tracing.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/otel",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Distributed tracing for nifra — W3C traceparent/tracestate propagation + OpenTelemetry-semantic-convention spans via a pluggable exporter. Bridge to the OpenTelemetry SDK or log spans directly; no SDK bundled. Edge-safe.",
5
5
  "keywords": [
6
6
  "bun",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "peerDependencies": {
42
- "@nifrajs/core": "^1.3.0"
42
+ "@nifrajs/core": "^1.4.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@nifrajs/core": "workspace:*"
package/src/index.ts CHANGED
@@ -9,10 +9,23 @@
9
9
  * app.use(tracing({ exporter: consoleSpanExporter(), serviceName: "orders-api" }))
10
10
  */
11
11
 
12
+ export {
13
+ type ActiveObservation,
14
+ createObservationLifecycle,
15
+ type EndObservation,
16
+ type ObservationClock,
17
+ type ObservationContext,
18
+ type ObservationLifecycle,
19
+ type ObservationLifecycleOptions,
20
+ type ObservationParent,
21
+ type StartObservation,
22
+ } from "./lifecycle.ts"
12
23
  export {
13
24
  type AttributeValue,
25
+ combineObservationAdapters,
14
26
  consoleSpanExporter,
15
27
  type NifraSpan,
28
+ type ObservationAdapter,
16
29
  type SpanExporter,
17
30
  type SpanStatus,
18
31
  } from "./span.ts"
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The deep request-observation lifecycle. This module alone owns trace parent selection, identity,
3
+ * timing, error recording, final status classification, and exactly-once completion. Integrations
4
+ * should adapt completed spans; they should not reimplement this state machine.
5
+ */
6
+
7
+ import type { AttributeValue, NifraSpan, ObservationAdapter, SpanStatus } from "./span.ts"
8
+ import {
9
+ formatTraceparent,
10
+ generateSpanId,
11
+ generateTraceId,
12
+ parseTraceparent,
13
+ } from "./traceparent.ts"
14
+
15
+ export interface ObservationContext {
16
+ readonly traceId: string
17
+ readonly spanId: string
18
+ readonly parentSpanId?: string
19
+ readonly sampled: boolean
20
+ /** This span's outbound W3C trace context. */
21
+ readonly traceparent: string
22
+ }
23
+
24
+ export interface ObservationParent {
25
+ readonly traceId: string
26
+ readonly spanId: string
27
+ readonly sampled: boolean
28
+ }
29
+
30
+ export interface StartObservation {
31
+ readonly name: string
32
+ /** Explicit parent. `null` forces a root span; `undefined` falls back to `traceparent`. */
33
+ readonly parent?: ObservationParent | null
34
+ /** Inbound W3C header used when `parent` is undefined. */
35
+ readonly traceparent?: string | null
36
+ readonly attributes?: Readonly<Record<string, AttributeValue>>
37
+ }
38
+
39
+ export interface EndObservation {
40
+ readonly statusCode?: number
41
+ /** Explicit status wins over status-code and recorded-error classification. */
42
+ readonly status?: Exclude<SpanStatus, "unset">
43
+ readonly attributes?: Readonly<Record<string, AttributeValue>>
44
+ }
45
+
46
+ export interface ActiveObservation {
47
+ readonly span: NifraSpan
48
+ readonly context: ObservationContext
49
+ /** Adds a sink to this in-flight span. Re-adding the same adapter is idempotent. */
50
+ addAdapter(adapter: ObservationAdapter): void
51
+ /** Starts a child that inherits this span's trace identity and sampling decision. */
52
+ startChild(
53
+ input: Omit<StartObservation, "parent" | "traceparent">,
54
+ additionalAdapters?: readonly ObservationAdapter[],
55
+ ): ActiveObservation
56
+ /** Records failure evidence without deciding the final status until the response is known. */
57
+ recordError(error: unknown): void
58
+ /** Ends and exports once. Repeated calls return the same completed span without notifying again. */
59
+ end(input?: EndObservation): NifraSpan
60
+ }
61
+
62
+ export interface ObservationLifecycle {
63
+ start(
64
+ input: StartObservation,
65
+ additionalAdapters?: readonly ObservationAdapter[],
66
+ ): ActiveObservation
67
+ }
68
+
69
+ export interface ObservationClock {
70
+ /** Epoch milliseconds for interoperable start/end timestamps. */
71
+ wallTime(): number
72
+ /** Monotonic milliseconds for durations. */
73
+ monotonicTime(): number
74
+ }
75
+
76
+ export interface ObservationLifecycleOptions {
77
+ readonly adapters?: readonly ObservationAdapter[]
78
+ /** Injectable seams are primarily useful for deterministic tests and constrained runtimes. */
79
+ readonly clock?: ObservationClock
80
+ readonly generateTraceId?: () => string
81
+ readonly generateSpanId?: () => string
82
+ }
83
+
84
+ const defaultClock: ObservationClock = {
85
+ wallTime: () => Date.now(),
86
+ monotonicTime: () => (typeof performance === "undefined" ? Date.now() : performance.now()),
87
+ }
88
+
89
+ const errorMessage = (error: unknown): string =>
90
+ error instanceof Error ? error.message : String(error)
91
+
92
+ /** Creates an independent lifecycle factory. Adapters are always called fail-open. */
93
+ export function createObservationLifecycle(
94
+ options: ObservationLifecycleOptions = {},
95
+ ): ObservationLifecycle {
96
+ const adapters = options.adapters ?? []
97
+ const clock = options.clock ?? defaultClock
98
+ const newTraceId = options.generateTraceId ?? generateTraceId
99
+ const newSpanId = options.generateSpanId ?? generateSpanId
100
+
101
+ const notifyStart = (span: NifraSpan, spanAdapters: readonly ObservationAdapter[]): void => {
102
+ for (const adapter of spanAdapters) {
103
+ try {
104
+ adapter.onStart?.(span)
105
+ } catch {
106
+ // Observation must never change request behavior.
107
+ }
108
+ }
109
+ }
110
+
111
+ const notifyEnd = (span: NifraSpan, spanAdapters: readonly ObservationAdapter[]): void => {
112
+ for (const adapter of spanAdapters) {
113
+ try {
114
+ adapter.onEnd(span)
115
+ } catch {
116
+ // Observation must never change request behavior.
117
+ }
118
+ }
119
+ }
120
+
121
+ const start = (
122
+ input: StartObservation,
123
+ additionalAdapters: readonly ObservationAdapter[] = [],
124
+ ): ActiveObservation => {
125
+ const spanAdapters = new Set([...adapters, ...additionalAdapters])
126
+ const parent =
127
+ input.parent === undefined ? parseTraceparent(input.traceparent ?? null) : input.parent
128
+ const traceId = parent?.traceId ?? newTraceId()
129
+ const spanId = newSpanId()
130
+ const sampled = parent?.sampled ?? true
131
+ const startTime = clock.wallTime()
132
+ const monotonicStart = clock.monotonicTime()
133
+ const span: NifraSpan = {
134
+ traceId,
135
+ spanId,
136
+ ...(parent === null ? {} : { parentSpanId: parent.spanId }),
137
+ sampled,
138
+ name: input.name,
139
+ startTime,
140
+ status: "unset",
141
+ attributes: { ...input.attributes },
142
+ }
143
+ const context: ObservationContext = {
144
+ traceId,
145
+ spanId,
146
+ ...(parent === null ? {} : { parentSpanId: parent.spanId }),
147
+ sampled,
148
+ traceparent: formatTraceparent(traceId, spanId, sampled),
149
+ }
150
+ let recordedError = false
151
+ let ended = false
152
+
153
+ const active: ActiveObservation = {
154
+ span,
155
+ context,
156
+ addAdapter(adapter) {
157
+ if (ended || spanAdapters.has(adapter)) return
158
+ spanAdapters.add(adapter)
159
+ try {
160
+ adapter.onStart?.(span)
161
+ } catch {
162
+ // Dynamic adapters have the same fail-open guarantee as initial adapters.
163
+ }
164
+ },
165
+ startChild(child, childAdapters) {
166
+ return start(
167
+ {
168
+ ...child,
169
+ parent: context,
170
+ },
171
+ childAdapters,
172
+ )
173
+ },
174
+ recordError(error) {
175
+ if (ended) return
176
+ recordedError = true
177
+ span.attributes["error.message"] = errorMessage(error)
178
+ },
179
+ end(endInput = {}) {
180
+ if (ended) return span
181
+ ended = true
182
+ Object.assign(span.attributes, endInput.attributes)
183
+ span.endTime = clock.wallTime()
184
+ span.durationMs = Math.max(0, clock.monotonicTime() - monotonicStart)
185
+ span.status =
186
+ endInput.status ??
187
+ (endInput.statusCode === undefined
188
+ ? recordedError
189
+ ? "error"
190
+ : "ok"
191
+ : endInput.statusCode >= 500
192
+ ? "error"
193
+ : "ok")
194
+ // Publish an immutable completed span: a misbehaving adapter can no longer mutate the shared
195
+ // attributes bag and corrupt what other adapters (or the caller) observe. Adapters are fail-open,
196
+ // so a stray write throws into their own try/catch rather than changing request behavior.
197
+ Object.freeze(span.attributes)
198
+ Object.freeze(span)
199
+ notifyEnd(span, [...spanAdapters])
200
+ return span
201
+ },
202
+ }
203
+
204
+ notifyStart(span, [...spanAdapters])
205
+ return active
206
+ }
207
+
208
+ return { start }
209
+ }
package/src/span.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The span model + exporter seam. Attribute names follow OpenTelemetry HTTP semantic conventions
3
3
  * (`http.request.method`, `url.path`, `http.response.status_code`, …) so a span maps cleanly onto an
4
- * OTel `Span` when bridged — but nothing here depends on the OTel SDK. You supply a {@link SpanExporter}
4
+ * OTel `Span` when bridged — but nothing here depends on the OTel SDK. You supply an
5
+ * {@link ObservationAdapter}
5
6
  * (a ~10-line adapter to `@opentelemetry/api`, or the bundled {@link consoleSpanExporter}).
6
7
  */
7
8
 
@@ -36,11 +37,43 @@ export interface NifraSpan {
36
37
  * real `Span` from a `Tracer`), ship to a collector, or just log. `onStart` is optional (most
37
38
  * backends only need the completed span).
38
39
  */
39
- export interface SpanExporter {
40
+ export interface ObservationAdapter {
40
41
  onStart?(span: NifraSpan): void
41
42
  onEnd(span: NifraSpan): void
42
43
  }
43
44
 
45
+ /** Backwards-compatible name for an observation sink. */
46
+ export type SpanExporter = ObservationAdapter
47
+
48
+ /**
49
+ * Fan out lifecycle notifications to several adapters. Each adapter is isolated: an exception in
50
+ * one sink cannot prevent the remaining sinks from observing the span.
51
+ */
52
+ export function combineObservationAdapters(
53
+ adapters: readonly ObservationAdapter[],
54
+ ): ObservationAdapter {
55
+ return {
56
+ onStart(span) {
57
+ for (const adapter of adapters) {
58
+ try {
59
+ adapter.onStart?.(span)
60
+ } catch {
61
+ // Observation is fail-open by contract.
62
+ }
63
+ }
64
+ },
65
+ onEnd(span) {
66
+ for (const adapter of adapters) {
67
+ try {
68
+ adapter.onEnd(span)
69
+ } catch {
70
+ // Observation is fail-open by contract.
71
+ }
72
+ }
73
+ },
74
+ }
75
+ }
76
+
44
77
  /** A no-frills exporter that logs each completed span as one structured line. Useful in dev or as a
45
78
  * starting point before wiring a real backend. */
46
79
  export function consoleSpanExporter(
package/src/tracing.ts CHANGED
@@ -7,31 +7,20 @@
7
7
 
8
8
  import { definePlugin } from "@nifrajs/core"
9
9
  import {
10
- type AttributeValue,
11
- consoleSpanExporter,
12
- type NifraSpan,
13
- type SpanExporter,
14
- } from "./span.ts"
15
- import {
16
- formatTraceparent,
17
- generateSpanId,
18
- generateTraceId,
19
- parseTraceparent,
20
- } from "./traceparent.ts"
10
+ type ActiveObservation,
11
+ createObservationLifecycle,
12
+ type ObservationContext,
13
+ } from "./lifecycle.ts"
14
+ import { consoleSpanExporter, type ObservationAdapter, type SpanExporter } from "./span.ts"
21
15
 
22
16
  /** The trace context exposed on the handler `c.trace` (typed, threaded via `derive`). */
23
- export interface TraceContext {
24
- readonly traceId: string
25
- readonly spanId: string
26
- readonly parentSpanId?: string
27
- readonly sampled: boolean
28
- /** This request's outbound `traceparent` — forward it on downstream calls to continue the trace. */
29
- readonly traceparent: string
30
- }
17
+ export type TraceContext = ObservationContext
31
18
 
32
19
  export interface TracingOptions {
33
20
  /** Where spans are sent. Default: {@link consoleSpanExporter}. */
34
21
  readonly exporter?: SpanExporter
22
+ /** Additional observation adapters (DevTools, a private redacting backend, metrics, …). */
23
+ readonly adapters?: readonly ObservationAdapter[]
35
24
  /** Sets the `service.name` attribute on every span. */
36
25
  readonly serviceName?: string
37
26
  /**
@@ -65,60 +54,56 @@ const pathOf = (url: string): string => {
65
54
  * ```
66
55
  */
67
56
  export function tracing(options: TracingOptions = {}) {
68
- const exporter = options.exporter ?? consoleSpanExporter()
57
+ const adapters: ObservationAdapter[] = [
58
+ ...(options.exporter === undefined && options.adapters === undefined
59
+ ? [consoleSpanExporter()]
60
+ : options.exporter === undefined
61
+ ? []
62
+ : [options.exporter]),
63
+ ...(options.adapters ?? []),
64
+ ]
65
+ const lifecycle = createObservationLifecycle({ adapters })
69
66
  const serviceName = options.serviceName
70
- // Span per in-flight request, keyed by the Request — ended in onResponse (mirrors @nifrajs/middleware
71
- // timing). A WeakMap so an abandoned request can't leak a span.
72
- const inFlight = new WeakMap<Request, NifraSpan>()
67
+ // A WeakMap so an abandoned request cannot leak its active observation.
68
+ const inFlight = new WeakMap<Request, ActiveObservation>()
73
69
 
74
70
  return definePlugin("tracing", (app) =>
75
71
  app
76
72
  .derive((c) => {
77
- const parent = parseTraceparent(c.req.headers.get("traceparent"))
78
- const traceId = parent?.traceId ?? generateTraceId()
79
- const spanId = generateSpanId()
80
- const sampled = parent?.sampled ?? true
81
73
  const path = pathOf(c.req.url)
82
- const attributes: Record<string, AttributeValue> = {
83
- "http.request.method": c.req.method,
84
- "url.path": path,
85
- }
86
- if (serviceName !== undefined) attributes["service.name"] = serviceName
87
- const span: NifraSpan = {
88
- traceId,
89
- spanId,
90
- ...(parent !== null ? { parentSpanId: parent.spanId } : {}),
91
- sampled,
74
+ const observation = lifecycle.start({
92
75
  name: `${c.req.method} ${path}`,
93
- startTime: Date.now(),
94
- status: "unset",
95
- attributes,
96
- }
97
- inFlight.set(c.req, span)
98
- exporter.onStart?.(span)
99
- const traceparent = formatTraceparent(traceId, spanId, sampled)
100
- if (options.responseHeader) c.set.headers.traceparent = traceparent
101
- const trace: TraceContext = {
102
- traceId,
103
- spanId,
104
- ...(parent !== null ? { parentSpanId: parent.spanId } : {}),
105
- sampled,
106
- traceparent,
107
- }
108
- return { trace }
76
+ traceparent: c.req.headers.get("traceparent"),
77
+ attributes: {
78
+ "http.request.method": c.req.method,
79
+ "url.path": path,
80
+ ...(serviceName === undefined ? {} : { "service.name": serviceName }),
81
+ },
82
+ })
83
+ inFlight.set(c.req, observation)
84
+ if (options.responseHeader) c.set.headers.traceparent = observation.context.traceparent
85
+ return { trace: observation.context, observation }
109
86
  })
110
87
  .use({
111
88
  name: "tracing-end",
89
+ onError: (error, context) => {
90
+ inFlight.get(context.request)?.recordError(error)
91
+ return undefined
92
+ },
112
93
  onResponse: (res, req) => {
113
- const span = inFlight.get(req)
114
- if (span === undefined) return res
94
+ const observation = inFlight.get(req)
95
+ if (observation === undefined) return res
115
96
  inFlight.delete(req)
116
- span.endTime = Date.now()
117
- span.durationMs = span.endTime - span.startTime
118
- span.attributes["http.response.status_code"] = res.status
119
- // 5xx is a server error; everything else (incl. handled 4xx) is a normal outcome.
120
- span.status = res.status >= 500 ? "error" : "ok"
121
- exporter.onEnd(span)
97
+ const bodySize = Number(res.headers.get("content-length") ?? "0") || 0
98
+ const isrStatus = res.headers.get("x-nifra-isr")
99
+ observation.end({
100
+ statusCode: res.status,
101
+ attributes: {
102
+ "http.response.status_code": res.status,
103
+ "http.response.body.size": bodySize,
104
+ ...(isrStatus === null ? {} : { "nifra.isr.status": isrStatus }),
105
+ },
106
+ })
122
107
  return res
123
108
  },
124
109
  }),