@alxia/telemetry 0.1.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/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/attributes.d.ts +21 -0
- package/dist/attributes.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +100 -0
- package/dist/index.js.map +11 -0
- package/dist/telemetry.d.ts +54 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/docs/README.md +12 -0
- package/docs/guide.md +461 -0
- package/docs/roadmap.md +49 -0
- package/docs/troubleshooting.md +361 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steve Tsala
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @alxia/telemetry
|
|
2
|
+
|
|
3
|
+
Traces and logs for [alxia](https://www.npmjs.com/package/@alxia/core), on
|
|
4
|
+
[`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry): no
|
|
5
|
+
OpenTelemetry SDK, no dependency. One server span per request, around
|
|
6
|
+
everything the request runs, named for its route — and every log written
|
|
7
|
+
inside it carrying its trace id.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add @alxia/telemetry @nxgt/telemetry @alxia/core
|
|
11
|
+
bun add -d typescript
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`@nxgt/telemetry` is a peer: the app's own copy, shared with its loggers.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { alxia } from '@alxia/core';
|
|
20
|
+
import { telemetry } from '@alxia/telemetry';
|
|
21
|
+
import { consoleExporter, createLogger } from '@nxgt/telemetry';
|
|
22
|
+
|
|
23
|
+
const tracing = telemetry({
|
|
24
|
+
service: 'checkout',
|
|
25
|
+
version: '1.4.0',
|
|
26
|
+
exporters: [consoleExporter()], // or @nxgt/telemetry-otlp's otlpExporter
|
|
27
|
+
traced: (ctx) => ctx.url.pathname !== '/health',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const log = createLogger('Orders');
|
|
31
|
+
|
|
32
|
+
const app = alxia()
|
|
33
|
+
.use(tracing)
|
|
34
|
+
.get('/orders/:id', ({ params, span, reply }) => {
|
|
35
|
+
span?.attribute('order.id', params.id);
|
|
36
|
+
log.info('order read'); // carries this request's traceId
|
|
37
|
+
return reply(200, { id: params.id });
|
|
38
|
+
})
|
|
39
|
+
.onStop(() => tracing.telemetry.close()); // awaited, or the last batch is lost
|
|
40
|
+
|
|
41
|
+
app.listen(3000);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## The span
|
|
45
|
+
|
|
46
|
+
- It is opened by an `around` hook: it holds the hooks, the handler,
|
|
47
|
+
everything they await and the `onResponse` hooks.
|
|
48
|
+
- An inbound `traceparent` continues its trace, as a child of the caller's
|
|
49
|
+
span. An unusable one starts a fresh trace: the header came from a
|
|
50
|
+
stranger.
|
|
51
|
+
- It starts as `GET /orders/o-1` and is renamed `GET /orders/:id` once
|
|
52
|
+
routing has matched, with `http.route`: one dashboard row per route, not
|
|
53
|
+
per order. A request no route matched keeps its path.
|
|
54
|
+
- A route's error is its exception. Only a 5xx makes the span an error: a
|
|
55
|
+
401 a guard answered is the server working.
|
|
56
|
+
- `traceResponse: true` says the `traceparent` back on the response.
|
|
57
|
+
|
|
58
|
+
## What it records
|
|
59
|
+
|
|
60
|
+
| attribute | |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `http.request.method`, `url.path`, `url.scheme` | the request |
|
|
63
|
+
| `server.address`, `server.port`, `client.address` | where it was addressed, and from |
|
|
64
|
+
| `http.route` | the route, once matched |
|
|
65
|
+
| `http.response.status_code` | the status |
|
|
66
|
+
|
|
67
|
+
They are `@nxgt/telemetry-hono`'s names: a span from either reads the same
|
|
68
|
+
in a dashboard.
|
|
69
|
+
|
|
70
|
+
## Options
|
|
71
|
+
|
|
72
|
+
The usual `@nxgt/telemetry` options and a `service`, or an existing
|
|
73
|
+
telemetry as `instance` — adopted, never closed. And:
|
|
74
|
+
|
|
75
|
+
| option | default | |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `traced` | every request | `(ctx) => boolean`: a health check |
|
|
78
|
+
| `spanName` | `"<METHOD> <path>"` | the name before routing |
|
|
79
|
+
| `traceResponse` | `false` | says `traceparent` back |
|
|
80
|
+
|
|
81
|
+
A hook that throws costs its answer, never the request.
|
|
82
|
+
|
|
83
|
+
## API
|
|
84
|
+
|
|
85
|
+
| export | |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `telemetry(options)` | the plugin, with the telemetry it writes to as `.telemetry`; routes after it read `span` and `telemetry` |
|
|
88
|
+
| `TelemetryPluginOptions` | its options: `service` and `@nxgt/telemetry`'s options, or an `instance`; `traced`, `spanName`, `traceResponse` |
|
|
89
|
+
| `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
|
+
|
|
91
|
+
## Documentation
|
|
92
|
+
|
|
93
|
+
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/telemetry/docs): the span each request gets and what it records, how a trace crosses services, every option with its default, shutting down, and testing.
|
|
94
|
+
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/telemetry/docs/troubleshooting.md): a `tsc` error, an export failure, or a span or a log that is missing.
|
|
95
|
+
- [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/telemetry/docs/roadmap.md): what is coming, and what is not planned.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Attributes } from '@nxgt/telemetry';
|
|
2
|
+
/**
|
|
3
|
+
* The semantic conventions a server span carries, by name: the ones a
|
|
4
|
+
* backend's HTTP dashboards look for. They are `@nxgt/telemetry-hono`'s
|
|
5
|
+
* names, kept twice on purpose — importing them would make this package
|
|
6
|
+
* depend on Hono — and a server span here and a client span from
|
|
7
|
+
* `@nxgt/telemetry-httpyz` must agree on them.
|
|
8
|
+
*/
|
|
9
|
+
export declare const HTTP_METHOD = "http.request.method";
|
|
10
|
+
export declare const URL_PATH = "url.path";
|
|
11
|
+
export declare const URL_SCHEME = "url.scheme";
|
|
12
|
+
export declare const HTTP_ROUTE = "http.route";
|
|
13
|
+
export declare const HTTP_STATUS = "http.response.status_code";
|
|
14
|
+
export declare const SERVER_ADDRESS = "server.address";
|
|
15
|
+
export declare const SERVER_PORT = "server.port";
|
|
16
|
+
export declare const CLIENT_ADDRESS = "client.address";
|
|
17
|
+
/** A 4xx is the server working: only a 5xx marks a span. */
|
|
18
|
+
export declare function serverFailed(status: number): boolean;
|
|
19
|
+
/** What is known of a request before routing: the route is not, yet. */
|
|
20
|
+
export declare function requestAttributes(url: URL, method: string, ip: string | undefined): Attributes;
|
|
21
|
+
//# sourceMappingURL=attributes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attributes.d.ts","sourceRoot":"","sources":["../src/attributes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,wBAAwB,CAAC;AACjD,eAAO,MAAM,QAAQ,aAAa,CAAC;AACnC,eAAO,MAAM,UAAU,eAAe,CAAC;AACvC,eAAO,MAAM,UAAU,eAAe,CAAC;AACvC,eAAO,MAAM,WAAW,8BAA8B,CAAC;AACvD,eAAO,MAAM,cAAc,mBAAmB,CAAC;AAC/C,eAAO,MAAM,WAAW,gBAAgB,CAAC;AACzC,eAAO,MAAM,cAAc,mBAAmB,CAAC;AAE/C,4DAA4D;AAC5D,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,wEAAwE;AACxE,wBAAgB,iBAAiB,CAChC,GAAG,EAAE,GAAG,EACR,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,MAAM,GAAG,SAAS,GACpB,UAAU,CASZ"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,cAAc,EACd,WAAW,EACX,UAAU,EACV,WAAW,EACX,cAAc,EACd,WAAW,EACX,QAAQ,EACR,UAAU,GACV,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,KAAK,sBAAsB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// src/attributes.ts
|
|
2
|
+
var HTTP_METHOD = "http.request.method";
|
|
3
|
+
var URL_PATH = "url.path";
|
|
4
|
+
var URL_SCHEME = "url.scheme";
|
|
5
|
+
var HTTP_ROUTE = "http.route";
|
|
6
|
+
var HTTP_STATUS = "http.response.status_code";
|
|
7
|
+
var SERVER_ADDRESS = "server.address";
|
|
8
|
+
var SERVER_PORT = "server.port";
|
|
9
|
+
var CLIENT_ADDRESS = "client.address";
|
|
10
|
+
function serverFailed(status) {
|
|
11
|
+
return status >= 500;
|
|
12
|
+
}
|
|
13
|
+
function requestAttributes(url, method, ip) {
|
|
14
|
+
return {
|
|
15
|
+
[HTTP_METHOD]: method,
|
|
16
|
+
[URL_PATH]: url.pathname,
|
|
17
|
+
[URL_SCHEME]: url.protocol.replace(":", ""),
|
|
18
|
+
[SERVER_ADDRESS]: url.hostname,
|
|
19
|
+
...url.port === "" ? {} : { [SERVER_PORT]: Number(url.port) },
|
|
20
|
+
...ip === undefined ? {} : { [CLIENT_ADDRESS]: ip }
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// src/telemetry.ts
|
|
24
|
+
import { alxia } from "@alxia/core";
|
|
25
|
+
import {
|
|
26
|
+
continuing,
|
|
27
|
+
createTelemetry,
|
|
28
|
+
withTelemetry
|
|
29
|
+
} from "@nxgt/telemetry";
|
|
30
|
+
function telemetry(options) {
|
|
31
|
+
const instance = options.instance ?? createTelemetry(options.service, options).install();
|
|
32
|
+
const traced = guarded(options.traced ?? (() => true), () => true);
|
|
33
|
+
const spanName = guarded(options.spanName ?? defaultName, defaultName);
|
|
34
|
+
const scopes = new WeakMap;
|
|
35
|
+
const plugin = alxia().around((ctx, next) => {
|
|
36
|
+
if (!traced(ctx))
|
|
37
|
+
return next();
|
|
38
|
+
return withTelemetry(instance, () => continuing(ctx.request.headers.get("traceparent"), spanName(ctx), { kind: "server" }, async (scope) => {
|
|
39
|
+
scope.attributes(requestAttributes(ctx.url, ctx.request.method, ctx.ip));
|
|
40
|
+
scopes.set(ctx.request, scope);
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await next();
|
|
44
|
+
} finally {
|
|
45
|
+
if (ctx.route !== undefined) {
|
|
46
|
+
scope.name = `${ctx.request.method} ${ctx.route}`;
|
|
47
|
+
scope.attribute(HTTP_ROUTE, ctx.route);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
record(scope, response.status, ctx.error);
|
|
51
|
+
if (options.traceResponse) {
|
|
52
|
+
try {
|
|
53
|
+
response.headers.set("traceparent", scope.traceparent());
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
return response;
|
|
57
|
+
}));
|
|
58
|
+
}).derive(({ request }) => ({
|
|
59
|
+
span: scopes.get(request),
|
|
60
|
+
telemetry: instance
|
|
61
|
+
}));
|
|
62
|
+
return Object.assign(plugin, { telemetry: instance });
|
|
63
|
+
}
|
|
64
|
+
function record(scope, status, error) {
|
|
65
|
+
if (error !== undefined) {
|
|
66
|
+
const before = scope.status;
|
|
67
|
+
scope.fail(error);
|
|
68
|
+
if (!serverFailed(status))
|
|
69
|
+
scope.status = before;
|
|
70
|
+
}
|
|
71
|
+
scope.attribute(HTTP_STATUS, status);
|
|
72
|
+
if (serverFailed(status) && scope.status === "ok")
|
|
73
|
+
scope.status = "error";
|
|
74
|
+
}
|
|
75
|
+
function defaultName(ctx) {
|
|
76
|
+
return `${ctx.request.method} ${ctx.url.pathname}`;
|
|
77
|
+
}
|
|
78
|
+
function guarded(hook, fallback) {
|
|
79
|
+
return (ctx) => {
|
|
80
|
+
try {
|
|
81
|
+
return hook(ctx);
|
|
82
|
+
} catch {
|
|
83
|
+
return fallback(ctx);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export {
|
|
88
|
+
CLIENT_ADDRESS,
|
|
89
|
+
HTTP_METHOD,
|
|
90
|
+
HTTP_ROUTE,
|
|
91
|
+
HTTP_STATUS,
|
|
92
|
+
SERVER_ADDRESS,
|
|
93
|
+
SERVER_PORT,
|
|
94
|
+
URL_PATH,
|
|
95
|
+
URL_SCHEME,
|
|
96
|
+
telemetry
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
//# debugId=1F0C16FED53E6A3964756E2164756E21
|
|
100
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/attributes.ts", "../src/telemetry.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import type { Attributes } from '@nxgt/telemetry';\n\n/**\n * The semantic conventions a server span carries, by name: the ones a\n * backend's HTTP dashboards look for. They are `@nxgt/telemetry-hono`'s\n * names, kept twice on purpose — importing them would make this package\n * depend on Hono — and a server span here and a client span from\n * `@nxgt/telemetry-httpyz` must agree on them.\n */\nexport const HTTP_METHOD = 'http.request.method';\nexport const URL_PATH = 'url.path';\nexport const URL_SCHEME = 'url.scheme';\nexport const HTTP_ROUTE = 'http.route';\nexport const HTTP_STATUS = 'http.response.status_code';\nexport const SERVER_ADDRESS = 'server.address';\nexport const SERVER_PORT = 'server.port';\nexport const CLIENT_ADDRESS = 'client.address';\n\n/** A 4xx is the server working: only a 5xx marks a span. */\nexport function serverFailed(status: number): boolean {\n\treturn status >= 500;\n}\n\n/** What is known of a request before routing: the route is not, yet. */\nexport function requestAttributes(\n\turl: URL,\n\tmethod: string,\n\tip: string | undefined,\n): Attributes {\n\treturn {\n\t\t[HTTP_METHOD]: method,\n\t\t[URL_PATH]: url.pathname,\n\t\t[URL_SCHEME]: url.protocol.replace(':', ''),\n\t\t[SERVER_ADDRESS]: url.hostname,\n\t\t...(url.port === '' ? {} : { [SERVER_PORT]: Number(url.port) }),\n\t\t...(ip === undefined ? {} : { [CLIENT_ADDRESS]: ip }),\n\t};\n}\n",
|
|
6
|
+
"import { alxia, type RequestContext } from '@alxia/core';\nimport {\n\tcontinuing,\n\tcreateTelemetry,\n\ttype SpanScope,\n\ttype Telemetry,\n\ttype TelemetryOptions,\n\twithTelemetry,\n} from '@nxgt/telemetry';\nimport {\n\tHTTP_ROUTE,\n\tHTTP_STATUS,\n\trequestAttributes,\n\tserverFailed,\n} from './attributes';\n\ninterface Hooks {\n\t/**\n\t * Whether a request gets a span. Every one does by default: a library\n\t * that decides which requests do not matter hides the one that did.\n\t */\n\treadonly traced?: (ctx: RequestContext) => boolean;\n\t/** The span's name before routing. `\"<METHOD> <path>\"` by default, then `\"<METHOD> <route>\"`. */\n\treadonly spanName?: (ctx: RequestContext) => string;\n\t/** Whether the response says `traceparent` back, so a caller can find the trace. Off by default. */\n\treadonly traceResponse?: boolean;\n}\n\n/**\n * A telemetry built from `service` and `@nxgt/telemetry`'s options, and\n * installed; or one handed over as `instance`, adopted and not closed.\n */\nexport type TelemetryPluginOptions =\n\t| (Hooks &\n\t\t\tTelemetryOptions & {\n\t\t\t\t/** The service name: everything groups by it. */\n\t\t\t\treadonly service: string;\n\t\t\t\treadonly instance?: undefined;\n\t\t\t})\n\t| (Hooks & {\n\t\t\treadonly instance: Telemetry;\n\t\t\treadonly service?: undefined;\n\t });\n\n/**\n * One server span per request, with [`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry),\n * as a plugin.\n *\n * The span is opened by an `around` hook, so it holds everything the\n * request runs — the hooks, the handler, what they await, the `onResponse`\n * hooks — and every log written with `createLogger` inside it carries its\n * trace id. An inbound `traceparent` continues its trace; an unusable one\n * starts a fresh trace. The span is named for the route, `GET /users/:id`,\n * once routing has matched. Only a 5xx marks it an error.\n *\n * Routes declared after the plugin read the span as `span`, and the\n * telemetry as `telemetry`.\n *\n * ```ts\n * const tracing = telemetry({ service: 'checkout', exporters: [otlpExporter({ endpoint })] });\n * const app = alxia().use(tracing).get(...);\n * app.onStop(() => tracing.telemetry.close());\n * ```\n */\nexport function telemetry(options: TelemetryPluginOptions) {\n\tconst instance =\n\t\toptions.instance ?? createTelemetry(options.service, options).install();\n\tconst traced = guarded(options.traced ?? (() => true), () => true);\n\tconst spanName = guarded(options.spanName ?? defaultName, defaultName);\n\tconst scopes = new WeakMap<Request, SpanScope>();\n\n\tconst plugin = alxia()\n\t\t.around((ctx, next) => {\n\t\t\tif (!traced(ctx)) return next();\n\t\t\treturn withTelemetry(instance, () =>\n\t\t\t\tcontinuing(\n\t\t\t\t\tctx.request.headers.get('traceparent'),\n\t\t\t\t\tspanName(ctx),\n\t\t\t\t\t{ kind: 'server' },\n\t\t\t\t\tasync (scope) => {\n\t\t\t\t\t\t// The span's own, not `SpanOptions.attributes`: those every span\n\t\t\t\t\t\t// and log inside inherits, and a database call is not the request.\n\t\t\t\t\t\tscope.attributes(\n\t\t\t\t\t\t\trequestAttributes(ctx.url, ctx.request.method, ctx.ip),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tscopes.set(ctx.request, scope);\n\t\t\t\t\t\tlet response: Response;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tresponse = await next();\n\t\t\t\t\t\t} finally {\n\t\t\t\t\t\t\t// Even a request that failed was routed, and the route is the name.\n\t\t\t\t\t\t\tif (ctx.route !== undefined) {\n\t\t\t\t\t\t\t\tscope.name = `${ctx.request.method} ${ctx.route}`;\n\t\t\t\t\t\t\t\tscope.attribute(HTTP_ROUTE, ctx.route);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\trecord(scope, response.status, ctx.error);\n\t\t\t\t\t\tif (options.traceResponse) {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tresponse.headers.set('traceparent', scope.traceparent());\n\t\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t\t// An immutable response keeps its headers; the span is what matters.\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn response;\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t);\n\t\t})\n\t\t.derive(({ request }) => ({\n\t\t\t/** The server span around this request; `undefined` when `traced` said no. */\n\t\t\tspan: scopes.get(request),\n\t\t\ttelemetry: instance,\n\t\t}));\n\n\treturn Object.assign(plugin, { telemetry: instance });\n}\n\n/**\n * The status, and the failure. A route's error is recorded as the span's\n * exception, but only a 5xx makes it an error: a 401 a guard answered is\n * the server working.\n */\nfunction record(scope: SpanScope, status: number, error: unknown): void {\n\tif (error !== undefined) {\n\t\tconst before = scope.status;\n\t\tscope.fail(error);\n\t\tif (!serverFailed(status)) scope.status = before;\n\t}\n\tscope.attribute(HTTP_STATUS, status);\n\tif (serverFailed(status) && scope.status === 'ok') scope.status = 'error';\n}\n\nfunction defaultName(ctx: RequestContext): string {\n\treturn `${ctx.request.method} ${ctx.url.pathname}`;\n}\n\n/** A hook, and what to answer when it throws: observability never costs the request. */\nfunction guarded<T>(\n\thook: (ctx: RequestContext) => T,\n\tfallback: (ctx: RequestContext) => T,\n): (ctx: RequestContext) => T {\n\treturn (ctx) => {\n\t\ttry {\n\t\t\treturn hook(ctx);\n\t\t} catch {\n\t\t\treturn fallback(ctx);\n\t\t}\n\t};\n}\n"
|
|
7
|
+
],
|
|
8
|
+
"mappings": ";AASO,IAAM,cAAc;AACpB,IAAM,WAAW;AACjB,IAAM,aAAa;AACnB,IAAM,aAAa;AACnB,IAAM,cAAc;AACpB,IAAM,iBAAiB;AACvB,IAAM,cAAc;AACpB,IAAM,iBAAiB;AAGvB,SAAS,YAAY,CAAC,QAAyB;AAAA,EACrD,OAAO,UAAU;AAAA;AAIX,SAAS,iBAAiB,CAChC,KACA,QACA,IACa;AAAA,EACb,OAAO;AAAA,KACL,cAAc;AAAA,KACd,WAAW,IAAI;AAAA,KACf,aAAa,IAAI,SAAS,QAAQ,KAAK,EAAE;AAAA,KACzC,iBAAiB,IAAI;AAAA,OAClB,IAAI,SAAS,KAAK,CAAC,IAAI,GAAG,cAAc,OAAO,IAAI,IAAI,EAAE;AAAA,OACzD,OAAO,YAAY,CAAC,IAAI,GAAG,iBAAiB,GAAG;AAAA,EACpD;AAAA;;ACpCD;AACA;AAAA;AAAA;AAAA;AAAA;AA+DO,SAAS,SAAS,CAAC,SAAiC;AAAA,EAC1D,MAAM,WACL,QAAQ,YAAY,gBAAgB,QAAQ,SAAS,OAAO,EAAE,QAAQ;AAAA,EACvE,MAAM,SAAS,QAAQ,QAAQ,WAAW,MAAM,OAAO,MAAM,IAAI;AAAA,EACjE,MAAM,WAAW,QAAQ,QAAQ,YAAY,aAAa,WAAW;AAAA,EACrE,MAAM,SAAS,IAAI;AAAA,EAEnB,MAAM,SAAS,MAAM,EACnB,OAAO,CAAC,KAAK,SAAS;AAAA,IACtB,IAAI,CAAC,OAAO,GAAG;AAAA,MAAG,OAAO,KAAK;AAAA,IAC9B,OAAO,cAAc,UAAU,MAC9B,WACC,IAAI,QAAQ,QAAQ,IAAI,aAAa,GACrC,SAAS,GAAG,GACZ,EAAE,MAAM,SAAS,GACjB,OAAO,UAAU;AAAA,MAGhB,MAAM,WACL,kBAAkB,IAAI,KAAK,IAAI,QAAQ,QAAQ,IAAI,EAAE,CACtD;AAAA,MACA,OAAO,IAAI,IAAI,SAAS,KAAK;AAAA,MAC7B,IAAI;AAAA,MACJ,IAAI;AAAA,QACH,WAAW,MAAM,KAAK;AAAA,gBACrB;AAAA,QAED,IAAI,IAAI,UAAU,WAAW;AAAA,UAC5B,MAAM,OAAO,GAAG,IAAI,QAAQ,UAAU,IAAI;AAAA,UAC1C,MAAM,UAAU,YAAY,IAAI,KAAK;AAAA,QACtC;AAAA;AAAA,MAED,OAAO,OAAO,SAAS,QAAQ,IAAI,KAAK;AAAA,MACxC,IAAI,QAAQ,eAAe;AAAA,QAC1B,IAAI;AAAA,UACH,SAAS,QAAQ,IAAI,eAAe,MAAM,YAAY,CAAC;AAAA,UACtD,MAAM;AAAA,MAGT;AAAA,MACA,OAAO;AAAA,KAET,CACD;AAAA,GACA,EACA,OAAO,GAAG,eAAe;AAAA,IAEzB,MAAM,OAAO,IAAI,OAAO;AAAA,IACxB,WAAW;AAAA,EACZ,EAAE;AAAA,EAEH,OAAO,OAAO,OAAO,QAAQ,EAAE,WAAW,SAAS,CAAC;AAAA;AAQrD,SAAS,MAAM,CAAC,OAAkB,QAAgB,OAAsB;AAAA,EACvE,IAAI,UAAU,WAAW;AAAA,IACxB,MAAM,SAAS,MAAM;AAAA,IACrB,MAAM,KAAK,KAAK;AAAA,IAChB,IAAI,CAAC,aAAa,MAAM;AAAA,MAAG,MAAM,SAAS;AAAA,EAC3C;AAAA,EACA,MAAM,UAAU,aAAa,MAAM;AAAA,EACnC,IAAI,aAAa,MAAM,KAAK,MAAM,WAAW;AAAA,IAAM,MAAM,SAAS;AAAA;AAGnE,SAAS,WAAW,CAAC,KAA6B;AAAA,EACjD,OAAO,GAAG,IAAI,QAAQ,UAAU,IAAI,IAAI;AAAA;AAIzC,SAAS,OAAU,CAClB,MACA,UAC6B;AAAA,EAC7B,OAAO,CAAC,QAAQ;AAAA,IACf,IAAI;AAAA,MACH,OAAO,KAAK,GAAG;AAAA,MACd,MAAM;AAAA,MACP,OAAO,SAAS,GAAG;AAAA;AAAA;AAAA;",
|
|
9
|
+
"debugId": "1F0C16FED53E6A3964756E2164756E21",
|
|
10
|
+
"names": []
|
|
11
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type RequestContext } from '@alxia/core';
|
|
2
|
+
import { type SpanScope, type Telemetry, type TelemetryOptions } from '@nxgt/telemetry';
|
|
3
|
+
interface Hooks {
|
|
4
|
+
/**
|
|
5
|
+
* Whether a request gets a span. Every one does by default: a library
|
|
6
|
+
* that decides which requests do not matter hides the one that did.
|
|
7
|
+
*/
|
|
8
|
+
readonly traced?: (ctx: RequestContext) => boolean;
|
|
9
|
+
/** The span's name before routing. `"<METHOD> <path>"` by default, then `"<METHOD> <route>"`. */
|
|
10
|
+
readonly spanName?: (ctx: RequestContext) => string;
|
|
11
|
+
/** Whether the response says `traceparent` back, so a caller can find the trace. Off by default. */
|
|
12
|
+
readonly traceResponse?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A telemetry built from `service` and `@nxgt/telemetry`'s options, and
|
|
16
|
+
* installed; or one handed over as `instance`, adopted and not closed.
|
|
17
|
+
*/
|
|
18
|
+
export type TelemetryPluginOptions = (Hooks & TelemetryOptions & {
|
|
19
|
+
/** The service name: everything groups by it. */
|
|
20
|
+
readonly service: string;
|
|
21
|
+
readonly instance?: undefined;
|
|
22
|
+
}) | (Hooks & {
|
|
23
|
+
readonly instance: Telemetry;
|
|
24
|
+
readonly service?: undefined;
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* One server span per request, with [`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry),
|
|
28
|
+
* as a plugin.
|
|
29
|
+
*
|
|
30
|
+
* The span is opened by an `around` hook, so it holds everything the
|
|
31
|
+
* request runs — the hooks, the handler, what they await, the `onResponse`
|
|
32
|
+
* hooks — and every log written with `createLogger` inside it carries its
|
|
33
|
+
* trace id. An inbound `traceparent` continues its trace; an unusable one
|
|
34
|
+
* starts a fresh trace. The span is named for the route, `GET /users/:id`,
|
|
35
|
+
* once routing has matched. Only a 5xx marks it an error.
|
|
36
|
+
*
|
|
37
|
+
* Routes declared after the plugin read the span as `span`, and the
|
|
38
|
+
* telemetry as `telemetry`.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* const tracing = telemetry({ service: 'checkout', exporters: [otlpExporter({ endpoint })] });
|
|
42
|
+
* const app = alxia().use(tracing).get(...);
|
|
43
|
+
* app.onStop(() => tracing.telemetry.close());
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare function telemetry(options: TelemetryPluginOptions): import("@alxia/core").Alxia<import("@alxia/core").Empty & {
|
|
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
|
+
};
|
|
53
|
+
export {};
|
|
54
|
+
//# sourceMappingURL=telemetry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAGN,KAAK,SAAS,EACd,KAAK,SAAS,EACd,KAAK,gBAAgB,EAErB,MAAM,iBAAiB,CAAC;AAQzB,UAAU,KAAK;IACd;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC;IACnD,iGAAiG;IACjG,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,MAAM,CAAC;IACpD,oGAAoG;IACpG,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CACjC;AAED;;;GAGG;AACH,MAAM,MAAM,sBAAsB,GAC/B,CAAC,KAAK,GACN,gBAAgB,GAAG;IAClB,iDAAiD;IACjD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;CAC9B,CAAC,GACF,CAAC,KAAK,GAAG;IACT,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;CAC5B,CAAC,CAAC;AAEN;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,sBAAsB;IA8CtD,8EAA8E;;;;;EAMhF"}
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @alxia/telemetry documentation
|
|
2
|
+
|
|
3
|
+
The [package README](../README.md) is the short version. This folder is
|
|
4
|
+
the long one: the span each request gets and what it records, how a trace
|
|
5
|
+
crosses services, every option with its default, and what to do when a
|
|
6
|
+
span or a log does not show up where you expected it.
|
|
7
|
+
|
|
8
|
+
| Page | Read it when |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| [Guide](guide.md) | choosing between `service` and `instance`, leaving a health check untraced, continuing a caller's trace or calling another service, closing the telemetry on shutdown, sending to a collector, or testing the spans |
|
|
11
|
+
| [Troubleshooting](troubleshooting.md) | `tsc` refused an option or a route, the server logged `[telemetry] export failed`, or a span or a log is missing or not what you expected |
|
|
12
|
+
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
package/docs/guide.md
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# Guide
|
|
2
|
+
|
|
3
|
+
This page covers what `telemetry()` records and when: the span it opens
|
|
4
|
+
around each request, how it is named, what it carries, how a trace crosses
|
|
5
|
+
services, each option with its default, and how to close it, test it, and
|
|
6
|
+
call another service from inside it.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { alxia } from '@alxia/core';
|
|
10
|
+
import { telemetry } from '@alxia/telemetry';
|
|
11
|
+
import { consoleExporter, createLogger } from '@nxgt/telemetry';
|
|
12
|
+
|
|
13
|
+
const tracing = telemetry({ service: 'checkout', exporters: [consoleExporter()] });
|
|
14
|
+
const log = createLogger('Orders');
|
|
15
|
+
|
|
16
|
+
const app = alxia()
|
|
17
|
+
.use(tracing)
|
|
18
|
+
.get('/orders/:id', ({ params, span, reply }) => {
|
|
19
|
+
span?.attribute('order.id', params.id);
|
|
20
|
+
log.info('order read');
|
|
21
|
+
return reply(200, { id: params.id });
|
|
22
|
+
})
|
|
23
|
+
.onStop(() => tracing.telemetry.close());
|
|
24
|
+
|
|
25
|
+
app.listen(3000);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`GET /orders/o-1` now writes a server span named `GET /orders/:id` to the
|
|
29
|
+
console, and the `order read` log line carries that span's `traceId` and
|
|
30
|
+
`spanId`. Swap `consoleExporter()` for any other `@nxgt/telemetry`
|
|
31
|
+
exporter, and nothing else changes.
|
|
32
|
+
|
|
33
|
+
## The signature
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
function telemetry(
|
|
37
|
+
options: TelemetryPluginOptions,
|
|
38
|
+
): Alxia<Empty & { span: SpanScope | undefined; telemetry: Telemetry }, Empty, '', never> & {
|
|
39
|
+
telemetry: Telemetry;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type TelemetryPluginOptions =
|
|
43
|
+
| (Hooks & TelemetryOptions & { readonly service: string; readonly instance?: undefined })
|
|
44
|
+
| (Hooks & { readonly instance: Telemetry; readonly service?: undefined });
|
|
45
|
+
|
|
46
|
+
interface Hooks {
|
|
47
|
+
readonly traced?: (ctx: RequestContext) => boolean;
|
|
48
|
+
readonly spanName?: (ctx: RequestContext) => string;
|
|
49
|
+
readonly traceResponse?: boolean;
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`SpanScope`, `Telemetry` and `TelemetryOptions` are `@nxgt/telemetry`'s;
|
|
54
|
+
`RequestContext` is `@alxia/core`'s. `telemetry()` returns an app plugin:
|
|
55
|
+
pass it to `use`, called. It adds a global `around` hook, which traces every
|
|
56
|
+
request of the app, and a `derive`, which gives the routes declared
|
|
57
|
+
**after** it `span` and `telemetry`. The telemetry it writes to is also on
|
|
58
|
+
the plugin itself, as `.telemetry`, for the code that is not a route.
|
|
59
|
+
|
|
60
|
+
## The span
|
|
61
|
+
|
|
62
|
+
One span per request, of kind `server`, opened by the `around` hook. It
|
|
63
|
+
holds everything the request runs: the `onRequest` hooks, routing,
|
|
64
|
+
validation, the route's hooks and handler, whatever they await, and the
|
|
65
|
+
`onResponse` hooks. A log written with `createLogger` anywhere inside it,
|
|
66
|
+
and a span opened with `span()`, belong to it.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { alxia } from '@alxia/core';
|
|
70
|
+
import { telemetry } from '@alxia/telemetry';
|
|
71
|
+
import { consoleExporter, createLogger, span } from '@nxgt/telemetry';
|
|
72
|
+
|
|
73
|
+
const log = createLogger('Orders');
|
|
74
|
+
|
|
75
|
+
const app = alxia()
|
|
76
|
+
.use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
|
|
77
|
+
.get('/orders/:id', async ({ params, reply }) => {
|
|
78
|
+
const order = await span('orders.find', () => ({ id: params.id })); // a child of the server span
|
|
79
|
+
log.info('order read'); // carries the server span's ids
|
|
80
|
+
return reply(200, order);
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Because the hook is global, a route declared before `use(telemetry(...))`
|
|
85
|
+
is traced too; it only cannot read `span` and `telemetry` from its context.
|
|
86
|
+
A WebSocket upgrade is not traced: `@alxia/core` runs no `around` hook for
|
|
87
|
+
it, since there is no response to wrap.
|
|
88
|
+
|
|
89
|
+
### Its name
|
|
90
|
+
|
|
91
|
+
| The request | The span's name |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| before routing | `spanName(ctx)`, by default `"<METHOD> <path>"`: `GET /orders/o-1` |
|
|
94
|
+
| routing matched a route | `"<METHOD> <route>"`: `GET /orders/:id`, and `http.route` is set |
|
|
95
|
+
| no route matched (`404`, `405`) | stays what it was before routing |
|
|
96
|
+
|
|
97
|
+
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. A
|
|
99
|
+
`HEAD` request answered by a `GET` route is named `HEAD /orders/:id`.
|
|
100
|
+
|
|
101
|
+
### Its status, and the route's error
|
|
102
|
+
|
|
103
|
+
| The response | The span's status | Its exception |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `2xx`, `3xx`, `4xx` | `ok` | none |
|
|
106
|
+
| a `4xx` an `onError` hook made of a thrown error | `ok` | the error |
|
|
107
|
+
| a `5xx` from a throw | `error` | the error |
|
|
108
|
+
| a `5xx` the route replied | `error` | none |
|
|
109
|
+
|
|
110
|
+
A `401` a guard answered is the server working, so a 4xx never marks a
|
|
111
|
+
span. The error the route failed with is still recorded, as `ctx.error`
|
|
112
|
+
holds it:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { alxia } from '@alxia/core';
|
|
116
|
+
import { telemetry } from '@alxia/telemetry';
|
|
117
|
+
import { consoleExporter } from '@nxgt/telemetry';
|
|
118
|
+
|
|
119
|
+
const app = alxia()
|
|
120
|
+
.use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
|
|
121
|
+
.onError((error, { reply }) =>
|
|
122
|
+
error instanceof RangeError ? reply(400, { error: 'out_of_range' as const }) : undefined,
|
|
123
|
+
)
|
|
124
|
+
.get('/range', () => {
|
|
125
|
+
throw new RangeError('out of range');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await app.request('/range'); // 400; the span is ok, with `out of range` as its exception
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### What it records
|
|
132
|
+
|
|
133
|
+
| Attribute | Exported as | Value | When |
|
|
134
|
+
| --- | --- | --- | --- |
|
|
135
|
+
| `http.request.method` | `HTTP_METHOD` | `GET` | always |
|
|
136
|
+
| `url.path` | `URL_PATH` | `/orders/o-1` | always |
|
|
137
|
+
| `url.scheme` | `URL_SCHEME` | `http` | always |
|
|
138
|
+
| `server.address` | `SERVER_ADDRESS` | the request URL's host name | always |
|
|
139
|
+
| `server.port` | `SERVER_PORT` | `3000`, a number | when the request URL names a port |
|
|
140
|
+
| `client.address` | `CLIENT_ADDRESS` | the caller's address, as the app's `ip` option reads it | when there is one: not through `app.request` |
|
|
141
|
+
| `http.route` | `HTTP_ROUTE` | `/orders/:id` | once routing matched |
|
|
142
|
+
| `http.response.status_code` | `HTTP_STATUS` | `200`, a number | always |
|
|
143
|
+
|
|
144
|
+
They are the names of `@nxgt/telemetry-hono`, so a span from either reads
|
|
145
|
+
the same in a dashboard. The constants are exported for code that reads
|
|
146
|
+
spans back, a test for one:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { HTTP_ROUTE, HTTP_STATUS } from '@alxia/telemetry';
|
|
150
|
+
import type { SpanRecord } from '@nxgt/telemetry';
|
|
151
|
+
|
|
152
|
+
function routeOf(span: SpanRecord): string {
|
|
153
|
+
return `${String(span.attributes[HTTP_ROUTE])} ${String(span.attributes[HTTP_STATUS])}`;
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
All of them are the server span's own: a child span — a database call, an
|
|
158
|
+
outgoing request — and a log written inside the request carry its trace
|
|
159
|
+
and span ids, not the request's path, method or the client's address. An
|
|
160
|
+
attribute every log of a request should carry is yours to give, with
|
|
161
|
+
`@nxgt/telemetry`'s `withAttributes`: it reaches what runs inside it, so
|
|
162
|
+
an `around` hook declared after the plugin covers the whole request:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { alxia } from '@alxia/core';
|
|
166
|
+
import { telemetry } from '@alxia/telemetry';
|
|
167
|
+
import { withAttributes } from '@nxgt/telemetry';
|
|
168
|
+
|
|
169
|
+
const app = alxia()
|
|
170
|
+
.use(telemetry({ service: 'shop' }))
|
|
171
|
+
.around((ctx, next) =>
|
|
172
|
+
withAttributes({ 'tenant.id': ctx.request.headers.get('x-tenant') ?? 'none' }, next),
|
|
173
|
+
);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Across services
|
|
177
|
+
|
|
178
|
+
**In.** An inbound `traceparent` header continues its trace: the span takes
|
|
179
|
+
its trace id, and the caller's span id as its parent. A header that cannot
|
|
180
|
+
be read starts a fresh trace, as a request without one does.
|
|
181
|
+
|
|
182
|
+
**Out.** Inside a request, the current span says the header an outgoing
|
|
183
|
+
call should carry:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { alxia } from '@alxia/core';
|
|
187
|
+
import { telemetry } from '@alxia/telemetry';
|
|
188
|
+
import { consoleExporter, span } from '@nxgt/telemetry';
|
|
189
|
+
|
|
190
|
+
const app = alxia()
|
|
191
|
+
.use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
|
|
192
|
+
.post('/orders/:id/reserve', async ({ params, reply }) => {
|
|
193
|
+
const stock = await span('stock.reserve', { kind: 'client' }, (scope) =>
|
|
194
|
+
fetch(`https://stock.example.com/reserve/${params.id}`, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { traceparent: scope.traceparent() },
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
return reply(stock.ok ? 200 : 502, { reserved: stock.ok });
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`currentTraceparent()` from `@nxgt/telemetry` says the same thing without a
|
|
204
|
+
scope at hand. With httpyz, [`@nxgt/telemetry-httpyz`](https://www.npmjs.com/package/@nxgt/telemetry-httpyz)
|
|
205
|
+
does it for every call.
|
|
206
|
+
|
|
207
|
+
**Back.** With `traceResponse: true`, the response carries the span's
|
|
208
|
+
`traceparent`, so a caller can find the trace of the request it made:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import { alxia } from '@alxia/core';
|
|
212
|
+
import { telemetry } from '@alxia/telemetry';
|
|
213
|
+
import { consoleExporter } from '@nxgt/telemetry';
|
|
214
|
+
|
|
215
|
+
const app = alxia()
|
|
216
|
+
.use(telemetry({ service: 'checkout', exporters: [consoleExporter()], traceResponse: true }))
|
|
217
|
+
.get('/', ({ reply }) => reply(200, 'ok'));
|
|
218
|
+
|
|
219
|
+
const response = await app.request('/');
|
|
220
|
+
response.headers.get('traceparent'); // '00-<trace id>-<span id>-01'
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Options
|
|
224
|
+
|
|
225
|
+
`telemetry()` takes either a `service`, and builds the telemetry, or an
|
|
226
|
+
`instance`, and adopts it. Never both.
|
|
227
|
+
|
|
228
|
+
| Option | Type | Default | Effect |
|
|
229
|
+
| --- | --- | --- | --- |
|
|
230
|
+
| `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 plugin |
|
|
232
|
+
| `traced` | `(ctx: RequestContext) => boolean` | every request | whether a request gets a span |
|
|
233
|
+
| `spanName` | `(ctx: RequestContext) => string` | `"<METHOD> <path>"` | the span's name before routing, kept when no route matches |
|
|
234
|
+
| `traceResponse` | `boolean` | `false` | sets `traceparent` on the response |
|
|
235
|
+
|
|
236
|
+
With `service`, every [`TelemetryOptions`](https://www.npmjs.com/package/@nxgt/telemetry)
|
|
237
|
+
of `@nxgt/telemetry` is accepted alongside:
|
|
238
|
+
|
|
239
|
+
| Option | Type | Default | Effect |
|
|
240
|
+
| --- | --- | --- | --- |
|
|
241
|
+
| `version`, `environment` | `string` | none | stamped on every signal's resource |
|
|
242
|
+
| `attributes` | `Record<string, unknown>` | none | stamped on the resource too |
|
|
243
|
+
| `exporters` | `readonly Exporter[]` | none | where signals go, in order; with none, they go nowhere |
|
|
244
|
+
| `sampler` | `Sampler` | `alwaysSample` | which traces keep their spans; logs are never sampled |
|
|
245
|
+
| `minimum` | `Severity` | `info` | logs below it are never built |
|
|
246
|
+
| `stackTraces` | `boolean` | `true` | whether a recorded exception carries its stack |
|
|
247
|
+
| `batch`, `linger`, `drainTimeout` | `number` | `512`, `1000` ms, `10000` ms | when the pipeline flushes, and how long `close()` waits |
|
|
248
|
+
| `onExportError` | `(failure: unknown) => void` | `console.error` | what a failed export does |
|
|
249
|
+
|
|
250
|
+
### `service` or `instance`
|
|
251
|
+
|
|
252
|
+
`service` is the short way, for an app whose telemetry is this plugin's:
|
|
253
|
+
the telemetry is installed, so a logger used outside any request — at
|
|
254
|
+
start-up, in a job — finds it too.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import { telemetry } from '@alxia/telemetry';
|
|
258
|
+
import { consoleExporter } from '@nxgt/telemetry';
|
|
259
|
+
|
|
260
|
+
const tracing = telemetry({
|
|
261
|
+
service: 'checkout',
|
|
262
|
+
version: '1.4.0',
|
|
263
|
+
environment: 'production',
|
|
264
|
+
exporters: [consoleExporter()],
|
|
265
|
+
});
|
|
266
|
+
tracing.telemetry; // the Telemetry it built, to close on shutdown
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`instance` is for a telemetry the app already has: built at start-up and
|
|
270
|
+
shared with a worker, or built per test.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { alxia } from '@alxia/core';
|
|
274
|
+
import { telemetry } from '@alxia/telemetry';
|
|
275
|
+
import { consoleExporter, createTelemetry } from '@nxgt/telemetry';
|
|
276
|
+
|
|
277
|
+
const shared = createTelemetry('checkout', { exporters: [consoleExporter()] }).install();
|
|
278
|
+
|
|
279
|
+
const app = alxia().use(telemetry({ instance: shared }));
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The plugin runs each traced request inside the instance, so the logs in it
|
|
283
|
+
reach it either way. Outside a request, and in a request `traced` said no
|
|
284
|
+
to, a logger only finds a telemetry that is installed: call `install()` on
|
|
285
|
+
an instance the whole process writes to, as above.
|
|
286
|
+
|
|
287
|
+
### `traced`
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
telemetry({
|
|
291
|
+
service: 'checkout',
|
|
292
|
+
exporters: [consoleExporter()],
|
|
293
|
+
traced: (ctx) => ctx.url.pathname !== '/health' && ctx.url.pathname !== '/ready',
|
|
294
|
+
});
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
A request it says no to runs without a span: its routes read `span` as
|
|
298
|
+
`undefined`, and still read `telemetry`.
|
|
299
|
+
|
|
300
|
+
### `spanName`
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
telemetry({
|
|
304
|
+
service: 'checkout',
|
|
305
|
+
exporters: [consoleExporter()],
|
|
306
|
+
spanName: (ctx) => `${ctx.request.method} ${ctx.url.pathname.split('/')[1] ?? ''}`,
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
It runs before routing, so `ctx.route` is always `undefined` there; once a
|
|
311
|
+
route matches, the span is renamed after it whatever `spanName` said.
|
|
312
|
+
|
|
313
|
+
A `traced` or `spanName` that throws costs its answer, never the request:
|
|
314
|
+
the request is traced, or named `"<METHOD> <path>"`, and nothing is logged.
|
|
315
|
+
|
|
316
|
+
## What the routes read
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { alxia } from '@alxia/core';
|
|
320
|
+
import { telemetry } from '@alxia/telemetry';
|
|
321
|
+
import { consoleExporter } from '@nxgt/telemetry';
|
|
322
|
+
|
|
323
|
+
const app = alxia()
|
|
324
|
+
.use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
|
|
325
|
+
.get('/orders/:id', ({ params, span, telemetry: current, reply }) => {
|
|
326
|
+
span?.attribute('order.id', params.id); // SpanScope | undefined
|
|
327
|
+
span?.event('cache.miss');
|
|
328
|
+
return reply(200, { service: current.resource.service });
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
| Field | Type | What it is |
|
|
333
|
+
| --- | --- | --- |
|
|
334
|
+
| `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 plugin writes to, traced or not |
|
|
336
|
+
|
|
337
|
+
Only the routes declared after `use(telemetry(...))`, in the same app or
|
|
338
|
+
group, read them.
|
|
339
|
+
|
|
340
|
+
## Shutting down
|
|
341
|
+
|
|
342
|
+
The telemetry batches what it receives; `close()` ships the backlog, and
|
|
343
|
+
has to be awaited, or the last batch is lost. The plugin closes nothing,
|
|
344
|
+
not even a telemetry it built: close it in `onStop`, which `app.stop()`
|
|
345
|
+
runs, and stop the app when the process is asked to end.
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
import { alxia } from '@alxia/core';
|
|
349
|
+
import { telemetry } from '@alxia/telemetry';
|
|
350
|
+
import { consoleExporter } from '@nxgt/telemetry';
|
|
351
|
+
|
|
352
|
+
const tracing = telemetry({ service: 'checkout', exporters: [consoleExporter()] });
|
|
353
|
+
|
|
354
|
+
const app = alxia()
|
|
355
|
+
.use(tracing)
|
|
356
|
+
.get('/', ({ reply }) => reply(200, 'ok'))
|
|
357
|
+
.onStop(() => tracing.telemetry.close());
|
|
358
|
+
|
|
359
|
+
app.listen(3000);
|
|
360
|
+
|
|
361
|
+
process.on('SIGTERM', async () => {
|
|
362
|
+
await app.stop();
|
|
363
|
+
process.exit(0);
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Once closed, a telemetry takes nothing more: the app still answers, and
|
|
368
|
+
the spans of later requests are dropped.
|
|
369
|
+
|
|
370
|
+
## Sending to a collector
|
|
371
|
+
|
|
372
|
+
[`@nxgt/telemetry-otlp`](https://www.npmjs.com/package/@nxgt/telemetry-otlp)
|
|
373
|
+
ships to any OpenTelemetry collector:
|
|
374
|
+
|
|
375
|
+
```sh
|
|
376
|
+
bun add @nxgt/telemetry-otlp
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
import { alxia } from '@alxia/core';
|
|
381
|
+
import { telemetry } from '@alxia/telemetry';
|
|
382
|
+
import { ratioSampler } from '@nxgt/telemetry';
|
|
383
|
+
import { otlpExporter } from '@nxgt/telemetry-otlp';
|
|
384
|
+
|
|
385
|
+
const tracing = telemetry({
|
|
386
|
+
service: 'checkout',
|
|
387
|
+
version: '1.4.0',
|
|
388
|
+
environment: 'production',
|
|
389
|
+
sampler: ratioSampler(0.1),
|
|
390
|
+
exporters: [otlpExporter({ endpoint: 'http://localhost:4318' })],
|
|
391
|
+
traced: (ctx) => ctx.url.pathname !== '/health',
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const app = alxia()
|
|
395
|
+
.use(tracing)
|
|
396
|
+
.get('/', ({ reply }) => reply(200, 'ok'))
|
|
397
|
+
.onStop(() => tracing.telemetry.close());
|
|
398
|
+
|
|
399
|
+
app.listen(3000);
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
With `ratioSampler(0.1)`, one trace in ten keeps its spans. The others
|
|
403
|
+
still open one, so their logs carry a trace id, and `span` is defined in
|
|
404
|
+
the routes; it is only not exported.
|
|
405
|
+
|
|
406
|
+
## Testing
|
|
407
|
+
|
|
408
|
+
Give each test its own telemetry, as an `instance`, with an exporter that
|
|
409
|
+
keeps what it receives, and close it before reading: `close()` is what
|
|
410
|
+
flushes.
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
import { afterEach, expect, test } from 'bun:test';
|
|
414
|
+
import { alxia } from '@alxia/core';
|
|
415
|
+
import { telemetry } from '@alxia/telemetry';
|
|
416
|
+
import {
|
|
417
|
+
createTelemetry,
|
|
418
|
+
type Exporter,
|
|
419
|
+
type Signal,
|
|
420
|
+
type SpanRecord,
|
|
421
|
+
uninstallTelemetry,
|
|
422
|
+
} from '@nxgt/telemetry';
|
|
423
|
+
|
|
424
|
+
afterEach(() => uninstallTelemetry());
|
|
425
|
+
|
|
426
|
+
test('a route gets one server span, named after it', async () => {
|
|
427
|
+
const signals: Signal[] = [];
|
|
428
|
+
const exporter: Exporter = {
|
|
429
|
+
export(_resource, batch) {
|
|
430
|
+
signals.push(...batch);
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
const instance = createTelemetry('test', { exporters: [exporter] });
|
|
434
|
+
const app = alxia()
|
|
435
|
+
.use(telemetry({ instance }))
|
|
436
|
+
.get('/users/:id', ({ params, reply }) => reply(200, { id: params.id }));
|
|
437
|
+
|
|
438
|
+
await app.request('/users/7');
|
|
439
|
+
await instance.close();
|
|
440
|
+
|
|
441
|
+
const spans = signals.filter((signal): signal is SpanRecord => signal.type === 'span');
|
|
442
|
+
expect(spans[0]?.name).toBe('GET /users/:id');
|
|
443
|
+
expect(spans[0]?.attributes['http.response.status_code']).toBe(200);
|
|
444
|
+
});
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
An `instance` is not installed, so tests do not share one through the
|
|
448
|
+
process. A plugin built with `service` installs its telemetry for the whole
|
|
449
|
+
process; `uninstallTelemetry()` after each test takes it back out.
|
|
450
|
+
|
|
451
|
+
To continue a trace in a test, send the header a caller would:
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
await app.request('/users/1', {
|
|
455
|
+
headers: { traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' },
|
|
456
|
+
});
|
|
457
|
+
// the span's trace id is 4bf92f3577b34da6a3ce929d0e0e4736, its parent 00f067aa0ba902b7
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
When something does not show up as expected, see
|
|
461
|
+
[Troubleshooting](troubleshooting.md).
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
What `@alxia/telemetry` gives an app, and what is coming. This page is a
|
|
4
|
+
direction, not a commitment: the version something shipped in is the only
|
|
5
|
+
number on it. Every release, with each change it made, is in
|
|
6
|
+
[`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/telemetry/CHANGELOG.md).
|
|
7
|
+
|
|
8
|
+
## Now
|
|
9
|
+
|
|
10
|
+
Nothing scheduled yet.
|
|
11
|
+
|
|
12
|
+
## Next
|
|
13
|
+
|
|
14
|
+
Nothing scheduled yet.
|
|
15
|
+
|
|
16
|
+
## Later
|
|
17
|
+
|
|
18
|
+
Nothing scheduled yet.
|
|
19
|
+
|
|
20
|
+
## Not planned
|
|
21
|
+
|
|
22
|
+
- **A second telemetry implementation.** `@alxia/telemetry` is an adapter
|
|
23
|
+
over `@nxgt/telemetry`, a peer: the spans, the logs, the sampling and the
|
|
24
|
+
exporters are its, and so is everything they gain.
|
|
25
|
+
- **The OpenTelemetry SDK.** Signals go out through `@nxgt/telemetry`'s
|
|
26
|
+
exporters — to a collector with `@nxgt/telemetry-otlp` — with no SDK and
|
|
27
|
+
no runtime dependency.
|
|
28
|
+
|
|
29
|
+
## Shipped
|
|
30
|
+
|
|
31
|
+
### 0.1.0
|
|
32
|
+
|
|
33
|
+
- **One server span per request.** `alxia().use(telemetry({ service, exporters }))`
|
|
34
|
+
opens a span around everything a request runs — hooks, handler, what
|
|
35
|
+
they await — and every log written with `@nxgt/telemetry`'s
|
|
36
|
+
`createLogger` inside it carries its trace id.
|
|
37
|
+
- **Named for the route.** The span is renamed `GET /orders/:id` once
|
|
38
|
+
routing has matched, with `http.route`: one dashboard row per route.
|
|
39
|
+
- **Traces across services.** An inbound `traceparent` is continued, an
|
|
40
|
+
unreadable one starts a fresh trace, and `traceResponse` says it back.
|
|
41
|
+
- **Errors where they belong.** A route's error is the span's exception;
|
|
42
|
+
only a 5xx marks the span an error.
|
|
43
|
+
- **The same names as Hono's.** The attributes are
|
|
44
|
+
`@nxgt/telemetry-hono`'s, exported as constants, so a span from either
|
|
45
|
+
reads the same in a dashboard.
|
|
46
|
+
- **Your telemetry, or one built for you.** `service` with
|
|
47
|
+
`@nxgt/telemetry`'s options, or an existing `instance`, adopted; `traced`
|
|
48
|
+
and `spanName` to choose and name the spans. Routes read `span` and
|
|
49
|
+
`telemetry`.
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Each entry is headed by the text you see: an error from `tsc`, a line in
|
|
4
|
+
the server log, or, for what prints nothing, what you see in your traces.
|
|
5
|
+
|
|
6
|
+
**Types**
|
|
7
|
+
|
|
8
|
+
- [`Property 'span' does not exist on type 'Context<…>'`](#property-span-does-not-exist-on-type-context)
|
|
9
|
+
- [`Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'`](#type-alxiaempty-empty--never-is-not-assignable-to-type-telemetrypluginoptions)
|
|
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
|
+
- [`Type 'Telemetry' is not assignable to type 'undefined'`](#type-telemetry-is-not-assignable-to-type-undefined)
|
|
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--)
|
|
13
|
+
- [`Type 'string | undefined' is not assignable to type 'string'` in `spanName`](#type-string--undefined-is-not-assignable-to-type-string-in-spanname)
|
|
14
|
+
|
|
15
|
+
**Server log**
|
|
16
|
+
|
|
17
|
+
- [`[telemetry] export failed`](#telemetry-export-failed)
|
|
18
|
+
|
|
19
|
+
**Missing signals**
|
|
20
|
+
|
|
21
|
+
- [Nothing is exported, or the last requests are missing](#nothing-is-exported-or-the-last-requests-are-missing)
|
|
22
|
+
- [A log written in a route has no `traceId`, or never arrives](#a-log-written-in-a-route-has-no-traceid-or-never-arrives)
|
|
23
|
+
- [A log written outside a request never arrives](#a-log-written-outside-a-request-never-arrives)
|
|
24
|
+
- [Logs carry a `traceId`, but its span is never exported](#logs-carry-a-traceid-but-its-span-is-never-exported)
|
|
25
|
+
- [Spans stop arriving, and the app still answers](#spans-stop-arriving-and-the-app-still-answers)
|
|
26
|
+
- [No span for a WebSocket connection](#no-span-for-a-websocket-connection)
|
|
27
|
+
- [The response has no `traceparent` header](#the-response-has-no-traceparent-header)
|
|
28
|
+
|
|
29
|
+
**Unexpected spans**
|
|
30
|
+
|
|
31
|
+
- [The span starts a new trace although the caller sent `traceparent`](#the-span-starts-a-new-trace-although-the-caller-sent-traceparent)
|
|
32
|
+
- [`spanName` only shows on requests no route matched](#spanname-only-shows-on-requests-no-route-matched)
|
|
33
|
+
- [A span has an exception, and its status is `ok`](#a-span-has-an-exception-and-its-status-is-ok)
|
|
34
|
+
|
|
35
|
+
## Types
|
|
36
|
+
|
|
37
|
+
### `Property 'span' does not exist on type 'Context<…>'`
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
error TS2339: Property 'span' does not exist on type 'Context<Empty, "/before", Empty>'.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**When:** a route reads `span` or `telemetry`, and is declared before
|
|
44
|
+
`use(telemetry(...))`.
|
|
45
|
+
|
|
46
|
+
**Why:** the plugin gives `span` and `telemetry` to the routes declared
|
|
47
|
+
after it. The request is still traced, since the span is opened by a global
|
|
48
|
+
hook; the route only cannot reach it.
|
|
49
|
+
|
|
50
|
+
**Fix:** use the plugin first:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const app = alxia()
|
|
54
|
+
.use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }))
|
|
55
|
+
.get('/orders/:id', ({ params, span, reply }) => {
|
|
56
|
+
span?.attribute('order.id', params.id);
|
|
57
|
+
return reply(200, { id: params.id });
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### `Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'`
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
error TS2769: No overload matches this call.
|
|
65
|
+
Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => …): …', gave the following error.
|
|
66
|
+
Argument of type '(options: TelemetryPluginOptions) => …' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => …'.
|
|
67
|
+
Types of parameters 'options' and 'app' are incompatible.
|
|
68
|
+
Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'TelemetryPluginOptions'.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**When:** `app.use(telemetry)`, without calling it.
|
|
72
|
+
|
|
73
|
+
**Why:** `telemetry` makes the plugin; it is not the plugin, and it needs a
|
|
74
|
+
`service` or an `instance`.
|
|
75
|
+
|
|
76
|
+
**Fix:**
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
alxia().use(telemetry({ service: 'checkout', exporters: [consoleExporter()] }));
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `Property 'service' is missing in type '…' but required in type '{ readonly service: string; readonly instance?: undefined; }'`
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
error TS2345: Argument of type '{ exporters: never[]; }' is not assignable to parameter of type 'TelemetryPluginOptions'.
|
|
86
|
+
Type '{ exporters: never[]; }' is not assignable to type 'Hooks & TelemetryOptions & { readonly service: string; readonly instance?: undefined; }'.
|
|
87
|
+
Property 'service' is missing in type '{ exporters: never[]; }' but required in type '{ readonly service: string; readonly instance?: undefined; }'.
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**When:** `telemetry({ exporters: [...] })`, with neither `service` nor
|
|
91
|
+
`instance`.
|
|
92
|
+
|
|
93
|
+
**Why:** the telemetry the plugin builds needs a service name: every signal
|
|
94
|
+
groups by it, and there is no default.
|
|
95
|
+
|
|
96
|
+
**Fix:** name the service, or hand over a telemetry you built:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
telemetry({ service: 'checkout', exporters: [consoleExporter()] });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### `Type 'Telemetry' is not assignable to type 'undefined'`
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
error TS2345: Argument of type '{ service: string; instance: Telemetry; }' is not assignable to parameter of type 'TelemetryPluginOptions'.
|
|
106
|
+
Types of property 'instance' are incompatible.
|
|
107
|
+
Type 'Telemetry' is not assignable to type 'undefined'.
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**When:** `telemetry({ service, instance })`.
|
|
111
|
+
|
|
112
|
+
**Why:** `service` builds a telemetry, `instance` adopts one; the plugin
|
|
113
|
+
writes to exactly one.
|
|
114
|
+
|
|
115
|
+
**Fix:** keep `instance`, whose service name was given to `createTelemetry`:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const instance = createTelemetry('checkout', { exporters: [consoleExporter()] });
|
|
119
|
+
|
|
120
|
+
telemetry({ instance });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### `Object literal may only specify known properties, and 'version' does not exist in type 'Hooks & { readonly instance: Telemetry; … }'`
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
error TS2353: Object literal may only specify known properties, and 'version' does not exist in type 'Hooks & { readonly instance: Telemetry; readonly service?: undefined; }'.
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The same for `exporters`, `sampler`, `environment`, or any other
|
|
130
|
+
`@nxgt/telemetry` option.
|
|
131
|
+
|
|
132
|
+
**When:** `@nxgt/telemetry` options next to `instance`.
|
|
133
|
+
|
|
134
|
+
**Why:** an adopted telemetry is already built; the plugin cannot change
|
|
135
|
+
its exporters or its resource.
|
|
136
|
+
|
|
137
|
+
**Fix:** give them to `createTelemetry`:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const instance = createTelemetry('checkout', {
|
|
141
|
+
version: '1.4.0',
|
|
142
|
+
exporters: [consoleExporter()],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
telemetry({ instance, traceResponse: true });
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `Type 'string | undefined' is not assignable to type 'string'` in `spanName`
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
error TS2322: Type '(ctx: RequestContext) => string | undefined' is not assignable to type '(ctx: RequestContext) => string'.
|
|
152
|
+
Type 'string | undefined' is not assignable to type 'string'.
|
|
153
|
+
Type 'undefined' is not assignable to type 'string'.
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**When:** `spanName: (ctx) => ctx.route`.
|
|
157
|
+
|
|
158
|
+
**Why:** `spanName` runs before routing, where `ctx.route` is always
|
|
159
|
+
`undefined`. The route already names the span once it matches.
|
|
160
|
+
|
|
161
|
+
**Fix:** name what routing has not matched from the path, or leave
|
|
162
|
+
`spanName` out:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
telemetry({
|
|
166
|
+
service: 'checkout',
|
|
167
|
+
exporters: [consoleExporter()],
|
|
168
|
+
spanName: (ctx) => `${ctx.request.method} unmatched`,
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Server log
|
|
173
|
+
|
|
174
|
+
### `[telemetry] export failed`
|
|
175
|
+
|
|
176
|
+
Followed by the exporter's error, such as
|
|
177
|
+
`OtlpUnreachableError: [telemetry] http://localhost:4318/v1/traces did not answer for traces after 3 attempt(s)`.
|
|
178
|
+
|
|
179
|
+
**When:** an exporter throws or rejects: a collector that is down, a wrong
|
|
180
|
+
endpoint, a refused key.
|
|
181
|
+
|
|
182
|
+
**Why:** `@nxgt/telemetry` reports a failed export through
|
|
183
|
+
`onExportError`, which is `console.error` by default. The request it came
|
|
184
|
+
from was answered long before: an export never costs a request.
|
|
185
|
+
|
|
186
|
+
**Fix:** point the exporter at a collector that answers, and send the
|
|
187
|
+
failures where you want them:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
telemetry({
|
|
191
|
+
service: 'checkout',
|
|
192
|
+
exporters: [otlpExporter({ endpoint: 'http://localhost:4318' })],
|
|
193
|
+
onExportError: (failure) => metrics.increment('telemetry.export_failed', String(failure)),
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Missing signals
|
|
198
|
+
|
|
199
|
+
### Nothing is exported, or the last requests are missing
|
|
200
|
+
|
|
201
|
+
**When:** a script or a test exits right after its requests, or a server
|
|
202
|
+
is stopped, and the last spans and logs never reach the exporter — with
|
|
203
|
+
`consoleExporter()`, nothing is printed.
|
|
204
|
+
|
|
205
|
+
**Why:** the telemetry batches signals, and ships a batch when it is full
|
|
206
|
+
or a second after it started. A process that exits first loses it. The
|
|
207
|
+
plugin never closes the telemetry, not even one it built from `service`.
|
|
208
|
+
|
|
209
|
+
**Fix:** close it in `onStop`, await `app.stop()` on shutdown, and await
|
|
210
|
+
`close()` in a script or a test before reading what was exported:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
const app = alxia()
|
|
214
|
+
.use(tracing)
|
|
215
|
+
.onStop(() => tracing.telemetry.close());
|
|
216
|
+
|
|
217
|
+
process.on('SIGTERM', async () => {
|
|
218
|
+
await app.stop();
|
|
219
|
+
process.exit(0);
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### A log written in a route has no `traceId`, or never arrives
|
|
224
|
+
|
|
225
|
+
**When:** a span is exported for the request, but a `log.info` inside it
|
|
226
|
+
is missing, or arrives without `span`.
|
|
227
|
+
|
|
228
|
+
**Why:** the logger comes from another copy of `@nxgt/telemetry` than the
|
|
229
|
+
one `@alxia/telemetry` uses: the request's span is held by one copy, and
|
|
230
|
+
the other cannot see it. It is a peer for that reason, and a second copy
|
|
231
|
+
appears when a package depends on a version the app's range does not
|
|
232
|
+
satisfy.
|
|
233
|
+
|
|
234
|
+
**Fix:** keep one copy, with a single range for every package that needs
|
|
235
|
+
it, and check:
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
bun pm ls --all | grep @nxgt/telemetry@
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### A log written outside a request never arrives
|
|
242
|
+
|
|
243
|
+
**When:** a log at start-up, in a job or a timer, or in a request `traced`
|
|
244
|
+
said no to, while the logs in traced requests arrive.
|
|
245
|
+
|
|
246
|
+
**Why:** the plugin was given an `instance`. It runs each traced request
|
|
247
|
+
inside that telemetry, but does not install it, so a logger with no request
|
|
248
|
+
around it finds none.
|
|
249
|
+
|
|
250
|
+
**Fix:** install the instance the whole process writes to:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
const instance = createTelemetry('checkout', { exporters: [consoleExporter()] }).install();
|
|
254
|
+
|
|
255
|
+
alxia().use(telemetry({ instance }));
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Logs carry a `traceId`, but its span is never exported
|
|
259
|
+
|
|
260
|
+
**When:** a `sampler` such as `ratioSampler(0.1)` is set; most requests
|
|
261
|
+
have logs with a trace id that no span in the backend has.
|
|
262
|
+
|
|
263
|
+
**Why:** a sampler decides which traces keep their spans; logs are never
|
|
264
|
+
sampled. A sampled-out request still opens its span, so `span` is defined
|
|
265
|
+
in the route and the logs carry its ids, but the span is not exported.
|
|
266
|
+
|
|
267
|
+
**Fix:** that is sampling working. Raise the ratio, or sample nothing out:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
telemetry({ service: 'checkout', sampler: alwaysSample, exporters: [consoleExporter()] });
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Spans stop arriving, and the app still answers
|
|
274
|
+
|
|
275
|
+
**When:** after `close()` on the plugin's telemetry — often a test that
|
|
276
|
+
closes it in one case and sends requests in the next.
|
|
277
|
+
|
|
278
|
+
**Why:** a closed telemetry takes nothing more, and the plugin keeps
|
|
279
|
+
writing to it; the requests are answered as usual.
|
|
280
|
+
|
|
281
|
+
**Fix:** build a plugin, with its own telemetry, per test:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
const instance = createTelemetry('test', { exporters: [exporter] });
|
|
285
|
+
const app = alxia().use(telemetry({ instance }));
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### No span for a WebSocket connection
|
|
289
|
+
|
|
290
|
+
**When:** a route declared with `app.ws`.
|
|
291
|
+
|
|
292
|
+
**Why:** `@alxia/core` runs no `around` hook for a WebSocket upgrade, as
|
|
293
|
+
there is no response to wrap, and the span is opened by one.
|
|
294
|
+
|
|
295
|
+
**Fix:** open a span for the work a message does:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
app.ws('/rooms/:room', {}, {
|
|
299
|
+
message: (socket, message) =>
|
|
300
|
+
span('room.message', () => socket.send(String(message))),
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### The response has no `traceparent` header
|
|
305
|
+
|
|
306
|
+
**When:** a caller looks for the trace of the request it made.
|
|
307
|
+
|
|
308
|
+
**Why:** the plugin says `traceparent` back only with `traceResponse`, and
|
|
309
|
+
only on a request `traced` let through.
|
|
310
|
+
|
|
311
|
+
**Fix:**
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
telemetry({ service: 'checkout', exporters: [consoleExporter()], traceResponse: true });
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Unexpected spans
|
|
318
|
+
|
|
319
|
+
### The span starts a new trace although the caller sent `traceparent`
|
|
320
|
+
|
|
321
|
+
**When:** the server span has no parent, and a trace id of its own.
|
|
322
|
+
|
|
323
|
+
**Why:** the header could not be read as a W3C `traceparent`: a header from
|
|
324
|
+
a stranger is not trusted, so a fresh trace starts.
|
|
325
|
+
|
|
326
|
+
**Fix:** send the header the caller's current span says, unchanged, in the
|
|
327
|
+
`00-<32 hex trace id>-<16 hex span id>-<2 hex flags>` form:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
await fetch('https://checkout.example.com/orders/o-1', {
|
|
331
|
+
headers: { traceparent: currentTraceparent() ?? '' },
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### `spanName` only shows on requests no route matched
|
|
336
|
+
|
|
337
|
+
**When:** a `spanName` is set, and the matched requests are still named
|
|
338
|
+
`GET /orders/:id`.
|
|
339
|
+
|
|
340
|
+
**Why:** `spanName` is the name before routing. Once a route matches, the
|
|
341
|
+
span is renamed `"<METHOD> <route>"`, so a dashboard has one row per route.
|
|
342
|
+
|
|
343
|
+
**Fix:** to add to a routed span, set an attribute rather than the name:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
app.get('/orders/:id', ({ params, span, reply }) => {
|
|
347
|
+
span?.attribute('order.id', params.id);
|
|
348
|
+
return reply(200, { id: params.id });
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### A span has an exception, and its status is `ok`
|
|
353
|
+
|
|
354
|
+
**When:** a route throws, and an `onError` hook answers with a `4xx`.
|
|
355
|
+
|
|
356
|
+
**Why:** the error is recorded as the span's exception, but only a `5xx`
|
|
357
|
+
makes a span an error: a `400` the app chose to answer is the server
|
|
358
|
+
working.
|
|
359
|
+
|
|
360
|
+
**Fix:** none needed. For an error that should mark the span, answer it
|
|
361
|
+
with a `5xx`, or let it throw to the `500`.
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alxia/telemetry",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"package.json",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/softistx/alxia.git",
|
|
27
|
+
"directory": "packages/telemetry"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"registry": "https://registry.npmjs.org",
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "bun run ../../build.ts",
|
|
35
|
+
"test": "bun test src",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"alxia": {
|
|
39
|
+
"entrypoints": [
|
|
40
|
+
"src/index.ts"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@alxia/client": "^0.1.0",
|
|
45
|
+
"@alxia/core": "^0.1.0",
|
|
46
|
+
"@nxgt/telemetry": "^0.2.1",
|
|
47
|
+
"@types/bun": "^1.4.2",
|
|
48
|
+
"zod": "^4.6.5"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@alxia/core": "^0.1.0",
|
|
52
|
+
"@nxgt/telemetry": "^0.2.1",
|
|
53
|
+
"typescript": "^6.0.3 || ^7.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|