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