@alxia/telemetry 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,16 +43,23 @@ app.listen(3000);
43
43
 
44
44
  ## The span
45
45
 
46
- - It is opened by an `around` hook: it holds the hooks, the handler,
47
- everything they await and the `onResponse` hooks.
46
+ - It is opened by the middleware: it holds every middleware after it, the
47
+ handler, everything they await and the answer to an error. Give it to
48
+ `use` first, so it wraps everything, a request no route matches included.
49
+ A streamed body (a
50
+ page rendered as it goes, an event stream) keeps it open until the body
51
+ has been sent: one that fails midway makes it an error, a client that
52
+ leaves adds an `http.response.aborted` event.
48
53
  - An inbound `traceparent` continues its trace, as a child of the caller's
49
54
  span. An unusable one starts a fresh trace: the header came from a
50
55
  stranger.
51
56
  - It starts as `GET /orders/o-1` and is renamed `GET /orders/:id` once
52
57
  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.
58
+ per order. A request no route matched (a 404, a 405) gets a span too, and
59
+ keeps its path.
60
+ - A route's error is its exception. Only a 5xx, or a streamed body that
61
+ fails midway, makes the span an error: a 401 a guard answered is the
62
+ server working.
56
63
  - `traceResponse: true` says the `traceparent` back on the response.
57
64
 
58
65
  ## What it records
@@ -75,16 +82,18 @@ telemetry as `instance` — adopted, never closed. And:
75
82
  | option | default | |
76
83
  | --- | --- | --- |
77
84
  | `traced` | every request | `(ctx) => boolean`: a health check |
78
- | `spanName` | `"<METHOD> <path>"` | the name before routing |
85
+ | `spanName` | `"<METHOD> <path>"` | the name the span opens with; a matched route renames it |
79
86
  | `traceResponse` | `false` | says `traceparent` back |
80
87
 
81
- A hook that throws costs its answer, never the request.
88
+ A `traced` or `spanName` that throws costs its answer, never the request.
82
89
 
83
90
  ## API
84
91
 
85
92
  | export | |
86
93
  | --- | --- |
87
- | `telemetry(options)` | the plugin, with the telemetry it writes to as `.telemetry`; routes after it read `span` and `telemetry` |
94
+ | `telemetry(options)` | the middleware, given to `app.use`, with the telemetry it writes to as `.telemetry`; what is after it reads `span` and `telemetry` |
95
+ | `TelemetryContext` | what it adds to the context: `span` and `telemetry` |
96
+ | `TelemetryMiddleware` | what `telemetry()` returns: a middleware adding `TelemetryContext`, with `.telemetry` |
88
97
  | `TelemetryPluginOptions` | its options: `service` and `@nxgt/telemetry`'s options, or an `instance`; `traced`, `spanName`, `traceResponse` |
