@zerotal/telemetry 1.0.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/package.json +51 -0
- package/src/Span.ts +134 -0
- package/src/SpanContext.ts +18 -0
- package/src/Tracer.ts +118 -0
- package/src/bridge.ts +68 -0
- package/src/config.ts +42 -0
- package/src/exporters/ConsoleExporter.ts +32 -0
- package/src/exporters/NoopExporter.ts +7 -0
- package/src/exporters/OtlpExporter.ts +128 -0
- package/src/exporters/SpanExporter.ts +8 -0
- package/src/index.ts +34 -0
- package/src/middleware/TelemetryMiddleware.ts +65 -0
- package/src/provider/TelemetryProvider.ts +74 -0
- package/src/withSpan.ts +43 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog — @zerotal/telemetry
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `beta`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.0.0] — 2026-08-05
|
|
12
|
+
|
|
13
|
+
_First public release._
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Renamed config interface to `TelemetryConfigShape`; added a `TelemetryConfig()` factory.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
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,91 @@
|
|
|
1
|
+
# @zerotal/telemetry
|
|
2
|
+
|
|
3
|
+
> OpenTelemetry-style distributed tracing with zero external SDK dependency.
|
|
4
|
+
|
|
5
|
+
A self-contained tracer with an OTLP-compatible span model, `AsyncLocalStorage` context propagation, and pluggable exporters. Wrap any operation in `withSpan()` and child spans automatically inherit the parent trace; export to the console, an OTLP endpoint, or a custom exporter.
|
|
6
|
+
|
|
7
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @zerotal/telemetry
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
Register the provider in `bootstrap/providers.ts`. List it **before** other providers so the global tracer is ready when the rest of the application boots:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { TelemetryProvider } from "@zerotal/telemetry";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
`withSpan()` is the primary call-site API. It uses the global tracer; the span is ended, exported, and its status set automatically:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { withSpan } from "@zerotal/telemetry";
|
|
29
|
+
|
|
30
|
+
async function processOrder(id: string) {
|
|
31
|
+
return withSpan("process-order", async (span) => {
|
|
32
|
+
span.setAttribute("order.id", id);
|
|
33
|
+
const order = await Order.findOrFail(id);
|
|
34
|
+
await notifyWarehouse(order);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Nested `withSpan()` calls automatically become children of the active span; the current span is available anywhere in the async stack:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { currentSpan } from "@zerotal/telemetry";
|
|
43
|
+
|
|
44
|
+
await withSpan("handle-request", async () => {
|
|
45
|
+
await withSpan("validate-input", async () => {
|
|
46
|
+
/* … */
|
|
47
|
+
});
|
|
48
|
+
await withSpan("query-db", async (span) => {
|
|
49
|
+
span.addEvent("db.query", { table: "orders", rows: 5 });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const traceId = currentSpan()?.data.traceId;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Trace every HTTP request with `TelemetryMiddleware` (register after `LoggerMiddleware`):
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { TelemetryMiddleware } from "@zerotal/telemetry";
|
|
60
|
+
|
|
61
|
+
app.use([LoggerMiddleware, TelemetryMiddleware]);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
For advanced scenarios, create a `Tracer` directly with a chosen exporter:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { Tracer, ConsoleExporter } from "@zerotal/telemetry";
|
|
68
|
+
|
|
69
|
+
const tracer = new Tracer({ exporter: new ConsoleExporter(), minDurationMs: 5 });
|
|
70
|
+
|
|
71
|
+
await tracer.withSpan("my-op", async (span) => {
|
|
72
|
+
span.setAttribute("foo", "bar");
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Exports
|
|
77
|
+
|
|
78
|
+
- `withSpan` — global helper to run a callback inside an auto-managed span.
|
|
79
|
+
- `currentSpan` — read the active span anywhere in the async call stack.
|
|
80
|
+
- `Span` / `NoopSpan` — span primitives (`setAttribute`, `setAttributes`, `setStatus`, `addEvent`, `recordException`, `end`).
|
|
81
|
+
- `SpanContext` — `AsyncLocalStorage`-backed context propagation.
|
|
82
|
+
- `Tracer` — create a tracer with `withSpan()` and `startSpan()` for manual lifetime management.
|
|
83
|
+
- `TelemetryMiddleware` — creates a `server`-kind root span per HTTP request; `.with({ spanName })` to customize.
|
|
84
|
+
- Exporters: `NoopExporter` (default), `ConsoleExporter`, `OtlpExporter` (any OTLP HTTP/JSON backend).
|
|
85
|
+
- `TelemetryProvider` — service provider; registers the global tracer.
|
|
86
|
+
- `TelemetryConfig` — config factory.
|
|
87
|
+
- Types: `SpanData`, `SpanEvent`, `SpanStatus`, `SpanStatusCode`, `SpanKind`, `TracerOptions`, `SpanOptions`, `SpanExporter`, `OtlpExporterOptions`, `TelemetryOptions`, `TelemetryConfigShape`.
|
|
88
|
+
|
|
89
|
+
## Documentation
|
|
90
|
+
|
|
91
|
+
- [Telemetry](../../docs/telemetry.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/telemetry",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "beta",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"src",
|
|
16
|
+
"!src/**/*.test.ts",
|
|
17
|
+
"!src/**/*.test.tsx",
|
|
18
|
+
"!src/**/*.spec.ts",
|
|
19
|
+
"!src/**/__fixtures__/**"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.3.14"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@zerotal/core": "1.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"typescript": "^5.8.0"
|
|
36
|
+
},
|
|
37
|
+
"description": "OpenTelemetry tracing bridge for Zerotal framework events.",
|
|
38
|
+
"keywords": [
|
|
39
|
+
"zerotal",
|
|
40
|
+
"bun",
|
|
41
|
+
"typescript",
|
|
42
|
+
"framework"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
47
|
+
"directory": "packages/telemetry"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/telemetry#readme",
|
|
50
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
51
|
+
}
|
package/src/Span.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type SpanStatusCode = "unset" | "ok" | "error";
|
|
4
|
+
export type SpanKind = "internal" | "server" | "client" | "producer" | "consumer";
|
|
5
|
+
|
|
6
|
+
export interface SpanStatus {
|
|
7
|
+
code: SpanStatusCode;
|
|
8
|
+
message?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SpanEvent {
|
|
12
|
+
name: string;
|
|
13
|
+
timeMs: number;
|
|
14
|
+
attributes?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SpanData {
|
|
18
|
+
traceId: string;
|
|
19
|
+
spanId: string;
|
|
20
|
+
parentId: string | undefined;
|
|
21
|
+
name: string;
|
|
22
|
+
kind: SpanKind;
|
|
23
|
+
startMs: number;
|
|
24
|
+
endMs: number | undefined;
|
|
25
|
+
attributes: Record<string, string | number | boolean>;
|
|
26
|
+
status: SpanStatus;
|
|
27
|
+
events: SpanEvent[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Span ──────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A single unit of work in a distributed trace.
|
|
34
|
+
*
|
|
35
|
+
* Spans are created by `Tracer.withSpan()` and `withSpan()`. They are
|
|
36
|
+
* automatically ended and exported when the wrapped callback resolves or
|
|
37
|
+
* throws. Manual `end()` is only needed for fire-and-forget patterns.
|
|
38
|
+
*/
|
|
39
|
+
export class Span {
|
|
40
|
+
readonly data: SpanData;
|
|
41
|
+
|
|
42
|
+
constructor(name: string, parentId?: string, traceId?: string, kind: SpanKind = "internal") {
|
|
43
|
+
this.data = {
|
|
44
|
+
traceId: traceId ?? _randomHex(16),
|
|
45
|
+
spanId: _randomHex(8),
|
|
46
|
+
parentId,
|
|
47
|
+
name,
|
|
48
|
+
kind,
|
|
49
|
+
startMs: Date.now(),
|
|
50
|
+
endMs: undefined,
|
|
51
|
+
attributes: {},
|
|
52
|
+
status: { code: "unset" },
|
|
53
|
+
events: [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setAttribute(key: string, value: string | number | boolean): this {
|
|
58
|
+
this.data.attributes[key] = value;
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setAttributes(attrs: Record<string, string | number | boolean>): this {
|
|
63
|
+
Object.assign(this.data.attributes, attrs);
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setStatus(code: "ok" | "error", message?: string): this {
|
|
68
|
+
this.data.status = message !== undefined ? { code, message } : { code };
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
addEvent(name: string, attributes?: Record<string, unknown>): this {
|
|
73
|
+
const event: SpanEvent = { name, timeMs: Date.now() };
|
|
74
|
+
if (attributes !== undefined) event.attributes = attributes;
|
|
75
|
+
this.data.events.push(event);
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
recordException(error: Error): this {
|
|
80
|
+
return this.addEvent("exception", {
|
|
81
|
+
"exception.type": error.name,
|
|
82
|
+
"exception.message": error.message,
|
|
83
|
+
"exception.stack": error.stack ?? "",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
end(): void {
|
|
88
|
+
if (this.data.endMs === undefined) this.data.endMs = Date.now();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get durationMs(): number {
|
|
92
|
+
return (this.data.endMs ?? Date.now()) - this.data.startMs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get isEnded(): boolean {
|
|
96
|
+
return this.data.endMs !== undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── NoopSpan ──────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A span whose methods are all no-ops.
|
|
104
|
+
* Used when telemetry is disabled so call sites never need null-checks.
|
|
105
|
+
*/
|
|
106
|
+
export class NoopSpan extends Span {
|
|
107
|
+
constructor() {
|
|
108
|
+
super("noop");
|
|
109
|
+
}
|
|
110
|
+
override setAttribute(_key: string, _value: string | number | boolean): this {
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
override setAttributes(_attrs: Record<string, string | number | boolean>): this {
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
override setStatus(_code: "ok" | "error", _message?: string): this {
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
override addEvent(_name: string, _attributes?: Record<string, unknown>): this {
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
override recordException(_error: Error): this {
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
override end(): void {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export function _randomHex(bytes: number): string {
|
|
131
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(bytes)))
|
|
132
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
133
|
+
.join("");
|
|
134
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { Span } from "./Span.ts";
|
|
3
|
+
|
|
4
|
+
interface ActiveContext {
|
|
5
|
+
span: Span;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Propagates the active span through async call stacks via AsyncLocalStorage.
|
|
10
|
+
* Set by `Tracer.withSpan()`; read by nested `withSpan()` calls to attach
|
|
11
|
+
* child spans to the correct parent.
|
|
12
|
+
*/
|
|
13
|
+
export const SpanContext = new AsyncLocalStorage<ActiveContext>();
|
|
14
|
+
|
|
15
|
+
/** Return the currently active span, or `undefined` if none is active. */
|
|
16
|
+
export function currentSpan(): Span | undefined {
|
|
17
|
+
return SpanContext.getStore()?.span;
|
|
18
|
+
}
|
package/src/Tracer.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Span } from "./Span.ts";
|
|
2
|
+
import { SpanContext } from "./SpanContext.ts";
|
|
3
|
+
import type { SpanKind } from "./Span.ts";
|
|
4
|
+
import type { SpanExporter } from "./exporters/SpanExporter.ts";
|
|
5
|
+
|
|
6
|
+
export interface TracerOptions {
|
|
7
|
+
exporter: SpanExporter;
|
|
8
|
+
/** Drop spans in which the callback took less than this many ms. Default: 0 (keep all). */
|
|
9
|
+
minDurationMs?: number;
|
|
10
|
+
/** When true, export() errors are surfaced rather than swallowed. Default: false. */
|
|
11
|
+
rethrowExportErrors?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SpanOptions {
|
|
15
|
+
kind?: SpanKind;
|
|
16
|
+
attributes?: Record<string, string | number | boolean>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates and manages spans. Normally one tracer lives for the lifetime of
|
|
21
|
+
* the process, registered via `TelemetryProvider`.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* const tracer = new Tracer({ exporter: new ConsoleExporter() });
|
|
25
|
+
*
|
|
26
|
+
* await tracer.withSpan('do-work', async (span) => {
|
|
27
|
+
* span.setAttribute('work.type', 'heavy');
|
|
28
|
+
* await heavyWork();
|
|
29
|
+
* });
|
|
30
|
+
*/
|
|
31
|
+
export class Tracer {
|
|
32
|
+
private readonly _exporter: SpanExporter;
|
|
33
|
+
private readonly _minDuration: number;
|
|
34
|
+
private readonly _rethrow: boolean;
|
|
35
|
+
|
|
36
|
+
constructor(options: TracerOptions) {
|
|
37
|
+
this._exporter = options.exporter;
|
|
38
|
+
this._minDuration = options.minDurationMs ?? 0;
|
|
39
|
+
this._rethrow = options.rethrowExportErrors ?? false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runs `fn` inside a new span. The span is automatically ended and exported
|
|
44
|
+
* when `fn` resolves or throws. Child spans created with `withSpan()` inside
|
|
45
|
+
* `fn` will automatically be attached as children.
|
|
46
|
+
*/
|
|
47
|
+
async withSpan<T>(
|
|
48
|
+
name: string,
|
|
49
|
+
fn: (span: Span) => Promise<T>,
|
|
50
|
+
options: SpanOptions = {},
|
|
51
|
+
): Promise<T> {
|
|
52
|
+
const parent = SpanContext.getStore()?.span;
|
|
53
|
+
const traceId = parent?.data.traceId;
|
|
54
|
+
const span = new Span(name, parent?.data.spanId, traceId, options.kind ?? "internal");
|
|
55
|
+
|
|
56
|
+
if (options.attributes) span.setAttributes(options.attributes);
|
|
57
|
+
|
|
58
|
+
let result: T;
|
|
59
|
+
try {
|
|
60
|
+
result = await SpanContext.run({ span }, () => fn(span));
|
|
61
|
+
if (span.data.status.code === "unset") span.setStatus("ok");
|
|
62
|
+
} catch (err) {
|
|
63
|
+
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
64
|
+
span.setStatus("error", err instanceof Error ? err.message : String(err));
|
|
65
|
+
span.end();
|
|
66
|
+
await this._tryExport(span);
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
span.end();
|
|
71
|
+
await this._tryExport(span);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Record an already-completed unit of work as a span. Used to bridge synchronous
|
|
77
|
+
* framework events (which carry their own `durationMs`) into the trace pipeline:
|
|
78
|
+
* the span is back-dated by `durationMs`, ended, and exported immediately. It has
|
|
79
|
+
* no parent/active context — these spans represent self-contained, past work.
|
|
80
|
+
*/
|
|
81
|
+
async recordCompleted(
|
|
82
|
+
name: string,
|
|
83
|
+
durationMs: number,
|
|
84
|
+
options: SpanOptions & { status?: "ok" | "error"; errorMessage?: string } = {},
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const span = new Span(name, undefined, undefined, options.kind ?? "internal");
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
span.data.startMs = now - Math.max(0, durationMs);
|
|
89
|
+
span.data.endMs = now;
|
|
90
|
+
if (options.attributes) span.setAttributes(options.attributes);
|
|
91
|
+
span.setStatus(options.status ?? "ok", options.errorMessage);
|
|
92
|
+
await this._tryExport(span);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Start a span manually. Caller is responsible for calling `span.end()`. */
|
|
96
|
+
startSpan(name: string, options: SpanOptions = {}): Span {
|
|
97
|
+
const parent = SpanContext.getStore()?.span;
|
|
98
|
+
const traceId = parent?.data.traceId;
|
|
99
|
+
const span = new Span(name, parent?.data.spanId, traceId, options.kind ?? "internal");
|
|
100
|
+
if (options.attributes) span.setAttributes(options.attributes);
|
|
101
|
+
return span;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Flush any pending spans and tear down the exporter. */
|
|
105
|
+
async shutdown(): Promise<void> {
|
|
106
|
+
await this._exporter.shutdown?.();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async _tryExport(span: Span): Promise<void> {
|
|
110
|
+
if (span.durationMs < this._minDuration) return;
|
|
111
|
+
try {
|
|
112
|
+
await this._exporter.export(span.data);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (this._rethrow) throw err;
|
|
115
|
+
// Silently drop — telemetry failures must never affect the application
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { FrameworkEvents } from "@zerotal/core";
|
|
2
|
+
import type { AppBooted, RequestHandled, RequestFailed } from "@zerotal/core";
|
|
3
|
+
import type { Tracer } from "./Tracer.ts";
|
|
4
|
+
|
|
5
|
+
/** Extract the HTTP-readable fields from the opaque HttpContext object. */
|
|
6
|
+
function _http(raw: object) {
|
|
7
|
+
return raw as {
|
|
8
|
+
response?: { status?: number };
|
|
9
|
+
request?: { method?: string };
|
|
10
|
+
url?: { pathname?: string };
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Bridge the core lifecycle events (`AppBooted`, `RequestHandled`, `RequestFailed`)
|
|
16
|
+
* into the trace pipeline as completed spans. Telemetry subscribes to core; core
|
|
17
|
+
* never depends on telemetry.
|
|
18
|
+
*
|
|
19
|
+
* Feature packages forward their own signal (DB queries, jobs, scheduled tasks)
|
|
20
|
+
* through their own telemetry bridge, which resolves this tracer from the container
|
|
21
|
+
* when telemetry is installed. Telemetry therefore knows nothing about feature
|
|
22
|
+
* packages, and adding one requires no change here.
|
|
23
|
+
*
|
|
24
|
+
* Returns a function that removes every subscription (call in `onStopping()`).
|
|
25
|
+
*/
|
|
26
|
+
export function installEventBridge(tracer: Tracer): () => void {
|
|
27
|
+
const unsubs: Array<() => void> = [
|
|
28
|
+
// ── Application lifecycle ──────────────────────────────────────────────────
|
|
29
|
+
FrameworkEvents.on<AppBooted>("AppBooted", (e) => {
|
|
30
|
+
void tracer.recordCompleted("app.boot", e.durationMs, {
|
|
31
|
+
attributes: { "app.environment": e.environment, "app.providers": e.providerCount },
|
|
32
|
+
});
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
// ── HTTP ───────────────────────────────────────────────────────────────────
|
|
36
|
+
FrameworkEvents.on<RequestHandled>("RequestHandled", (e) => {
|
|
37
|
+
const c = _http(e.ctx);
|
|
38
|
+
const status = c.response?.status ?? 0;
|
|
39
|
+
void tracer.recordCompleted("http.request", e.durationMs, {
|
|
40
|
+
kind: "server",
|
|
41
|
+
attributes: {
|
|
42
|
+
"http.method": c.request?.method ?? "?",
|
|
43
|
+
"http.route": c.url?.pathname ?? "?",
|
|
44
|
+
"http.status_code": status,
|
|
45
|
+
},
|
|
46
|
+
status: status >= 500 ? "error" : "ok",
|
|
47
|
+
});
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
FrameworkEvents.on<RequestFailed>("RequestFailed", (e) => {
|
|
51
|
+
const c = _http(e.ctx);
|
|
52
|
+
void tracer.recordCompleted("http.request", e.durationMs, {
|
|
53
|
+
kind: "server",
|
|
54
|
+
attributes: {
|
|
55
|
+
"http.method": c.request?.method ?? "?",
|
|
56
|
+
"http.route": c.url?.pathname ?? "?",
|
|
57
|
+
"http.status_code": e.status,
|
|
58
|
+
},
|
|
59
|
+
status: "error",
|
|
60
|
+
errorMessage: e.error,
|
|
61
|
+
});
|
|
62
|
+
}),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
for (const unsub of unsubs) unsub();
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { deepMerge } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
export interface TelemetryConfigShape {
|
|
4
|
+
/**
|
|
5
|
+
* Exporter backend.
|
|
6
|
+
* - `'noop'` - discards all spans (default when telemetry is not configured)
|
|
7
|
+
* - `'console'` - prints to stdout, useful in development
|
|
8
|
+
* - `'otlp'` - sends to an OTLP HTTP endpoint
|
|
9
|
+
*/
|
|
10
|
+
exporter?: "noop" | "console" | "otlp";
|
|
11
|
+
|
|
12
|
+
/** Only relevant when `exporter` is `'otlp'`. */
|
|
13
|
+
otlp?: {
|
|
14
|
+
endpoint?: string;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Service name sent as a resource attribute. Defaults to `APP_NAME` env var or `'zerotal-app'`. */
|
|
19
|
+
serviceName?: string;
|
|
20
|
+
|
|
21
|
+
/** Service version. Defaults to `APP_VERSION` env var or `'0.0.0'`. */
|
|
22
|
+
serviceVersion?: string;
|
|
23
|
+
|
|
24
|
+
/** Drop spans shorter than this many milliseconds. Default: 0 (keep all). */
|
|
25
|
+
minDurationMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaults: TelemetryConfigShape = {
|
|
29
|
+
exporter: "noop",
|
|
30
|
+
serviceName: "zerotal-app",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function TelemetryConfig(options: Partial<TelemetryConfigShape> = {}): TelemetryConfigShape {
|
|
34
|
+
return deepMerge(defaults, options);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Register this package's config namespace for typed config() dot-paths.
|
|
38
|
+
declare module "@zerotal/core" {
|
|
39
|
+
interface ConfigRegistry {
|
|
40
|
+
telemetry: TelemetryConfigShape;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SpanExporter } from "./SpanExporter.ts";
|
|
2
|
+
import type { SpanData } from "../Span.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prints completed spans to stdout in a human-readable format.
|
|
6
|
+
* Intended for development and debugging — not for production use.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* new Tracer({ exporter: new ConsoleExporter() })
|
|
10
|
+
*/
|
|
11
|
+
export class ConsoleExporter implements SpanExporter {
|
|
12
|
+
async export(span: SpanData): Promise<void> {
|
|
13
|
+
const duration = span.endMs !== undefined ? span.endMs - span.startMs : "?";
|
|
14
|
+
const parent = span.parentId ? `← ${span.parentId.slice(0, 8)}` : "(root)";
|
|
15
|
+
const status = span.status.code === "unset" ? "ok" : span.status.code;
|
|
16
|
+
const attrs = Object.entries(span.attributes)
|
|
17
|
+
.map(([k, v]) => ` ${k}: ${v}`)
|
|
18
|
+
.join("\n");
|
|
19
|
+
|
|
20
|
+
const lines = [
|
|
21
|
+
`[telemetry] ${span.name}`,
|
|
22
|
+
` trace=${span.traceId.slice(0, 16)} span=${span.spanId} ${parent}`,
|
|
23
|
+
` status=${status} duration=${duration}ms`,
|
|
24
|
+
];
|
|
25
|
+
if (attrs) lines.push(attrs);
|
|
26
|
+
if (span.events.length > 0) {
|
|
27
|
+
lines.push(` events: ${span.events.map((e) => e.name).join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(lines.join("\n"));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SpanExporter } from "./SpanExporter.ts";
|
|
2
|
+
import type { SpanData } from "../Span.ts";
|
|
3
|
+
|
|
4
|
+
/** Discards all spans. Used when telemetry is explicitly disabled. */
|
|
5
|
+
export class NoopExporter implements SpanExporter {
|
|
6
|
+
async export(_span: SpanData): Promise<void> {}
|
|
7
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { SpanExporter } from "./SpanExporter.ts";
|
|
2
|
+
import type { SpanData, SpanKind } from "../Span.ts";
|
|
3
|
+
|
|
4
|
+
export interface OtlpExporterOptions {
|
|
5
|
+
/** OTLP HTTP/JSON endpoint. Default: `'http://localhost:4318/v1/traces'`. */
|
|
6
|
+
endpoint?: string;
|
|
7
|
+
/** Extra headers (e.g. `{ 'x-honeycomb-team': '<api-key>' }`). */
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
/** Service name sent as a resource attribute. */
|
|
10
|
+
serviceName?: string;
|
|
11
|
+
/** Service version sent as a resource attribute. */
|
|
12
|
+
serviceVersion?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SPAN_KIND: Record<SpanKind, number> = {
|
|
16
|
+
internal: 1,
|
|
17
|
+
server: 2,
|
|
18
|
+
client: 3,
|
|
19
|
+
producer: 4,
|
|
20
|
+
consumer: 5,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const STATUS_CODE: Record<string, number> = {
|
|
24
|
+
unset: 0,
|
|
25
|
+
ok: 1,
|
|
26
|
+
error: 2,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sends completed spans to an OTLP-compatible backend over HTTP/JSON.
|
|
31
|
+
*
|
|
32
|
+
* Compatible with OpenTelemetry Collector, Jaeger, Honeycomb, Grafana Tempo,
|
|
33
|
+
* and any service that accepts the OTLP trace JSON format.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* new OtlpExporter({
|
|
37
|
+
* endpoint: 'https://api.honeycomb.io/v1/traces',
|
|
38
|
+
* headers: { 'x-honeycomb-team': env('HONEYCOMB_API_KEY') },
|
|
39
|
+
* serviceName: 'my-api',
|
|
40
|
+
* })
|
|
41
|
+
*/
|
|
42
|
+
export class OtlpExporter implements SpanExporter {
|
|
43
|
+
private readonly _endpoint: string;
|
|
44
|
+
private readonly _headers: Record<string, string>;
|
|
45
|
+
private readonly _serviceName: string;
|
|
46
|
+
private readonly _serviceVer: string;
|
|
47
|
+
|
|
48
|
+
constructor(options: OtlpExporterOptions = {}) {
|
|
49
|
+
this._endpoint = options.endpoint ?? "http://localhost:4318/v1/traces";
|
|
50
|
+
this._headers = options.headers ?? {};
|
|
51
|
+
this._serviceName = options.serviceName ?? "zerotal-app";
|
|
52
|
+
this._serviceVer = options.serviceVersion ?? "0.0.0";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async export(span: SpanData): Promise<void> {
|
|
56
|
+
const body = this._buildPayload(span);
|
|
57
|
+
try {
|
|
58
|
+
await fetch(this._endpoint, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
...this._headers,
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify(body),
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
// Silently drop — telemetry failures must not affect the application
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private _buildPayload(span: SpanData): object {
|
|
72
|
+
const toNanoStr = (ms: number) => (BigInt(ms) * 1_000_000n).toString();
|
|
73
|
+
|
|
74
|
+
const otlpAttrs = (attrs: Record<string, string | number | boolean>) =>
|
|
75
|
+
Object.entries(attrs).map(([key, value]) => ({
|
|
76
|
+
key,
|
|
77
|
+
value:
|
|
78
|
+
typeof value === "string"
|
|
79
|
+
? { stringValue: value }
|
|
80
|
+
: typeof value === "number"
|
|
81
|
+
? Number.isInteger(value)
|
|
82
|
+
? { intValue: value }
|
|
83
|
+
: { doubleValue: value }
|
|
84
|
+
: { boolValue: value },
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
resourceSpans: [
|
|
89
|
+
{
|
|
90
|
+
resource: {
|
|
91
|
+
attributes: otlpAttrs({
|
|
92
|
+
"service.name": this._serviceName,
|
|
93
|
+
"service.version": this._serviceVer,
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
scopeSpans: [
|
|
97
|
+
{
|
|
98
|
+
scope: { name: "@zerotal/telemetry" },
|
|
99
|
+
spans: [
|
|
100
|
+
{
|
|
101
|
+
traceId: span.traceId,
|
|
102
|
+
spanId: span.spanId,
|
|
103
|
+
parentSpanId: span.parentId ?? "",
|
|
104
|
+
name: span.name,
|
|
105
|
+
kind: SPAN_KIND[span.kind] ?? 1,
|
|
106
|
+
startTimeUnixNano: toNanoStr(span.startMs),
|
|
107
|
+
endTimeUnixNano: toNanoStr(span.endMs ?? span.startMs),
|
|
108
|
+
attributes: otlpAttrs(span.attributes),
|
|
109
|
+
status: {
|
|
110
|
+
code: STATUS_CODE[span.status.code] ?? 0,
|
|
111
|
+
message: span.status.message ?? "",
|
|
112
|
+
},
|
|
113
|
+
events: span.events.map((e) => ({
|
|
114
|
+
name: e.name,
|
|
115
|
+
timeUnixNano: toNanoStr(e.timeMs),
|
|
116
|
+
attributes: otlpAttrs(
|
|
117
|
+
(e.attributes ?? {}) as Record<string, string | number | boolean>,
|
|
118
|
+
),
|
|
119
|
+
})),
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SpanData } from "../Span.ts";
|
|
2
|
+
|
|
3
|
+
/** Receives completed spans and sends them to a backend. */
|
|
4
|
+
export interface SpanExporter {
|
|
5
|
+
export(span: SpanData): Promise<void>;
|
|
6
|
+
/** Called on graceful shutdown — flush pending spans and close connections. */
|
|
7
|
+
shutdown?(): Promise<void>;
|
|
8
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Core primitives
|
|
2
|
+
export { Span, NoopSpan, _randomHex } from "./Span.ts";
|
|
3
|
+
export type { SpanData, SpanEvent, SpanStatus, SpanStatusCode, SpanKind } from "./Span.ts";
|
|
4
|
+
|
|
5
|
+
// Context propagation
|
|
6
|
+
export { SpanContext, currentSpan } from "./SpanContext.ts";
|
|
7
|
+
|
|
8
|
+
// Tracer
|
|
9
|
+
export { Tracer } from "./Tracer.ts";
|
|
10
|
+
export type { TracerOptions, SpanOptions } from "./Tracer.ts";
|
|
11
|
+
|
|
12
|
+
// Global helper
|
|
13
|
+
export { withSpan } from "./withSpan.ts";
|
|
14
|
+
|
|
15
|
+
// Exporters
|
|
16
|
+
export type { SpanExporter } from "./exporters/SpanExporter.ts";
|
|
17
|
+
export { NoopExporter } from "./exporters/NoopExporter.ts";
|
|
18
|
+
export { ConsoleExporter } from "./exporters/ConsoleExporter.ts";
|
|
19
|
+
export { OtlpExporter } from "./exporters/OtlpExporter.ts";
|
|
20
|
+
export type { OtlpExporterOptions } from "./exporters/OtlpExporter.ts";
|
|
21
|
+
|
|
22
|
+
// Middleware
|
|
23
|
+
export { TelemetryMiddleware } from "./middleware/TelemetryMiddleware.ts";
|
|
24
|
+
export type { TelemetryOptions } from "./middleware/TelemetryMiddleware.ts";
|
|
25
|
+
|
|
26
|
+
// Provider
|
|
27
|
+
export { TelemetryProvider } from "./provider/TelemetryProvider.ts";
|
|
28
|
+
|
|
29
|
+
// Event → span bridge (FrameworkEvents → trace pipeline)
|
|
30
|
+
export { installEventBridge } from "./bridge.ts";
|
|
31
|
+
|
|
32
|
+
// Config factory + types
|
|
33
|
+
export { TelemetryConfig } from "./config.ts";
|
|
34
|
+
export type { TelemetryConfigShape } from "./config.ts";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { NextFn } from "@zerotal/core";
|
|
2
|
+
import type { HttpContext } from "@zerotal/core";
|
|
3
|
+
import { BaseMiddleware } from "@zerotal/core";
|
|
4
|
+
import { _getGlobalTracer } from "../withSpan.ts";
|
|
5
|
+
|
|
6
|
+
export interface TelemetryOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Custom function to derive a span name from the request.
|
|
9
|
+
* Default: `"${method} ${pathname}"`.
|
|
10
|
+
*/
|
|
11
|
+
spanName?: (ctx: HttpContext) => string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a server-kind root span for every incoming HTTP request.
|
|
16
|
+
*
|
|
17
|
+
* Standard `http.*` attributes are set automatically:
|
|
18
|
+
* - `http.method`, `http.url`, `http.route`, `http.status_code`
|
|
19
|
+
* - `http.request_id`
|
|
20
|
+
*
|
|
21
|
+
* Any child spans created with `withSpan()` inside route handlers or
|
|
22
|
+
* downstream middleware will automatically attach to this root span.
|
|
23
|
+
*
|
|
24
|
+
* Register near the top of the middleware stack, after `LoggerMiddleware`:
|
|
25
|
+
* `app.use([LoggerMiddleware, TelemetryMiddleware, ...])`
|
|
26
|
+
*/
|
|
27
|
+
export class TelemetryMiddleware extends BaseMiddleware<TelemetryOptions> {
|
|
28
|
+
protected options: TelemetryOptions = {};
|
|
29
|
+
|
|
30
|
+
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
31
|
+
const tracer = _getGlobalTracer();
|
|
32
|
+
if (!tracer) return next();
|
|
33
|
+
|
|
34
|
+
const method = http.request.method.toUpperCase();
|
|
35
|
+
const path = http.url.pathname;
|
|
36
|
+
const name = this.options.spanName?.(http) ?? `${method} ${path}`;
|
|
37
|
+
|
|
38
|
+
await tracer.withSpan(
|
|
39
|
+
name,
|
|
40
|
+
async (span) => {
|
|
41
|
+
span.setAttributes({
|
|
42
|
+
"http.method": method,
|
|
43
|
+
"http.url": http.url.href,
|
|
44
|
+
"http.request_id": http.requestId,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await next();
|
|
48
|
+
|
|
49
|
+
const status = http.response?.status ?? 0;
|
|
50
|
+
span.setAttribute("http.status_code", status);
|
|
51
|
+
|
|
52
|
+
if (http.url.pathname !== path) {
|
|
53
|
+
// pathname changed — record the matched route
|
|
54
|
+
span.setAttribute("http.route", http.url.pathname);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (status >= 500) span.setStatus("error", `HTTP ${status}`);
|
|
58
|
+
else span.setStatus("ok");
|
|
59
|
+
},
|
|
60
|
+
{ kind: "server" },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return http.response;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ServiceProvider } from "@zerotal/core";
|
|
2
|
+
import type { AppEnvironment } from "@zerotal/core";
|
|
3
|
+
import type { ConfigManager } from "@zerotal/core/config";
|
|
4
|
+
import { Tracer } from "../Tracer.ts";
|
|
5
|
+
import { installEventBridge } from "../bridge.ts";
|
|
6
|
+
import { _setGlobalTracer, _getGlobalTracer } from "../withSpan.ts";
|
|
7
|
+
import { NoopExporter } from "../exporters/NoopExporter.ts";
|
|
8
|
+
import { ConsoleExporter } from "../exporters/ConsoleExporter.ts";
|
|
9
|
+
import { OtlpExporter } from "../exporters/OtlpExporter.ts";
|
|
10
|
+
import type { SpanExporter } from "../exporters/SpanExporter.ts";
|
|
11
|
+
import type { TelemetryConfigShape } from "../config.ts";
|
|
12
|
+
|
|
13
|
+
declare module "@zerotal/core" {
|
|
14
|
+
interface ContainerBindings {
|
|
15
|
+
telemetry: Tracer;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class TelemetryProvider extends ServiceProvider {
|
|
20
|
+
static override provides = ["telemetry"] as const;
|
|
21
|
+
static override environments: AppEnvironment[] = ["web", "console", "test", "repl"];
|
|
22
|
+
|
|
23
|
+
private _disposeBridge: (() => void) | undefined = undefined;
|
|
24
|
+
|
|
25
|
+
override onRegister(): void {
|
|
26
|
+
this.app.container.singleton("telemetry", () => {
|
|
27
|
+
const config = this.app.container.makeSync("config") as ConfigManager;
|
|
28
|
+
const telConfig = config.get<TelemetryConfigShape>("telemetry", {});
|
|
29
|
+
|
|
30
|
+
const exporterType = telConfig.exporter ?? "noop";
|
|
31
|
+
const serviceName = telConfig.serviceName ?? "zerotal-app";
|
|
32
|
+
const serviceVersion = telConfig.serviceVersion ?? "0.0.0";
|
|
33
|
+
|
|
34
|
+
let exporter: SpanExporter;
|
|
35
|
+
switch (exporterType) {
|
|
36
|
+
case "console":
|
|
37
|
+
exporter = new ConsoleExporter();
|
|
38
|
+
break;
|
|
39
|
+
case "otlp": {
|
|
40
|
+
const otlpOpts: import("../exporters/OtlpExporter.ts").OtlpExporterOptions = {
|
|
41
|
+
serviceName,
|
|
42
|
+
serviceVersion,
|
|
43
|
+
};
|
|
44
|
+
if (telConfig.otlp?.endpoint) otlpOpts.endpoint = telConfig.otlp.endpoint;
|
|
45
|
+
if (telConfig.otlp?.headers) otlpOpts.headers = telConfig.otlp.headers;
|
|
46
|
+
exporter = new OtlpExporter(otlpOpts);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case "noop":
|
|
50
|
+
default:
|
|
51
|
+
exporter = new NoopExporter();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tracerOpts: import("../Tracer.ts").TracerOptions = { exporter };
|
|
55
|
+
if (telConfig.minDurationMs !== undefined) tracerOpts.minDurationMs = telConfig.minDurationMs;
|
|
56
|
+
return new Tracer(tracerOpts);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
override async onBooted(): Promise<void> {
|
|
61
|
+
const tracer = (await this.app.container.make("telemetry")) as Tracer;
|
|
62
|
+
_setGlobalTracer(tracer);
|
|
63
|
+
// Forward framework events (HTTP, DB, queue, scheduler, boot) into the trace
|
|
64
|
+
// pipeline so an OTLP backend sees the same signal the devtools panel does.
|
|
65
|
+
this._disposeBridge = installEventBridge(tracer);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
override async onStopping(): Promise<void> {
|
|
69
|
+
this._disposeBridge?.();
|
|
70
|
+
this._disposeBridge = undefined;
|
|
71
|
+
await _getGlobalTracer()?.shutdown();
|
|
72
|
+
_setGlobalTracer(undefined);
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/withSpan.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Span } from "./Span.ts";
|
|
2
|
+
import { NoopSpan } from "./Span.ts";
|
|
3
|
+
import type { SpanOptions } from "./Tracer.ts";
|
|
4
|
+
|
|
5
|
+
// Module-level tracer reference — set by TelemetryProvider on boot.
|
|
6
|
+
let _globalTracer: import("./Tracer.ts").Tracer | undefined;
|
|
7
|
+
|
|
8
|
+
/** @internal — called by TelemetryProvider */
|
|
9
|
+
export function _setGlobalTracer(tracer: import("./Tracer.ts").Tracer | undefined): void {
|
|
10
|
+
_globalTracer = tracer;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @internal — exposed for testing */
|
|
14
|
+
export function _getGlobalTracer(): import("./Tracer.ts").Tracer | undefined {
|
|
15
|
+
return _globalTracer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs `fn` inside a named span using the global tracer registered by
|
|
20
|
+
* `TelemetryProvider`. If no tracer is registered the callback runs normally
|
|
21
|
+
* and receives a no-op span — no error is thrown.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* import { withSpan } from '@zerotal/telemetry';
|
|
25
|
+
*
|
|
26
|
+
* async function processOrder(id: string) {
|
|
27
|
+
* return withSpan('process-order', async (span) => {
|
|
28
|
+
* span.setAttribute('order.id', id);
|
|
29
|
+
* // ...
|
|
30
|
+
* });
|
|
31
|
+
* }
|
|
32
|
+
*/
|
|
33
|
+
export async function withSpan<T>(
|
|
34
|
+
name: string,
|
|
35
|
+
fn: (span: Span) => Promise<T>,
|
|
36
|
+
options: SpanOptions = {},
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
if (_globalTracer) {
|
|
39
|
+
return _globalTracer.withSpan(name, fn, options);
|
|
40
|
+
}
|
|
41
|
+
// No tracer — run the callback with a no-op span so call sites are always safe
|
|
42
|
+
return fn(new NoopSpan());
|
|
43
|
+
}
|