@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 +7 -3
- package/dist/body.d.ts +20 -0
- package/dist/body.d.ts.map +1 -0
- package/dist/index.js +94 -20
- package/dist/index.js.map +5 -4
- package/dist/telemetry.d.ts +5 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/docs/guide.md +40 -1
- package/docs/roadmap.md +8 -0
- package/docs/troubleshooting.md +22 -3
- package/package.json +4 -4
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
|
|
55
|
-
401 a guard answered is the
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
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=
|
|
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
|
|
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": ";AASO,IAAM,cAAc;AACpB,IAAM,WAAW;AACjB,IAAM,aAAa;AACnB,IAAM,aAAa;AACnB,IAAM,cAAc;AACpB,IAAM,iBAAiB;AACvB,IAAM,cAAc;AACpB,IAAM,iBAAiB;AAGvB,SAAS,YAAY,CAAC,QAAyB;AAAA,EACrD,OAAO,UAAU;AAAA;AAIX,SAAS,iBAAiB,CAChC,KACA,QACA,IACa;AAAA,EACb,OAAO;AAAA,KACL,cAAc;AAAA,KACd,WAAW,IAAI;AAAA,KACf,aAAa,IAAI,SAAS,QAAQ,KAAK,EAAE;AAAA,KACzC,iBAAiB,IAAI;AAAA,OAClB,IAAI,SAAS,KAAK,CAAC,IAAI,GAAG,cAAc,OAAO,IAAI,IAAI,EAAE;AAAA,OACzD,OAAO,YAAY,CAAC,IAAI,GAAG,iBAAiB,GAAG;AAAA,EACpD;AAAA;;ACpCD;AACA;AAAA;AAAA;AAAA;AAAA;
|
|
9
|
-
"debugId": "
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "66C32AC8AF05FED064756E2164756E21",
|
|
10
11
|
"names": []
|
|
11
12
|
}
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/telemetry.d.ts.map
CHANGED
|
@@ -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;
|
|
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 }))`
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
|
357
|
-
|
|
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.
|
|
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.
|
|
45
|
-
"@alxia/core": "^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.
|
|
51
|
+
"@alxia/core": "^0.3.0",
|
|
52
52
|
"@nxgt/telemetry": "^0.2.1",
|
|
53
53
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
54
54
|
}
|