89
98
  | `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
99
 
package/dist/body.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** How a streamed body ended: sent whole, left by its client, or failed. */
2
+ export type Outcome = 'completed' | 'aborted' | 'errored';
3
+ /**
4
+ * Whether `response` is sent as it is, with nothing left to time: no body,
5
+ * or a `Content-Length` header, which `@alxia/core` sets on every reply of
6
+ * a string, JSON, a buffer or a file. Bun sends those without JavaScript,
7
+ * so they are not wrapped. A raw `Response` has no such header, even of a
8
+ * string, and is watched. The header is read first: it does not make Bun
9
+ * build the body's stream.
10
+ */
11
+ export declare function settled(response: Response): boolean;
12
+ /**
13
+ * `body`, passed through chunk by chunk, calling `end` once when it has
14
+ * been read to its end, cancelled by its reader (the client left: Bun
15
+ * cancels the body), or failed, with the failure. A cancel goes on to
16
+ * `body`, so an event stream releases its generator. One chunk is read per
17
+ * pull, never ahead.
18
+ */
19
+ export declare function watched(body: ReadableStream<Uint8Array>, end: (outcome: Outcome, error?: unknown) => void): ReadableStream<Uint8Array>;
20
+ //# sourceMappingURL=body.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"body.d.ts","sourceRoot":"","sources":["../src/body.ts"],"names":[],"mappings":"AACA,4EAA4E;AAC5E,MAAM,MAAM,OAAO,GAAG,WAAW,GAAG,SAAS,GAAG,SAAS,CAAC;AAE1D;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAEnD;AAED;;;;;;GAMG;AACH,wBAAgB,OAAO,CACtB,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,GAAG,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,GAC9C,cAAc,CAAC,UAAU,CAAC,CAsC5B"}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
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';
2
+ export { type TelemetryContext, type TelemetryMiddleware, type TelemetryPluginOptions, telemetry, } from './telemetry';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
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,EACN,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,SAAS,GACT,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -21,31 +21,77 @@ function requestAttributes(url, method, ip) {
21
21
  };
22
22
  }
23
23
  // src/telemetry.ts
24
- import { alxia } from "@alxia/core";
24
+ import {
25
+ defineMiddleware,
26
+ settle
27
+ } from "@alxia/core";
25
28
  import {
26
29
  continuing,
27
30
  createTelemetry,
28
31
  withTelemetry
29
32
  } from "@nxgt/telemetry";
33
+
34
+ // src/body.ts
35
+ function settled(response) {
36
+ return response.headers.has("content-length") || response.body === null;
37
+ }
38
+ function watched(body, end) {
39
+ const reader = body.getReader();
40
+ let ended = false;
41
+ const finish = (outcome, error) => {
42
+ if (ended)
43
+ return;
44
+ ended = true;
45
+ try {
46
+ end(outcome, error);
47
+ } catch (failure) {
48
+ try {
49
+ console.error(failure);
50
+ } catch {}
51
+ }
52
+ };
53
+ return new ReadableStream({
54
+ async pull(controller) {
55
+ let next;
56
+ try {
57
+ next = await reader.read();
58
+ } catch (error) {
59
+ finish("errored", error);
60
+ controller.error(error);
61
+ return;
62
+ }
63
+ if (next.done) {
64
+ finish("completed");
65
+ controller.close();
66
+ } else
67
+ controller.enqueue(next.value);
68
+ },
69
+ cancel(reason) {
70
+ finish("aborted");
71
+ return reader.cancel(reason);
72
+ }
73
+ }, { highWaterMark: 0 });
74
+ }
75
+
76
+ // src/telemetry.ts
30
77
  function telemetry(options) {
31
78
  const instance = options.instance ?? createTelemetry(options.service, options).install();
32
79
  const traced = guarded(options.traced ?? (() => true), () => true);
33
80
  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) => {
81
+ const middleware = defineMiddleware(async (ctx, next) => {
82
+ const untraced = { span: undefined, telemetry: instance };
83
+ const upgrade = ctx.request.headers.get("upgrade")?.toLowerCase() === "websocket";
84
+ if (upgrade || !traced(ctx))
85
+ return next(untraced);
86
+ const { promise, resolve, reject } = Promise.withResolvers();
87
+ const answer = (response) => resolve(response);
88
+ withTelemetry(instance, () => continuing(ctx.request.headers.get("traceparent"), spanName(ctx), { kind: "server" }, async (scope) => {
39
89
  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
- }
90
+ const added = { span: scope, telemetry: instance };
91
+ const response = await settle(ctx, next(added));
92
+ if (ctx.route !== undefined) {
93
+ scope.name = `${ctx.request.method} ${ctx.route}`;
94
+ scope.attribute(HTTP_ROUTE, ctx.route);
49
95
  }
50
96
  record(scope, response.status, ctx.error);
51
97
  if (options.traceResponse) {
@@ -53,13 +99,11 @@ function telemetry(options) {
53
99
  response.headers.set("traceparent", scope.traceparent());
54
100
  } catch {}
55
101
  }
56
- return response;
57
- }));
58
- }).derive(({ request }) => ({
59
- span: scopes.get(request),
60
- telemetry: instance
61
- }));
62
- return Object.assign(plugin, { telemetry: instance });
102
+ return handOver(scope, response, answer);
103
+ })).then(answer, reject);
104
+ return promise;
105
+ });
106
+ return Object.assign(middleware, { telemetry: instance });
63
107
  }
64
108
  function record(scope, status, error) {
65
109
  if (error !== undefined) {
@@ -72,6 +116,34 @@ function record(scope, status, error) {
72
116
  if (serverFailed(status) && scope.status === "ok")
73
117
  scope.status = "error";
74
118
  }
119
+ async function handOver(scope, response, resolve) {
120
+ if (settled(response) || !scope.context.sampled)
121
+ return response;
122
+ const { promise: sent, resolve: end } = Promise.withResolvers();
123
+ const body = watched(response.body, (outcome, error) => {
124
+ try {
125
+ ended(scope, outcome, error);
126
+ } finally {
127
+ end();
128
+ }
129
+ });
130
+ const streamed = new Response(body, {
131
+ status: response.status,
132
+ statusText: response.statusText,
133
+ headers: response.headers
134
+ });
135
+ resolve(streamed);
136
+ await sent;
137
+ return streamed;
138
+ }
139
+ function ended(scope, outcome, error) {
140
+ if (outcome === "errored") {
141
+ scope.fail(error);
142
+ scope.status = "error";
143
+ } else if (outcome === "aborted")
144
+ scope.event(RESPONSE_ABORTED);
145
+ }
146
+ var RESPONSE_ABORTED = "http.response.aborted";
75
147
  function defaultName(ctx) {
76
148
  return `${ctx.request.method} ${ctx.url.pathname}`;
77
149
  }
@@ -96,5 +168,5 @@ export {
96
168
  telemetry
97
169
  };
98
170
 
99
- //# debugId=1F0C16FED53E6A3964756E2164756E21
171
+ //# debugId=E98136B2AF2AC44B64756E2164756E21
100
172
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/attributes.ts", "../src/telemetry.ts"],
3
+ "sources": ["../src/attributes.ts", "../src/telemetry.ts", "../src/body.ts"],
4
4
  "sourcesContent": [
5
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"
6
+ "import {\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\ttype MiddlewareMark,\n\ttype Next,\n\ttype RequestContext,\n\tsettle,\n} 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';\nimport { type Outcome, settled, watched } from './body';\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 name the span opens with. `\"<METHOD> <path>\"` by default; renamed `\"<METHOD> <route>\"` once answered, when a route matched. */\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/** What the routes after `telemetry()` read. */\nexport interface TelemetryContext {\n\t/** The server span around this request; `undefined` when `traced` said no. */\n\treadonly span: SpanScope | undefined;\n\treadonly telemetry: Telemetry;\n}\n\n/**\n * What `telemetry()` makes: a middleware that gives `span` and\n * `telemetry`, with the telemetry on it, to close on stop.\n */\nexport type TelemetryMiddleware = Middleware<\n\tEmpty,\n\tPromise<Next<TelemetryContext>>\n> &\n\tMiddlewareMark & { telemetry: Telemetry };\n\n/**\n * One server span per request, with [`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry),\n * as a middleware.\n *\n * Give it to `use` first: the span then holds everything the request\n * runs after it — the middlewares, the handler, what they await, the\n * answer to an error — and every log written with `createLogger` inside\n * it carries its trace id. A request no route matches gets one too. An\n * inbound `traceparent` continues its trace; an unusable one starts a\n * fresh trace. The span is named for the route, `GET /users/:id`, once\n * routing has matched. Only a 5xx marks it an error. A WebSocket upgrade\n * gets no span: there is no response to time.\n *\n * A streamed body (a page rendered as it goes, an event stream) keeps the\n * span open until it has been sent: a body that fails midway marks it an\n * error, and a client that leaves midway adds an `http.response.aborted`\n * event. A body of known length, or none, ends the span with the response.\n *\n * Routes declared after it read the span as `span`, and the telemetry as\n * `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(\n\toptions: TelemetryPluginOptions,\n): TelemetryMiddleware {\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\n\tconst middleware = defineMiddleware(async (ctx, next) => {\n\t\tconst untraced: TelemetryContext = { span: undefined, telemetry: instance };\n\t\tconst upgrade =\n\t\t\tctx.request.headers.get('upgrade')?.toLowerCase() === 'websocket';\n\t\tif (upgrade || !traced(ctx)) return next(untraced);\n\t\t// Resolved with the response as soon as there is one; the span itself\n\t\t// stays open until a streamed body has been sent, or has stopped.\n\t\tconst { promise, resolve, reject } =\n\t\t\tPromise.withResolvers<Next<TelemetryContext>>();\n\t\tconst answer = (response: Response) =>\n\t\t\tresolve(response as Next<TelemetryContext>);\n\t\twithTelemetry(instance, () =>\n\t\t\tcontinuing(\n\t\t\t\tctx.request.headers.get('traceparent'),\n\t\t\t\tspanName(ctx),\n\t\t\t\t{ kind: 'server' },\n\t\t\t\tasync (scope) => {\n\t\t\t\t\t// The span's own, not `SpanOptions.attributes`: those every span\n\t\t\t\t\t// and log inside inherits, and a database call is not the request.\n\t\t\t\t\tscope.attributes(\n\t\t\t\t\t\trequestAttributes(ctx.url, ctx.request.method, ctx.ip),\n\t\t\t\t\t);\n\t\t\t\t\tconst added: TelemetryContext = { span: scope, telemetry: instance };\n\t\t\t\t\tconst response = await settle(ctx, next(added));\n\t\t\t\t\tif (ctx.route !== undefined) {\n\t\t\t\t\t\tscope.name = `${ctx.request.method} ${ctx.route}`;\n\t\t\t\t\t\tscope.attribute(HTTP_ROUTE, ctx.route);\n\t\t\t\t\t}\n\t\t\t\t\trecord(scope, response.status, ctx.error);\n\t\t\t\t\tif (options.traceResponse) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tresponse.headers.set('traceparent', scope.traceparent());\n\t\t\t\t\t\t} catch {\n\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}\n\t\t\t\t\t}\n\t\t\t\t\treturn handOver(scope, response, answer);\n\t\t\t\t},\n\t\t\t),\n\t\t).then(answer, reject);\n\t\treturn promise;\n\t});\n\n\treturn Object.assign(middleware, { 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\n/**\n * The response the span ends with. A body with nothing left to time, or a\n * span never exported, is returned as it is: the span ends, then the\n * response is handed over. A streamed body is handed over at once through\n * `resolve`, watched, and returned once it has ended, so the span ends then.\n */\nasync function handOver(\n\tscope: SpanScope,\n\tresponse: Response,\n\tresolve: (response: Response) => void,\n): Promise<Response> {\n\tif (settled(response) || !scope.context.sampled) return response;\n\tconst { promise: sent, resolve: end } = Promise.withResolvers<void>();\n\tconst body = watched(\n\t\tresponse.body as ReadableStream<Uint8Array>,\n\t\t(outcome, error) => {\n\t\t\ttry {\n\t\t\t\tended(scope, outcome, error);\n\t\t\t} finally {\n\t\t\t\tend();\n\t\t\t}\n\t\t},\n\t);\n\tconst streamed = new Response(body, {\n\t\tstatus: response.status,\n\t\tstatusText: response.statusText,\n\t\theaders: response.headers,\n\t});\n\tresolve(streamed);\n\tawait sent;\n\treturn streamed;\n}\n\n/**\n * How a streamed body ended, on its span: a body that failed fails the\n * span, as a 5xx does; a client that left is an event, the server having\n * done nothing wrong.\n */\nfunction ended(scope: SpanScope, outcome: Outcome, error: unknown): void {\n\tif (outcome === 'errored') {\n\t\tscope.fail(error);\n\t\t// `fail` keeps a failure recorded before, and its status with it.\n\t\tscope.status = 'error';\n\t} else if (outcome === 'aborted') scope.event(RESPONSE_ABORTED);\n}\n\n/** The event of a server span whose client left before the body was sent. */\nconst RESPONSE_ABORTED = 'http.response.aborted';\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
+ "// Kept twice: `logger/src/body.ts` is the same file. Change both together.\n/** How a streamed body ended: sent whole, left by its client, or failed. */\nexport type Outcome = 'completed' | 'aborted' | 'errored';\n\n/**\n * Whether `response` is sent as it is, with nothing left to time: no body,\n * or a `Content-Length` header, which `@alxia/core` sets on every reply of\n * a string, JSON, a buffer or a file. Bun sends those without JavaScript,\n * so they are not wrapped. A raw `Response` has no such header, even of a\n * string, and is watched. The header is read first: it does not make Bun\n * build the body's stream.\n */\nexport function settled(response: Response): boolean {\n\treturn response.headers.has('content-length') || response.body === null;\n}\n\n/**\n * `body`, passed through chunk by chunk, calling `end` once when it has\n * been read to its end, cancelled by its reader (the client left: Bun\n * cancels the body), or failed, with the failure. A cancel goes on to\n * `body`, so an event stream releases its generator. One chunk is read per\n * pull, never ahead.\n */\nexport function watched(\n\tbody: ReadableStream<Uint8Array>,\n\tend: (outcome: Outcome, error?: unknown) => void,\n): ReadableStream<Uint8Array> {\n\tconst reader = body.getReader();\n\tlet ended = false;\n\tconst finish = (outcome: Outcome, error?: unknown) => {\n\t\tif (ended) return;\n\t\tended = true;\n\t\ttry {\n\t\t\tend(outcome, error);\n\t\t} catch (failure) {\n\t\t\t// What watches the body never stops it: a cancel still reaches it.\n\t\t\ttry {\n\t\t\t\tconsole.error(failure);\n\t\t\t} catch {}\n\t\t}\n\t};\n\treturn new ReadableStream<Uint8Array>(\n\t\t{\n\t\t\tasync pull(controller) {\n\t\t\t\tlet next: Awaited<ReturnType<typeof reader.read>>;\n\t\t\t\ttry {\n\t\t\t\t\tnext = await reader.read();\n\t\t\t\t} catch (error) {\n\t\t\t\t\tfinish('errored', error);\n\t\t\t\t\tcontroller.error(error);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (next.done) {\n\t\t\t\t\tfinish('completed');\n\t\t\t\t\tcontroller.close();\n\t\t\t\t} else controller.enqueue(next.value);\n\t\t\t},\n\t\t\tcancel(reason) {\n\t\t\t\tfinish('aborted');\n\t\t\t\treturn reader.cancel(reason);\n\t\t\t},\n\t\t},\n\t\t{ highWaterMark: 0 },\n\t);\n}\n"
7
8
  ],
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",
9
+ "mappings": "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",
10
+ "debugId": "E98136B2AF2AC44B64756E2164756E21",
10
11
  "names": []
