@alxia/telemetry 0.2.0 → 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,8 +43,10 @@ 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. A streamed body (a
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
48
50
  page rendered as it goes, an event stream) keeps it open until the body
49
51
  has been sent: one that fails midway makes it an error, a client that
50
52
  leaves adds an `http.response.aborted` event.
@@ -53,7 +55,8 @@ app.listen(3000);
53
55
  stranger.
54
56
  - It starts as `GET /orders/o-1` and is renamed `GET /orders/:id` once
55
57
  routing has matched, with `http.route`: one dashboard row per route, not
56
- per order. A request no route matched keeps its path.
58
+ per order. A request no route matched (a 404, a 405) gets a span too, and
59
+ keeps its path.
57
60
  - A route's error is its exception. Only a 5xx, or a streamed body that
58
61
  fails midway, makes the span an error: a 401 a guard answered is the
59
62
  server working.
@@ -79,16 +82,18 @@ telemetry as `instance` — adopted, never closed. And:
79
82
  | option | default | |
80
83
  | --- | --- | --- |
81
84
  | `traced` | every request | `(ctx) => boolean`: a health check |
82
- | `spanName` | `"<METHOD> <path>"` | the name before routing |
85
+ | `spanName` | `"<METHOD> <path>"` | the name the span opens with; a matched route renames it |
83
86
  | `traceResponse` | `false` | says `traceparent` back |
84
87
 
85
- A hook that throws costs its answer, never the request.
88
+ A `traced` or `spanName` that throws costs its answer, never the request.
86
89
 
87
90
  ## API
88
91
 
89
92
  | export | |
90
93
  | --- | --- |
91
- | `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` |
92
97
  | `TelemetryPluginOptions` | its options: `service` and `@nxgt/telemetry`'s options, or an `instance`; `traced`, `spanName`, `traceResponse` |
93
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` |
94
99
 
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,7 +21,10 @@ 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,
@@ -75,37 +78,32 @@ function telemetry(options) {
75
78
  const instance = options.instance ?? createTelemetry(options.service, options).install();
76
79
  const traced = guarded(options.traced ?? (() => true), () => true);
77
80
  const spanName = guarded(options.spanName ?? defaultName, defaultName);
78
- const scopes = new WeakMap;
79
- const plugin = alxia().around((ctx, next) => {
80
- if (!traced(ctx))
81
- return next();
82
- return new Promise((resolve, reject) => {
83
- withTelemetry(instance, () => continuing(ctx.request.headers.get("traceparent"), spanName(ctx), { kind: "server" }, async (scope) => {
84
- scope.attributes(requestAttributes(ctx.url, ctx.request.method, ctx.ip));
85
- scopes.set(ctx.request, scope);
86
- let response;
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) => {
89
+ scope.attributes(requestAttributes(ctx.url, ctx.request.method, ctx.ip));
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);
95
+ }
96
+ record(scope, response.status, ctx.error);
97
+ if (options.traceResponse) {
87
98
  try {
88
- response = await next();
89
- } finally {
90
- if (ctx.route !== undefined) {
91
- scope.name = `${ctx.request.method} ${ctx.route}`;
92
- scope.attribute(HTTP_ROUTE, ctx.route);
93
- }
94
- }
95
- record(scope, response.status, ctx.error);
96
- if (options.traceResponse) {
97
- try {
98
- response.headers.set("traceparent", scope.traceparent());
99
- } catch {}
100
- }
101
- return handOver(scope, response, resolve);
102
- })).then(resolve, reject);
103
- });
104
- }).derive(({ request }) => ({
105
- span: scopes.get(request),
106
- telemetry: instance
107
- }));
108
- return Object.assign(plugin, { telemetry: instance });
99
+ response.headers.set("traceparent", scope.traceparent());
100
+ } catch {}
101
+ }
102
+ return handOver(scope, response, answer);
103
+ })).then(answer, reject);
104
+ return promise;
105
+ });
106
+ return Object.assign(middleware, { telemetry: instance });
109
107
  }
110
108
  function record(scope, status, error) {
111
109
  if (error !== undefined) {
@@ -170,5 +168,5 @@ export {
170
168
  telemetry
171
169
  };
172
170
 
173
- //# debugId=66C32AC8AF05FED064756E2164756E21
171
+ //# debugId=E98136B2AF2AC44B64756E2164756E21
174
172
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -3,10 +3,10 @@
3
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';\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 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 * 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 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\t// Resolved with the response as soon as there is one; the span itself\n\t\t\t// stays open until a streamed body has been sent, or has stopped.\n\t\t\treturn new Promise<Response>((resolve, reject) => {\n\t\t\t\twithTelemetry(instance, () =>\n\t\t\t\t\tcontinuing(\n\t\t\t\t\t\tctx.request.headers.get('traceparent'),\n\t\t\t\t\t\tspanName(ctx),\n\t\t\t\t\t\t{ kind: 'server' },\n\t\t\t\t\t\tasync (scope) => {\n\t\t\t\t\t\t\t// The span's own, not `SpanOptions.attributes`: those every span\n\t\t\t\t\t\t\t// and log inside inherits, and a database call is not the request.\n\t\t\t\t\t\t\tscope.attributes(\n\t\t\t\t\t\t\t\trequestAttributes(ctx.url, ctx.request.method, ctx.ip),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tscopes.set(ctx.request, scope);\n\t\t\t\t\t\t\tlet response: Response;\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tresponse = await next();\n\t\t\t\t\t\t\t} finally {\n\t\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\t\tif (ctx.route !== undefined) {\n\t\t\t\t\t\t\t\t\tscope.name = `${ctx.request.method} ${ctx.route}`;\n\t\t\t\t\t\t\t\t\tscope.attribute(HTTP_ROUTE, ctx.route);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\trecord(scope, response.status, ctx.error);\n\t\t\t\t\t\t\tif (options.traceResponse) {\n\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\tresponse.headers.set('traceparent', scope.traceparent());\n\t\t\t\t\t\t\t\t} catch {\n\t\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\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn handOver(scope, response, resolve);\n\t\t\t\t\t\t},\n\t\t\t\t\t),\n\t\t\t\t).then(resolve, reject);\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\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",
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
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"
8
8
  ],
9
- "mappings": "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",
10
- "debugId": "66C32AC8AF05FED064756E2164756E21",
9
+ "mappings": "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",
10
+ "debugId": "E98136B2AF2AC44B64756E2164756E21",
11
11
  "names": []
12
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,24 +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
52
  * A streamed body (a page rendered as it goes, an event stream) keeps the
38
53
  * span open until it has been sent: a body that fails midway marks it an
39
54
  * error, and a client that leaves midway adds an `http.response.aborted`
40
55
  * event. A body of known length, or none, ends the span with the response.
41
56
  *
42
- * Routes declared after the plugin read the span as `span`, and the
43
- * telemetry as `telemetry`.
57
+ * Routes declared after it read the span as `span`, and the telemetry as
58
+ * `telemetry`.
44
59
  *
45
60
  * ```ts
