@nifrajs/otel 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/span.d.ts +43 -0
- package/dist/span.d.ts.map +1 -0
- package/dist/span.js +26 -0
- package/dist/span.js.map +1 -0
- package/dist/traceparent.d.ts +27 -0
- package/dist/traceparent.d.ts.map +1 -0
- package/dist/traceparent.js +53 -0
- package/dist/traceparent.js.map +1 -0
- package/dist/tracing.d.ts +43 -0
- package/dist/tracing.d.ts.map +1 -0
- package/dist/tracing.js +92 -0
- package/dist/tracing.js.map +1 -0
- package/package.json +56 -0
- package/src/index.ts +26 -0
- package/src/span.ts +66 -0
- package/src/traceparent.ts +58 -0
- package/src/tracing.ts +126 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nifra Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @nifrajs/otel
|
|
2
|
+
|
|
3
|
+
Distributed tracing for nifra. The `tracing()` plugin continues (or starts) a **W3C trace** per
|
|
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.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { tracing, traceHeaders, consoleSpanExporter } from "@nifrajs/otel"
|
|
10
|
+
|
|
11
|
+
const app = server()
|
|
12
|
+
.use(tracing({ exporter: consoleSpanExporter(), serviceName: "orders-api" }))
|
|
13
|
+
.get("/orders/:id", async (c) => {
|
|
14
|
+
// continue the trace into a downstream call:
|
|
15
|
+
const res = await fetch(`${INVENTORY_URL}/stock`, { headers: traceHeaders(c.trace) })
|
|
16
|
+
return { id: c.params.id, inStock: (await res.json()).ok }
|
|
17
|
+
})
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## What it does per request
|
|
21
|
+
|
|
22
|
+
- **Continues an inbound trace** — parses the `traceparent` header; reuses its `trace-id` and records
|
|
23
|
+
the inbound span as the parent. No inbound header → starts a fresh trace.
|
|
24
|
+
- **Opens a span** with HTTP semantic-convention attributes (`http.request.method`, `url.path`,
|
|
25
|
+
`http.response.status_code`, optional `service.name`), ended on response with duration + status
|
|
26
|
+
(`error` for 5xx, `ok` otherwise).
|
|
27
|
+
- **Exposes `c.trace`** (`{ traceId, spanId, parentSpanId?, sampled, traceparent }`) — spread
|
|
28
|
+
`traceHeaders(c.trace)` into any downstream `fetch`/`ctx.api` call to continue the trace.
|
|
29
|
+
- `responseHeader: true` also sets `traceparent` on the response (browser/client correlation).
|
|
30
|
+
|
|
31
|
+
## Exporters
|
|
32
|
+
|
|
33
|
+
Implement `SpanExporter` to send spans wherever you collect them:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
interface SpanExporter {
|
|
37
|
+
onStart?(span: NifraSpan): void
|
|
38
|
+
onEnd(span: NifraSpan): void
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `consoleSpanExporter()` — logs each completed span as one structured line (dev / starting point).
|
|
43
|
+
- **OpenTelemetry SDK bridge** — a ~10-line adapter maps `NifraSpan` onto a real OTel `Span` from a
|
|
44
|
+
`Tracer` (the attribute names already follow OTel conventions, so they pass straight through). Your
|
|
45
|
+
app depends on `@opentelemetry/*`; `@nifrajs/otel` does not.
|
|
46
|
+
|
|
47
|
+
## Connect your collector
|
|
48
|
+
|
|
49
|
+
Use `traceparent` and the built-in semantic attributes in every request span, then send spans to
|
|
50
|
+
your own collector through an exporter. Keep the package edge-safe by installing the OpenTelemetry
|
|
51
|
+
SDK only in apps that need that exporter.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/otel — distributed tracing for nifra. The `tracing()` plugin continues (or starts) a W3C
|
|
3
|
+
* trace per request, opens an OpenTelemetry-semantic-convention span, and exposes `c.trace` so you
|
|
4
|
+
* can forward the trace to downstream services with `traceHeaders(c.trace)`. Spans go to a pluggable
|
|
5
|
+
* `SpanExporter` — bridge to the OpenTelemetry SDK, or log with `consoleSpanExporter`. No SDK
|
|
6
|
+
* bundled; edge-safe.
|
|
7
|
+
*
|
|
8
|
+
* import { tracing, traceHeaders, consoleSpanExporter } from "@nifrajs/otel"
|
|
9
|
+
* app.use(tracing({ exporter: consoleSpanExporter(), serviceName: "orders-api" }))
|
|
10
|
+
*/
|
|
11
|
+
export { type AttributeValue, consoleSpanExporter, type NifraSpan, type SpanExporter, type SpanStatus, } from "./span.js";
|
|
12
|
+
export { formatTraceparent, generateSpanId, generateTraceId, type ParsedTraceparent, parseTraceparent, } from "./traceparent.js";
|
|
13
|
+
export { type TraceContext, type TracingOptions, traceHeaders, tracing } from "./tracing.js";
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/otel — distributed tracing for nifra. The `tracing()` plugin continues (or starts) a W3C
|
|
3
|
+
* trace per request, opens an OpenTelemetry-semantic-convention span, and exposes `c.trace` so you
|
|
4
|
+
* can forward the trace to downstream services with `traceHeaders(c.trace)`. Spans go to a pluggable
|
|
5
|
+
* `SpanExporter` — bridge to the OpenTelemetry SDK, or log with `consoleSpanExporter`. No SDK
|
|
6
|
+
* bundled; edge-safe.
|
|
7
|
+
*
|
|
8
|
+
* import { tracing, traceHeaders, consoleSpanExporter } from "@nifrajs/otel"
|
|
9
|
+
* app.use(tracing({ exporter: consoleSpanExporter(), serviceName: "orders-api" }))
|
|
10
|
+
*/
|
|
11
|
+
export { consoleSpanExporter, } from "./span.js";
|
|
12
|
+
export { formatTraceparent, generateSpanId, generateTraceId, parseTraceparent, } from "./traceparent.js";
|
|
13
|
+
export { traceHeaders, tracing } from "./tracing.js";
|
|
14
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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"}
|
package/dist/span.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The span model + exporter seam. Attribute names follow OpenTelemetry HTTP semantic conventions
|
|
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}
|
|
5
|
+
* (a ~10-line adapter to `@opentelemetry/api`, or the bundled {@link consoleSpanExporter}).
|
|
6
|
+
*/
|
|
7
|
+
export type SpanStatus = "unset" | "ok" | "error";
|
|
8
|
+
export type AttributeValue = string | number | boolean;
|
|
9
|
+
/** A completed (or in-flight) server span for one request. */
|
|
10
|
+
export interface NifraSpan {
|
|
11
|
+
/** 32-hex W3C trace id — shared across every span/service in the trace. */
|
|
12
|
+
readonly traceId: string;
|
|
13
|
+
/** 16-hex id of this span. */
|
|
14
|
+
readonly spanId: string;
|
|
15
|
+
/** The inbound span's id, if this request continued an upstream trace. */
|
|
16
|
+
readonly parentSpanId?: string;
|
|
17
|
+
/** Whether the trace is sampled (the W3C flag). */
|
|
18
|
+
readonly sampled: boolean;
|
|
19
|
+
/** Span name — `"<METHOD> <path>"`. */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** Wall-clock start (epoch ms). */
|
|
22
|
+
readonly startTime: number;
|
|
23
|
+
/** Wall-clock end (epoch ms) — set on completion. */
|
|
24
|
+
endTime?: number;
|
|
25
|
+
/** Duration in ms (monotonic). */
|
|
26
|
+
durationMs?: number;
|
|
27
|
+
status: SpanStatus;
|
|
28
|
+
/** OTel-semantic-convention attributes. */
|
|
29
|
+
readonly attributes: Record<string, AttributeValue>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Where ended spans go. Implement this to bridge to the OpenTelemetry SDK (map each field onto a
|
|
33
|
+
* real `Span` from a `Tracer`), ship to a collector, or just log. `onStart` is optional (most
|
|
34
|
+
* backends only need the completed span).
|
|
35
|
+
*/
|
|
36
|
+
export interface SpanExporter {
|
|
37
|
+
onStart?(span: NifraSpan): void;
|
|
38
|
+
onEnd(span: NifraSpan): void;
|
|
39
|
+
}
|
|
40
|
+
/** A no-frills exporter that logs each completed span as one structured line. Useful in dev or as a
|
|
41
|
+
* starting point before wiring a real backend. */
|
|
42
|
+
export declare function consoleSpanExporter(log?: (line: string) => void): SpanExporter;
|
|
43
|
+
//# sourceMappingURL=span.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/span.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The span model + exporter seam. Attribute names follow OpenTelemetry HTTP semantic conventions
|
|
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}
|
|
5
|
+
* (a ~10-line adapter to `@opentelemetry/api`, or the bundled {@link consoleSpanExporter}).
|
|
6
|
+
*/
|
|
7
|
+
/** A no-frills exporter that logs each completed span as one structured line. Useful in dev or as a
|
|
8
|
+
* starting point before wiring a real backend. */
|
|
9
|
+
export function consoleSpanExporter(log = (l) => {
|
|
10
|
+
console.log(l);
|
|
11
|
+
}) {
|
|
12
|
+
return {
|
|
13
|
+
onEnd(span) {
|
|
14
|
+
log(JSON.stringify({
|
|
15
|
+
name: span.name,
|
|
16
|
+
traceId: span.traceId,
|
|
17
|
+
spanId: span.spanId,
|
|
18
|
+
parentSpanId: span.parentSpanId,
|
|
19
|
+
durationMs: span.durationMs,
|
|
20
|
+
status: span.status,
|
|
21
|
+
...span.attributes,
|
|
22
|
+
}));
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=span.js.map
|
package/dist/span.js.map
ADDED
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W3C Trace Context — `traceparent` parsing/formatting + id generation. This is OpenTelemetry's
|
|
3
|
+
* wire format for propagating a trace across services, so producing/forwarding it correctly is the
|
|
4
|
+
* core of distributed tracing regardless of which backend ends up collecting the spans.
|
|
5
|
+
*
|
|
6
|
+
* traceparent: 00-<32-hex trace-id>-<16-hex span-id>-<2-hex flags>
|
|
7
|
+
*
|
|
8
|
+
* Pure + dependency-free; the request plugin is a thin layer over this.
|
|
9
|
+
*/
|
|
10
|
+
/** A parsed inbound `traceparent`. */
|
|
11
|
+
export interface ParsedTraceparent {
|
|
12
|
+
readonly traceId: string;
|
|
13
|
+
readonly spanId: string;
|
|
14
|
+
readonly sampled: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse a `traceparent` header, or `null` if it's absent/malformed/version-unknown — per the spec,
|
|
18
|
+
* a bad header means "start a fresh trace", never an error. Only version `00` is accepted.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseTraceparent(header: string | null | undefined): ParsedTraceparent | null;
|
|
21
|
+
/** Format a `traceparent` header value (version `00`). */
|
|
22
|
+
export declare function formatTraceparent(traceId: string, spanId: string, sampled: boolean): string;
|
|
23
|
+
/** A fresh 16-byte (32-hex) trace id. */
|
|
24
|
+
export declare function generateTraceId(): string;
|
|
25
|
+
/** A fresh 8-byte (16-hex) span id. */
|
|
26
|
+
export declare function generateSpanId(): string;
|
|
27
|
+
//# sourceMappingURL=traceparent.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"traceparent.d.ts","sourceRoot":"","sources":["../src/traceparent.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,sCAAsC;AACtC,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;AAMD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,iBAAiB,GAAG,IAAI,CAW5F;AAED,0DAA0D;AAC1D,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAE3F;AAQD,yCAAyC;AACzC,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,uCAAuC;AACvC,wBAAgB,cAAc,IAAI,MAAM,CAEvC"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W3C Trace Context — `traceparent` parsing/formatting + id generation. This is OpenTelemetry's
|
|
3
|
+
* wire format for propagating a trace across services, so producing/forwarding it correctly is the
|
|
4
|
+
* core of distributed tracing regardless of which backend ends up collecting the spans.
|
|
5
|
+
*
|
|
6
|
+
* traceparent: 00-<32-hex trace-id>-<16-hex span-id>-<2-hex flags>
|
|
7
|
+
*
|
|
8
|
+
* Pure + dependency-free; the request plugin is a thin layer over this.
|
|
9
|
+
*/
|
|
10
|
+
const HEX = /^[0-9a-f]+$/;
|
|
11
|
+
const ZERO_TRACE = "00000000000000000000000000000000";
|
|
12
|
+
const ZERO_SPAN = "0000000000000000";
|
|
13
|
+
/**
|
|
14
|
+
* Parse a `traceparent` header, or `null` if it's absent/malformed/version-unknown — per the spec,
|
|
15
|
+
* a bad header means "start a fresh trace", never an error. Only version `00` is accepted.
|
|
16
|
+
*/
|
|
17
|
+
export function parseTraceparent(header) {
|
|
18
|
+
if (!header)
|
|
19
|
+
return null;
|
|
20
|
+
const parts = header.trim().split("-");
|
|
21
|
+
if (parts.length !== 4)
|
|
22
|
+
return null;
|
|
23
|
+
const [version, traceId, spanId, flags] = parts;
|
|
24
|
+
if (version !== "00")
|
|
25
|
+
return null;
|
|
26
|
+
if (traceId.length !== 32 || !HEX.test(traceId) || traceId === ZERO_TRACE)
|
|
27
|
+
return null;
|
|
28
|
+
if (spanId.length !== 16 || !HEX.test(spanId) || spanId === ZERO_SPAN)
|
|
29
|
+
return null;
|
|
30
|
+
if (flags.length !== 2 || !HEX.test(flags))
|
|
31
|
+
return null;
|
|
32
|
+
// bit 0 of the flags byte = sampled
|
|
33
|
+
return { traceId, spanId, sampled: (Number.parseInt(flags, 16) & 0x01) === 1 };
|
|
34
|
+
}
|
|
35
|
+
/** Format a `traceparent` header value (version `00`). */
|
|
36
|
+
export function formatTraceparent(traceId, spanId, sampled) {
|
|
37
|
+
return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`;
|
|
38
|
+
}
|
|
39
|
+
const toHex = (bytes) => {
|
|
40
|
+
let out = "";
|
|
41
|
+
for (const b of bytes)
|
|
42
|
+
out += b.toString(16).padStart(2, "0");
|
|
43
|
+
return out;
|
|
44
|
+
};
|
|
45
|
+
/** A fresh 16-byte (32-hex) trace id. */
|
|
46
|
+
export function generateTraceId() {
|
|
47
|
+
return toHex(crypto.getRandomValues(new Uint8Array(16)));
|
|
48
|
+
}
|
|
49
|
+
/** A fresh 8-byte (16-hex) span id. */
|
|
50
|
+
export function generateSpanId() {
|
|
51
|
+
return toHex(crypto.getRandomValues(new Uint8Array(8)));
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=traceparent.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"traceparent.js","sourceRoot":"","sources":["../src/traceparent.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AASH,MAAM,GAAG,GAAG,aAAa,CAAA;AACzB,MAAM,UAAU,GAAG,kCAAkC,CAAA;AACrD,MAAM,SAAS,GAAG,kBAAkB,CAAA;AAEpC;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAiC;IAChE,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAA;IACxB,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IACtC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACnC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG,KAAyC,CAAA;IACnF,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,IAAI,CAAA;IACjC,IAAI,OAAO,CAAC,MAAM,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,OAAO,KAAK,UAAU;QAAE,OAAO,IAAI,CAAA;IACtF,IAAI,MAAM,CAAC,MAAM,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,IAAI,CAAA;IAClF,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACvD,oCAAoC;IACpC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAA;AAChF,CAAC;AAED,0DAA0D;AAC1D,MAAM,UAAU,iBAAiB,CAAC,OAAe,EAAE,MAAc,EAAE,OAAgB;IACjF,OAAO,MAAM,OAAO,IAAI,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;AAC3D,CAAC;AAED,MAAM,KAAK,GAAG,CAAC,KAAiB,EAAU,EAAE;IAC1C,IAAI,GAAG,GAAG,EAAE,CAAA;IACZ,KAAK,MAAM,CAAC,IAAI,KAAK;QAAE,GAAG,IAAI,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;IAC7D,OAAO,GAAG,CAAA;AACZ,CAAC,CAAA;AAED,yCAAyC;AACzC,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,CAAC,MAAM,CAAC,eAAe,CAAC,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;AAC1D,CAAC;AAED,uCAAuC;AACvC,MAAM,UAAU,cAAc;IAC5B,OAAO,KAAK,CAAC,MAAM,CAAC,eAAe,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AACzD,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `tracing()` plugin — establishes a span per request, propagates W3C trace context, and exposes
|
|
3
|
+
* `c.trace` for forwarding the trace to downstream services. OpenTelemetry-compatible by wire format
|
|
4
|
+
* (traceparent) and attribute names (HTTP semantic conventions); the span itself is exported through
|
|
5
|
+
* your {@link SpanExporter} (bridge to the OTel SDK, or log via `consoleSpanExporter`).
|
|
6
|
+
*/
|
|
7
|
+
import { type SpanExporter } from "./span.js";
|
|
8
|
+
/** 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
|
+
}
|
|
17
|
+
export interface TracingOptions {
|
|
18
|
+
/** Where spans are sent. Default: {@link consoleSpanExporter}. */
|
|
19
|
+
readonly exporter?: SpanExporter;
|
|
20
|
+
/** Sets the `service.name` attribute on every span. */
|
|
21
|
+
readonly serviceName?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Set the outbound `traceparent` on the RESPONSE too (handy for browser/client correlation).
|
|
24
|
+
* Default false — most setups only propagate downstream, not back to the caller.
|
|
25
|
+
*/
|
|
26
|
+
readonly responseHeader?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Spread into an outgoing `fetch`/`ctx.api` call's headers to continue the trace downstream:
|
|
29
|
+
* `fetch(url, { headers: traceHeaders(c.trace) })`. */
|
|
30
|
+
export declare function traceHeaders(trace: TraceContext): {
|
|
31
|
+
traceparent: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Distributed-tracing plugin. Each request continues the inbound trace (or starts one), opens a
|
|
35
|
+
* server span, and ends it on response with the status + HTTP attributes. Idempotent.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* app.use(tracing({ exporter: myOtelExporter, serviceName: "orders-api" }))
|
|
39
|
+
* // in a handler: fetch(url, { headers: traceHeaders(c.trace) }) // continue the trace downstream
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare function tracing(options?: TracingOptions): import("@nifrajs/core").NifraPlugin<import("@nifrajs/core").AnyServer, import("@nifrajs/core").Server<any, any>>;
|
|
43
|
+
//# sourceMappingURL=tracing.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/tracing.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `tracing()` plugin — establishes a span per request, propagates W3C trace context, and exposes
|
|
3
|
+
* `c.trace` for forwarding the trace to downstream services. OpenTelemetry-compatible by wire format
|
|
4
|
+
* (traceparent) and attribute names (HTTP semantic conventions); the span itself is exported through
|
|
5
|
+
* your {@link SpanExporter} (bridge to the OTel SDK, or log via `consoleSpanExporter`).
|
|
6
|
+
*/
|
|
7
|
+
import { definePlugin } from "@nifrajs/core";
|
|
8
|
+
import { consoleSpanExporter, } from "./span.js";
|
|
9
|
+
import { formatTraceparent, generateSpanId, generateTraceId, parseTraceparent, } from "./traceparent.js";
|
|
10
|
+
/** Spread into an outgoing `fetch`/`ctx.api` call's headers to continue the trace downstream:
|
|
11
|
+
* `fetch(url, { headers: traceHeaders(c.trace) })`. */
|
|
12
|
+
export function traceHeaders(trace) {
|
|
13
|
+
return { traceparent: trace.traceparent };
|
|
14
|
+
}
|
|
15
|
+
const pathOf = (url) => {
|
|
16
|
+
try {
|
|
17
|
+
return new URL(url).pathname;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return url;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Distributed-tracing plugin. Each request continues the inbound trace (or starts one), opens a
|
|
25
|
+
* server span, and ends it on response with the status + HTTP attributes. Idempotent.
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* app.use(tracing({ exporter: myOtelExporter, serviceName: "orders-api" }))
|
|
29
|
+
* // in a handler: fetch(url, { headers: traceHeaders(c.trace) }) // continue the trace downstream
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function tracing(options = {}) {
|
|
33
|
+
const exporter = options.exporter ?? consoleSpanExporter();
|
|
34
|
+
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.
|
|
37
|
+
const inFlight = new WeakMap();
|
|
38
|
+
return definePlugin("tracing", (app) => app
|
|
39
|
+
.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
|
+
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,
|
|
56
|
+
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);
|
|
64
|
+
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 };
|
|
74
|
+
})
|
|
75
|
+
.use({
|
|
76
|
+
name: "tracing-end",
|
|
77
|
+
onResponse: (res, req) => {
|
|
78
|
+
const span = inFlight.get(req);
|
|
79
|
+
if (span === undefined)
|
|
80
|
+
return res;
|
|
81
|
+
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);
|
|
88
|
+
return res;
|
|
89
|
+
},
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=tracing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracing.js","sourceRoot":"","sources":["../src/tracing.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAC5C,OAAO,EAEL,mBAAmB,GAGpB,MAAM,WAAW,CAAA;AAClB,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,eAAe,EACf,gBAAgB,GACjB,MAAM,kBAAkB,CAAA;AAwBzB;uDACuD;AACvD,MAAM,UAAU,YAAY,CAAC,KAAmB;IAC9C,OAAO,EAAE,WAAW,EAAE,KAAK,CAAC,WAAW,EAAE,CAAA;AAC3C,CAAC;AAED,MAAM,MAAM,GAAG,CAAC,GAAW,EAAU,EAAE;IACrC,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAA;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,CAAA;IACZ,CAAC;AACH,CAAC,CAAA;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,OAAO,CAAC,UAA0B,EAAE;IAClD,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,mBAAmB,EAAE,CAAA;IAC1D,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAA;IACvC,sGAAsG;IACtG,gEAAgE;IAChE,MAAM,QAAQ,GAAG,IAAI,OAAO,EAAsB,CAAA;IAElD,OAAO,YAAY,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,EAAE,CACrC,GAAG;SACA,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE;QACZ,MAAM,MAAM,GAAG,gBAAgB,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAA;QACjE,MAAM,OAAO,GAAG,MAAM,EAAE,OAAO,IAAI,eAAe,EAAE,CAAA;QACpD,MAAM,MAAM,GAAG,cAAc,EAAE,CAAA;QAC/B,MAAM,OAAO,GAAG,MAAM,EAAE,OAAO,IAAI,IAAI,CAAA;QACvC,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAC9B,MAAM,UAAU,GAAmC;YACjD,qBAAqB,EAAE,CAAC,CAAC,GAAG,CAAC,MAAM;YACnC,UAAU,EAAE,IAAI;SACjB,CAAA;QACD,IAAI,WAAW,KAAK,SAAS;YAAE,UAAU,CAAC,cAAc,CAAC,GAAG,WAAW,CAAA;QACvE,MAAM,IAAI,GAAc;YACtB,OAAO;YACP,MAAM;YACN,GAAG,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC3D,OAAO;YACP,IAAI,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,IAAI,IAAI,EAAE;YAC/B,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;YACrB,MAAM,EAAE,OAAO;YACf,UAAU;SACX,CAAA;QACD,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,IAAI,CAAC,CAAA;QACzB,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAA;QACxB,MAAM,WAAW,GAAG,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,CAAA;QAC/D,IAAI,OAAO,CAAC,cAAc;YAAE,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,WAAW,GAAG,WAAW,CAAA;QACnE,MAAM,KAAK,GAAiB;YAC1B,OAAO;YACP,MAAM;YACN,GAAG,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC3D,OAAO;YACP,WAAW;SACZ,CAAA;QACD,OAAO,EAAE,KAAK,EAAE,CAAA;IAClB,CAAC,CAAC;SACD,GAAG,CAAC;QACH,IAAI,EAAE,aAAa;QACnB,UAAU,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;YACvB,MAAM,IAAI,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;YAC9B,IAAI,IAAI,KAAK,SAAS;gBAAE,OAAO,GAAG,CAAA;YAClC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;YACpB,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;YACzB,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,SAAS,CAAA;YAC/C,IAAI,CAAC,UAAU,CAAC,2BAA2B,CAAC,GAAG,GAAG,CAAC,MAAM,CAAA;YACzD,kFAAkF;YAClF,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAA;YAChD,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;YACpB,OAAO,GAAG,CAAA;QACZ,CAAC;KACF,CAAC,CACL,CAAA;AACH,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/otel",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"web-framework",
|
|
10
|
+
"full-stack",
|
|
11
|
+
"edge",
|
|
12
|
+
"ssr",
|
|
13
|
+
"opentelemetry",
|
|
14
|
+
"tracing",
|
|
15
|
+
"observability",
|
|
16
|
+
"w3c-traceparent"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"bun": "./src/index.ts",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"src"
|
|
32
|
+
],
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
36
|
+
},
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@nifrajs/core": "workspace:*"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@nifrajs/core": "workspace:*"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
50
|
+
"directory": "packages/otel"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
56
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/otel — distributed tracing for nifra. The `tracing()` plugin continues (or starts) a W3C
|
|
3
|
+
* trace per request, opens an OpenTelemetry-semantic-convention span, and exposes `c.trace` so you
|
|
4
|
+
* can forward the trace to downstream services with `traceHeaders(c.trace)`. Spans go to a pluggable
|
|
5
|
+
* `SpanExporter` — bridge to the OpenTelemetry SDK, or log with `consoleSpanExporter`. No SDK
|
|
6
|
+
* bundled; edge-safe.
|
|
7
|
+
*
|
|
8
|
+
* import { tracing, traceHeaders, consoleSpanExporter } from "@nifrajs/otel"
|
|
9
|
+
* app.use(tracing({ exporter: consoleSpanExporter(), serviceName: "orders-api" }))
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
type AttributeValue,
|
|
14
|
+
consoleSpanExporter,
|
|
15
|
+
type NifraSpan,
|
|
16
|
+
type SpanExporter,
|
|
17
|
+
type SpanStatus,
|
|
18
|
+
} from "./span.ts"
|
|
19
|
+
export {
|
|
20
|
+
formatTraceparent,
|
|
21
|
+
generateSpanId,
|
|
22
|
+
generateTraceId,
|
|
23
|
+
type ParsedTraceparent,
|
|
24
|
+
parseTraceparent,
|
|
25
|
+
} from "./traceparent.ts"
|
|
26
|
+
export { type TraceContext, type TracingOptions, traceHeaders, tracing } from "./tracing.ts"
|
package/src/span.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The span model + exporter seam. Attribute names follow OpenTelemetry HTTP semantic conventions
|
|
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}
|
|
5
|
+
* (a ~10-line adapter to `@opentelemetry/api`, or the bundled {@link consoleSpanExporter}).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type SpanStatus = "unset" | "ok" | "error"
|
|
9
|
+
export type AttributeValue = string | number | boolean
|
|
10
|
+
|
|
11
|
+
/** A completed (or in-flight) server span for one request. */
|
|
12
|
+
export interface NifraSpan {
|
|
13
|
+
/** 32-hex W3C trace id — shared across every span/service in the trace. */
|
|
14
|
+
readonly traceId: string
|
|
15
|
+
/** 16-hex id of this span. */
|
|
16
|
+
readonly spanId: string
|
|
17
|
+
/** The inbound span's id, if this request continued an upstream trace. */
|
|
18
|
+
readonly parentSpanId?: string
|
|
19
|
+
/** Whether the trace is sampled (the W3C flag). */
|
|
20
|
+
readonly sampled: boolean
|
|
21
|
+
/** Span name — `"<METHOD> <path>"`. */
|
|
22
|
+
readonly name: string
|
|
23
|
+
/** Wall-clock start (epoch ms). */
|
|
24
|
+
readonly startTime: number
|
|
25
|
+
/** Wall-clock end (epoch ms) — set on completion. */
|
|
26
|
+
endTime?: number
|
|
27
|
+
/** Duration in ms (monotonic). */
|
|
28
|
+
durationMs?: number
|
|
29
|
+
status: SpanStatus
|
|
30
|
+
/** OTel-semantic-convention attributes. */
|
|
31
|
+
readonly attributes: Record<string, AttributeValue>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where ended spans go. Implement this to bridge to the OpenTelemetry SDK (map each field onto a
|
|
36
|
+
* real `Span` from a `Tracer`), ship to a collector, or just log. `onStart` is optional (most
|
|
37
|
+
* backends only need the completed span).
|
|
38
|
+
*/
|
|
39
|
+
export interface SpanExporter {
|
|
40
|
+
onStart?(span: NifraSpan): void
|
|
41
|
+
onEnd(span: NifraSpan): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A no-frills exporter that logs each completed span as one structured line. Useful in dev or as a
|
|
45
|
+
* starting point before wiring a real backend. */
|
|
46
|
+
export function consoleSpanExporter(
|
|
47
|
+
log: (line: string) => void = (l) => {
|
|
48
|
+
console.log(l)
|
|
49
|
+
},
|
|
50
|
+
): SpanExporter {
|
|
51
|
+
return {
|
|
52
|
+
onEnd(span) {
|
|
53
|
+
log(
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
name: span.name,
|
|
56
|
+
traceId: span.traceId,
|
|
57
|
+
spanId: span.spanId,
|
|
58
|
+
parentSpanId: span.parentSpanId,
|
|
59
|
+
durationMs: span.durationMs,
|
|
60
|
+
status: span.status,
|
|
61
|
+
...span.attributes,
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W3C Trace Context — `traceparent` parsing/formatting + id generation. This is OpenTelemetry's
|
|
3
|
+
* wire format for propagating a trace across services, so producing/forwarding it correctly is the
|
|
4
|
+
* core of distributed tracing regardless of which backend ends up collecting the spans.
|
|
5
|
+
*
|
|
6
|
+
* traceparent: 00-<32-hex trace-id>-<16-hex span-id>-<2-hex flags>
|
|
7
|
+
*
|
|
8
|
+
* Pure + dependency-free; the request plugin is a thin layer over this.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A parsed inbound `traceparent`. */
|
|
12
|
+
export interface ParsedTraceparent {
|
|
13
|
+
readonly traceId: string
|
|
14
|
+
readonly spanId: string
|
|
15
|
+
readonly sampled: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const HEX = /^[0-9a-f]+$/
|
|
19
|
+
const ZERO_TRACE = "00000000000000000000000000000000"
|
|
20
|
+
const ZERO_SPAN = "0000000000000000"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse a `traceparent` header, or `null` if it's absent/malformed/version-unknown — per the spec,
|
|
24
|
+
* a bad header means "start a fresh trace", never an error. Only version `00` is accepted.
|
|
25
|
+
*/
|
|
26
|
+
export function parseTraceparent(header: string | null | undefined): ParsedTraceparent | null {
|
|
27
|
+
if (!header) return null
|
|
28
|
+
const parts = header.trim().split("-")
|
|
29
|
+
if (parts.length !== 4) return null
|
|
30
|
+
const [version, traceId, spanId, flags] = parts as [string, string, string, string]
|
|
31
|
+
if (version !== "00") return null
|
|
32
|
+
if (traceId.length !== 32 || !HEX.test(traceId) || traceId === ZERO_TRACE) return null
|
|
33
|
+
if (spanId.length !== 16 || !HEX.test(spanId) || spanId === ZERO_SPAN) return null
|
|
34
|
+
if (flags.length !== 2 || !HEX.test(flags)) return null
|
|
35
|
+
// bit 0 of the flags byte = sampled
|
|
36
|
+
return { traceId, spanId, sampled: (Number.parseInt(flags, 16) & 0x01) === 1 }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Format a `traceparent` header value (version `00`). */
|
|
40
|
+
export function formatTraceparent(traceId: string, spanId: string, sampled: boolean): string {
|
|
41
|
+
return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const toHex = (bytes: Uint8Array): string => {
|
|
45
|
+
let out = ""
|
|
46
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0")
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A fresh 16-byte (32-hex) trace id. */
|
|
51
|
+
export function generateTraceId(): string {
|
|
52
|
+
return toHex(crypto.getRandomValues(new Uint8Array(16)))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A fresh 8-byte (16-hex) span id. */
|
|
56
|
+
export function generateSpanId(): string {
|
|
57
|
+
return toHex(crypto.getRandomValues(new Uint8Array(8)))
|
|
58
|
+
}
|
package/src/tracing.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `tracing()` plugin — establishes a span per request, propagates W3C trace context, and exposes
|
|
3
|
+
* `c.trace` for forwarding the trace to downstream services. OpenTelemetry-compatible by wire format
|
|
4
|
+
* (traceparent) and attribute names (HTTP semantic conventions); the span itself is exported through
|
|
5
|
+
* your {@link SpanExporter} (bridge to the OTel SDK, or log via `consoleSpanExporter`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { definePlugin } from "@nifrajs/core"
|
|
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"
|
|
21
|
+
|
|
22
|
+
/** 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
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TracingOptions {
|
|
33
|
+
/** Where spans are sent. Default: {@link consoleSpanExporter}. */
|
|
34
|
+
readonly exporter?: SpanExporter
|
|
35
|
+
/** Sets the `service.name` attribute on every span. */
|
|
36
|
+
readonly serviceName?: string
|
|
37
|
+
/**
|
|
38
|
+
* Set the outbound `traceparent` on the RESPONSE too (handy for browser/client correlation).
|
|
39
|
+
* Default false — most setups only propagate downstream, not back to the caller.
|
|
40
|
+
*/
|
|
41
|
+
readonly responseHeader?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Spread into an outgoing `fetch`/`ctx.api` call's headers to continue the trace downstream:
|
|
45
|
+
* `fetch(url, { headers: traceHeaders(c.trace) })`. */
|
|
46
|
+
export function traceHeaders(trace: TraceContext): { traceparent: string } {
|
|
47
|
+
return { traceparent: trace.traceparent }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pathOf = (url: string): string => {
|
|
51
|
+
try {
|
|
52
|
+
return new URL(url).pathname
|
|
53
|
+
} catch {
|
|
54
|
+
return url
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Distributed-tracing plugin. Each request continues the inbound trace (or starts one), opens a
|
|
60
|
+
* server span, and ends it on response with the status + HTTP attributes. Idempotent.
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* app.use(tracing({ exporter: myOtelExporter, serviceName: "orders-api" }))
|
|
64
|
+
* // in a handler: fetch(url, { headers: traceHeaders(c.trace) }) // continue the trace downstream
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function tracing(options: TracingOptions = {}) {
|
|
68
|
+
const exporter = options.exporter ?? consoleSpanExporter()
|
|
69
|
+
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>()
|
|
73
|
+
|
|
74
|
+
return definePlugin("tracing", (app) =>
|
|
75
|
+
app
|
|
76
|
+
.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
|
+
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,
|
|
92
|
+
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 }
|
|
109
|
+
})
|
|
110
|
+
.use({
|
|
111
|
+
name: "tracing-end",
|
|
112
|
+
onResponse: (res, req) => {
|
|
113
|
+
const span = inFlight.get(req)
|
|
114
|
+
if (span === undefined) return res
|
|
115
|
+
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)
|
|
122
|
+
return res
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
}
|