11
12
  }
@@ -1,4 +1,4 @@
1
- import { type RequestContext } from '@alxia/core';
1
+ import { type Empty, type Middleware, type MiddlewareMark, type Next, type RequestContext } from '@alxia/core';
2
2
  import { type SpanScope, type Telemetry, type TelemetryOptions } from '@nxgt/telemetry';
3
3
  interface Hooks {
4
4
  /**
@@ -6,7 +6,7 @@ interface Hooks {
6
6
  * that decides which requests do not matter hides the one that did.
7
7
  */
8
8
  readonly traced?: (ctx: RequestContext) => boolean;
9
- /** The span's name before routing. `"<METHOD> <path>"` by default, then `"<METHOD> <route>"`. */
9
+ /** The name the span opens with. `"<METHOD> <path>"` by default; renamed `"<METHOD> <route>"` once answered, when a route matched. */
10
10
  readonly spanName?: (ctx: RequestContext) => string;
11
11
  /** Whether the response says `traceparent` back, so a caller can find the trace. Off by default. */
12
12
  readonly traceResponse?: boolean;
@@ -23,19 +23,39 @@ export type TelemetryPluginOptions = (Hooks & TelemetryOptions & {
23
23
  readonly instance: Telemetry;
24
24
  readonly service?: undefined;
25
25
  });
26
+ /** What the routes after `telemetry()` read. */
27
+ export interface TelemetryContext {
28
+ /** The server span around this request; `undefined` when `traced` said no. */
29
+ readonly span: SpanScope | undefined;
30
+ readonly telemetry: Telemetry;
31
+ }
32
+ /**
33
+ * What `telemetry()` makes: a middleware that gives `span` and
34
+ * `telemetry`, with the telemetry on it, to close on stop.
35
+ */
36
+ export type TelemetryMiddleware = Middleware<Empty, Promise<Next<TelemetryContext>>> & MiddlewareMark & {
37
+ telemetry: Telemetry;
38
+ };
26
39
  /**
27
40
  * One server span per request, with [`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry),
28
- * as a plugin.
41
+ * as a middleware.
29
42
  *
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.
43
+ * Give it to `use` first: the span then holds everything the request
44
+ * runs after it — the middlewares, the handler, what they await, the
45
+ * answer to an error — and every log written with `createLogger` inside
46
+ * it carries its trace id. A request no route matches gets one too. An
47
+ * inbound `traceparent` continues its trace; an unusable one starts a
48
+ * fresh trace. The span is named for the route, `GET /users/:id`, once
49
+ * routing has matched. Only a 5xx marks it an error. A WebSocket upgrade
50
+ * gets no span: there is no response to time.
36
51
  *
37
- * Routes declared after the plugin read the span as `span`, and the
38
- * telemetry as `telemetry`.
52
+ * A streamed body (a page rendered as it goes, an event stream) keeps the
53
+ * span open until it has been sent: a body that fails midway marks it an
54
+ * error, and a client that leaves midway adds an `http.response.aborted`
55
+ * event. A body of known length, or none, ends the span with the response.
56
+ *
57
+ * Routes declared after it read the span as `span`, and the telemetry as
58
+ * `telemetry`.
39
59
  *
40
60
  * ```ts
41
61
  * const tracing = telemetry({ service: 'checkout', exporters: [otlpExporter({ endpoint })] });
@@ -43,12 +63,6 @@ export type TelemetryPluginOptions = (Hooks & TelemetryOptions & {
43
63
  * app.onStop(() => tracing.telemetry.close());
44
64
  * ```
45
65
  */
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
- };
66
+ export declare function telemetry(options: TelemetryPluginOptions): TelemetryMiddleware;
53
67
  export {};
