@alxia/telemetry 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
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,95 @@
1
+ # @alxia/telemetry
2
+
3
+ Traces and logs for [alxia](https://www.npmjs.com/package/@alxia/core), on
4
+ [`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry): no
5
+ OpenTelemetry SDK, no dependency. One server span per request, around
6
+ everything the request runs, named for its route — and every log written
7
+ inside it carrying its trace id.
8
+
9
+ ```sh
10
+ bun add @alxia/telemetry @nxgt/telemetry @alxia/core
11
+ bun add -d typescript
12
+ ```
13
+
14
+ `@nxgt/telemetry` is a peer: the app's own copy, shared with its loggers.
15
+
16
+ ## Usage
17
+
18
+ ```ts
19
+ import { alxia } from '@alxia/core';
20
+ import { telemetry } from '@alxia/telemetry';
21
+ import { consoleExporter, createLogger } from '@nxgt/telemetry';
22
+
23
+ const tracing = telemetry({
24
+ service: 'checkout',
25
+ version: '1.4.0',
26
+ exporters: [consoleExporter()], // or @nxgt/telemetry-otlp's otlpExporter
27
+ traced: (ctx) => ctx.url.pathname !== '/health',
28
+ });
29
+
30
+ const log = createLogger('Orders');
31
+
32
+ const app = alxia()
33
+ .use(tracing)
34
+ .get('/orders/:id', ({ params, span, reply }) => {
35
+ span?.attribute('order.id', params.id);
36
+ log.info('order read'); // carries this request's traceId
37
+ return reply(200, { id: params.id });
38
+ })
39
+ .onStop(() => tracing.telemetry.close()); // awaited, or the last batch is lost
40
+
41
+ app.listen(3000);
42
+ ```
43
+
44
+ ## The span
45
+
46
+ - It is opened by an `around` hook: it holds the hooks, the handler,
47
+ everything they await and the `onResponse` hooks.
48
+ - An inbound `traceparent` continues its trace, as a child of the caller's
49
+ span. An unusable one starts a fresh trace: the header came from a
50
+ stranger.
51
+ - It starts as `GET /orders/o-1` and is renamed `GET /orders/:id` once
52
+ routing has matched, with `http.route`: one dashboard row per route, not
53
+ per order. A request no route matched keeps its path.
54
+ - A route's error is its exception. Only a 5xx makes the span an error: a
55
+ 401 a guard answered is the server working.
56
+ - `traceResponse: true` says the `traceparent` back on the response.
57
+
58
+ ## What it records
59
+
60
+ | attribute | |
61
+ | --- | --- |
62
+ | `http.request.method`, `url.path`, `url.scheme` | the request |
63
+ | `server.address`, `server.port`, `client.address` | where it was addressed, and from |
64
+ | `http.route` | the route, once matched |
65
+ | `http.response.status_code` | the status |
66
+
67
+ They are `@nxgt/telemetry-hono`'s names: a span from either reads the same
68
+ in a dashboard.
69
+
70
+ ## Options
71
+
72
+ The usual `@nxgt/telemetry` options and a `service`, or an existing
73
+ telemetry as `instance` — adopted, never closed. And:
74
+
75
+ | option | default | |
76
+ | --- | --- | --- |
77
+ | `traced` | every request | `(ctx) => boolean`: a health check |
78
+ | `spanName` | `"<METHOD> <path>"` | the name before routing |
79
+ | `traceResponse` | `false` | says `traceparent` back |
80
+
81
+ A hook that throws costs its answer, never the request.
82
+
83
+ ## API
84
+
85
+ | export | |
86
+ | --- | --- |
87
+ | `telemetry(options)` | the plugin, with the telemetry it writes to as `.telemetry`; routes after it read `span` and `telemetry` |
88
+ | `TelemetryPluginOptions` | its options: `service` and `@nxgt/telemetry`'s options, or an `instance`; `traced`, `spanName`, `traceResponse` |
89
+ | `HTTP_METHOD`, `URL_PATH`, `URL_SCHEME`, `HTTP_ROUTE`, `HTTP_STATUS`, `SERVER_ADDRESS`, `SERVER_PORT`, `CLIENT_ADDRESS` | the attribute names a server span carries: `http.request.method`, `url.path`, `url.scheme`, `http.route`, `http.response.status_code`, `server.address`, `server.port`, `client.address` |
90
+
91
+ ## Documentation
92
+
93
+ - [Guide](https://github.com/softistx/alxia/tree/develop/packages/telemetry/docs): the span each request gets and what it records, how a trace crosses services, every option with its default, shutting down, and testing.
94
+ - [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/telemetry/docs/troubleshooting.md): a `tsc` error, an export failure, or a span or a log that is missing.
95
+ - [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/telemetry/docs/roadmap.md): what is coming, and what is not planned.
@@ -0,0 +1,21 @@
1
+ import type { Attributes } from '@nxgt/telemetry';
2
+ /**
3
+ * The semantic conventions a server span carries, by name: the ones a
4
+ * backend's HTTP dashboards look for. They are `@nxgt/telemetry-hono`'s
5
+ * names, kept twice on purpose — importing them would make this package
6
+ * depend on Hono — and a server span here and a client span from
7
+ * `@nxgt/telemetry-httpyz` must agree on them.
8
+ */
9
+ export declare const HTTP_METHOD = "http.request.method";
10
+ export declare const URL_PATH = "url.path";
11
+ export declare const URL_SCHEME = "url.scheme";
12
+ export declare const HTTP_ROUTE = "http.route";
13
+ export declare const HTTP_STATUS = "http.response.status_code";
14
+ export declare const SERVER_ADDRESS = "server.address";
15
+ export declare const SERVER_PORT = "server.port";
16
+ export declare const CLIENT_ADDRESS = "client.address";
17
+ /** A 4xx is the server working: only a 5xx marks a span. */
18
+ export declare function serverFailed(status: number): boolean;
19
+ /** What is known of a request before routing: the route is not, yet. */
20
+ export declare function requestAttributes(url: URL, method: string, ip: string | undefined): Attributes;
21
+ //# sourceMappingURL=attributes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attributes.d.ts","sourceRoot":"","sources":["../src/attributes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,wBAAwB,CAAC;AACjD,eAAO,MAAM,QAAQ,aAAa,CAAC;AACnC,eAAO,MAAM,UAAU,eAAe,CAAC;AACvC,eAAO,MAAM,UAAU,eAAe,CAAC;AACvC,eAAO,MAAM,WAAW,8BAA8B,CAAC;AACvD,eAAO,MAAM,cAAc,mBAAmB,CAAC;AAC/C,eAAO,MAAM,WAAW,gBAAgB,CAAC;AACzC,eAAO,MAAM,cAAc,mBAAmB,CAAC;AAE/C,4DAA4D;AAC5D,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,wEAAwE;AACxE,wBAAgB,iBAAiB,CAChC,GAAG,EAAE,GAAG,EACR,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,MAAM,GAAG,SAAS,GACpB,UAAU,CASZ"}
@@ -0,0 +1,3 @@
1
+ export { CLIENT_ADDRESS, HTTP_METHOD, HTTP_ROUTE, HTTP_STATUS, SERVER_ADDRESS, SERVER_PORT, URL_PATH, URL_SCHEME, } from './attributes';
2
+ export { type TelemetryPluginOptions, telemetry } from './telemetry';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,cAAc,EACd,WAAW,EACX,UAAU,EACV,WAAW,EACX,cAAc,EACd,WAAW,EACX,QAAQ,EACR,UAAU,GACV,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,KAAK,sBAAsB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,100 @@
1
+ // src/attributes.ts
2
+ var HTTP_METHOD = "http.request.method";
3
+ var URL_PATH = "url.path";
4
+ var URL_SCHEME = "url.scheme";
5
+ var HTTP_ROUTE = "http.route";
6
+ var HTTP_STATUS = "http.response.status_code";
7
+ var SERVER_ADDRESS = "server.address";
8
+ var SERVER_PORT = "server.port";
9
+ var CLIENT_ADDRESS = "client.address";
10
+ function serverFailed(status) {
11
+ return status >= 500;
12
+ }
13
+ function requestAttributes(url, method, ip) {
14
+ return {
15
+ [HTTP_METHOD]: method,
16
+ [URL_PATH]: url.pathname,
17
+ [URL_SCHEME]: url.protocol.replace(":", ""),
18
+ [SERVER_ADDRESS]: url.hostname,
19
+ ...url.port === "" ? {} : { [SERVER_PORT]: Number(url.port) },
20
+ ...ip === undefined ? {} : { [CLIENT_ADDRESS]: ip }
21
+ };
22
+ }
23
+ // src/telemetry.ts
24
+ import { alxia } from "@alxia/core";
25
+ import {
26
+ continuing,
27
+ createTelemetry,
28
+ withTelemetry
29
+ } from "@nxgt/telemetry";
30
+ function telemetry(options) {
31
+ const instance = options.instance ?? createTelemetry(options.service, options).install();
32
+ const traced = guarded(options.traced ?? (() => true), () => true);
33
+ const spanName = guarded(options.spanName ?? defaultName, defaultName);
34
+ const scopes = new WeakMap;
35
+ const plugin = alxia().around((ctx, next) => {
36
+ if (!traced(ctx))
37
+ return next();
38
+ return withTelemetry(instance, () => continuing(ctx.request.headers.get("traceparent"), spanName(ctx), { kind: "server" }, async (scope) => {
39
+ scope.attributes(requestAttributes(ctx.url, ctx.request.method, ctx.ip));
40
+ scopes.set(ctx.request, scope);
41
+ let response;
42
+ try {
43
+ response = await next();
44
+ } finally {
45
+ if (ctx.route !== undefined) {
46
+ scope.name = `${ctx.request.method} ${ctx.route}`;
47
+ scope.attribute(HTTP_ROUTE, ctx.route);
48
+ }
49
+ }
50
+ record(scope, response.status, ctx.error);
51
+ if (options.traceResponse) {
52
+ try {
53
+ response.headers.set("traceparent", scope.traceparent());
54
+ } catch {}
55
+ }
56
+ return response;
57
+ }));
58
+ }).derive(({ request }) => ({
59
+ span: scopes.get(request),
60
+ telemetry: instance
61
+ }));
62
+ return Object.assign(plugin, { telemetry: instance });
63
+ }
64
+ function record(scope, status, error) {
65
+ if (error !== undefined) {
66
+ const before = scope.status;
67
+ scope.fail(error);
68
+ if (!serverFailed(status))
69
+ scope.status = before;
70
+ }
71
+ scope.attribute(HTTP_STATUS, status);
72
+ if (serverFailed(status) && scope.status === "ok")
73
+ scope.status = "error";
74
+ }
75
+ function defaultName(ctx) {
76
+ return `${ctx.request.method} ${ctx.url.pathname}`;
77
+ }
78
+ function guarded(hook, fallback) {
79
+ return (ctx) => {
80
+ try {
81
+ return hook(ctx);
82
+ } catch {
83
+ return fallback(ctx);
84
+ }
85
+ };
86
+ }
87
+ export {
88
+ CLIENT_ADDRESS,
89
+ HTTP_METHOD,
90
+ HTTP_ROUTE,
91
+ HTTP_STATUS,
92
+ SERVER_ADDRESS,
93
+ SERVER_PORT,
94
+ URL_PATH,
95
+ URL_SCHEME,
96
+ telemetry
97
+ };
98
+
99
+ //# debugId=1F0C16FED53E6A3964756E2164756E21
100
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/attributes.ts", "../src/telemetry.ts"],
4
+ "sourcesContent": [
5
+ "import type { Attributes } from '@nxgt/telemetry';\n\n/**\n * The semantic conventions a server span carries, by name: the ones a\n * backend's HTTP dashboards look for. They are `@nxgt/telemetry-hono`'s\n * names, kept twice on purpose — importing them would make this package\n * depend on Hono — and a server span here and a client span from\n * `@nxgt/telemetry-httpyz` must agree on them.\n */\nexport const HTTP_METHOD = 'http.request.method';\nexport const URL_PATH = 'url.path';\nexport const URL_SCHEME = 'url.scheme';\nexport const HTTP_ROUTE = 'http.route';\nexport const HTTP_STATUS = 'http.response.status_code';\nexport const SERVER_ADDRESS = 'server.address';\nexport const SERVER_PORT = 'server.port';\nexport const CLIENT_ADDRESS = 'client.address';\n\n/** A 4xx is the server working: only a 5xx marks a span. */\nexport function serverFailed(status: number): boolean {\n\treturn status >= 500;\n}\n\n/** What is known of a request before routing: the route is not, yet. */\nexport function requestAttributes(\n\turl: URL,\n\tmethod: string,\n\tip: string | undefined,\n): Attributes {\n\treturn {\n\t\t[HTTP_METHOD]: method,\n\t\t[URL_PATH]: url.pathname,\n\t\t[URL_SCHEME]: url.protocol.replace(':', ''),\n\t\t[SERVER_ADDRESS]: url.hostname,\n\t\t...(url.port === '' ? {} : { [SERVER_PORT]: Number(url.port) }),\n\t\t...(ip === undefined ? {} : { [CLIENT_ADDRESS]: ip }),\n\t};\n}\n",
6
+ "import { alxia, type RequestContext } from '@alxia/core';\nimport {\n\tcontinuing,\n\tcreateTelemetry,\n\ttype SpanScope,\n\ttype Telemetry,\n\ttype TelemetryOptions,\n\twithTelemetry,\n} from '@nxgt/telemetry';\nimport {\n\tHTTP_ROUTE,\n\tHTTP_STATUS,\n\trequestAttributes,\n\tserverFailed,\n} from './attributes';\n\ninterface Hooks {\n\t/**\n\t * Whether a request gets a span. Every one does by default: a library\n\t * that decides which requests do not matter hides the one that did.\n\t */\n\treadonly traced?: (ctx: RequestContext) => boolean;\n\t/** The span's name before routing. `\"<METHOD> <path>\"` by default, then `\"<METHOD> <route>\"`. */\n\treadonly spanName?: (ctx: RequestContext) => string;\n\t/** Whether the response says `traceparent` back, so a caller can find the trace. Off by default. */\n\treadonly traceResponse?: boolean;\n}\n\n/**\n * A telemetry built from `service` and `@nxgt/telemetry`'s options, and\n * installed; or one handed over as `instance`, adopted and not closed.\n */\nexport type TelemetryPluginOptions =\n\t| (Hooks &\n\t\t\tTelemetryOptions & {\n\t\t\t\t/** The service name: everything groups by it. */\n\t\t\t\treadonly service: string;\n\t\t\t\treadonly instance?: undefined;\n\t\t\t})\n\t| (Hooks & {\n\t\t\treadonly instance: Telemetry;\n\t\t\treadonly service?: undefined;\n\t });\n\n/**\n * One server span per request, with [`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry),\n * as a plugin.\n *\n * The span is opened by an `around` hook, so it holds everything the\n * request runs — the hooks, the handler, what they await, the `onResponse`\n * hooks — and every log written with `createLogger` inside it carries its\n * trace id. An inbound `traceparent` continues its trace; an unusable one\n * starts a fresh trace. The span is named for the route, `GET /users/:id`,\n * once routing has matched. Only a 5xx marks it an error.\n *\n * Routes declared after the plugin read the span as `span`, and the\n * telemetry as `telemetry`.\n *\n * ```ts\n * const tracing = telemetry({ service: 'checkout', exporters: [otlpExporter({ endpoint })] });\n * const app = alxia().use(tracing).get(...);\n * app.onStop(() => tracing.telemetry.close());\n * ```\n */\nexport function telemetry(options: TelemetryPluginOptions) {\n\tconst instance =\n\t\toptions.instance ?? createTelemetry(options.service, options).install();\n\tconst traced = guarded(options.traced ?? (() => true), () => true);\n\tconst spanName = guarded(options.spanName ?? defaultName, defaultName);\n\tconst scopes = new WeakMap<Request, SpanScope>();\n\n\tconst plugin = alxia()\n\t\t.around((ctx, next) => {\n\t\t\tif (!traced(ctx)) return next();\n\t\t\treturn withTelemetry(instance, () =>\n\t\t\t\tcontinuing(\n\t\t\t\t\tctx.request.headers.get('traceparent'),\n\t\t\t\t\tspanName(ctx),\n\t\t\t\t\t{ kind: 'server' },\n\t\t\t\t\tasync (scope) => {\n\t\t\t\t\t\t// The span's own, not `SpanOptions.attributes`: those every span\n\t\t\t\t\t\t// and log inside inherits, and a database call is not the request.\n\t\t\t\t\t\tscope.attributes(\n\t\t\t\t\t\t\trequestAttributes(ctx.url, ctx.request.method, ctx.ip),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tscopes.set(ctx.request, scope);\n\t\t\t\t\t\tlet response: Response;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tresponse = await next();\n\t\t\t\t\t\t} finally {\n\t\t\t\t\t\t\t// Even a request that failed was routed, and the route is the name.\n\t\t\t\t\t\t\tif (ctx.route !== undefined) {\n\t\t\t\t\t\t\t\tscope.name = `${ctx.request.method} ${ctx.route}`;\n\t\t\t\t\t\t\t\tscope.attribute(HTTP_ROUTE, ctx.route);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\trecord(scope, response.status, ctx.error);\n\t\t\t\t\t\tif (options.traceResponse) {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tresponse.headers.set('traceparent', scope.traceparent());\n\t\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t\t// An immutable response keeps its headers; the span is what matters.\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn response;\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t);\n\t\t})\n\t\t.derive(({ request }) => ({\n\t\t\t/** The server span around this request; `undefined` when `traced` said no. */\n\t\t\tspan: scopes.get(request),\n\t\t\ttelemetry: instance,\n\t\t}));\n\n\treturn Object.assign(plugin, { telemetry: instance });\n}\n\n/**\n * The status, and the failure. A route's error is recorded as the span's\n * exception, but only a 5xx makes it an error: a 401 a guard answered is\n * the server working.\n */\nfunction record(scope: SpanScope, status: number, error: unknown): void {\n\tif (error !== undefined) {\n\t\tconst before = scope.status;\n\t\tscope.fail(error);\n\t\tif (!serverFailed(status)) scope.status = before;\n\t}\n\tscope.attribute(HTTP_STATUS, status);\n\tif (serverFailed(status) && scope.status === 'ok') scope.status = 'error';\n}\n\nfunction defaultName(ctx: RequestContext): string {\n\treturn `${ctx.request.method} ${ctx.url.pathname}`;\n}\n\n/** A hook, and what to answer when it throws: observability never costs the request. */\nfunction guarded<T>(\n\thook: (ctx: RequestContext) => T,\n\tfallback: (ctx: RequestContext) => T,\n): (ctx: RequestContext) => T {\n\treturn (ctx) => {\n\t\ttry {\n\t\t\treturn hook(ctx);\n\t\t} catch {\n\t\t\treturn fallback(ctx);\n\t\t}\n\t};\n}\n"
7
+ ],
8
+ "mappings": ";AASO,IAAM,cAAc;AACpB,IAAM,WAAW;AACjB,IAAM,aAAa;AACnB,IAAM,aAAa;AACnB,IAAM,cAAc;AACpB,IAAM,iBAAiB;AACvB,IAAM,cAAc;AACpB,IAAM,iBAAiB;AAGvB,SAAS,YAAY,CAAC,QAAyB;AAAA,EACrD,OAAO,UAAU;AAAA;AAIX,SAAS,iBAAiB,CAChC,KACA,QACA,IACa;AAAA,EACb,OAAO;AAAA,KACL,cAAc;AAAA,KACd,WAAW,IAAI;AAAA,KACf,aAAa,IAAI,SAAS,QAAQ,KAAK,EAAE;AAAA,KACzC,iBAAiB,IAAI;AAAA,OAClB,IAAI,SAAS,KAAK,CAAC,IAAI,GAAG,cAAc,OAAO,IAAI,IAAI,EAAE;AAAA,OACzD,OAAO,YAAY,CAAC,IAAI,GAAG,iBAAiB,GAAG;AAAA,EACpD;AAAA;;ACpCD;AACA;AAAA;AAAA;AAAA;AAAA;AA+DO,SAAS,SAAS,CAAC,SAAiC;AAAA,EAC1D,MAAM,WACL,QAAQ,YAAY,gBAAgB,QAAQ,SAAS,OAAO,EAAE,QAAQ;AAAA,EACvE,MAAM,SAAS,QAAQ,QAAQ,WAAW,MAAM,OAAO,MAAM,IAAI;AAAA,EACjE,MAAM,WAAW,QAAQ,QAAQ,YAAY,aAAa,WAAW;AAAA,EACrE,MAAM,SAAS,IAAI;AAAA,EAEnB,MAAM,SAAS,MAAM,EACnB,OAAO,CAAC,KAAK,SAAS;AAAA,IACtB,IAAI,CAAC,OAAO,GAAG;AAAA,MAAG,OAAO,KAAK;AAAA,IAC9B,OAAO,cAAc,UAAU,MAC9B,WACC,IAAI,QAAQ,QAAQ,IAAI,aAAa,GACrC,SAAS,GAAG,GACZ,EAAE,MAAM,SAAS,GACjB,OAAO,UAAU;AAAA,MAGhB,MAAM,WACL,kBAAkB,IAAI,KAAK,IAAI,QAAQ,QAAQ,IAAI,EAAE,CACtD;AAAA,MACA,OAAO,IAAI,IAAI,SAAS,KAAK;AAAA,MAC7B,IAAI;AAAA,MACJ,IAAI;AAAA,QACH,WAAW,MAAM,KAAK;AAAA,gBACrB;AAAA,QAED,IAAI,IAAI,UAAU,WAAW;AAAA,UAC5B,MAAM,OAAO,GAAG,IAAI,QAAQ,UAAU,IAAI;AAAA,UAC1C,MAAM,UAAU,YAAY,IAAI,KAAK;AAAA,QACtC;AAAA;AAAA,MAED,OAAO,OAAO,SAAS,QAAQ,IAAI,KAAK;AAAA,MACxC,IAAI,QAAQ,eAAe;AAAA,QAC1B,IAAI;AAAA,UACH,SAAS,QAAQ,IAAI,eAAe,MAAM,YAAY,CAAC;AAAA,UACtD,MAAM;AAAA,MAGT;AAAA,MACA,OAAO;AAAA,KAET,CACD;AAAA,GACA,EACA,OAAO,GAAG,eAAe;AAAA,IAEzB,MAAM,OAAO,IAAI,OAAO;AAAA,IACxB,WAAW;AAAA,EACZ,EAAE;AAAA,EAEH,OAAO,OAAO,OAAO,QAAQ,EAAE,WAAW,SAAS,CAAC;AAAA;AAQrD,SAAS,MAAM,CAAC,OAAkB,QAAgB,OAAsB;AAAA,EACvE,IAAI,UAAU,WAAW;AAAA,IACxB,MAAM,SAAS,MAAM;AAAA,IACrB,MAAM,KAAK,KAAK;AAAA,IAChB,IAAI,CAAC,aAAa,MAAM;AAAA,MAAG,MAAM,SAAS;AAAA,EAC3C;AAAA,EACA,MAAM,UAAU,aAAa,MAAM;AAAA,EACnC,IAAI,aAAa,MAAM,KAAK,MAAM,WAAW;AAAA,IAAM,MAAM,SAAS;AAAA;AAGnE,SAAS,WAAW,CAAC,KAA6B;AAAA,EACjD,OAAO,GAAG,IAAI,QAAQ,UAAU,IAAI,IAAI;AAAA;AAIzC,SAAS,OAAU,CAClB,MACA,UAC6B;AAAA,EAC7B,OAAO,CAAC,QAAQ;AAAA,IACf,IAAI;AAAA,MACH,OAAO,KAAK,GAAG;AAAA,MACd,MAAM;AAAA,MACP,OAAO,SAAS,GAAG;AAAA;AAAA;AAAA;",
9
+ "debugId": "1F0C16FED53E6A3964756E2164756E21",
10
+ "names": []
11
+ }
@@ -0,0 +1,54 @@
1
+ import { type RequestContext } from '@alxia/core';
2
+ import { type SpanScope, type Telemetry, type TelemetryOptions } from '@nxgt/telemetry';
3
+ interface Hooks {
4
+ /**
5
+ * Whether a request gets a span. Every one does by default: a library
6
+ * that decides which requests do not matter hides the one that did.
7
+ */
8
+ readonly traced?: (ctx: RequestContext) => boolean;
9
+ /** The span's name before routing. `"<METHOD> <path>"` by default, then `"<METHOD> <route>"`. */
10
+ readonly spanName?: (ctx: RequestContext) => string;
11
+ /** Whether the response says `traceparent` back, so a caller can find the trace. Off by default. */
12
+ readonly traceResponse?: boolean;
13
+ }
14
+ /**
15
+ * A telemetry built from `service` and `@nxgt/telemetry`'s options, and
16
+ * installed; or one handed over as `instance`, adopted and not closed.
17
+ */
18
+ export type TelemetryPluginOptions = (Hooks & TelemetryOptions & {
19
+ /** The service name: everything groups by it. */
20
+ readonly service: string;
21
+ readonly instance?: undefined;
22
+ }) | (Hooks & {
23
+ readonly instance: Telemetry;
24
+ readonly service?: undefined;
25
+ });
26
+ /**
27
+ * One server span per request, with [`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry),
28
+ * as a plugin.
29
+ *
30
+ * The span is opened by an `around` hook, so it holds everything the
31
+ * request runs — the hooks, the handler, what they await, the `onResponse`
32
+ * hooks — and every log written with `createLogger` inside it carries its
33
+ * trace id. An inbound `traceparent` continues its trace; an unusable one
34
+ * starts a fresh trace. The span is named for the route, `GET /users/:id`,
35
+ * once routing has matched. Only a 5xx marks it an error.
36
+ *
37
+ * Routes declared after the plugin read the span as `span`, and the
38
+ * telemetry as `telemetry`.
39
+ *
40
+ * ```ts
41
+ * const tracing = telemetry({ service: 'checkout', exporters: [otlpExporter({ endpoint })] });
42
+ * const app = alxia().use(tracing).get(...);
43
+ * app.onStop(() => tracing.telemetry.close());
44
+ * ```
45
+ */
46
+ export declare function telemetry(options: TelemetryPluginOptions): import("@alxia/core").Alxia<import("@alxia/core").Empty & {
47
+ /** The server span around this request; `undefined` when `traced` said no. */
48
+ span: SpanScope | undefined;
49
+ telemetry: Telemetry;
50
+ }, import("@alxia/core").Empty, "", never> & {
51
+ telemetry: Telemetry;
52
+ };
53
+ export {};
54
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAGN,KAAK,SAAS,EACd,KAAK,SAAS,EACd,KAAK,gBAAgB,EAErB,MAAM,iBAAiB,CAAC;AAQzB,UAAU,KAAK;IACd;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC;IACnD,iGAAiG;IACjG,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,MAAM,CAAC;IACpD,oGAAoG;IACpG,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CACjC;AAED;;;GAGG;AACH,MAAM,MAAM,sBAAsB,GAC/B,CAAC,KAAK,GACN,gBAAgB,GAAG;IAClB,iDAAiD;IACjD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;CAC9B,CAAC,GACF,CAAC,KAAK,GAAG;IACT,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;CAC5B,CAAC,CAAC;AAEN;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,sBAAsB;IA8CtD,8EAA8E;;;;;EAMhF"}
package/docs/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # @alxia/telemetry documentation
2
+
3
+ The [package README](../README.md) is the short version. This folder is
4
+ the long one: the span each request gets and what it records, how a trace
5
+ crosses services, every option with its default, and what to do when a
6
+ span or a log does not show up where you expected it.
7
+
8
+ | Page | Read it when |
9
+ | --- | --- |
10
+ | [Guide](guide.md) | choosing between `service` and `instance`, leaving a health check untraced, continuing a caller's trace or calling another service, closing the telemetry on shutdown, sending to a collector, or testing the spans |
11
+ | [Troubleshooting](troubleshooting.md) | `tsc` refused an option or a route, the server logged `[telemetry] export failed`, or a span or a log is missing or not what you expected |
12
+ | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
package/docs/guide.md ADDED
@@ -0,0 +1,461 @@
1
+ # Guide
2
+
3
+ This page covers what `telemetry()` records and when: the span it opens
4
+ around each request, how it is named, what it carries, how a trace crosses
5
+ services, each option with its default, and how to close it, test it, and
6
+ call another service from inside it.
7
+
8
+ ```ts
9
+ import { alxia } from '@alxia/core';
10
+ import { telemetry } from '@alxia/telemetry';
11
+ import { consoleExporter, createLogger } from '@nxgt/telemetry';
12
+
13
+ const tracing = telemetry({ service: 'checkout', exporters: [consoleExporter()] });
14
+ const log = createLogger('Orders');
15
+
16
+ const app = alxia()
17
+ .use(tracing)
18
+ .get('/orders/:id', ({ params, span, reply }) => {
19
+ span?.attribute('order.id', params.id);
20
+ log.info('order read');
21
+ return reply(200, { id: params.id });
22
+ })
23
+ .onStop(() => tracing.telemetry.close());
24
+
25
+ app.listen(3000);
26
+ ```
27
+
28
+ `GET /orders/o-1` now writes a server span named `GET /orders/:id` to the
29
+ console, and the `order read` log line carries that span's `traceId` and
30
+ `spanId`. Swap `consoleExporter()` for any other `@nxgt/telemetry`
31
+ exporter, and nothing else changes.
32
+
33
+ ## The signature
34
+
35
+ ```ts
36
+ function telemetry(
37
+ options: TelemetryPluginOptions,
38
+ ): Alxia<Empty & { span: SpanScope | undefined; telemetry: Telemetry }, Empty, '', never> & {
39
+ telemetry: Telemetry;
40
+ };
41
+
42
+ type TelemetryPluginOptions =
43
+ | (Hooks & TelemetryOptions & { readonly service: string; readonly instance?: undefined })
44
+ | (Hooks & { readonly instance: Telemetry; readonly service?: undefined });
45
+
46
+ interface Hooks {
47
+ readonly traced?: (ctx: RequestContext) => boolean;
48
+ readonly spanName?: (ctx: RequestContext) => string;
49
+ readonly traceResponse?: boolean;
50
+ }
51
+ ```
52
+
53
+ `SpanScope`, `Telemetry` and `TelemetryOptions` are `@nxgt/telemetry`'s;
54
+ `RequestContext` is `@alxia/core`'s. `telemetry()` returns an app plugin:
55
+ pass it to `use`, called. It adds a global `around` hook, which traces every
56
+ request of the app, and a `derive`, which gives the routes declared
57
+ **after** it `span` and `telemetry`. The telemetry it writes to is also on
58
+ the plugin itself, as `.telemetry`, for the code that is not a route.
59
+
60
+ ## The span
61
+
62
+ One span per request, of kind `server`, opened by the `around` hook. It
63
+ holds everything the request runs: the `onRequest` hooks, routing,
64
+ validation, the route's hooks and handler, whatever they await, and the
65
+ `onResponse` hooks. A log written with `createLogger` anywhere inside it,
66
+ and a span opened with `span()`, belong to it.
67
+
68
+ ```ts
69
+ import { alxia } from '@alxia/core';
70
+ import { telemetry } from '@alxia/telemetry';
71
+ import { consoleExporter, createLogger, span } from '@nxgt/telemetry';
72
+
73
+ const log = createLogger('Orders');
74
+
75
+ const app = alxia()
76
+ .use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
77
+ .get('/orders/:id', async ({ params, reply }) => {
78
+ const order = await span('orders.find', () => ({ id: params.id })); // a child of the server span
79
+ log.info('order read'); // carries the server span's ids
80
+ return reply(200, order);
81
+ });
82
+ ```
83
+
84
+ Because the hook is global, a route declared before `use(telemetry(...))`
85
+ is traced too; it only cannot read `span` and `telemetry` from its context.
86
+ A WebSocket upgrade is not traced: `@alxia/core` runs no `around` hook for
87
+ it, since there is no response to wrap.
88
+
89
+ ### Its name
90
+
91
+ | The request | The span's name |
92
+ | --- | --- |
93
+ | before routing | `spanName(ctx)`, by default `"<METHOD> <path>"`: `GET /orders/o-1` |
94
+ | routing matched a route | `"<METHOD> <route>"`: `GET /orders/:id`, and `http.route` is set |
95
+ | no route matched (`404`, `405`) | stays what it was before routing |
96
+
97
+ A route's name replaces any `spanName`: one dashboard row per route, not
98
+ one per order. So `spanName` only names what routing did not match. A
99
+ `HEAD` request answered by a `GET` route is named `HEAD /orders/:id`.
100
+
101
+ ### Its status, and the route's error
102
+
103
+ | The response | The span's status | Its exception |
104
+ | --- | --- | --- |
105
+ | `2xx`, `3xx`, `4xx` | `ok` | none |
106
+ | a `4xx` an `onError` hook made of a thrown error | `ok` | the error |
107
+ | a `5xx` from a throw | `error` | the error |
108
+ | a `5xx` the route replied | `error` | none |
109
+
110
+ A `401` a guard answered is the server working, so a 4xx never marks a
111
+ span. The error the route failed with is still recorded, as `ctx.error`
112
+ holds it:
113
+
114
+ ```ts
115
+ import { alxia } from '@alxia/core';
116
+ import { telemetry } from '@alxia/telemetry';
117
+ import { consoleExporter } from '@nxgt/telemetry';
118
+
119
+ const app = alxia()
120
+ .use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
121
+ .onError((error, { reply }) =>
122
+ error instanceof RangeError ? reply(400, { error: 'out_of_range' as const }) : undefined,
123
+ )
124
+ .get('/range', () => {
125
+ throw new RangeError('out of range');
126
+ });
127
+
128
+ await app.request('/range'); // 400; the span is ok, with `out of range` as its exception
129
+ ```
130
+
131
+ ### What it records
132
+
133
+ | Attribute | Exported as | Value | When |
134
+ | --- | --- | --- | --- |
135
+ | `http.request.method` | `HTTP_METHOD` | `GET` | always |
136
+ | `url.path` | `URL_PATH` | `/orders/o-1` | always |
137
+ | `url.scheme` | `URL_SCHEME` | `http` | always |
138
+ | `server.address` | `SERVER_ADDRESS` | the request URL's host name | always |
139
+ | `server.port` | `SERVER_PORT` | `3000`, a number | when the request URL names a port |
140
+ | `client.address` | `CLIENT_ADDRESS` | the caller's address, as the app's `ip` option reads it | when there is one: not through `app.request` |
141
+ | `http.route` | `HTTP_ROUTE` | `/orders/:id` | once routing matched |
142
+ | `http.response.status_code` | `HTTP_STATUS` | `200`, a number | always |
143
+
144
+ They are the names of `@nxgt/telemetry-hono`, so a span from either reads
145
+ the same in a dashboard. The constants are exported for code that reads
146
+ spans back, a test for one:
147
+
148
+ ```ts
149
+ import { HTTP_ROUTE, HTTP_STATUS } from '@alxia/telemetry';
150
+ import type { SpanRecord } from '@nxgt/telemetry';
151
+
152
+ function routeOf(span: SpanRecord): string {
153
+ return `${String(span.attributes[HTTP_ROUTE])} ${String(span.attributes[HTTP_STATUS])}`;
154
+ }
155
+ ```
156
+
157
+ All of them are the server span's own: a child span — a database call, an
158
+ outgoing request — and a log written inside the request carry its trace
159
+ and span ids, not the request's path, method or the client's address. An
160
+ attribute every log of a request should carry is yours to give, with
161
+ `@nxgt/telemetry`'s `withAttributes`: it reaches what runs inside it, so
162
+ an `around` hook declared after the plugin covers the whole request:
163
+
164
+ ```ts
165
+ import { alxia } from '@alxia/core';
166
+ import { telemetry } from '@alxia/telemetry';
167
+ import { withAttributes } from '@nxgt/telemetry';
168
+
169
+ const app = alxia()
170
+ .use(telemetry({ service: 'shop' }))
171
+ .around((ctx, next) =>
172
+ withAttributes({ 'tenant.id': ctx.request.headers.get('x-tenant') ?? 'none' }, next),
173
+ );
174
+ ```
175
+
176
+ ## Across services
177
+
178
+ **In.** An inbound `traceparent` header continues its trace: the span takes
179
+ its trace id, and the caller's span id as its parent. A header that cannot
180
+ be read starts a fresh trace, as a request without one does.
181
+
182
+ **Out.** Inside a request, the current span says the header an outgoing
183
+ call should carry:
184
+
185
+ ```ts
186
+ import { alxia } from '@alxia/core';
187
+ import { telemetry } from '@alxia/telemetry';
188
+ import { consoleExporter, span } from '@nxgt/telemetry';
189
+
190
+ const app = alxia()
191
+ .use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
192
+ .post('/orders/:id/reserve', async ({ params, reply }) => {
193
+ const stock = await span('stock.reserve', { kind: 'client' }, (scope) =>
194
+ fetch(`https://stock.example.com/reserve/${params.id}`, {
195
+ method: 'POST',
196
+ headers: { traceparent: scope.traceparent() },
197
+ }),
198
+ );
199
+ return reply(stock.ok ? 200 : 502, { reserved: stock.ok });
200
+ });
201
+ ```
202
+
203
+ `currentTraceparent()` from `@nxgt/telemetry` says the same thing without a
204
+ scope at hand. With httpyz, [`@nxgt/telemetry-httpyz`](https://www.npmjs.com/package/@nxgt/telemetry-httpyz)
205
+ does it for every call.
206
+
207
+ **Back.** With `traceResponse: true`, the response carries the span's
208
+ `traceparent`, so a caller can find the trace of the request it made:
209
+
210
+ ```ts
211
+ import { alxia } from '@alxia/core';
212
+ import { telemetry } from '@alxia/telemetry';
213
+ import { consoleExporter } from '@nxgt/telemetry';
214
+
215
+ const app = alxia()
216
+ .use(telemetry({ service: 'checkout', exporters: [consoleExporter()], traceResponse: true }))
217
+ .get('/', ({ reply }) => reply(200, 'ok'));
218
+
219
+ const response = await app.request('/');
220
+ response.headers.get('traceparent'); // '00-<trace id>-<span id>-01'
221
+ ```
222
+
223
+ ## Options
224
+
225
+ `telemetry()` takes either a `service`, and builds the telemetry, or an
226
+ `instance`, and adopts it. Never both.
227
+
228
+ | Option | Type | Default | Effect |
229
+ | --- | --- | --- | --- |
230
+ | `service` | `string` | required, without `instance` | the service name every signal groups by; the telemetry is built with `createTelemetry(service, options)` and installed |
231
+ | `instance` | `Telemetry` | required, without `service` | a telemetry you built: used as it is, neither installed nor closed by the plugin |
232
+ | `traced` | `(ctx: RequestContext) => boolean` | every request | whether a request gets a span |
233
+ | `spanName` | `(ctx: RequestContext) => string` | `"<METHOD> <path>"` | the span's name before routing, kept when no route matches |
234
+ | `traceResponse` | `boolean` | `false` | sets `traceparent` on the response |
235
+
236
+ With `service`, every [`TelemetryOptions`](https://www.npmjs.com/package/@nxgt/telemetry)
237
+ of `@nxgt/telemetry` is accepted alongside:
238
+
239
+ | Option | Type | Default | Effect |
240
+ | --- | --- | --- | --- |
241
+ | `version`, `environment` | `string` | none | stamped on every signal's resource |
242
+ | `attributes` | `Record<string, unknown>` | none | stamped on the resource too |
243
+ | `exporters` | `readonly Exporter[]` | none | where signals go, in order; with none, they go nowhere |
244
+ | `sampler` | `Sampler` | `alwaysSample` | which traces keep their spans; logs are never sampled |
245
+ | `minimum` | `Severity` | `info` | logs below it are never built |
246
+ | `stackTraces` | `boolean` | `true` | whether a recorded exception carries its stack |
247
+ | `batch`, `linger`, `drainTimeout` | `number` | `512`, `1000` ms, `10000` ms | when the pipeline flushes, and how long `close()` waits |
248
+ | `onExportError` | `(failure: unknown) => void` | `console.error` | what a failed export does |
249
+
250
+ ### `service` or `instance`
251
+
252
+ `service` is the short way, for an app whose telemetry is this plugin's:
253
+ the telemetry is installed, so a logger used outside any request — at
254
+ start-up, in a job — finds it too.
255
+
256
+ ```ts
257
+ import { telemetry } from '@alxia/telemetry';
258
+ import { consoleExporter } from '@nxgt/telemetry';
259
+
260
+ const tracing = telemetry({
261
+ service: 'checkout',
262
+ version: '1.4.0',
263
+ environment: 'production',
264
+ exporters: [consoleExporter()],
265
+ });
266
+ tracing.telemetry; // the Telemetry it built, to close on shutdown
267
+ ```
268
+
269
+ `instance` is for a telemetry the app already has: built at start-up and
270
+ shared with a worker, or built per test.
271
+
272
+ ```ts
273
+ import { alxia } from '@alxia/core';
274
+ import { telemetry } from '@alxia/telemetry';
275
+ import { consoleExporter, createTelemetry } from '@nxgt/telemetry';
276
+
277
+ const shared = createTelemetry('checkout', { exporters: [consoleExporter()] }).install();
278
+
279
+ const app = alxia().use(telemetry({ instance: shared }));
280
+ ```
281
+
282
+ The plugin runs each traced request inside the instance, so the logs in it
283
+ reach it either way. Outside a request, and in a request `traced` said no
284
+ to, a logger only finds a telemetry that is installed: call `install()` on
285
+ an instance the whole process writes to, as above.
286
+
287
+ ### `traced`
288
+
289
+ ```ts
290
+ telemetry({
291
+ service: 'checkout',
292
+ exporters: [consoleExporter()],
293
+ traced: (ctx) => ctx.url.pathname !== '/health' && ctx.url.pathname !== '/ready',
294
+ });
295
+ ```
296
+
297
+ A request it says no to runs without a span: its routes read `span` as
298
+ `undefined`, and still read `telemetry`.
299
+
300
+ ### `spanName`
301
+
302
+ ```ts
303
+ telemetry({
304
+ service: 'checkout',
305
+ exporters: [consoleExporter()],
306
+ spanName: (ctx) => `${ctx.request.method} ${ctx.url.pathname.split('/')[1] ?? ''}`,
307
+ });
308
+ ```
309
+
310
+ It runs before routing, so `ctx.route` is always `undefined` there; once a
311
+ route matches, the span is renamed after it whatever `spanName` said.
312
+
313
+ A `traced` or `spanName` that throws costs its answer, never the request:
314
+ the request is traced, or named `"<METHOD> <path>"`, and nothing is logged.
315
+
316
+ ## What the routes read
317
+
318
+ ```ts
319
+ import { alxia } from '@alxia/core';
320
+ import { telemetry } from '@alxia/telemetry';
321
+ import { consoleExporter } from '@nxgt/telemetry';
322
+
323
+ const app = alxia()
324
+ .use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
325
+ .get('/orders/:id', ({ params, span, telemetry: current, reply }) => {
326
+ span?.attribute('order.id', params.id); // SpanScope | undefined
327
+ span?.event('cache.miss');
328
+ return reply(200, { service: current.resource.service });
329
+ });
330
+ ```
331
+
332
+ | Field | Type | What it is |
333
+ | --- | --- | --- |
334
+ | `span` | `SpanScope \| undefined` | the server span: `attribute`, `attributes`, `event`, `fail`, `traceparent()`, a writable `name` and `status`; `undefined` when `traced` said no |
335
+ | `telemetry` | `Telemetry` | the telemetry the plugin writes to, traced or not |
336
+
337
+ Only the routes declared after `use(telemetry(...))`, in the same app or
338
+ group, read them.
339
+
340
+ ## Shutting down
341
+
342
+ The telemetry batches what it receives; `close()` ships the backlog, and
343
+ has to be awaited, or the last batch is lost. The plugin closes nothing,
344
+ not even a telemetry it built: close it in `onStop`, which `app.stop()`
345
+ runs, and stop the app when the process is asked to end.
346
+
347
+ ```ts
348
+ import { alxia } from '@alxia/core';
349
+ import { telemetry } from '@alxia/telemetry';
350
+ import { consoleExporter } from '@nxgt/telemetry';
351
+
352
+ const tracing = telemetry({ service: 'checkout', exporters: [consoleExporter()] });
353
+
354
+ const app = alxia()
355
+ .use(tracing)
356
+ .get('/', ({ reply }) => reply(200, 'ok'))
357
+ .onStop(() => tracing.telemetry.close());
358
+
359
+ app.listen(3000);
360
+
361
+ process.on('SIGTERM', async () => {
362
+ await app.stop();
363
+ process.exit(0);
364
+ });
365
+ ```
366
+
367
+ Once closed, a telemetry takes nothing more: the app still answers, and
368
+ the spans of later requests are dropped.
369
+
370
+ ## Sending to a collector
371
+
372
+ [`@nxgt/telemetry-otlp`](https://www.npmjs.com/package/@nxgt/telemetry-otlp)
373
+ ships to any OpenTelemetry collector:
374
+
375
+ ```sh
376
+ bun add @nxgt/telemetry-otlp
377
+ ```
378
+
379
+ ```ts
380
+ import { alxia } from '@alxia/core';
381
+ import { telemetry } from '@alxia/telemetry';
382
+ import { ratioSampler } from '@nxgt/telemetry';
383
+ import { otlpExporter } from '@nxgt/telemetry-otlp';
384
+
385
+ const tracing = telemetry({
386
+ service: 'checkout',
387
+ version: '1.4.0',
388
+ environment: 'production',
389
+ sampler: ratioSampler(0.1),
390
+ exporters: [otlpExporter({ endpoint: 'http://localhost:4318' })],
391
+ traced: (ctx) => ctx.url.pathname !== '/health',
392
+ });
393
+
394
+ const app = alxia()
395
+ .use(tracing)
396
+ .get('/', ({ reply }) => reply(200, 'ok'))
397
+ .onStop(() => tracing.telemetry.close());
398
+
399
+ app.listen(3000);
400
+ ```
401
+
402
+ With `ratioSampler(0.1)`, one trace in ten keeps its spans. The others
403
+ still open one, so their logs carry a trace id, and `span` is defined in
404
+ the routes; it is only not exported.
405
+
406
+ ## Testing
407
+
408
+ Give each test its own telemetry, as an `instance`, with an exporter that
409
+ keeps what it receives, and close it before reading: `close()` is what
410
+ flushes.
411
+
412
+ ```ts
413
+ import { afterEach, expect, test } from 'bun:test';
414
+ import { alxia } from '@alxia/core';
415
+ import { telemetry } from '@alxia/telemetry';
416
+ import {
417
+ createTelemetry,
418
+ type Exporter,
419
+ type Signal,
420
+ type SpanRecord,
421
+ uninstallTelemetry,
422
+ } from '@nxgt/telemetry';
423
+
424
+ afterEach(() => uninstallTelemetry());
425
+
426
+ test('a route gets one server span, named after it', async () => {
427
+ const signals: Signal[] = [];
428
+ const exporter: Exporter = {
429
+ export(_resource, batch) {
430
+ signals.push(...batch);
431
+ },
432
+ };
433
+ const instance = createTelemetry('test', { exporters: [exporter] });
434
+ const app = alxia()
435
+ .use(telemetry({ instance }))
436
+ .get('/users/:id', ({ params, reply }) => reply(200, { id: params.id }));
437
+
438
+ await app.request('/users/7');
439
+ await instance.close();
440
+
441
+ const spans = signals.filter((signal): signal is SpanRecord => signal.type === 'span');
442
+ expect(spans[0]?.name).toBe('GET /users/:id');
443
+ expect(spans[0]?.attributes['http.response.status_code']).toBe(200);
444
+ });
445
+ ```
446
+
447
+ An `instance` is not installed, so tests do not share one through the
448
+ process. A plugin built with `service` installs its telemetry for the whole
449
+ process; `uninstallTelemetry()` after each test takes it back out.
450
+
451
+ To continue a trace in a test, send the header a caller would:
452
+
453
+ ```ts
454
+ await app.request('/users/1', {
455
+ headers: { traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' },
456
+ });
457
+ // the span's trace id is 4bf92f3577b34da6a3ce929d0e0e4736, its parent 00f067aa0ba902b7
458
+ ```
459
+
460
+ When something does not show up as expected, see
461
+ [Troubleshooting](troubleshooting.md).
@@ -0,0 +1,49 @@
1
+ # Roadmap
2
+
3
+ What `@alxia/telemetry` gives an app, and what is coming. This page is a
4
+ direction, not a commitment: the version something shipped in is the only
5
+ number on it. Every release, with each change it made, is in
6
+ [`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/telemetry/CHANGELOG.md).
7
+
8
+ ## Now
9
+
10
+ Nothing scheduled yet.
11
+
12
+ ## Next
13
+
14
+ Nothing scheduled yet.
15
+
16
+ ## Later
17
+
18
+ Nothing scheduled yet.
19
+
20
+ ## Not planned
21
+
22
+ - **A second telemetry implementation.** `@alxia/telemetry` is an adapter
23
+ over `@nxgt/telemetry`, a peer: the spans, the logs, the sampling and the
24
+ exporters are its, and so is everything they gain.
25
+ - **The OpenTelemetry SDK.** Signals go out through `@nxgt/telemetry`'s
26
+ exporters — to a collector with `@nxgt/telemetry-otlp` — with no SDK and
27
+ no runtime dependency.
28
+
29
+ ## Shipped
30
+
31
+ ### 0.1.0
32
+
33
+ - **One server span per request.** `alxia().use(telemetry({ service, exporters }))`
34
+ opens a span around everything a request runs — hooks, handler, what
35
+ they await — and every log written with `@nxgt/telemetry`'s
36
+ `createLogger` inside it carries its trace id.
37
+ - **Named for the route.** The span is renamed `GET /orders/:id` once
38
+ routing has matched, with `http.route`: one dashboard row per route.
39
+ - **Traces across services.** An inbound `traceparent` is continued, an
40
+ unreadable one starts a fresh trace, and `traceResponse` says it back.
41
+ - **Errors where they belong.** A route's error is the span's exception;
42
+ only a 5xx marks the span an error.
43
+ - **The same names as Hono's.** The attributes are
44
+ `@nxgt/telemetry-hono`'s, exported as constants, so a span from either
45
+ reads the same in a dashboard.
46
+ - **Your telemetry, or one built for you.** `service` with
47
+ `@nxgt/telemetry`'s options, or an existing `instance`, adopted; `traced`
48
+ and `spanName` to choose and name the spans. Routes read `span` and
49
+ `telemetry`.
@@ -0,0 +1,361 @@
1
+ # Troubleshooting
2
+
3
+ Each entry is headed by the text you see: an error from `tsc`, a line in
4
+ the server log, or, for what prints nothing, what you see in your traces.
5
+
6
+ **Types**
7
+
8
+ - [`Property 'span' does not exist on type 'Context<…>'`](#property-span-does-not-exist-on-type-context)
9
+ - [`Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'`](#type-alxiaempty-empty--never-is-not-assignable-to-type-telemetrypluginoptions)
10
+ - [`Property 'service' is missing in type '…' but required in type '{ readonly service: string; readonly instance?: undefined; }'`](#property-service-is-missing-in-type--but-required-in-type--readonly-service-string-readonly-instance-undefined-)
11
+ - [`Type 'Telemetry' is not assignable to type 'undefined'`](#type-telemetry-is-not-assignable-to-type-undefined)
12
+ - [`Object literal may only specify known properties, and 'version' does not exist in type 'Hooks & { readonly instance: Telemetry; … }'`](#object-literal-may-only-specify-known-properties-and-version-does-not-exist-in-type-hooks---readonly-instance-telemetry--)
13
+ - [`Type 'string | undefined' is not assignable to type 'string'` in `spanName`](#type-string--undefined-is-not-assignable-to-type-string-in-spanname)
14
+
15
+ **Server log**
16
+
17
+ - [`[telemetry] export failed`](#telemetry-export-failed)
18
+
19
+ **Missing signals**
20
+
21
+ - [Nothing is exported, or the last requests are missing](#nothing-is-exported-or-the-last-requests-are-missing)
22
+ - [A log written in a route has no `traceId`, or never arrives](#a-log-written-in-a-route-has-no-traceid-or-never-arrives)
23
+ - [A log written outside a request never arrives](#a-log-written-outside-a-request-never-arrives)
24
+ - [Logs carry a `traceId`, but its span is never exported](#logs-carry-a-traceid-but-its-span-is-never-exported)
25
+ - [Spans stop arriving, and the app still answers](#spans-stop-arriving-and-the-app-still-answers)
26
+ - [No span for a WebSocket connection](#no-span-for-a-websocket-connection)
27
+ - [The response has no `traceparent` header](#the-response-has-no-traceparent-header)
28
+
29
+ **Unexpected spans**
30
+
31
+ - [The span starts a new trace although the caller sent `traceparent`](#the-span-starts-a-new-trace-although-the-caller-sent-traceparent)
32
+ - [`spanName` only shows on requests no route matched](#spanname-only-shows-on-requests-no-route-matched)
33
+ - [A span has an exception, and its status is `ok`](#a-span-has-an-exception-and-its-status-is-ok)
34
+
35
+ ## Types
36
+
37
+ ### `Property 'span' does not exist on type 'Context<…>'`
38
+
39
+ ```text
40
+ error TS2339: Property 'span' does not exist on type 'Context<Empty, "/before", Empty>'.
41
+ ```
42
+
43
+ **When:** a route reads `span` or `telemetry`, and is declared before
44
+ `use(telemetry(...))`.
45
+
46
+ **Why:** the plugin gives `span` and `telemetry` to the routes declared
47
+ after it. The request is still traced, since the span is opened by a global
48
+ hook; the route only cannot reach it.
49
+
50
+ **Fix:** use the plugin first:
51
+
52
+ ```ts
53
+ const app = alxia()
54
+ .use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
55
+ .get('/orders/:id', ({ params, span, reply }) => {
56
+ span?.attribute('order.id', params.id);
57
+ return reply(200, { id: params.id });
58
+ });
59
+ ```
60
+
61
+ ### `Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'`
62
+
63
+ ```text
64
+ error TS2769: No overload matches this call.
65
+ Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => …): …', gave the following error.
66
+ Argument of type '(options: TelemetryPluginOptions) => …' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => …'.
67
+ Types of parameters 'options' and 'app' are incompatible.
68
+ Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'.
69
+ ```
70
+
71
+ **When:** `app.use(telemetry)`, without calling it.
72
+
73
+ **Why:** `telemetry` makes the plugin; it is not the plugin, and it needs a
74
+ `service` or an `instance`.
75
+
76
+ **Fix:**
77
+
78
+ ```ts
79
+ alxia().use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }));
80
+ ```
81
+
82
+ ### `Property 'service' is missing in type '…' but required in type '{ readonly service: string; readonly instance?: undefined; }'`
83
+
84
+ ```text
85
+ error TS2345: Argument of type '{ exporters: never[]; }' is not assignable to parameter of type 'TelemetryPluginOptions'.
86
+ Type '{ exporters: never[]; }' is not assignable to type 'Hooks & TelemetryOptions & { readonly service: string; readonly instance?: undefined; }'.
87
+ Property 'service' is missing in type '{ exporters: never[]; }' but required in type '{ readonly service: string; readonly instance?: undefined; }'.
88
+ ```
89
+
90
+ **When:** `telemetry({ exporters: [...] })`, with neither `service` nor
91
+ `instance`.
92
+
93
+ **Why:** the telemetry the plugin builds needs a service name: every signal
94
+ groups by it, and there is no default.
95
+
96
+ **Fix:** name the service, or hand over a telemetry you built:
97
+
98
+ ```ts
99
+ telemetry({ service: 'checkout', exporters: [consoleExporter()] });
100
+ ```
101
+
102
+ ### `Type 'Telemetry' is not assignable to type 'undefined'`
103
+
104
+ ```text
105
+ error TS2345: Argument of type '{ service: string; instance: Telemetry; }' is not assignable to parameter of type 'TelemetryPluginOptions'.
106
+ Types of property 'instance' are incompatible.
107
+ Type 'Telemetry' is not assignable to type 'undefined'.
108
+ ```
109
+
110
+ **When:** `telemetry({ service, instance })`.
111
+
112
+ **Why:** `service` builds a telemetry, `instance` adopts one; the plugin
113
+ writes to exactly one.
114
+
115
+ **Fix:** keep `instance`, whose service name was given to `createTelemetry`:
116
+
117
+ ```ts
118
+ const instance = createTelemetry('checkout', { exporters: [consoleExporter()] });
119
+
120
+ telemetry({ instance });
121
+ ```
122
+
123
+ ### `Object literal may only specify known properties, and 'version' does not exist in type 'Hooks & { readonly instance: Telemetry; … }'`
124
+
125
+ ```text
126
+ error TS2353: Object literal may only specify known properties, and 'version' does not exist in type 'Hooks & { readonly instance: Telemetry; readonly service?: undefined; }'.
127
+ ```
128
+
129
+ The same for `exporters`, `sampler`, `environment`, or any other
130
+ `@nxgt/telemetry` option.
131
+
132
+ **When:** `@nxgt/telemetry` options next to `instance`.
133
+
134
+ **Why:** an adopted telemetry is already built; the plugin cannot change
135
+ its exporters or its resource.
136
+
137
+ **Fix:** give them to `createTelemetry`:
138
+
139
+ ```ts
140
+ const instance = createTelemetry('checkout', {
141
+ version: '1.4.0',
142
+ exporters: [consoleExporter()],
143
+ });
144
+
145
+ telemetry({ instance, traceResponse: true });
146
+ ```
147
+
148
+ ### `Type 'string | undefined' is not assignable to type 'string'` in `spanName`
149
+
150
+ ```text
151
+ error TS2322: Type '(ctx: RequestContext) => string | undefined' is not assignable to type '(ctx: RequestContext) => string'.
152
+ Type 'string | undefined' is not assignable to type 'string'.
153
+ Type 'undefined' is not assignable to type 'string'.
154
+ ```
155
+
156
+ **When:** `spanName: (ctx) => ctx.route`.
157
+
158
+ **Why:** `spanName` runs before routing, where `ctx.route` is always
159
+ `undefined`. The route already names the span once it matches.
160
+
161
+ **Fix:** name what routing has not matched from the path, or leave
162
+ `spanName` out:
163
+
164
+ ```ts
165
+ telemetry({
166
+ service: 'checkout',
167
+ exporters: [consoleExporter()],
168
+ spanName: (ctx) => `${ctx.request.method} unmatched`,
169
+ });
170
+ ```
171
+
172
+ ## Server log
173
+
174
+ ### `[telemetry] export failed`
175
+
176
+ Followed by the exporter's error, such as
177
+ `OtlpUnreachableError: [telemetry] http://localhost:4318/v1/traces did not answer for traces after 3 attempt(s)`.
178
+
179
+ **When:** an exporter throws or rejects: a collector that is down, a wrong
180
+ endpoint, a refused key.
181
+
182
+ **Why:** `@nxgt/telemetry` reports a failed export through
183
+ `onExportError`, which is `console.error` by default. The request it came
184
+ from was answered long before: an export never costs a request.
185
+
186
+ **Fix:** point the exporter at a collector that answers, and send the
187
+ failures where you want them:
188
+
189
+ ```ts
190
+ telemetry({
191
+ service: 'checkout',
192
+ exporters: [otlpExporter({ endpoint: 'http://localhost:4318' })],
193
+ onExportError: (failure) => metrics.increment('telemetry.export_failed', String(failure)),
194
+ });
195
+ ```
196
+
197
+ ## Missing signals
198
+
199
+ ### Nothing is exported, or the last requests are missing
200
+
201
+ **When:** a script or a test exits right after its requests, or a server
202
+ is stopped, and the last spans and logs never reach the exporter — with
203
+ `consoleExporter()`, nothing is printed.
204
+
205
+ **Why:** the telemetry batches signals, and ships a batch when it is full
206
+ or a second after it started. A process that exits first loses it. The
207
+ plugin never closes the telemetry, not even one it built from `service`.
208
+
209
+ **Fix:** close it in `onStop`, await `app.stop()` on shutdown, and await
210
+ `close()` in a script or a test before reading what was exported:
211
+
212
+ ```ts
213
+ const app = alxia()
214
+ .use(tracing)
215
+ .onStop(() => tracing.telemetry.close());
216
+
217
+ process.on('SIGTERM', async () => {
218
+ await app.stop();
219
+ process.exit(0);
220
+ });
221
+ ```
222
+
223
+ ### A log written in a route has no `traceId`, or never arrives
224
+
225
+ **When:** a span is exported for the request, but a `log.info` inside it
226
+ is missing, or arrives without `span`.
227
+
228
+ **Why:** the logger comes from another copy of `@nxgt/telemetry` than the
229
+ one `@alxia/telemetry` uses: the request's span is held by one copy, and
230
+ the other cannot see it. It is a peer for that reason, and a second copy
231
+ appears when a package depends on a version the app's range does not
232
+ satisfy.
233
+
234
+ **Fix:** keep one copy, with a single range for every package that needs
235
+ it, and check:
236
+
237
+ ```sh
238
+ bun pm ls --all | grep @nxgt/telemetry@
239
+ ```
240
+
241
+ ### A log written outside a request never arrives
242
+
243
+ **When:** a log at start-up, in a job or a timer, or in a request `traced`
244
+ said no to, while the logs in traced requests arrive.
245
+
246
+ **Why:** the plugin was given an `instance`. It runs each traced request
247
+ inside that telemetry, but does not install it, so a logger with no request
248
+ around it finds none.
249
+
250
+ **Fix:** install the instance the whole process writes to:
251
+
252
+ ```ts
253
+ const instance = createTelemetry('checkout', { exporters: [consoleExporter()] }).install();
254
+
255
+ alxia().use(telemetry({ instance }));
256
+ ```
257
+
258
+ ### Logs carry a `traceId`, but its span is never exported
259
+
260
+ **When:** a `sampler` such as `ratioSampler(0.1)` is set; most requests
261
+ have logs with a trace id that no span in the backend has.
262
+
263
+ **Why:** a sampler decides which traces keep their spans; logs are never
264
+ sampled. A sampled-out request still opens its span, so `span` is defined
265
+ in the route and the logs carry its ids, but the span is not exported.
266
+
267
+ **Fix:** that is sampling working. Raise the ratio, or sample nothing out:
268
+
269
+ ```ts
270
+ telemetry({ service: 'checkout', sampler: alwaysSample, exporters: [consoleExporter()] });
271
+ ```
272
+
273
+ ### Spans stop arriving, and the app still answers
274
+
275
+ **When:** after `close()` on the plugin's telemetry — often a test that
276
+ closes it in one case and sends requests in the next.
277
+
278
+ **Why:** a closed telemetry takes nothing more, and the plugin keeps
279
+ writing to it; the requests are answered as usual.
280
+
281
+ **Fix:** build a plugin, with its own telemetry, per test:
282
+
283
+ ```ts
284
+ const instance = createTelemetry('test', { exporters: [exporter] });
285
+ const app = alxia().use(telemetry({ instance }));
286
+ ```
287
+
288
+ ### No span for a WebSocket connection
289
+
290
+ **When:** a route declared with `app.ws`.
291
+
292
+ **Why:** `@alxia/core` runs no `around` hook for a WebSocket upgrade, as
293
+ there is no response to wrap, and the span is opened by one.
294
+
295
+ **Fix:** open a span for the work a message does:
296
+
297
+ ```ts
298
+ app.ws('/rooms/:room', {}, {
299
+ message: (socket, message) =>
300
+ span('room.message', () => socket.send(String(message))),
301
+ });
302
+ ```
303
+
304
+ ### The response has no `traceparent` header
305
+
306
+ **When:** a caller looks for the trace of the request it made.
307
+
308
+ **Why:** the plugin says `traceparent` back only with `traceResponse`, and
309
+ only on a request `traced` let through.
310
+
311
+ **Fix:**
312
+
313
+ ```ts
314
+ telemetry({ service: 'checkout', exporters: [consoleExporter()], traceResponse: true });
315
+ ```
316
+
317
+ ## Unexpected spans
318
+
319
+ ### The span starts a new trace although the caller sent `traceparent`
320
+
321
+ **When:** the server span has no parent, and a trace id of its own.
322
+
323
+ **Why:** the header could not be read as a W3C `traceparent`: a header from
324
+ a stranger is not trusted, so a fresh trace starts.
325
+
326
+ **Fix:** send the header the caller's current span says, unchanged, in the
327
+ `00-<32 hex trace id>-<16 hex span id>-<2 hex flags>` form:
328
+
329
+ ```ts
330
+ await fetch('https://checkout.example.com/orders/o-1', {
331
+ headers: { traceparent: currentTraceparent() ?? '' },
332
+ });
333
+ ```
334
+
335
+ ### `spanName` only shows on requests no route matched
336
+
337
+ **When:** a `spanName` is set, and the matched requests are still named
338
+ `GET /orders/:id`.
339
+
340
+ **Why:** `spanName` is the name before routing. Once a route matches, the
341
+ span is renamed `"<METHOD> <route>"`, so a dashboard has one row per route.
342
+
343
+ **Fix:** to add to a routed span, set an attribute rather than the name:
344
+
345
+ ```ts
346
+ app.get('/orders/:id', ({ params, span, reply }) => {
347
+ span?.attribute('order.id', params.id);
348
+ return reply(200, { id: params.id });
349
+ });
350
+ ```
351
+
352
+ ### A span has an exception, and its status is `ok`
353
+
354
+ **When:** a route throws, and an `onError` hook answers with a `4xx`.
355
+
356
+ **Why:** the error is recorded as the span's exception, but only a `5xx`
357
+ makes a span an error: a `400` the app chose to answer is the server
358
+ working.
359
+
360
+ **Fix:** none needed. For an error that should mark the span, answer it
361
+ with a `5xx`, or let it throw to the `500`.
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@alxia/telemetry",
3
+ "version": "0.1.0",
4
+ "description": "Traces and logs for alxia on @nxgt/telemetry: one server span per request around everything it runs, the traceparent continued, the route as its name",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md",
13
+ "package.json",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/softistx/alxia.git",
27
+ "directory": "packages/telemetry"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org",
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "bun run ../../build.ts",
35
+ "test": "bun test src",
36
+ "typecheck": "tsc --noEmit"
37
+ },
38
+ "alxia": {
39
+ "entrypoints": [
40
+ "src/index.ts"
41
+ ]
42
+ },
43
+ "devDependencies": {
44
+ "@alxia/client": "^0.1.0",
45
+ "@alxia/core": "^0.1.0",
46
+ "@nxgt/telemetry": "^0.2.1",
47
+ "@types/bun": "^1.4.2",
48
+ "zod": "^4.6.5"
49
+ },
50
+ "peerDependencies": {
51
+ "@alxia/core": "^0.1.0",
52
+ "@nxgt/telemetry": "^0.2.1",
53
+ "typescript": "^6.0.3 || ^7.0.0"
54
+ }
55
+ }