@alxia/telemetry 0.1.1 → 0.2.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
@@ -44,15 +44,19 @@ app.listen(3000);
44
44
  ## The span
45
45
 
46
46
  - It is opened by an `around` hook: it holds the hooks, the handler,
47
- everything they await and the `onResponse` hooks.
47
+ everything they await and the `onResponse` hooks. A streamed body (a
48
+ page rendered as it goes, an event stream) keeps it open until the body
49
+ has been sent: one that fails midway makes it an error, a client that
50
+ leaves adds an `http.response.aborted` event.
48
51
  - An inbound `traceparent` continues its trace, as a child of the caller's
49
52
  span. An unusable one starts a fresh trace: the header came from a
50
53
  stranger.
51
54
  - It starts as `GET /orders/o-1` and is renamed `GET /orders/:id` once
52
55
  routing has matched, with `http.route`: one dashboard row per route, not
53
56
  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.
57
+ - A route's error is its exception. Only a 5xx, or a streamed body that
58
+ fails midway, makes the span an error: a 401 a guard answered is the
59
+ server working.
56
60
  - `traceResponse: true` says the `traceparent` back on the response.
57
61
 
58
62
  ## What it records
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.js CHANGED
@@ -27,6 +27,50 @@ import {
27
27
  createTelemetry,
28
28
  withTelemetry
29
29
  } from "@nxgt/telemetry";