54
68
  //# sourceMappingURL=telemetry.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,KAAK,EACV,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,IAAI,EACT,KAAK,cAAc,EAEnB,MAAM,aAAa,CAAC;AACrB,OAAO,EAGN,KAAK,SAAS,EACd,KAAK,SAAS,EACd,KAAK,gBAAgB,EAErB,MAAM,iBAAiB,CAAC;AASzB,UAAU,KAAK;IACd;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC;IACnD,sIAAsI;IACtI,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,gDAAgD;AAChD,MAAM,WAAW,gBAAgB;IAChC,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;CAC9B;AAED;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,UAAU,CAC3C,KAAK,EACL,OAAO,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,CAC/B,GACA,cAAc,GAAG;IAAE,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AAE3C;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,SAAS,CACxB,OAAO,EAAE,sBAAsB,GAC7B,mBAAmB,CAkDrB"}
package/docs/guide.md CHANGED
@@ -35,9 +35,12 @@ exporter, and nothing else changes.
35
35
  ```ts
36
36
  function telemetry(
37
37
  options: TelemetryPluginOptions,
38
- ): Alxia<Empty & { span: SpanScope | undefined; telemetry: Telemetry }, Empty, '', never> & {
39
- telemetry: Telemetry;
40
- };
38
+ ): Middleware<…> & { telemetry: Telemetry }; // give it to `app.use`, first
39
+
40
+ interface TelemetryContext {
41
+ readonly span: SpanScope | undefined;
42
+ readonly telemetry: Telemetry;
43
+ }
41
44
 
42
45
  type TelemetryPluginOptions =
43
46
  | (Hooks & TelemetryOptions & { readonly service: string; readonly instance?: undefined })
@@ -51,18 +54,21 @@ interface Hooks {
51
54
  ```
52
55
 