46
61
  * const tracing = telemetry({ service: 'checkout', exporters: [otlpExporter({ endpoint })] });
@@ -48,12 +63,6 @@ export type TelemetryPluginOptions = (Hooks & TelemetryOptions & {
48
63
  * app.onStop(() => tracing.telemetry.close());
49
64
  * ```
50
65
  */
51
- export declare function telemetry(options: TelemetryPluginOptions): import("@alxia/core").Alxia<import("@alxia/core").Empty & {
52
- /** The server span around this request; `undefined` when `traced` said no. */
53
- span: SpanScope | undefined;
54
- telemetry: Telemetry;
55
- }, import("@alxia/core").Empty, "", never> & {
56
- telemetry: Telemetry;
57
- };
66
+ export declare function telemetry(options: TelemetryPluginOptions): TelemetryMiddleware;
58
67
  export {};
59
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;AASzB,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;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,sBAAsB;IAkDtD,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,17 +87,18 @@ 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.
88
95
 
89
96
  ### A streamed body
90
97
 
91
98
  A response whose body is a stream of unknown length (a React Router page
92
99
  rendered as it goes, an `eventStream` reply, a `ReadableStream` of your
93
100
  own) keeps the span open until that body has ended, so the span's
94
- duration is the time to the last byte. The plugin passes the body through
101
+ duration is the time to the last byte. The middleware passes the body through
95
102
  a stream of its own, one chunk at a time:
96
103
 
97
104
  | The body | The span |
@@ -104,15 +111,15 @@ An endless event stream's span ends when its client leaves. A response
104
111
  with no body, or with a `Content-Length` header (`@alxia/core` sets it on
105
112
  every reply of a string, JSON, a buffer or a file), ends the span when it
106
113
  is handed over and is left as it is: Bun sends those bodies without
107
- JavaScript, a file with `sendfile`. A raw `Response` a hook builds, even
114
+ JavaScript, a file with `sendfile`. A raw `Response` a handler or a middleware builds, even
108
115
  of a string or a `Bun.file`, has no such header until Bun sends it: it is
109
116
  treated as a stream, and timed to its end. An unsampled span is never
110
117
  exported, so its body is never wrapped.
111
118
 
112
- What decides is the `Content-Length` of the response the app finally
113
- sends, after every `onResponse` hook. `@alxia/compress` removes it from
114
- the body it compresses, so under compress a compressed JSON reply is a
115
- stream too, and its span lasts until it has been sent.
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.
116
123
 
117
124
  In a test, a streamed route's span ends only once its body has been read
118
125
  or cancelled: read it before `close()`, or the span is never exported.
@@ -127,12 +134,13 @@ await instance.close();
127
134
 
128
135
  | The request | The span's name |
129
136
  | --- | --- |
130
- | 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` |
131
138
  | routing matched a route | `"<METHOD> <route>"`: `GET /orders/:id`, and `http.route` is set |
132
- | no route matched (`404`, `405`) | stays what it was before routing |
139
+ | no route matched (`404`, `405`) | stays `spanName(ctx)`, `"<METHOD> <path>"` by default |
133
140
 
134
141
  A route's name replaces any `spanName`: one dashboard row per route, not
135
- 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
136
144
  `HEAD` request answered by a `GET` route is named `HEAD /orders/:id`.
137
145
 
138
146
  ### Its status, and the route's error
@@ -140,31 +148,41 @@ one per order. So `spanName` only names what routing did not match. A
140
148
  | The response | The span's status | Its exception |
141
149
  | --- | --- | --- |
142
150
  | `2xx`, `3xx`, `4xx` replied | `ok` | none |
143
- | a `4xx` an `onError` hook made of a thrown error | `ok` | the error |
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 |
144
153
  | `499`, the client hung up mid-request | `ok` | the `AbortError` |
145
154
  | a `5xx` from a throw | `error` | the error |
146
155
  | a `5xx` the route replied | `error` | none |
147
156
  | a streamed body that failed midway ([A streamed body](#a-streamed-body)) | `error` | the stream's error |
148
157
 
149
158
  A `401` a guard answered is the server working, so a 4xx never marks a
150
- span. The error the route failed with is still recorded, as `ctx.error`
151
- 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:
152
163
 
153
164
  ```ts
154
- import { alxia } from '@alxia/core';
165
+ import { alxia, defineMiddleware } from '@alxia/core';
155
166
  import { telemetry } from '@alxia/telemetry';
156
167
  import { consoleExporter } from '@nxgt/telemetry';
157
168
 
158
169
  const app = alxia()
159
170
  .use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
160
- .onError((error, { reply }) =>
161
- 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
+ }),
162
180
  )
163
181
  .get('/range', () => {
164
182
  throw new RangeError('out of range');
165
183
  });
166
184
 
167
- 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
168
186
  ```
169
187
 
170
188
  ### What it records
@@ -198,17 +216,19 @@ outgoing request — and a log written inside the request carry its trace
198
216
  and span ids, not the request's path, method or the client's address. An
199
217
  attribute every log of a request should carry is yours to give, with
200
218
  `@nxgt/telemetry`'s `withAttributes`: it reaches what runs inside it, so
201
- an `around` hook declared after the plugin covers the whole request:
219
+ a middleware declared after `telemetry()` covers the rest of the request:
202
220
 
203
221
  ```ts
204
- import { alxia } from '@alxia/core';
222
+ import { alxia, defineMiddleware } from '@alxia/core';
205
223
  import { telemetry } from '@alxia/telemetry';
206
224
  import { withAttributes } from '@nxgt/telemetry';
207
225
 
208
226
  const app = alxia()
209
227
  .use(telemetry({ service: 'shop' }))
210
- .around((ctx, next) =>
211
- 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
+ ),
212
232
  );
213
233
  ```
214
234
 
@@ -267,9 +287,9 @@ response.headers.get('traceparent'); // '00-<trace id>-<span id>-01'
267
287
  | Option | Type | Default | Effect |
268
288
  | --- | --- | --- | --- |
269
289
  | `service` | `string` | required, without `instance` | the service name every signal groups by; the telemetry is built with `createTelemetry(service, options)` and installed |
270
- | `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 |
271
291
  | `traced` | `(ctx: RequestContext) => boolean` | every request | whether a request gets a span |
272
- | `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 |
273
293
  | `traceResponse` | `boolean` | `false` | sets `traceparent` on the response |
274
294
 
275
295
  With `service`, every [`TelemetryOptions`](https://www.npmjs.com/package/@nxgt/telemetry)
@@ -288,7 +308,7 @@ of `@nxgt/telemetry` is accepted alongside:
288
308
 
289
309
  ### `service` or `instance`
290
310
 
291
- `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:
292
312
  the telemetry is installed, so a logger used outside any request — at
293
313
  start-up, in a job — finds it too.
294
314
 
@@ -318,7 +338,7 @@ const shared = createTelemetry('checkout', { exporters: [consoleExporter()] }).i
318
338
  const app = alxia().use(telemetry({ instance: shared }));
319
339
  ```
320
340
 
321
- 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
322
342
  reach it either way. Outside a request, and in a request `traced` said no
323
343
  to, a logger only finds a telemetry that is installed: call `install()` on
324
344
  an instance the whole process writes to, as above.
@@ -346,8 +366,9 @@ telemetry({
346
366
  });
347
367
  ```
348
368
 
349
- It runs before routing, so `ctx.route` is always `undefined` there; once a
350
- 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.
351
372
 
352
373
  A `traced` or `spanName` that throws costs its answer, never the request:
353
374
  the request is traced, or named `"<METHOD> <path>"`, and nothing is logged.
@@ -371,7 +392,7 @@ const app = alxia()
371
392
  | Field | Type | What it is |
372
393
  | --- | --- | --- |
373
394
  | `span` | `SpanScope \| undefined` | the server span: `attribute`, `attributes`, `event`, `fail`, `traceparent()`, a writable `name` and `status`; `undefined` when `traced` said no |
374
- | `telemetry` | `Telemetry` | the telemetry the plugin writes to, traced or not |
395
+ | `telemetry` | `Telemetry` | the telemetry the middleware writes to, traced or not |
375
396
 
376
397
  Only the routes declared after `use(telemetry(...))`, in the same app or
377
398
  group, read them.
@@ -379,7 +400,7 @@ group, read them.
379
400
  ## Shutting down
380
401
 
381
402
  The telemetry batches what it receives; `close()` ships the backlog, and
382
- 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,
383
404
  not even a telemetry it built: close it in `onStop`, which `app.stop()`
384
405
  runs, and stop the app when the process is asked to end.
385
406
 
@@ -484,7 +505,7 @@ test('a route gets one server span, named after it', async () => {
484
505
  ```
485
506
 
486
507
  An `instance` is not installed, so tests do not share one through the
487
- 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
488
509
  process; `uninstallTelemetry()` after each test takes it back out.
489
510
 
490
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
 
@@ -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,6 +24,7 @@ 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**
@@ -44,11 +45,11 @@ error TS2339: Property 'span' does not exist on type 'Context<Empty, "/before",
44
45
  **When:** a route reads `span` or `telemetry`, and is declared before
45
46
  `use(telemetry(...))`.
46
47
 
47
- **Why:** the plugin gives `span` and `telemetry` to the routes declared
48
- after it. The request is still traced, since the span is opened by a global
49
- 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.
50
51
 
51
- **Fix:** use the plugin first:
52
+ **Fix:** `use` it first:
52
53
 
53
54
  ```ts
54
55
  const app = alxia()
@@ -59,20 +60,20 @@ const app = alxia()
59
60
  });
60
61
  ```
61
62
 
62
- ### `Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'`
63
+ ### `Type 'Alxia<Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'`
63
64
 
64
65
  ```text
65
66
  error TS2769: No overload matches this call.
66
- Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => …): …', gave the following error.
67
- 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'.
68
69
  Types of parameters 'options' and 'app' are incompatible.
69
- Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'.
70
+ Type 'Alxia<Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'.
70
71
  ```
71
72
 
72
73
  **When:** `app.use(telemetry)`, without calling it.
73
74
 
74
- **Why:** `telemetry` makes the plugin; it is not the plugin, and it needs a
75
- `service` or an `instance`.
75
+ **Why:** `telemetry` makes the middleware; it is not the middleware, and it
76
+ needs a `service` or an `instance`.
76
77
 
77
78
  **Fix:**
78
79
 
@@ -91,7 +92,7 @@ error TS2345: Argument of type '{ exporters: never[]; }' is not assignable to pa
91
92
  **When:** `telemetry({ exporters: [...] })`, with neither `service` nor
92
93
  `instance`.
93
94
 
94
- **Why:** the telemetry the plugin builds needs a service name: every signal
95
+ **Why:** the telemetry it builds needs a service name: every signal
95
96
  groups by it, and there is no default.
96
97
 
97
98
  **Fix:** name the service, or hand over a telemetry you built:
@@ -110,7 +111,7 @@ error TS2345: Argument of type '{ service: string; instance: Telemetry; }' is no
110
111
 
111
112
  **When:** `telemetry({ service, instance })`.
112
113
 
113
- **Why:** `service` builds a telemetry, `instance` adopts one; the plugin
114
+ **Why:** `service` builds a telemetry, `instance` adopts one; the middleware
114
115
  writes to exactly one.
115
116
 
116
117
  **Fix:** keep `instance`, whose service name was given to `createTelemetry`:
@@ -132,7 +133,7 @@ The same for `exporters`, `sampler`, `environment`, or any other
132
133
 
133
134
  **When:** `@nxgt/telemetry` options next to `instance`.
134
135
 
135
- **Why:** an adopted telemetry is already built; the plugin cannot change
136
+ **Why:** an adopted telemetry is already built; the middleware cannot change
136
137
  its exporters or its resource.
137
138
 
138
139
  **Fix:** give them to `createTelemetry`:
@@ -156,8 +157,8 @@ error TS2322: Type '(ctx: RequestContext) => string | undefined' is not assignab
156
157
 
157
158
  **When:** `spanName: (ctx) => ctx.route`.
158
159
 
159
- **Why:** `spanName` runs before routing, where `ctx.route` is always
160
- `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.
161
162
 
162
163
  **Fix:** name what routing has not matched from the path, or leave
163
164
  `spanName` out:
@@ -205,7 +206,7 @@ is stopped, and the last spans and logs never reach the exporter — with
205
206
 
206
207
  **Why:** the telemetry batches signals, and ships a batch when it is full
207
208
  or a second after it started. A process that exits first loses it. The
208
- 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`.
209
210
 
210
211
  **Fix:** close it in `onStop`, await `app.stop()` on shutdown, and await
211
212
  `close()` in a script or a test before reading what was exported:
@@ -244,7 +245,7 @@ bun pm ls --all | grep @nxgt/telemetry@
244
245
  **When:** a log at start-up, in a job or a timer, or in a request `traced`
245
246
  said no to, while the logs in traced requests arrive.
246
247
 
247
- **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
248
249
  inside that telemetry, but does not install it, so a logger with no request
249
250
  around it finds none.
250
251
 
@@ -273,13 +274,13 @@ telemetry({ service: 'checkout', sampler: alwaysSample, exporters: [consoleExpor
273
274
 
274
275
  ### Spans stop arriving, and the app still answers
275
276
 
276
- **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
277
278
  closes it in one case and sends requests in the next.
278
279
 
279
- **Why:** a closed telemetry takes nothing more, and the plugin keeps
280
+ **Why:** a closed telemetry takes nothing more, and the middleware keeps
280
281
  writing to it; the requests are answered as usual.
281
282
 
282
- **Fix:** build a plugin, with its own telemetry, per test:
283
+ **Fix:** build a middleware, with its own telemetry, per test:
283
284
 
284
285
  ```ts
285
286
  const instance = createTelemetry('test', { exporters: [exporter] });
@@ -290,8 +291,8 @@ const app = alxia().use(telemetry({ instance }));
290
291
 
291
292
  **When:** a route declared with `app.ws`.
292
293
 
293
- **Why:** `@alxia/core` runs no `around` hook for a WebSocket upgrade, as
294
- 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.
295
296
 
296
297
  **Fix:** open a span for the work a message does:
297
298
 
@@ -302,11 +303,30 @@ app.ws('/rooms/:room', {}, {
302
303
  });
303
304
  ```
304
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
+
305
325
  ### The response has no `traceparent` header
306
326
 
307
327
  **When:** a caller looks for the trace of the request it made.
308
328
 
309
- **Why:** the plugin says `traceparent` back only with `traceResponse`, and
329
+ **Why:** the middleware says `traceparent` back only with `traceResponse`, and
310
330
  only on a request `traced` let through.
311
331
 
312
332
  **Fix:**
@@ -338,7 +358,7 @@ await fetch('https://checkout.example.com/orders/o-1', {
338
358
  **When:** a `spanName` is set, and the matched requests are still named
339
359
  `GET /orders/:id`.
340
360
 
341
- **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
342
362
  span is renamed `"<METHOD> <route>"`, so a dashboard has one row per route.
343
363
 
344
364
  **Fix:** to add to a routed span, set an attribute rather than the name:
@@ -370,7 +390,9 @@ app.use(telemetry({ service: 'checkout', exporters, traced: (ctx) => ctx.url.pat
370
390
 
371
391
  ### A span has an exception, and its status is `ok`
372
392
 
373
- **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.
374
396
 
375
397
  **Why:** the error is recorded as the span's exception, but only a
376
398
  `5xx`, or a streamed body that fails midway, makes a span an error: a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/telemetry",
3
- "version": "0.2.0",
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.1",
45
- "@alxia/core": "^0.3.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.3.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
  }