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