53
56
  `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.
57
+ `RequestContext` is `@alxia/core`'s. `telemetry()` returns a middleware:
58
+ pass it to `app.use`, called. A `use()` on the app runs on every request,
59
+ so it traces them all, a 404 included, and it adds `span` and `telemetry`
60
+ to what is declared **after** it: the middlewares and the routes. The
61
+ telemetry it writes to is also on the middleware itself, as `.telemetry`,
62
+ for the code that is not a route. Give it to `use` first, with the other
63
+ observers (`logger`, `secureHeaders`, `cors`, `compress`).
59
64
 
60
65
  ## The span
61
66
 
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,
67
+ One span per request, of kind `server`, opened by the middleware. It
68
+ holds everything the request runs after it: the middlewares, validation,
69
+ the route's own middlewares and handler, whatever they await, and the
70
+ answer to an error: `telemetry()` settles `next()`, so the span sees the
71
+ response the client gets, an `onError` reply or a 500 included. A log written with `createLogger` anywhere inside it,
66
72
  and a span opened with `span()`, belong to it.
67
73
 
68
74
  ```ts
@@ -81,51 +87,102 @@ const app = alxia()
81
87
  });
82
88
  ```
83
89
 
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.
90
+ Because a `use()` on the app runs on every request, a route declared before
91
+ `use(telemetry(...))` is traced too; it only cannot read `span` and
92
+ `telemetry` from its context, and a request no route matches is traced as
93
+ well. A middleware declared **before** `telemetry()` is outside the span.
94
+ A WebSocket upgrade is not traced: there is no response to time.
95
+
96
+ ### A streamed body
97
+
98
+ A response whose body is a stream of unknown length (a React Router page
99
+ rendered as it goes, an `eventStream` reply, a `ReadableStream` of your
100
+ own) keeps the span open until that body has ended, so the span's
101
+ duration is the time to the last byte. The middleware passes the body through
102
+ a stream of its own, one chunk at a time:
103
+
104
+ | The body | The span |
105
+ | --- | --- |
106
+ | sent whole | ends then, its status from the response |
107
+ | the client left before its end | ends then, `ok`, with an `http.response.aborted` event |
108
+ | the stream failed | ends then, `error`, with the stream's error as its exception |
109
+
110
+ An endless event stream's span ends when its client leaves. A response
111
+ with no body, or with a `Content-Length` header (`@alxia/core` sets it on
112
+ every reply of a string, JSON, a buffer or a file), ends the span when it
113
+ is handed over and is left as it is: Bun sends those bodies without
114
+ JavaScript, a file with `sendfile`. A raw `Response` a handler or a middleware builds, even
115
+ of a string or a `Bun.file`, has no such header until Bun sends it: it is
116
+ treated as a stream, and timed to its end. An unsampled span is never
117
+ exported, so its body is never wrapped.
118
+
119
+ What decides is the `Content-Length` of the response `telemetry()` sees on
120
+ the way out. `@alxia/compress` removes it from the body it compresses, so
121
+ with `use(compress())` declared before `use(telemetry(...))`, a compressed
122
+ JSON reply is a stream too, and its span lasts until it has been sent.
123
+
124
+ In a test, a streamed route's span ends only once its body has been read
125
+ or cancelled: read it before `close()`, or the span is never exported.
126
+
127
+ ```ts
128
+ const response = await app.request('/events');
129
+ await response.body?.cancel(); // or `await response.text()` for a body that ends
130
+ await instance.close();
131
+ ```
88
132
 
89
133
  ### Its name
90
134
 
91
135
  | The request | The span's name |
92
136
  | --- | --- |
93
- | before routing | `spanName(ctx)`, by default `"<METHOD> <path>"`: `GET /orders/o-1` |
137
+ | when it opens | `spanName(ctx)`, by default `"<METHOD> <path>"`: `GET /orders/o-1` |
94
138
  | 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 |
139
+ | no route matched (`404`, `405`) | stays `spanName(ctx)`, `"<METHOD> <path>"` by default |
96
140
 
97
141
  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
142
+ one per order. So `spanName` only names what routing did not match (an
143
+ unmatched request still gets its span). A
99
144
  `HEAD` request answered by a `GET` route is named `HEAD /orders/:id`.
100
145
 
101
146
  ### Its status, and the route's error
102
147
 
103
148
  | The response | The span's status | Its exception |
104
149
  | --- | --- | --- |
105
- | `2xx`, `3xx`, `4xx` | `ok` | none |
106
- | a `4xx` an `onError` hook made of a thrown error | `ok` | the error |
150
+ | `2xx`, `3xx`, `4xx` replied | `ok` | none |
151
+ | a `4xx` an error-handling middleware made of a thrown error | `ok` | none: the middleware caught it |
152
+ | a `4xx` the route boundary made of a thrown error (a deprecated `onError` hook, an `HttpError`) | `ok` | the error |
153
+ | `499`, the client hung up mid-request | `ok` | the `AbortError` |
107
154
  | a `5xx` from a throw | `error` | the error |
108
155
  | a `5xx` the route replied | `error` | none |
156
+ | a streamed body that failed midway ([A streamed body](#a-streamed-body)) | `error` | the stream's error |
109
157
 
110
158
  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:
159
+ span. An error that reaches the route boundary is still recorded, as
160
+ `ctx.error` holds it. One an error-handling middleware catches is not: the
161
+ span sees only the 400 it answered. Here a middleware after `telemetry`
162
+ turns a `RangeError` into a 400:
113
163
 
114
164
  ```ts
115
- import { alxia } from '@alxia/core';
165
+ import { alxia, defineMiddleware } from '@alxia/core';
116
166
  import { telemetry } from '@alxia/telemetry';
117
167
  import { consoleExporter } from '@nxgt/telemetry';
118
168
 
119
169
  const app = alxia()
120
170
  .use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