30
+
31
+ // src/body.ts
32
+ function settled(response) {
33
+ return response.headers.has("content-length") || response.body === null;
34
+ }
35
+ function watched(body, end) {
36
+ const reader = body.getReader();
37
+ let ended = false;
38
+ const finish = (outcome, error) => {
39
+ if (ended)
40
+ return;
41
+ ended = true;
42
+ try {
43
+ end(outcome, error);
44
+ } catch (failure) {
45
+ try {
46
+ console.error(failure);
47
+ } catch {}
48
+ }
49
+ };
50
+ return new ReadableStream({
51
+ async pull(controller) {
52
+ let next;
53
+ try {
54
+ next = await reader.read();
55
+ } catch (error) {
56
+ finish("errored", error);
57
+ controller.error(error);
58
+ return;
59
+ }
60
+ if (next.done) {
61
+ finish("completed");
62
+ controller.close();
63
+ } else
64
+ controller.enqueue(next.value);
65
+ },
66
+ cancel(reason) {
67
+ finish("aborted");
68
+ return reader.cancel(reason);
69
+ }
70
+ }, { highWaterMark: 0 });
71
+ }
72
+
73
+ // src/telemetry.ts
30
74
  function telemetry(options) {
31
75
  const instance = options.instance ?? createTelemetry(options.service, options).install();
32
76
  const traced = guarded(options.traced ?? (() => true), () => true);
@@ -35,26 +79,28 @@ function telemetry(options) {
35
79
  const plugin = alxia().around((ctx, next) => {
36
80
  if (!traced(ctx))
37
81
  return next();
38
- return withTelemetry(instance, () => continuing(ctx.request.headers.get("traceparent"), spanName(ctx), { kind: "server" }, async (scope) => {
39
- scope.attributes(requestAttributes(ctx.url, ctx.request.method, ctx.ip));
40
- scopes.set(ctx.request, scope);
41
- let response;
42
- try {
43
- response = await next();
44
- } finally {
45
- if (ctx.route !== undefined) {
46
- scope.name = `${ctx.request.method} ${ctx.route}`;
47
- scope.attribute(HTTP_ROUTE, ctx.route);
48
- }
49
- }
50
- record(scope, response.status, ctx.error);
51
- if (options.traceResponse) {
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;
52
87
  try {
53
- response.headers.set("traceparent", scope.traceparent());
54
- } catch {}
55
- }
56
- return response;
57
- }));
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
+ });
58
104
  }).derive(({ request }) => ({
59
105
  span: scopes.get(request),
60
106
  telemetry: instance
@@ -72,6 +118,34 @@ function record(scope, status, error) {
72
118
  if (serverFailed(status) && scope.status === "ok")
73
119
  scope.status = "error";
74
120
  }
121
+ async function handOver(scope, response, resolve) {
122
+ if (settled(response) || !scope.context.sampled)
123
+ return response;
124
+ const { promise: sent, resolve: end } = Promise.withResolvers();
125
+ const body = watched(response.body, (outcome, error) => {
126
+ try {
127
+ ended(scope, outcome, error);
128
+ } finally {
129
+ end();
130
+ }
131
+ });
132
+ const streamed = new Response(body, {
133
+ status: response.status,
134
+ statusText: response.statusText,
135
+ headers: response.headers
136
+ });
137
+ resolve(streamed);
138
+ await sent;
139
+ return streamed;
140
+ }
141
+ function ended(scope, outcome, error) {
142
+ if (outcome === "errored") {
143
+ scope.fail(error);
144
+ scope.status = "error";
145
+ } else if (outcome === "aborted")
146
+ scope.event(RESPONSE_ABORTED);
147
+ }
148
+ var RESPONSE_ABORTED = "http.response.aborted";
75
149
  function defaultName(ctx) {
76
150
  return `${ctx.request.method} ${ctx.url.pathname}`;
77
151
  }
@@ -96,5 +170,5 @@ export {
96
170
  telemetry
97
171
  };
98
172
 
99
- //# debugId=1F0C16FED53E6A3964756E2164756E21
173
+ //# debugId=66C32AC8AF05FED064756E2164756E21
100
174
  //# 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 { 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",
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": "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",
9
- "debugId": "1F0C16FED53E6A3964756E2164756E21",
9
+ "mappings": "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",
10
+ "debugId": "66C32AC8AF05FED064756E2164756E21",
10
11
  "names": []
11
12
  }
@@ -34,6 +34,11 @@ export type TelemetryPluginOptions = (Hooks & TelemetryOptions & {
34
34
  * starts a fresh trace. The span is named for the route, `GET /users/:id`,
35
35
  * once routing has matched. Only a 5xx marks it an error.
36
36
  *
37
+ * A streamed body (a page rendered as it goes, an event stream) keeps the
38
+ * span open until it has been sent: a body that fails midway marks it an
39
+ * error, and a client that leaves midway adds an `http.response.aborted`
40
+ * event. A body of known length, or none, ends the span with the response.
41
+ *
37
42
  * Routes declared after the plugin read the span as `span`, and the
38
43
  * telemetry as `telemetry`.
39
44
  *
@@ -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,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"}
package/docs/guide.md CHANGED
@@ -86,6 +86,43 @@ is traced too; it only cannot read `span` and `telemetry` from its context.
86
86
  A WebSocket upgrade is not traced: `@alxia/core` runs no `around` hook for
87
87
  it, since there is no response to wrap.
88
88
 
89
+ ### A streamed body
90
+
91
+ A response whose body is a stream of unknown length (a React Router page
92
+ rendered as it goes, an `eventStream` reply, a `ReadableStream` of your
93
+ 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
95
+ a stream of its own, one chunk at a time:
96
+
97
+ | The body | The span |
98
+ | --- | --- |
99
+ | sent whole | ends then, its status from the response |
100
+ | the client left before its end | ends then, `ok`, with an `http.response.aborted` event |
101
+ | the stream failed | ends then, `error`, with the stream's error as its exception |
102
+
103
+ An endless event stream's span ends when its client leaves. A response
104
+ with no body, or with a `Content-Length` header (`@alxia/core` sets it on
105
+ every reply of a string, JSON, a buffer or a file), ends the span when it
106
+ 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
108
+ of a string or a `Bun.file`, has no such header until Bun sends it: it is
109
+ treated as a stream, and timed to its end. An unsampled span is never
110
+ exported, so its body is never wrapped.
111
+
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.
116
+
117
+ In a test, a streamed route's span ends only once its body has been read
118
+ or cancelled: read it before `close()`, or the span is never exported.
119
+
120
+ ```ts
121
+ const response = await app.request('/events');
122
+ await response.body?.cancel(); // or `await response.text()` for a body that ends
123
+ await instance.close();
124
+ ```
125
+
89
126
  ### Its name
90
127
 
91
128
  | The request | The span's name |
@@ -102,10 +139,12 @@ one per order. So `spanName` only names what routing did not match. A
102
139
 
103
140
  | The response | The span's status | Its exception |
104
141
  | --- | --- | --- |
105
- | `2xx`, `3xx`, `4xx` | `ok` | none |
142
+ | `2xx`, `3xx`, `4xx` replied | `ok` | none |
106
143
  | a `4xx` an `onError` hook made of a thrown error | `ok` | the error |
144
+ | `499`, the client hung up mid-request | `ok` | the `AbortError` |
107
145
  | a `5xx` from a throw | `error` | the error |
108
146
  | a `5xx` the route replied | `error` | none |
147
+ | a streamed body that failed midway ([A streamed body](#a-streamed-body)) | `error` | the stream's error |
109
148
 
110
149
  A `401` a guard answered is the server working, so a 4xx never marks a
111
150
  span. The error the route failed with is still recorded, as `ctx.error`
package/docs/roadmap.md CHANGED
@@ -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 }))`
@@ -30,6 +30,7 @@ the server log, or, for what prints nothing, what you see in your traces.
30
30
 
31
31
  - [The span starts a new trace although the caller sent `traceparent`](#the-span-starts-a-new-trace-although-the-caller-sent-traceparent)
32
32
  - [`spanName` only shows on requests no route matched](#spanname-only-shows-on-requests-no-route-matched)
33
+ - [A streamed request's span lasts as long as its stream](#a-streamed-requests-span-lasts-as-long-as-its-stream)
33
34
  - [A span has an exception, and its status is `ok`](#a-span-has-an-exception-and-its-status-is-ok)
34
35
 
35
36
  ## Types
@@ -349,13 +350,31 @@ app.get('/orders/:id', ({ params, span, reply }) => {
349
350
  });
350
351
  ```
351
352
 
353
+ ### A streamed request's span lasts as long as its stream
354
+
355
+ **When:** the span of a page streamed as it renders, or of an event
356
+ stream, lasts seconds or minutes, or ends only when the client closes the
357
+ tab, with an `http.response.aborted` event.
358
+
359
+ **Why:** a streamed body keeps the server span open until it has been
360
+ sent, so the span measures what the client received. An event stream
361
+ that never ends on its own ends its span when its client leaves; the
362
+ event says so, and the status stays `ok`: the server did nothing wrong.
363
+
364
+ **Fix:** none is needed. To keep an event stream out of a latency
365
+ dashboard, filter on its route, or leave it untraced:
366
+
367
+ ```ts
368
+ app.use(telemetry({ service: 'checkout', exporters, traced: (ctx) => ctx.url.pathname !== '/events' }));
369
+ ```
370
+
352
371
  ### A span has an exception, and its status is `ok`
353
372
 
354
373
  **When:** a route throws, and an `onError` hook answers with a `4xx`.
355
374
 
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.
375
+ **Why:** the error is recorded as the span's exception, but only a
376
+ `5xx`, or a streamed body that fails midway, makes a span an error: a
377
+ `400` the app chose to answer is the server working.
359
378
 
360
379
  **Fix:** none needed. For an error that should mark the span, answer it
361
380
  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.2.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,14 @@
41
41
  ]
42
42
  },
43
43
  "devDependencies": {
44
- "@alxia/client": "^0.2.0",
45
- "@alxia/core": "^0.2.0",
44
+ "@alxia/client": "^0.2.1",
45
+ "@alxia/core": "^0.3.0",
46
46
  "@nxgt/telemetry": "^0.2.1",
47
47
  "@types/bun": "^1.4.2",
48
48
  "zod": "^4.6.5"
49
49
  },
50
50
  "peerDependencies": {
51
- "@alxia/core": "^0.2.0",
51
+ "@alxia/core": "^0.3.0",
52
52
  "@nxgt/telemetry": "^0.2.1",
53
53
  "typescript": "^6.0.3 || ^7.0.0"
54
54
  }