121
- .onError((error, { reply }) =>
122
- error instanceof RangeError ? reply(400, { error: 'out_of_range' as const }) : undefined,
171
+ .use(
172
+ defineMiddleware(async ({ reply }, next) => {
173
+ try {
174
+ return await next();
175
+ } catch (error) {
176
+ if (error instanceof RangeError) return reply(400, { error: 'out_of_range' as const });
177
+ throw error;
178
+ }
179
+ }),
123
180
  )
124
181
  .get('/range', () => {
125
182
  throw new RangeError('out of range');
126
183
  });
127
184
 
128
- await app.request('/range'); // 400; the span is ok, with `out of range` as its exception
185
+ await app.request('/range'); // 400; the span is ok, with no exception: the middleware caught it
129
186
  ```
130
187
 
131
188
  ### What it records
@@ -159,17 +216,19 @@ outgoing request — and a log written inside the request carry its trace
159
216
  and span ids, not the request's path, method or the client's address. An
160
217
  attribute every log of a request should carry is yours to give, with
161
218
  `@nxgt/telemetry`'s `withAttributes`: it reaches what runs inside it, so
162
- an `around` hook declared after the plugin covers the whole request:
219
+ a middleware declared after `telemetry()` covers the rest of the request:
163
220
 
164
221
  ```ts
165
- import { alxia } from '@alxia/core';
222
+ import { alxia, defineMiddleware } from '@alxia/core';
166
223
  import { telemetry } from '@alxia/telemetry';
167
224
  import { withAttributes } from '@nxgt/telemetry';
168
225
 
169
226
  const app = alxia()
170
227
  .use(telemetry({ service: 'shop' }))
171
- .around((ctx, next) =>
172
- withAttributes({ 'tenant.id': ctx.request.headers.get('x-tenant') ?? 'none' }, next),
228
+ .use(
229
+ defineMiddleware((ctx, next) =>
230
+ withAttributes({ 'tenant.id': ctx.request.headers.get('x-tenant') ?? 'none' }, next),
231
+ ),
173
232
  );
174
233
  ```
175
234
 
@@ -228,9 +287,9 @@ response.headers.get('traceparent'); // '00-<trace id>-<span id>-01'
228
287
  | Option | Type | Default | Effect |
229
288
  | --- | --- | --- | --- |
230
289
  | `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 |
290
+ | `instance` | `Telemetry` | required, without `service` | a telemetry you built: used as it is, neither installed nor closed by the middleware |
232
291
  | `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 |
292
+ | `spanName` | `(ctx: RequestContext) => string` | `"<METHOD> <path>"` | the span's name when it opens, kept when no route matches |
234
293
  | `traceResponse` | `boolean` | `false` | sets `traceparent` on the response |
235
294
 
236
295
  With `service`, every [`TelemetryOptions`](https://www.npmjs.com/package/@nxgt/telemetry)
@@ -249,7 +308,7 @@ of `@nxgt/telemetry` is accepted alongside:
249
308
 
250
309
  ### `service` or `instance`
251
310
 
252
- `service` is the short way, for an app whose telemetry is this plugin's:
311
+ `service` is the short way, for an app whose telemetry is this middleware's:
253
312
  the telemetry is installed, so a logger used outside any request — at
254
313
  start-up, in a job — finds it too.
255
314
 
@@ -279,7 +338,7 @@ const shared = createTelemetry('checkout', { exporters: [consoleExporter()] }).i
279
338
  const app = alxia().use(telemetry({ instance: shared }));
280
339
  ```
281
340
 
282
- The plugin runs each traced request inside the instance, so the logs in it
341
+ The middleware runs each traced request inside the instance, so the logs in it
283
342
  reach it either way. Outside a request, and in a request `traced` said no
284
343
  to, a logger only finds a telemetry that is installed: call `install()` on
285
344
  an instance the whole process writes to, as above.
@@ -307,8 +366,9 @@ telemetry({
307
366
  });
308
367
  ```
309
368
 
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.
369
+ It runs when the span opens, where `ctx.route` is already known, and is
370
+ `undefined` on a request no route matches. Once a route has matched, the
371
+ span is renamed after it whatever `spanName` said.
312
372
 
313
373
  A `traced` or `spanName` that throws costs its answer, never the request:
314
374
  the request is traced, or named `"<METHOD> <path>"`, and nothing is logged.
@@ -332,7 +392,7 @@ const app = alxia()
332
392
  | Field | Type | What it is |
333
393
  | --- | --- | --- |
334
394
  | `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 |
395
+ | `telemetry` | `Telemetry` | the telemetry the middleware writes to, traced or not |
336
396
 
337
397
  Only the routes declared after `use(telemetry(...))`, in the same app or
338
398
  group, read them.
@@ -340,7 +400,7 @@ group, read them.
340
400
  ## Shutting down
341
401
 
342
402
  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,
403
+ has to be awaited, or the last batch is lost. The middleware closes nothing,
344
404
  not even a telemetry it built: close it in `onStop`, which `app.stop()`
345
405
  runs, and stop the app when the process is asked to end.
346
406
 
@@ -445,7 +505,7 @@ test('a route gets one server span, named after it', async () => {
445
505
  ```
446
506
 
447
507
  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
508
+ process. A middleware built with `service` installs its telemetry for the whole
449
509
  process; `uninstallTelemetry()` after each test takes it back out.
450
510
 
451
511
  To continue a trace in a test, send the header a caller would:
package/docs/roadmap.md CHANGED
@@ -7,7 +7,7 @@ number on it. Every release, with each change it made, is in
7
7
 
8
8
  ## Now
9
9
 
10
- Nothing scheduled yet.
10
+ - **A middleware, not a plugin (0.4).** `app.use(telemetry({ ... }))` opens a span for every request, a 404 or a 405 included, around everything after it, and sees the response the client gets. `app.plugin(telemetry(...))` still works, deprecated.
11
11
 
12
12
  ## Next
13
13
 
@@ -28,6 +28,14 @@ Nothing scheduled yet.
28
28
 
29
29
  ## Shipped
30
30
 
31
+ ### 0.2.0
32
+
33
+ - **A streamed body is in its span.** A page rendered as it goes, or an
34
+ event stream, keeps the server span open until its body has been sent:
35
+ the span lasts to the last byte, a body that fails midway makes it an
36
+ error, and a client that leaves midway adds an `http.response.aborted`
37
+ event.
38
+
31
39
  ### 0.1.0
32
40
 
33
41
  - **One server span per request.** `alxia().use(telemetry({ service, exporters }))`
@@ -6,7 +6,7 @@ the server log, or, for what prints nothing, what you see in your traces.
6
6
  **Types**
7
7
 
8
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)
9
+ - [`Type 'Alxia<Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'`](#type-alxiaempty--never-is-not-assignable-to-type-telemetrypluginoptions)
10
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
11
  - [`Type 'Telemetry' is not assignable to type 'undefined'`](#type-telemetry-is-not-assignable-to-type-undefined)
12
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--)
@@ -24,12 +24,14 @@ the server log, or, for what prints nothing, what you see in your traces.
24
24
  - [Logs carry a `traceId`, but its span is never exported](#logs-carry-a-traceid-but-its-span-is-never-exported)
25
25
  - [Spans stop arriving, and the app still answers](#spans-stop-arriving-and-the-app-still-answers)
26
26
  - [No span for a WebSocket connection](#no-span-for-a-websocket-connection)
27
+ - [A request no route matched has no span, or a request has none at all](#a-request-no-route-matched-has-no-span-or-a-request-has-none-at-all)
27
28
  - [The response has no `traceparent` header](#the-response-has-no-traceparent-header)
28
29
 
29
30
  **Unexpected spans**
30
31
 
31
32
  - [The span starts a new trace although the caller sent `traceparent`](#the-span-starts-a-new-trace-although-the-caller-sent-traceparent)
32
33
  - [`spanName` only shows on requests no route matched](#spanname-only-shows-on-requests-no-route-matched)
34
+ - [A streamed request's span lasts as long as its stream](#a-streamed-requests-span-lasts-as-long-as-its-stream)
33
35
  - [A span has an exception, and its status is `ok`](#a-span-has-an-exception-and-its-status-is-ok)
34
36
 
35
37
  ## Types
@@ -43,11 +45,11 @@ error TS2339: Property 'span' does not exist on type 'Context<Empty, "/before",
43
45
  **When:** a route reads `span` or `telemetry`, and is declared before
44
46
  `use(telemetry(...))`.
45
47
 
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.
48
+ **Why:** the middleware gives `span` and `telemetry` to what is declared
49
+ after it. The request is still traced, since a `use()` on the app runs on
50
+ every request; the route only cannot reach it.
49
51
 
50
- **Fix:** use the plugin first:
52
+ **Fix:** `use` it first:
51
53
 
52
54
  ```ts
53
55
  const app = alxia()
@@ -58,20 +60,20 @@ const app = alxia()
58
60
  });
59
61
  ```
60
62
 
61
- ### `Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'`
63
+ ### `Type 'Alxia<Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'`
62
64
 
63
65
  ```text
64
66
  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
+ Overload 1 of 11, '(plugin: (app: Alxia<Empty, "", never>) => AnyAlxia): AnyAlxia', gave the following error.
68
+ Argument of type '(options: TelemetryPluginOptions) => Middleware<…>' is not assignable to parameter of type '(app: Alxia<Empty, "", never>) => AnyAlxia'.
67
69
  Types of parameters 'options' and 'app' are incompatible.
68
- Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'.
70
+ Type 'Alxia<Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'.
69
71
  ```
70
72
 
71
73
  **When:** `app.use(telemetry)`, without calling it.
72
74
 
73
- **Why:** `telemetry` makes the plugin; it is not the plugin, and it needs a
74
- `service` or an `instance`.
75
+ **Why:** `telemetry` makes the middleware; it is not the middleware, and it
76
+ needs a `service` or an `instance`.
75
77
 
76
78
  **Fix:**
77
79
 
@@ -90,7 +92,7 @@ error TS2345: Argument of type '{ exporters: never[]; }' is not assignable to pa
90
92
  **When:** `telemetry({ exporters: [...] })`, with neither `service` nor
91
93
  `instance`.
92
94
 
93
- **Why:** the telemetry the plugin builds needs a service name: every signal
95
+ **Why:** the telemetry it builds needs a service name: every signal
94
96
  groups by it, and there is no default.
95
97
 
96
98
  **Fix:** name the service, or hand over a telemetry you built:
@@ -109,7 +111,7 @@ error TS2345: Argument of type '{ service: string; instance: Telemetry; }' is no
109
111
 
110
112
  **When:** `telemetry({ service, instance })`.
111
113
 
112
- **Why:** `service` builds a telemetry, `instance` adopts one; the plugin
114
+ **Why:** `service` builds a telemetry, `instance` adopts one; the middleware
113
115
  writes to exactly one.
114
116
 
115
117
  **Fix:** keep `instance`, whose service name was given to `createTelemetry`:
@@ -131,7 +133,7 @@ The same for `exporters`, `sampler`, `environment`, or any other
131
133
 
132
134
  **When:** `@nxgt/telemetry` options next to `instance`.
133
135
 
134
- **Why:** an adopted telemetry is already built; the plugin cannot change
136
+ **Why:** an adopted telemetry is already built; the middleware cannot change
135
137
  its exporters or its resource.
136
138
 
137
139
  **Fix:** give them to `createTelemetry`:
@@ -155,8 +157,8 @@ error TS2322: Type '(ctx: RequestContext) => string | undefined' is not assignab
155
157
 
156
158
  **When:** `spanName: (ctx) => ctx.route`.
157
159
 
158
- **Why:** `spanName` runs before routing, where `ctx.route` is always
159
- `undefined`. The route already names the span once it matches.
160
+ **Why:** `ctx.route` is `string | undefined`: it is `undefined` on a request
161
+ no route matches. And the route already names the span once it matches.
160
162
 
161
163
  **Fix:** name what routing has not matched from the path, or leave
162
164
  `spanName` out:
@@ -204,7 +206,7 @@ is stopped, and the last spans and logs never reach the exporter — with
204
206
 
205
207
  **Why:** the telemetry batches signals, and ships a batch when it is full
206
208
  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`.
209
+ middleware never closes the telemetry, not even one it built from `service`.
208
210
 
209
211
  **Fix:** close it in `onStop`, await `app.stop()` on shutdown, and await
210
212
  `close()` in a script or a test before reading what was exported:
@@ -243,7 +245,7 @@ bun pm ls --all | grep @nxgt/telemetry@
243
245
  **When:** a log at start-up, in a job or a timer, or in a request `traced`
244
246
  said no to, while the logs in traced requests arrive.
245
247
 
246
- **Why:** the plugin was given an `instance`. It runs each traced request
248
+ **Why:** the middleware was given an `instance`. It runs each traced request
247
249
  inside that telemetry, but does not install it, so a logger with no request
248
250
  around it finds none.
249
251
 
@@ -272,13 +274,13 @@ telemetry({ service: 'checkout', sampler: alwaysSample, exporters: [consoleExpor
272
274
 
273
275
  ### Spans stop arriving, and the app still answers
274
276
 
275
- **When:** after `close()` on the plugin's telemetry — often a test that
277
+ **When:** after `close()` on the middleware's telemetry — often a test that
276
278
  closes it in one case and sends requests in the next.
277
279
 
278
- **Why:** a closed telemetry takes nothing more, and the plugin keeps
280
+ **Why:** a closed telemetry takes nothing more, and the middleware keeps
279
281
  writing to it; the requests are answered as usual.
280
282
 
281
- **Fix:** build a plugin, with its own telemetry, per test:
283
+ **Fix:** build a middleware, with its own telemetry, per test:
282
284
 
283
285
  ```ts
284
286
  const instance = createTelemetry('test', { exporters: [exporter] });
@@ -289,8 +291,8 @@ const app = alxia().use(telemetry({ instance }));
289
291
 
290
292
  **When:** a route declared with `app.ws`.
291
293
 
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
+ **Why:** a WebSocket upgrade has no response to settle, and the span lasts
295
+ as long as the response it wraps.
294
296
 
295
297
  **Fix:** open a span for the work a message does:
296
298
 
@@ -301,11 +303,30 @@ app.ws('/rooms/:room', {}, {
301
303
  });
302
304
  ```
303
305
 
306
+ ### A request no route matched has no span, or a request has none at all
307
+
308
+ **When:** a 404 or a 405 is missing from your traces, or no request is.
309
+
310
+ **Why:** the span is opened by `use(telemetry(...))`, which runs on every
311
+ request the app takes, an unmatched one included. A request is missing when
312
+ `telemetry()` sits inside a `group` (it sees the group's routes and the
313
+ unmatched requests under its prefix, nothing else), when `traced` said no, or when another middleware
314
+ declared before it answered without calling `next()` (a preflight, a 401):
315
+ it is outside the span.
316
+
317
+ **Fix:** `use` it on the app, first:
318
+
319
+ ```ts
320
+ const app = alxia()
321
+ .use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
322
+ .use(bearer({ jwt }));
323
+ ```
324
+
304
325
  ### The response has no `traceparent` header
305
326
 
306
327
  **When:** a caller looks for the trace of the request it made.
307
328
 
308
- **Why:** the plugin says `traceparent` back only with `traceResponse`, and
329
+ **Why:** the middleware says `traceparent` back only with `traceResponse`, and
309
330
  only on a request `traced` let through.
310
331
 
311
332
  **Fix:**
@@ -337,7 +358,7 @@ await fetch('https://checkout.example.com/orders/o-1', {
337
358
  **When:** a `spanName` is set, and the matched requests are still named
338
359
  `GET /orders/:id`.
339
360
 
340
- **Why:** `spanName` is the name before routing. Once a route matches, the
361
+ **Why:** `spanName` is the name the span opens with. Once a route matches, the
341
362
  span is renamed `"<METHOD> <route>"`, so a dashboard has one row per route.
342
363
 
343
364
  **Fix:** to add to a routed span, set an attribute rather than the name:
@@ -349,13 +370,33 @@ app.get('/orders/:id', ({ params, span, reply }) => {
349
370
  });
350
371
  ```
351
372
 
373
+ ### A streamed request's span lasts as long as its stream
374
+
375
+ **When:** the span of a page streamed as it renders, or of an event
376
+ stream, lasts seconds or minutes, or ends only when the client closes the
377
+ tab, with an `http.response.aborted` event.
378
+
379
+ **Why:** a streamed body keeps the server span open until it has been
380
+ sent, so the span measures what the client received. An event stream
381
+ that never ends on its own ends its span when its client leaves; the
382
+ event says so, and the status stays `ok`: the server did nothing wrong.
383
+
384
+ **Fix:** none is needed. To keep an event stream out of a latency
385
+ dashboard, filter on its route, or leave it untraced:
386
+
387
+ ```ts
388
+ app.use(telemetry({ service: 'checkout', exporters, traced: (ctx) => ctx.url.pathname !== '/events' }));
389
+ ```
390
+
352
391
  ### A span has an exception, and its status is `ok`
353
392
 
354
- **When:** a route throws, and an `onError` hook answers with a `4xx`.
393
+ **When:** a route throws, and the route boundary (an `HttpError`, a
394
+ deprecated `onError` hook) answers with a `4xx`. An error-handling middleware
395
+ that catches it leaves the span `ok` with no exception at all.
355
396
 
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.
397
+ **Why:** the error is recorded as the span's exception, but only a
398
+ `5xx`, or a streamed body that fails midway, makes a span an error: a
399
+ `400` the app chose to answer is the server working.
359
400
 
360
401
  **Fix:** none needed. For an error that should mark the span, answer it
361
402
  with a `5xx`, or let it throw to the `500`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/telemetry",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
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
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,14 +41,13 @@
41
41
  ]
42
42
  },
43
43
  "devDependencies": {
44
- "@alxia/client": "^0.2.0",
45
- "@alxia/core": "^0.2.0",
44
+ "@alxia/core": "^0.4.0",
46
45
  "@nxgt/telemetry": "^0.2.1",
47
46
  "@types/bun": "^1.4.2",
48
47
  "zod": "^4.6.5"
49
48
  },
50
49
  "peerDependencies": {
51
- "@alxia/core": "^0.2.0",
50
+ "@alxia/core": "^0.4.0",
52
51
  "@nxgt/telemetry": "^0.2.1",
53
52
  "typescript": "^6.0.3 || ^7.0.0"
54
53
  }