@ayepi/otel 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 +112 -0
- package/dist/index.cjs +43 -0
- package/dist/index.d.cts +33 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +42 -0
- package/dist/server.cjs +273 -0
- package/dist/server.d.cts +141 -0
- package/dist/server.d.ts +141 -0
- package/dist/server.js +272 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philip Diffenderfer
|
|
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,112 @@
|
|
|
1
|
+
# @ayepi/otel
|
|
2
|
+
|
|
3
|
+
Telemetry middleware for [`@ayepi/core`](https://www.npmjs.com/package/@ayepi/core).
|
|
4
|
+
It **enriches the [`@ayepi/log`](https://www.npmjs.com/package/@ayepi/log) trace
|
|
5
|
+
context** so every inner `logger.*` call during a request carries the chosen fields,
|
|
6
|
+
and **optionally logs a request and/or response line** with a configurable, per-set
|
|
7
|
+
field selection.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @ayepi/otel @ayepi/core @ayepi/log
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`@ayepi/otel` ships as a **def / impl split**. The main entry is frontend-safe (no
|
|
14
|
+
`node:crypto`, no `@ayepi/log`) and exports `telemetry(opts?)`, a middleware **def
|
|
15
|
+
factory**. The server behaviour lives behind `@ayepi/otel/server`, which augments
|
|
16
|
+
`telemetry` with `.server(def, opts)` — the only place that pulls in node deps.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// shared.ts — frontend-safe: only the def + the spec
|
|
20
|
+
import { telemetry } from '@ayepi/otel'
|
|
21
|
+
|
|
22
|
+
const tel = telemetry() // a no-context middleware def (contributes nothing to ctx)
|
|
23
|
+
|
|
24
|
+
const api = spec({ endpoints: { ...tel.group({ getUser: { … } }) } })
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// server.ts — binds behaviour and imports node deps
|
|
29
|
+
import { telemetry } from '@ayepi/otel/server'
|
|
30
|
+
import { implement } from '@ayepi/core'
|
|
31
|
+
|
|
32
|
+
const app = implement(api)
|
|
33
|
+
.middleware(telemetry.server(tel, { echoRequestId: true, request: { ip: true } }))
|
|
34
|
+
.server()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`telemetry()` provides **nothing** to the handler context; it only logs and establishes
|
|
38
|
+
trace context. By default `.server` enriches every inner log with `{ requestId, method,
|
|
39
|
+
path }`, logs a `request` line with `{ method, path, requestId }`, and a `response` line
|
|
40
|
+
with `{ status, duration }`. The error path logs the serialized error + best-effort status
|
|
41
|
+
and **rethrows** (it never swallows).
|
|
42
|
+
|
|
43
|
+
## Def vs server
|
|
44
|
+
|
|
45
|
+
- `telemetry(opts?)` (def factory, `@ayepi/otel`) — frontend-safe. `opts = { name?,
|
|
46
|
+
requires? }`. Declares the contract; carries no behaviour and no node deps. A spec that
|
|
47
|
+
imports only this entry is safe to bundle for the frontend.
|
|
48
|
+
- `telemetry.server(def, opts)` (`@ayepi/otel/server`) — binds the implementation. **All**
|
|
49
|
+
behaviour options live here: `level, context, request, response, overrides, requestId,
|
|
50
|
+
echoRequestId, extra, logger, logWith, now`. Bind it with `implement(api).middleware(...)`.
|
|
51
|
+
|
|
52
|
+
## Three independent field sets
|
|
53
|
+
|
|
54
|
+
These move to `.server`:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
telemetry.server(tel, {
|
|
58
|
+
context: { requestId: true, traceId: true }, // inherited by every inner log
|
|
59
|
+
request: { method: true, path: true, ip: true }, // the request line (or `false` to disable)
|
|
60
|
+
response: { status: true, duration: true, error: true }, // the response line (or `false`)
|
|
61
|
+
level: 'info', // level of both lines
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Request fields: `name`, `requestId`, `method`, `path`, `ip`, `size`, `traceId`.
|
|
66
|
+
Response fields: `status`, `duration`, `type`, `error`, `size`.
|
|
67
|
+
|
|
68
|
+
## Request id
|
|
69
|
+
|
|
70
|
+
Default precedence: the `X-Request-ID` header, else a generated UUID (`node:crypto`).
|
|
71
|
+
Override with `requestId: (req) => string` on `.server`. A websocket **frame** id is not
|
|
72
|
+
reachable from a middleware, so it is not a source — see `ayepi-otel.md`.
|
|
73
|
+
|
|
74
|
+
## Per-endpoint overrides
|
|
75
|
+
|
|
76
|
+
The matched endpoint name is not reachable from a middleware at runtime, so overrides are
|
|
77
|
+
done the idiomatic ayepi way: attach a tailored `telemetry({...})` def to the specific
|
|
78
|
+
endpoint or group, and bind a matching `.server` impl.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// shared.ts
|
|
82
|
+
const base = telemetry()
|
|
83
|
+
const noisy = telemetry({ name: 'upload' })
|
|
84
|
+
|
|
85
|
+
spec({
|
|
86
|
+
endpoints: {
|
|
87
|
+
...base.group({ getUser, listUsers }),
|
|
88
|
+
upload: noisy.endpoint({ … }),
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// server.ts
|
|
93
|
+
implement(api)
|
|
94
|
+
.middleware(telemetry.server(base, {}))
|
|
95
|
+
.middleware(telemetry.server(noisy, { request: { method: true, path: true, ip: true, size: true } }))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
(You may also keep a single def and carry per-route tweaks in `.server`'s `overrides` map —
|
|
99
|
+
see `ayepi-otel.md`.)
|
|
100
|
+
|
|
101
|
+
## For AI coding agents
|
|
102
|
+
|
|
103
|
+
This package ships dense, machine-oriented reference docs written for **AI coding agents**
|
|
104
|
+
(Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
|
|
105
|
+
|
|
106
|
+
- [`ayepi-otel.md`](./ayepi-otel.md)
|
|
107
|
+
|
|
108
|
+
They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/otel) and are **not** shipped in the npm tarball.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT © Philip Diffenderfer
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _ayepi_core = require("@ayepi/core");
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* # @ayepi/otel
|
|
6
|
+
*
|
|
7
|
+
* A telemetry middleware **def** for `@ayepi/core`. This entry is **frontend-safe**:
|
|
8
|
+
* it declares a no-context middleware (it logs and establishes trace context, but
|
|
9
|
+
* contributes nothing to the handler payload) with no `node:crypto` and no
|
|
10
|
+
* `@ayepi/log` runtime import. All behaviour — field selection, the request/response
|
|
11
|
+
* lines, the trace context, the logger — is configured server-side when you bind it
|
|
12
|
+
* with [`telemetry.server`](./server) from `@ayepi/otel/server`.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* // shared.ts (frontend-safe)
|
|
16
|
+
* import { telemetry } from '@ayepi/otel';
|
|
17
|
+
* const tel = telemetry();
|
|
18
|
+
* spec({ endpoints: { ...tel.group({ … }) } });
|
|
19
|
+
*
|
|
20
|
+
* // server.ts
|
|
21
|
+
* import { telemetry } from '@ayepi/otel/server';
|
|
22
|
+
* implement(api).middleware(telemetry.server(tel, { echoRequestId: true }));
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Create a telemetry middleware **def** — a no-context, frontend-safe contract.
|
|
29
|
+
* Bind its behaviour with [`telemetry.server(def, opts)`](./server).
|
|
30
|
+
*
|
|
31
|
+
* @typeParam R - inferred from `requires`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* const tel = telemetry(); // default name 'otel'
|
|
36
|
+
* spec({ endpoints: { ...tel.group({ getUser }) } });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
function telemetry(opts) {
|
|
40
|
+
return (0, _ayepi_core.middleware)(opts?.name ?? "otel", { requires: opts?.requires ?? [] });
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
exports.telemetry = telemetry;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AnyMiddleware, EmptyObject, MiddlewareDef } from "@ayepi/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for the {@link telemetry} **def** — frontend-safe only.
|
|
7
|
+
*
|
|
8
|
+
* @typeParam R - middleware this one depends on (their context is typed in the
|
|
9
|
+
* server-side `extra`).
|
|
10
|
+
*/
|
|
11
|
+
interface TelemetryDefOptions<R extends readonly AnyMiddleware[]> {
|
|
12
|
+
/** Middleware name for docs/debugging, and the default value for the `name` field (default `'otel'`). */
|
|
13
|
+
readonly name?: string;
|
|
14
|
+
/** Middleware this one depends on — their context is available (and typed) in the server-side `extra`. */
|
|
15
|
+
readonly requires?: R;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create a telemetry middleware **def** — a no-context, frontend-safe contract.
|
|
19
|
+
* Bind its behaviour with [`telemetry.server(def, opts)`](./server).
|
|
20
|
+
*
|
|
21
|
+
* @typeParam R - inferred from `requires`.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* const tel = telemetry(); // default name 'otel'
|
|
26
|
+
* spec({ endpoints: { ...tel.group({ getUser }) } });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
declare function telemetry<const R extends readonly AnyMiddleware[] = readonly []>(opts?: TelemetryDefOptions<R>): TelemetryDef<R>;
|
|
30
|
+
/** The def type a {@link telemetry} call produces — what `telemetry.server` binds against. */
|
|
31
|
+
type TelemetryDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<EmptyObject, R>;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { TelemetryDef, TelemetryDefOptions, telemetry };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AnyMiddleware, EmptyObject, MiddlewareDef } from "@ayepi/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for the {@link telemetry} **def** — frontend-safe only.
|
|
7
|
+
*
|
|
8
|
+
* @typeParam R - middleware this one depends on (their context is typed in the
|
|
9
|
+
* server-side `extra`).
|
|
10
|
+
*/
|
|
11
|
+
interface TelemetryDefOptions<R extends readonly AnyMiddleware[]> {
|
|
12
|
+
/** Middleware name for docs/debugging, and the default value for the `name` field (default `'otel'`). */
|
|
13
|
+
readonly name?: string;
|
|
14
|
+
/** Middleware this one depends on — their context is available (and typed) in the server-side `extra`. */
|
|
15
|
+
readonly requires?: R;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create a telemetry middleware **def** — a no-context, frontend-safe contract.
|
|
19
|
+
* Bind its behaviour with [`telemetry.server(def, opts)`](./server).
|
|
20
|
+
*
|
|
21
|
+
* @typeParam R - inferred from `requires`.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* const tel = telemetry(); // default name 'otel'
|
|
26
|
+
* spec({ endpoints: { ...tel.group({ getUser }) } });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
declare function telemetry<const R extends readonly AnyMiddleware[] = readonly []>(opts?: TelemetryDefOptions<R>): TelemetryDef<R>;
|
|
30
|
+
/** The def type a {@link telemetry} call produces — what `telemetry.server` binds against. */
|
|
31
|
+
type TelemetryDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<EmptyObject, R>;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { TelemetryDef, TelemetryDefOptions, telemetry };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { middleware } from "@ayepi/core";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* # @ayepi/otel
|
|
5
|
+
*
|
|
6
|
+
* A telemetry middleware **def** for `@ayepi/core`. This entry is **frontend-safe**:
|
|
7
|
+
* it declares a no-context middleware (it logs and establishes trace context, but
|
|
8
|
+
* contributes nothing to the handler payload) with no `node:crypto` and no
|
|
9
|
+
* `@ayepi/log` runtime import. All behaviour — field selection, the request/response
|
|
10
|
+
* lines, the trace context, the logger — is configured server-side when you bind it
|
|
11
|
+
* with [`telemetry.server`](./server) from `@ayepi/otel/server`.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* // shared.ts (frontend-safe)
|
|
15
|
+
* import { telemetry } from '@ayepi/otel';
|
|
16
|
+
* const tel = telemetry();
|
|
17
|
+
* spec({ endpoints: { ...tel.group({ … }) } });
|
|
18
|
+
*
|
|
19
|
+
* // server.ts
|
|
20
|
+
* import { telemetry } from '@ayepi/otel/server';
|
|
21
|
+
* implement(api).middleware(telemetry.server(tel, { echoRequestId: true }));
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Create a telemetry middleware **def** — a no-context, frontend-safe contract.
|
|
28
|
+
* Bind its behaviour with [`telemetry.server(def, opts)`](./server).
|
|
29
|
+
*
|
|
30
|
+
* @typeParam R - inferred from `requires`.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const tel = telemetry(); // default name 'otel'
|
|
35
|
+
* spec({ endpoints: { ...tel.group({ getUser }) } });
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
function telemetry(opts) {
|
|
39
|
+
return middleware(opts?.name ?? "otel", { requires: opts?.requires ?? [] });
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
export { telemetry };
|
package/dist/server.cjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_index = require("./index.cjs");
|
|
3
|
+
let _ayepi_core = require("@ayepi/core");
|
|
4
|
+
let _ayepi_log = require("@ayepi/log");
|
|
5
|
+
let node_crypto = require("node:crypto");
|
|
6
|
+
//#region src/server.ts
|
|
7
|
+
/** Fallback HTTP status when a thrown error is not an {@link ApiError}. */
|
|
8
|
+
const DEFAULT_ERROR_STATUS = 500;
|
|
9
|
+
/** Default success status for a plain-object / void handler result. */
|
|
10
|
+
const DEFAULT_OK_STATUS = 200;
|
|
11
|
+
/** Header carrying a caller-supplied request id (the default requestId source). */
|
|
12
|
+
const REQUEST_ID_HEADER = "x-request-id";
|
|
13
|
+
/** Default header name used when {@link TelemetryServerOptions.echoRequestId} is `true`. */
|
|
14
|
+
const DEFAULT_ECHO_HEADER = "x-request-id";
|
|
15
|
+
/** Header carrying the upstream client ip (checked before `X-Real-IP`). */
|
|
16
|
+
const FORWARDED_FOR_HEADER = "x-forwarded-for";
|
|
17
|
+
/** Header carrying the client ip when there is no proxy chain. */
|
|
18
|
+
const REAL_IP_HEADER = "x-real-ip";
|
|
19
|
+
/** Header carrying a distributed-trace id (W3C `traceparent` or a bare id). */
|
|
20
|
+
const TRACE_HEADER = "x-trace-id";
|
|
21
|
+
/** Header carrying the request body size in bytes. */
|
|
22
|
+
const CONTENT_LENGTH_HEADER = "content-length";
|
|
23
|
+
/** Default log level for the request/response lines. */
|
|
24
|
+
const DEFAULT_LEVEL = "info";
|
|
25
|
+
/** Default message for the request log line. */
|
|
26
|
+
const DEFAULT_REQUEST_MSG = "request";
|
|
27
|
+
/** Default message for the response log line. */
|
|
28
|
+
const DEFAULT_RESPONSE_MSG = "response";
|
|
29
|
+
const DEFAULT_CONTEXT_FIELDS = {
|
|
30
|
+
requestId: true,
|
|
31
|
+
method: true,
|
|
32
|
+
path: true
|
|
33
|
+
};
|
|
34
|
+
const DEFAULT_REQUEST_FIELDS = {
|
|
35
|
+
method: true,
|
|
36
|
+
path: true,
|
|
37
|
+
requestId: true
|
|
38
|
+
};
|
|
39
|
+
const DEFAULT_RESPONSE_FIELDS = {
|
|
40
|
+
status: true,
|
|
41
|
+
duration: true
|
|
42
|
+
};
|
|
43
|
+
/** Resolve the per-call config for a route, applying its {@link TelemetryServerOptions.overrides} entry (if any). */
|
|
44
|
+
function resolveCall(base, overrides, routeName) {
|
|
45
|
+
const o = overrides?.[routeName];
|
|
46
|
+
const merged = o ? {
|
|
47
|
+
...base,
|
|
48
|
+
...o
|
|
49
|
+
} : base;
|
|
50
|
+
const request = merged.request;
|
|
51
|
+
const response = merged.response;
|
|
52
|
+
return {
|
|
53
|
+
name: merged.name,
|
|
54
|
+
level: merged.level,
|
|
55
|
+
contextFlags: merged.context ?? DEFAULT_CONTEXT_FIELDS,
|
|
56
|
+
requestFlags: request === false ? null : request ?? DEFAULT_REQUEST_FIELDS,
|
|
57
|
+
responseFlags: response === false ? null : response ?? DEFAULT_RESPONSE_FIELDS,
|
|
58
|
+
echoRequestId: merged.echoRequestId ?? false
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the request id. Precedence: caller `override(req)` → the ws **frame id**
|
|
63
|
+
* (`io.ws.id`, the real per-call id) → `X-Request-ID` header → a generated UUID.
|
|
64
|
+
*/
|
|
65
|
+
function resolveRequestId(io, override) {
|
|
66
|
+
if (override) return override(io.req);
|
|
67
|
+
if (io.ws) return io.ws.id;
|
|
68
|
+
const header = io.req.headers.get(REQUEST_ID_HEADER);
|
|
69
|
+
if (header) return header;
|
|
70
|
+
return (0, node_crypto.randomUUID)();
|
|
71
|
+
}
|
|
72
|
+
/** First-hop client ip from `X-Forwarded-For`, else `X-Real-IP`, else `undefined`. */
|
|
73
|
+
function resolveIp(req) {
|
|
74
|
+
const fwd = req.headers.get(FORWARDED_FOR_HEADER);
|
|
75
|
+
if (fwd) return fwd.split(",")[0].trim();
|
|
76
|
+
return req.headers.get(REAL_IP_HEADER) ?? void 0;
|
|
77
|
+
}
|
|
78
|
+
/** Body size in bytes from `Content-Length`, if present and numeric. */
|
|
79
|
+
function resolveSize(req) {
|
|
80
|
+
const raw = req.headers.get(CONTENT_LENGTH_HEADER);
|
|
81
|
+
if (raw === null) return;
|
|
82
|
+
const n = Number(raw);
|
|
83
|
+
return Number.isFinite(n) ? n : void 0;
|
|
84
|
+
}
|
|
85
|
+
/** The method/path for a route — present for endpoints, absent for events. */
|
|
86
|
+
function routeMethodPath(route) {
|
|
87
|
+
if (route.kind === "endpoint") return {
|
|
88
|
+
method: route.method,
|
|
89
|
+
path: route.path
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
method: void 0,
|
|
93
|
+
path: void 0
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Compute every candidate request field for this invocation. */
|
|
97
|
+
function deriveRequest(io, name, override) {
|
|
98
|
+
const { method, path } = routeMethodPath(io.route);
|
|
99
|
+
return {
|
|
100
|
+
name,
|
|
101
|
+
requestId: resolveRequestId(io, override),
|
|
102
|
+
method,
|
|
103
|
+
path,
|
|
104
|
+
transport: io.transport,
|
|
105
|
+
ip: resolveIp(io.req),
|
|
106
|
+
size: resolveSize(io.req),
|
|
107
|
+
traceId: io.req.headers.get(TRACE_HEADER) ?? void 0
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Pick the toggled-on request fields, dropping any that are `undefined`. */
|
|
111
|
+
function pickRequest(derived, flags) {
|
|
112
|
+
const out = {};
|
|
113
|
+
if (flags.name) out.name = derived.name;
|
|
114
|
+
if (flags.requestId) out.requestId = derived.requestId;
|
|
115
|
+
if (flags.method && derived.method !== void 0) out.method = derived.method;
|
|
116
|
+
if (flags.path && derived.path !== void 0) out.path = derived.path;
|
|
117
|
+
if (flags.transport) out.transport = derived.transport;
|
|
118
|
+
if (flags.ip && derived.ip !== void 0) out.ip = derived.ip;
|
|
119
|
+
if (flags.size && derived.size !== void 0) out.size = derived.size;
|
|
120
|
+
if (flags.traceId && derived.traceId !== void 0) out.traceId = derived.traceId;
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/** Inspect a successful handler result for status + type + (rarely) size. */
|
|
124
|
+
function inspectResult(result) {
|
|
125
|
+
if (result instanceof Response) {
|
|
126
|
+
const len = result.headers.get(CONTENT_LENGTH_HEADER);
|
|
127
|
+
const n = len === null ? void 0 : Number(len);
|
|
128
|
+
return {
|
|
129
|
+
status: result.status,
|
|
130
|
+
type: "response",
|
|
131
|
+
size: n !== void 0 && Number.isFinite(n) ? n : void 0
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (isMultiStatus(result)) return {
|
|
135
|
+
status: result.status,
|
|
136
|
+
type: "multi",
|
|
137
|
+
size: void 0
|
|
138
|
+
};
|
|
139
|
+
if (isAsyncIterable(result)) return {
|
|
140
|
+
status: DEFAULT_OK_STATUS,
|
|
141
|
+
type: "stream",
|
|
142
|
+
size: void 0
|
|
143
|
+
};
|
|
144
|
+
if (result === void 0 || result === null) return {
|
|
145
|
+
status: DEFAULT_OK_STATUS,
|
|
146
|
+
type: "empty",
|
|
147
|
+
size: void 0
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
status: DEFAULT_OK_STATUS,
|
|
151
|
+
type: "json",
|
|
152
|
+
size: void 0
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** True for a multi-status handler result of the shape `{ status, data }`. */
|
|
156
|
+
function isMultiStatus(v) {
|
|
157
|
+
return typeof v === "object" && v !== null && typeof v.status === "number" && "data" in v;
|
|
158
|
+
}
|
|
159
|
+
/** True for an async-iterable handler result (a typed/raw stream). */
|
|
160
|
+
function isAsyncIterable(v) {
|
|
161
|
+
return typeof v === "object" && v !== null && typeof v[Symbol.asyncIterator] === "function";
|
|
162
|
+
}
|
|
163
|
+
/** Build the response log fields for the success path. */
|
|
164
|
+
function pickResponseOk(result, duration, flags) {
|
|
165
|
+
const { status, type, size } = inspectResult(result);
|
|
166
|
+
const out = {};
|
|
167
|
+
if (flags.status) out.status = status;
|
|
168
|
+
if (flags.duration) out.duration = duration;
|
|
169
|
+
if (flags.type) out.type = type;
|
|
170
|
+
if (flags.size && size !== void 0) out.size = size;
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/** Build the non-error response fields for the error path (the raw error is logged separately). */
|
|
174
|
+
function pickResponseErr(err, duration, flags) {
|
|
175
|
+
const status = err instanceof _ayepi_core.ApiError ? err.status : DEFAULT_ERROR_STATUS;
|
|
176
|
+
const fields = {};
|
|
177
|
+
if (flags.status) fields.status = status;
|
|
178
|
+
if (flags.duration) fields.duration = duration;
|
|
179
|
+
if (flags.type) fields.type = "error";
|
|
180
|
+
return fields;
|
|
181
|
+
}
|
|
182
|
+
/** Resolve the echo header name, or `null` when echoing is off. */
|
|
183
|
+
function echoHeaderName(echo) {
|
|
184
|
+
if (echo === false) return null;
|
|
185
|
+
if (echo === true) return DEFAULT_ECHO_HEADER;
|
|
186
|
+
return echo;
|
|
187
|
+
}
|
|
188
|
+
/** Bind a {@link telemetry} def to its runtime behaviour. */
|
|
189
|
+
function telemetryServer(def, opts = {}) {
|
|
190
|
+
const name = def.name;
|
|
191
|
+
const log = opts.logger ?? _ayepi_log.logger;
|
|
192
|
+
const wrap = opts.logWith ?? _ayepi_log.logWith;
|
|
193
|
+
const now = opts.now ?? Date.now;
|
|
194
|
+
const overrides = opts.overrides;
|
|
195
|
+
const base = {
|
|
196
|
+
name,
|
|
197
|
+
level: opts.level ?? DEFAULT_LEVEL,
|
|
198
|
+
context: opts.context,
|
|
199
|
+
request: opts.request,
|
|
200
|
+
response: opts.response,
|
|
201
|
+
echoRequestId: opts.echoRequestId
|
|
202
|
+
};
|
|
203
|
+
const safely = (fn) => {
|
|
204
|
+
try {
|
|
205
|
+
fn();
|
|
206
|
+
} catch (err) {
|
|
207
|
+
try {
|
|
208
|
+
opts.onError?.(err);
|
|
209
|
+
} catch {}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const run = (io) => {
|
|
213
|
+
const call = resolveCall(base, overrides, io.route.name);
|
|
214
|
+
const derived = deriveRequest(io, call.name, opts.requestId);
|
|
215
|
+
const echoHeader = echoHeaderName(call.echoRequestId);
|
|
216
|
+
if (echoHeader) io.setHeader(echoHeader, derived.requestId);
|
|
217
|
+
let extra = {};
|
|
218
|
+
safely(() => {
|
|
219
|
+
extra = opts.extra ? opts.extra(io.ctx, io.req) : {};
|
|
220
|
+
});
|
|
221
|
+
let ctxFields = { ...extra };
|
|
222
|
+
safely(() => {
|
|
223
|
+
ctxFields = {
|
|
224
|
+
...extra,
|
|
225
|
+
...pickRequest(derived, call.contextFlags)
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
const body = () => {
|
|
229
|
+
safely(() => {
|
|
230
|
+
if (call.requestFlags) log.log(call.level, DEFAULT_REQUEST_MSG, {
|
|
231
|
+
...extra,
|
|
232
|
+
...pickRequest(derived, call.requestFlags)
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
const start = now();
|
|
236
|
+
return io.next().then((result) => {
|
|
237
|
+
safely(() => {
|
|
238
|
+
if (call.responseFlags) log.log(call.level, DEFAULT_RESPONSE_MSG, pickResponseOk(result, now() - start, call.responseFlags));
|
|
239
|
+
});
|
|
240
|
+
return result;
|
|
241
|
+
}, (err) => {
|
|
242
|
+
safely(() => {
|
|
243
|
+
if (call.responseFlags) {
|
|
244
|
+
const fields = pickResponseErr(err, now() - start, call.responseFlags);
|
|
245
|
+
if (call.responseFlags.error) log.error(DEFAULT_RESPONSE_MSG, fields, err);
|
|
246
|
+
else log.error(DEFAULT_RESPONSE_MSG, fields);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
throw err;
|
|
250
|
+
});
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
return wrap(ctxFields, body);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
safely(() => {
|
|
256
|
+
throw err;
|
|
257
|
+
});
|
|
258
|
+
return body();
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
return {
|
|
262
|
+
def,
|
|
263
|
+
impl: run
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The {@link telemetry} def factory, augmented with a `.server(def, opts)` binder.
|
|
268
|
+
* Import from `@ayepi/otel/server` in your server entry to bind a def created in a
|
|
269
|
+
* frontend-safe spec.
|
|
270
|
+
*/
|
|
271
|
+
const telemetry = Object.assign(require_index.telemetry, { server: telemetryServer });
|
|
272
|
+
//#endregion
|
|
273
|
+
exports.telemetry = telemetry;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { telemetry as telemetry$1 } from "./index.cjs";
|
|
2
|
+
import { AnyMiddleware, BoundMiddleware, StackCtx } from "@ayepi/core";
|
|
3
|
+
import { Level, Logger } from "@ayepi/log";
|
|
4
|
+
|
|
5
|
+
//#region src/server.d.ts
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Request-derived fields, each independently toggleable. A `true` includes the
|
|
9
|
+
* field (when derivable); a `false`/omitted excludes it.
|
|
10
|
+
*/
|
|
11
|
+
interface RequestFieldFlags {
|
|
12
|
+
/** The matched route name (`io.route.name`) — the endpoint/event label. */
|
|
13
|
+
readonly name?: boolean;
|
|
14
|
+
/** The resolved request id (see {@link TelemetryServerOptions.requestId}). */
|
|
15
|
+
readonly requestId?: boolean;
|
|
16
|
+
/** The HTTP method, from `io.route` (absent on an `event` route). */
|
|
17
|
+
readonly method?: boolean;
|
|
18
|
+
/** The route path, from `io.route` (absent on an `event` route). */
|
|
19
|
+
readonly path?: boolean;
|
|
20
|
+
/** The transport this invocation arrived on (`'http'` or `'ws'`), from `io.transport`. */
|
|
21
|
+
readonly transport?: boolean;
|
|
22
|
+
/** The client ip, from `X-Forwarded-For` (first hop) then `X-Real-IP`. */
|
|
23
|
+
readonly ip?: boolean;
|
|
24
|
+
/** The request body size in bytes, from `Content-Length`. */
|
|
25
|
+
readonly size?: boolean;
|
|
26
|
+
/** A distributed-trace id, from `X-Trace-Id` / `traceparent`. */
|
|
27
|
+
readonly traceId?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Response-derived fields, each independently toggleable. `duration` and `error`
|
|
31
|
+
* are reliable; `status` is best-effort and `size` is rarely derivable from a
|
|
32
|
+
* middleware (see {@link ResponseFieldFlags.size}).
|
|
33
|
+
*/
|
|
34
|
+
interface ResponseFieldFlags {
|
|
35
|
+
/** The response status (best-effort: 200 / multi-status `{ status }` / `ApiError.status` / 500). */
|
|
36
|
+
readonly status?: boolean;
|
|
37
|
+
/** The wall-clock duration in milliseconds. */
|
|
38
|
+
readonly duration?: boolean;
|
|
39
|
+
/** The response "type" — `'json' | 'multi' | 'stream' | 'response' | 'empty' | 'error'`. */
|
|
40
|
+
readonly type?: boolean;
|
|
41
|
+
/** A serialized error on the failure path. */
|
|
42
|
+
readonly error?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The response body size in bytes. Only derivable when a middleware
|
|
45
|
+
* short-circuits with a `Response` that carries `Content-Length`; omitted
|
|
46
|
+
* otherwise. Opt-in and honestly limited — see `ayepi-otel.md`.
|
|
47
|
+
*/
|
|
48
|
+
readonly size?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The subset of {@link TelemetryServerOptions} that can be overridden **per route**
|
|
52
|
+
* via {@link TelemetryServerOptions.overrides}. Everything here is per-call
|
|
53
|
+
* behaviour; the plumbing options (`logger`, `logWith`, `now`, `extra`) are not.
|
|
54
|
+
*/
|
|
55
|
+
interface PerCallOptions {
|
|
56
|
+
/** Override the emitted `name` field value for this route. */
|
|
57
|
+
readonly name?: string;
|
|
58
|
+
/** Override the log level for both lines on this route. */
|
|
59
|
+
readonly level?: Level;
|
|
60
|
+
/** Override the `context` (`logWith`) field selection on this route. */
|
|
61
|
+
readonly context?: RequestFieldFlags;
|
|
62
|
+
/** Override the request-line field selection on this route (`false` disables it). */
|
|
63
|
+
readonly request?: RequestFieldFlags | false;
|
|
64
|
+
/** Override the response-line field selection on this route (`false` disables it). */
|
|
65
|
+
readonly response?: ResponseFieldFlags | false;
|
|
66
|
+
/** Override the request-id echo behaviour on this route. */
|
|
67
|
+
readonly echoRequestId?: boolean | string;
|
|
68
|
+
}
|
|
69
|
+
/** The `requires` chain of a middleware def. */
|
|
70
|
+
type ReqOf<M extends AnyMiddleware> = M['__req'];
|
|
71
|
+
/**
|
|
72
|
+
* Server-side options for binding a {@link telemetry} def. Every field has a
|
|
73
|
+
* sensible default; the three field sets ({@link context}, {@link request},
|
|
74
|
+
* {@link response}) are configured independently.
|
|
75
|
+
*
|
|
76
|
+
* @typeParam M - the telemetry def being bound (its `requires` type the `extra`
|
|
77
|
+
* callback reads).
|
|
78
|
+
*/
|
|
79
|
+
interface TelemetryServerOptions<M extends AnyMiddleware> {
|
|
80
|
+
/** Log level for the request/response lines (default `'info'`). */
|
|
81
|
+
readonly level?: Level;
|
|
82
|
+
/**
|
|
83
|
+
* Which request fields go into the `logWith` trace context inherited by every
|
|
84
|
+
* inner log. Default: `{ requestId: true, method: true, path: true }`.
|
|
85
|
+
*/
|
|
86
|
+
readonly context?: RequestFieldFlags;
|
|
87
|
+
/**
|
|
88
|
+
* The request log line. `false` disables it; an object selects fields
|
|
89
|
+
* (default: `{ method: true, path: true, requestId: true }`).
|
|
90
|
+
*/
|
|
91
|
+
readonly request?: RequestFieldFlags | false;
|
|
92
|
+
/**
|
|
93
|
+
* The response log line. `false` disables it; an object selects fields
|
|
94
|
+
* (default: `{ status: true, duration: true }`).
|
|
95
|
+
*/
|
|
96
|
+
readonly response?: ResponseFieldFlags | false;
|
|
97
|
+
/**
|
|
98
|
+
* Per-route overrides, keyed by `io.route.name` (the endpoint/event key). The
|
|
99
|
+
* matching entry is shallow-merged over the base per-call config at call time.
|
|
100
|
+
*/
|
|
101
|
+
readonly overrides?: Record<string, PerCallOptions>;
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the request id from the upgrade/HTTP request. Highest precedence;
|
|
104
|
+
* default precedence when omitted is `io.ws?.id` (the ws frame id) →
|
|
105
|
+
* `X-Request-ID` header → a generated UUID.
|
|
106
|
+
*/
|
|
107
|
+
readonly requestId?: (req: Request) => string;
|
|
108
|
+
/**
|
|
109
|
+
* Echo the resolved request id back on the response via `io.setHeader`. `false`
|
|
110
|
+
* (default) does nothing; `true` uses the `x-request-id` header; a string uses
|
|
111
|
+
* that header name.
|
|
112
|
+
*/
|
|
113
|
+
readonly echoRequestId?: boolean | string;
|
|
114
|
+
/** Extra static/dynamic fields merged into every derived field bag (lowest precedence). */
|
|
115
|
+
readonly extra?: (ctx: StackCtx<ReqOf<M>>, req: Request) => Record<string, unknown>;
|
|
116
|
+
/** Logger used to emit the request/response lines (default the `@ayepi/log` default logger). */
|
|
117
|
+
readonly logger?: Logger;
|
|
118
|
+
/** `logWith` used to push the trace context (default the `@ayepi/log` default `logWith`). */
|
|
119
|
+
readonly logWith?: <T>(add: object, inner: () => T) => T;
|
|
120
|
+
/**
|
|
121
|
+
* Observe an error thrown by the telemetry itself — your `extra` callback, a log call, or
|
|
122
|
+
* the context push. Telemetry is **fail-open**: such an error never breaks the request (the
|
|
123
|
+
* handler runs and its result/error are returned untouched); this hook just lets you notice.
|
|
124
|
+
* Off by default. It must not throw; if it does, the throw is ignored.
|
|
125
|
+
*/
|
|
126
|
+
readonly onError?: (err: unknown) => void;
|
|
127
|
+
/** Clock for durations, in ms (default `Date.now`). Inject for deterministic tests. */
|
|
128
|
+
readonly now?: () => number;
|
|
129
|
+
}
|
|
130
|
+
/** Bind a {@link telemetry} def to its runtime behaviour. */
|
|
131
|
+
declare function telemetryServer<M extends AnyMiddleware>(def: M, opts?: TelemetryServerOptions<M>): BoundMiddleware<M>;
|
|
132
|
+
/**
|
|
133
|
+
* The {@link telemetry} def factory, augmented with a `.server(def, opts)` binder.
|
|
134
|
+
* Import from `@ayepi/otel/server` in your server entry to bind a def created in a
|
|
135
|
+
* frontend-safe spec.
|
|
136
|
+
*/
|
|
137
|
+
declare const telemetry: typeof telemetry$1 & {
|
|
138
|
+
server: typeof telemetryServer;
|
|
139
|
+
};
|
|
140
|
+
//#endregion
|
|
141
|
+
export { PerCallOptions, RequestFieldFlags, ResponseFieldFlags, TelemetryServerOptions, telemetry };
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { telemetry as telemetry$1 } from "./index.js";
|
|
2
|
+
import { AnyMiddleware, BoundMiddleware, StackCtx } from "@ayepi/core";
|
|
3
|
+
import { Level, Logger } from "@ayepi/log";
|
|
4
|
+
|
|
5
|
+
//#region src/server.d.ts
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Request-derived fields, each independently toggleable. A `true` includes the
|
|
9
|
+
* field (when derivable); a `false`/omitted excludes it.
|
|
10
|
+
*/
|
|
11
|
+
interface RequestFieldFlags {
|
|
12
|
+
/** The matched route name (`io.route.name`) — the endpoint/event label. */
|
|
13
|
+
readonly name?: boolean;
|
|
14
|
+
/** The resolved request id (see {@link TelemetryServerOptions.requestId}). */
|
|
15
|
+
readonly requestId?: boolean;
|
|
16
|
+
/** The HTTP method, from `io.route` (absent on an `event` route). */
|
|
17
|
+
readonly method?: boolean;
|
|
18
|
+
/** The route path, from `io.route` (absent on an `event` route). */
|
|
19
|
+
readonly path?: boolean;
|
|
20
|
+
/** The transport this invocation arrived on (`'http'` or `'ws'`), from `io.transport`. */
|
|
21
|
+
readonly transport?: boolean;
|
|
22
|
+
/** The client ip, from `X-Forwarded-For` (first hop) then `X-Real-IP`. */
|
|
23
|
+
readonly ip?: boolean;
|
|
24
|
+
/** The request body size in bytes, from `Content-Length`. */
|
|
25
|
+
readonly size?: boolean;
|
|
26
|
+
/** A distributed-trace id, from `X-Trace-Id` / `traceparent`. */
|
|
27
|
+
readonly traceId?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Response-derived fields, each independently toggleable. `duration` and `error`
|
|
31
|
+
* are reliable; `status` is best-effort and `size` is rarely derivable from a
|
|
32
|
+
* middleware (see {@link ResponseFieldFlags.size}).
|
|
33
|
+
*/
|
|
34
|
+
interface ResponseFieldFlags {
|
|
35
|
+
/** The response status (best-effort: 200 / multi-status `{ status }` / `ApiError.status` / 500). */
|
|
36
|
+
readonly status?: boolean;
|
|
37
|
+
/** The wall-clock duration in milliseconds. */
|
|
38
|
+
readonly duration?: boolean;
|
|
39
|
+
/** The response "type" — `'json' | 'multi' | 'stream' | 'response' | 'empty' | 'error'`. */
|
|
40
|
+
readonly type?: boolean;
|
|
41
|
+
/** A serialized error on the failure path. */
|
|
42
|
+
readonly error?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The response body size in bytes. Only derivable when a middleware
|
|
45
|
+
* short-circuits with a `Response` that carries `Content-Length`; omitted
|
|
46
|
+
* otherwise. Opt-in and honestly limited — see `ayepi-otel.md`.
|
|
47
|
+
*/
|
|
48
|
+
readonly size?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The subset of {@link TelemetryServerOptions} that can be overridden **per route**
|
|
52
|
+
* via {@link TelemetryServerOptions.overrides}. Everything here is per-call
|
|
53
|
+
* behaviour; the plumbing options (`logger`, `logWith`, `now`, `extra`) are not.
|
|
54
|
+
*/
|
|
55
|
+
interface PerCallOptions {
|
|
56
|
+
/** Override the emitted `name` field value for this route. */
|
|
57
|
+
readonly name?: string;
|
|
58
|
+
/** Override the log level for both lines on this route. */
|
|
59
|
+
readonly level?: Level;
|
|
60
|
+
/** Override the `context` (`logWith`) field selection on this route. */
|
|
61
|
+
readonly context?: RequestFieldFlags;
|
|
62
|
+
/** Override the request-line field selection on this route (`false` disables it). */
|
|
63
|
+
readonly request?: RequestFieldFlags | false;
|
|
64
|
+
/** Override the response-line field selection on this route (`false` disables it). */
|
|
65
|
+
readonly response?: ResponseFieldFlags | false;
|
|
66
|
+
/** Override the request-id echo behaviour on this route. */
|
|
67
|
+
readonly echoRequestId?: boolean | string;
|
|
68
|
+
}
|
|
69
|
+
/** The `requires` chain of a middleware def. */
|
|
70
|
+
type ReqOf<M extends AnyMiddleware> = M['__req'];
|
|
71
|
+
/**
|
|
72
|
+
* Server-side options for binding a {@link telemetry} def. Every field has a
|
|
73
|
+
* sensible default; the three field sets ({@link context}, {@link request},
|
|
74
|
+
* {@link response}) are configured independently.
|
|
75
|
+
*
|
|
76
|
+
* @typeParam M - the telemetry def being bound (its `requires` type the `extra`
|
|
77
|
+
* callback reads).
|
|
78
|
+
*/
|
|
79
|
+
interface TelemetryServerOptions<M extends AnyMiddleware> {
|
|
80
|
+
/** Log level for the request/response lines (default `'info'`). */
|
|
81
|
+
readonly level?: Level;
|
|
82
|
+
/**
|
|
83
|
+
* Which request fields go into the `logWith` trace context inherited by every
|
|
84
|
+
* inner log. Default: `{ requestId: true, method: true, path: true }`.
|
|
85
|
+
*/
|
|
86
|
+
readonly context?: RequestFieldFlags;
|
|
87
|
+
/**
|
|
88
|
+
* The request log line. `false` disables it; an object selects fields
|
|
89
|
+
* (default: `{ method: true, path: true, requestId: true }`).
|
|
90
|
+
*/
|
|
91
|
+
readonly request?: RequestFieldFlags | false;
|
|
92
|
+
/**
|
|
93
|
+
* The response log line. `false` disables it; an object selects fields
|
|
94
|
+
* (default: `{ status: true, duration: true }`).
|
|
95
|
+
*/
|
|
96
|
+
readonly response?: ResponseFieldFlags | false;
|
|
97
|
+
/**
|
|
98
|
+
* Per-route overrides, keyed by `io.route.name` (the endpoint/event key). The
|
|
99
|
+
* matching entry is shallow-merged over the base per-call config at call time.
|
|
100
|
+
*/
|
|
101
|
+
readonly overrides?: Record<string, PerCallOptions>;
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the request id from the upgrade/HTTP request. Highest precedence;
|
|
104
|
+
* default precedence when omitted is `io.ws?.id` (the ws frame id) →
|
|
105
|
+
* `X-Request-ID` header → a generated UUID.
|
|
106
|
+
*/
|
|
107
|
+
readonly requestId?: (req: Request) => string;
|
|
108
|
+
/**
|
|
109
|
+
* Echo the resolved request id back on the response via `io.setHeader`. `false`
|
|
110
|
+
* (default) does nothing; `true` uses the `x-request-id` header; a string uses
|
|
111
|
+
* that header name.
|
|
112
|
+
*/
|
|
113
|
+
readonly echoRequestId?: boolean | string;
|
|
114
|
+
/** Extra static/dynamic fields merged into every derived field bag (lowest precedence). */
|
|
115
|
+
readonly extra?: (ctx: StackCtx<ReqOf<M>>, req: Request) => Record<string, unknown>;
|
|
116
|
+
/** Logger used to emit the request/response lines (default the `@ayepi/log` default logger). */
|
|
117
|
+
readonly logger?: Logger;
|
|
118
|
+
/** `logWith` used to push the trace context (default the `@ayepi/log` default `logWith`). */
|
|
119
|
+
readonly logWith?: <T>(add: object, inner: () => T) => T;
|
|
120
|
+
/**
|
|
121
|
+
* Observe an error thrown by the telemetry itself — your `extra` callback, a log call, or
|
|
122
|
+
* the context push. Telemetry is **fail-open**: such an error never breaks the request (the
|
|
123
|
+
* handler runs and its result/error are returned untouched); this hook just lets you notice.
|
|
124
|
+
* Off by default. It must not throw; if it does, the throw is ignored.
|
|
125
|
+
*/
|
|
126
|
+
readonly onError?: (err: unknown) => void;
|
|
127
|
+
/** Clock for durations, in ms (default `Date.now`). Inject for deterministic tests. */
|
|
128
|
+
readonly now?: () => number;
|
|
129
|
+
}
|
|
130
|
+
/** Bind a {@link telemetry} def to its runtime behaviour. */
|
|
131
|
+
declare function telemetryServer<M extends AnyMiddleware>(def: M, opts?: TelemetryServerOptions<M>): BoundMiddleware<M>;
|
|
132
|
+
/**
|
|
133
|
+
* The {@link telemetry} def factory, augmented with a `.server(def, opts)` binder.
|
|
134
|
+
* Import from `@ayepi/otel/server` in your server entry to bind a def created in a
|
|
135
|
+
* frontend-safe spec.
|
|
136
|
+
*/
|
|
137
|
+
declare const telemetry: typeof telemetry$1 & {
|
|
138
|
+
server: typeof telemetryServer;
|
|
139
|
+
};
|
|
140
|
+
//#endregion
|
|
141
|
+
export { PerCallOptions, RequestFieldFlags, ResponseFieldFlags, TelemetryServerOptions, telemetry };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { telemetry as telemetry$1 } from "./index.js";
|
|
2
|
+
import { ApiError } from "@ayepi/core";
|
|
3
|
+
import { logWith, logger } from "@ayepi/log";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
//#region src/server.ts
|
|
6
|
+
/** Fallback HTTP status when a thrown error is not an {@link ApiError}. */
|
|
7
|
+
const DEFAULT_ERROR_STATUS = 500;
|
|
8
|
+
/** Default success status for a plain-object / void handler result. */
|
|
9
|
+
const DEFAULT_OK_STATUS = 200;
|
|
10
|
+
/** Header carrying a caller-supplied request id (the default requestId source). */
|
|
11
|
+
const REQUEST_ID_HEADER = "x-request-id";
|
|
12
|
+
/** Default header name used when {@link TelemetryServerOptions.echoRequestId} is `true`. */
|
|
13
|
+
const DEFAULT_ECHO_HEADER = "x-request-id";
|
|
14
|
+
/** Header carrying the upstream client ip (checked before `X-Real-IP`). */
|
|
15
|
+
const FORWARDED_FOR_HEADER = "x-forwarded-for";
|
|
16
|
+
/** Header carrying the client ip when there is no proxy chain. */
|
|
17
|
+
const REAL_IP_HEADER = "x-real-ip";
|
|
18
|
+
/** Header carrying a distributed-trace id (W3C `traceparent` or a bare id). */
|
|
19
|
+
const TRACE_HEADER = "x-trace-id";
|
|
20
|
+
/** Header carrying the request body size in bytes. */
|
|
21
|
+
const CONTENT_LENGTH_HEADER = "content-length";
|
|
22
|
+
/** Default log level for the request/response lines. */
|
|
23
|
+
const DEFAULT_LEVEL = "info";
|
|
24
|
+
/** Default message for the request log line. */
|
|
25
|
+
const DEFAULT_REQUEST_MSG = "request";
|
|
26
|
+
/** Default message for the response log line. */
|
|
27
|
+
const DEFAULT_RESPONSE_MSG = "response";
|
|
28
|
+
const DEFAULT_CONTEXT_FIELDS = {
|
|
29
|
+
requestId: true,
|
|
30
|
+
method: true,
|
|
31
|
+
path: true
|
|
32
|
+
};
|
|
33
|
+
const DEFAULT_REQUEST_FIELDS = {
|
|
34
|
+
method: true,
|
|
35
|
+
path: true,
|
|
36
|
+
requestId: true
|
|
37
|
+
};
|
|
38
|
+
const DEFAULT_RESPONSE_FIELDS = {
|
|
39
|
+
status: true,
|
|
40
|
+
duration: true
|
|
41
|
+
};
|
|
42
|
+
/** Resolve the per-call config for a route, applying its {@link TelemetryServerOptions.overrides} entry (if any). */
|
|
43
|
+
function resolveCall(base, overrides, routeName) {
|
|
44
|
+
const o = overrides?.[routeName];
|
|
45
|
+
const merged = o ? {
|
|
46
|
+
...base,
|
|
47
|
+
...o
|
|
48
|
+
} : base;
|
|
49
|
+
const request = merged.request;
|
|
50
|
+
const response = merged.response;
|
|
51
|
+
return {
|
|
52
|
+
name: merged.name,
|
|
53
|
+
level: merged.level,
|
|
54
|
+
contextFlags: merged.context ?? DEFAULT_CONTEXT_FIELDS,
|
|
55
|
+
requestFlags: request === false ? null : request ?? DEFAULT_REQUEST_FIELDS,
|
|
56
|
+
responseFlags: response === false ? null : response ?? DEFAULT_RESPONSE_FIELDS,
|
|
57
|
+
echoRequestId: merged.echoRequestId ?? false
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the request id. Precedence: caller `override(req)` → the ws **frame id**
|
|
62
|
+
* (`io.ws.id`, the real per-call id) → `X-Request-ID` header → a generated UUID.
|
|
63
|
+
*/
|
|
64
|
+
function resolveRequestId(io, override) {
|
|
65
|
+
if (override) return override(io.req);
|
|
66
|
+
if (io.ws) return io.ws.id;
|
|
67
|
+
const header = io.req.headers.get(REQUEST_ID_HEADER);
|
|
68
|
+
if (header) return header;
|
|
69
|
+
return randomUUID();
|
|
70
|
+
}
|
|
71
|
+
/** First-hop client ip from `X-Forwarded-For`, else `X-Real-IP`, else `undefined`. */
|
|
72
|
+
function resolveIp(req) {
|
|
73
|
+
const fwd = req.headers.get(FORWARDED_FOR_HEADER);
|
|
74
|
+
if (fwd) return fwd.split(",")[0].trim();
|
|
75
|
+
return req.headers.get(REAL_IP_HEADER) ?? void 0;
|
|
76
|
+
}
|
|
77
|
+
/** Body size in bytes from `Content-Length`, if present and numeric. */
|
|
78
|
+
function resolveSize(req) {
|
|
79
|
+
const raw = req.headers.get(CONTENT_LENGTH_HEADER);
|
|
80
|
+
if (raw === null) return;
|
|
81
|
+
const n = Number(raw);
|
|
82
|
+
return Number.isFinite(n) ? n : void 0;
|
|
83
|
+
}
|
|
84
|
+
/** The method/path for a route — present for endpoints, absent for events. */
|
|
85
|
+
function routeMethodPath(route) {
|
|
86
|
+
if (route.kind === "endpoint") return {
|
|
87
|
+
method: route.method,
|
|
88
|
+
path: route.path
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
method: void 0,
|
|
92
|
+
path: void 0
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Compute every candidate request field for this invocation. */
|
|
96
|
+
function deriveRequest(io, name, override) {
|
|
97
|
+
const { method, path } = routeMethodPath(io.route);
|
|
98
|
+
return {
|
|
99
|
+
name,
|
|
100
|
+
requestId: resolveRequestId(io, override),
|
|
101
|
+
method,
|
|
102
|
+
path,
|
|
103
|
+
transport: io.transport,
|
|
104
|
+
ip: resolveIp(io.req),
|
|
105
|
+
size: resolveSize(io.req),
|
|
106
|
+
traceId: io.req.headers.get(TRACE_HEADER) ?? void 0
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Pick the toggled-on request fields, dropping any that are `undefined`. */
|
|
110
|
+
function pickRequest(derived, flags) {
|
|
111
|
+
const out = {};
|
|
112
|
+
if (flags.name) out.name = derived.name;
|
|
113
|
+
if (flags.requestId) out.requestId = derived.requestId;
|
|
114
|
+
if (flags.method && derived.method !== void 0) out.method = derived.method;
|
|
115
|
+
if (flags.path && derived.path !== void 0) out.path = derived.path;
|
|
116
|
+
if (flags.transport) out.transport = derived.transport;
|
|
117
|
+
if (flags.ip && derived.ip !== void 0) out.ip = derived.ip;
|
|
118
|
+
if (flags.size && derived.size !== void 0) out.size = derived.size;
|
|
119
|
+
if (flags.traceId && derived.traceId !== void 0) out.traceId = derived.traceId;
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/** Inspect a successful handler result for status + type + (rarely) size. */
|
|
123
|
+
function inspectResult(result) {
|
|
124
|
+
if (result instanceof Response) {
|
|
125
|
+
const len = result.headers.get(CONTENT_LENGTH_HEADER);
|
|
126
|
+
const n = len === null ? void 0 : Number(len);
|
|
127
|
+
return {
|
|
128
|
+
status: result.status,
|
|
129
|
+
type: "response",
|
|
130
|
+
size: n !== void 0 && Number.isFinite(n) ? n : void 0
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (isMultiStatus(result)) return {
|
|
134
|
+
status: result.status,
|
|
135
|
+
type: "multi",
|
|
136
|
+
size: void 0
|
|
137
|
+
};
|
|
138
|
+
if (isAsyncIterable(result)) return {
|
|
139
|
+
status: DEFAULT_OK_STATUS,
|
|
140
|
+
type: "stream",
|
|
141
|
+
size: void 0
|
|
142
|
+
};
|
|
143
|
+
if (result === void 0 || result === null) return {
|
|
144
|
+
status: DEFAULT_OK_STATUS,
|
|
145
|
+
type: "empty",
|
|
146
|
+
size: void 0
|
|
147
|
+
};
|
|
148
|
+
return {
|
|
149
|
+
status: DEFAULT_OK_STATUS,
|
|
150
|
+
type: "json",
|
|
151
|
+
size: void 0
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** True for a multi-status handler result of the shape `{ status, data }`. */
|
|
155
|
+
function isMultiStatus(v) {
|
|
156
|
+
return typeof v === "object" && v !== null && typeof v.status === "number" && "data" in v;
|
|
157
|
+
}
|
|
158
|
+
/** True for an async-iterable handler result (a typed/raw stream). */
|
|
159
|
+
function isAsyncIterable(v) {
|
|
160
|
+
return typeof v === "object" && v !== null && typeof v[Symbol.asyncIterator] === "function";
|
|
161
|
+
}
|
|
162
|
+
/** Build the response log fields for the success path. */
|
|
163
|
+
function pickResponseOk(result, duration, flags) {
|
|
164
|
+
const { status, type, size } = inspectResult(result);
|
|
165
|
+
const out = {};
|
|
166
|
+
if (flags.status) out.status = status;
|
|
167
|
+
if (flags.duration) out.duration = duration;
|
|
168
|
+
if (flags.type) out.type = type;
|
|
169
|
+
if (flags.size && size !== void 0) out.size = size;
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/** Build the non-error response fields for the error path (the raw error is logged separately). */
|
|
173
|
+
function pickResponseErr(err, duration, flags) {
|
|
174
|
+
const status = err instanceof ApiError ? err.status : DEFAULT_ERROR_STATUS;
|
|
175
|
+
const fields = {};
|
|
176
|
+
if (flags.status) fields.status = status;
|
|
177
|
+
if (flags.duration) fields.duration = duration;
|
|
178
|
+
if (flags.type) fields.type = "error";
|
|
179
|
+
return fields;
|
|
180
|
+
}
|
|
181
|
+
/** Resolve the echo header name, or `null` when echoing is off. */
|
|
182
|
+
function echoHeaderName(echo) {
|
|
183
|
+
if (echo === false) return null;
|
|
184
|
+
if (echo === true) return DEFAULT_ECHO_HEADER;
|
|
185
|
+
return echo;
|
|
186
|
+
}
|
|
187
|
+
/** Bind a {@link telemetry} def to its runtime behaviour. */
|
|
188
|
+
function telemetryServer(def, opts = {}) {
|
|
189
|
+
const name = def.name;
|
|
190
|
+
const log = opts.logger ?? logger;
|
|
191
|
+
const wrap = opts.logWith ?? logWith;
|
|
192
|
+
const now = opts.now ?? Date.now;
|
|
193
|
+
const overrides = opts.overrides;
|
|
194
|
+
const base = {
|
|
195
|
+
name,
|
|
196
|
+
level: opts.level ?? DEFAULT_LEVEL,
|
|
197
|
+
context: opts.context,
|
|
198
|
+
request: opts.request,
|
|
199
|
+
response: opts.response,
|
|
200
|
+
echoRequestId: opts.echoRequestId
|
|
201
|
+
};
|
|
202
|
+
const safely = (fn) => {
|
|
203
|
+
try {
|
|
204
|
+
fn();
|
|
205
|
+
} catch (err) {
|
|
206
|
+
try {
|
|
207
|
+
opts.onError?.(err);
|
|
208
|
+
} catch {}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const run = (io) => {
|
|
212
|
+
const call = resolveCall(base, overrides, io.route.name);
|
|
213
|
+
const derived = deriveRequest(io, call.name, opts.requestId);
|
|
214
|
+
const echoHeader = echoHeaderName(call.echoRequestId);
|
|
215
|
+
if (echoHeader) io.setHeader(echoHeader, derived.requestId);
|
|
216
|
+
let extra = {};
|
|
217
|
+
safely(() => {
|
|
218
|
+
extra = opts.extra ? opts.extra(io.ctx, io.req) : {};
|
|
219
|
+
});
|
|
220
|
+
let ctxFields = { ...extra };
|
|
221
|
+
safely(() => {
|
|
222
|
+
ctxFields = {
|
|
223
|
+
...extra,
|
|
224
|
+
...pickRequest(derived, call.contextFlags)
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
const body = () => {
|
|
228
|
+
safely(() => {
|
|
229
|
+
if (call.requestFlags) log.log(call.level, DEFAULT_REQUEST_MSG, {
|
|
230
|
+
...extra,
|
|
231
|
+
...pickRequest(derived, call.requestFlags)
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
const start = now();
|
|
235
|
+
return io.next().then((result) => {
|
|
236
|
+
safely(() => {
|
|
237
|
+
if (call.responseFlags) log.log(call.level, DEFAULT_RESPONSE_MSG, pickResponseOk(result, now() - start, call.responseFlags));
|
|
238
|
+
});
|
|
239
|
+
return result;
|
|
240
|
+
}, (err) => {
|
|
241
|
+
safely(() => {
|
|
242
|
+
if (call.responseFlags) {
|
|
243
|
+
const fields = pickResponseErr(err, now() - start, call.responseFlags);
|
|
244
|
+
if (call.responseFlags.error) log.error(DEFAULT_RESPONSE_MSG, fields, err);
|
|
245
|
+
else log.error(DEFAULT_RESPONSE_MSG, fields);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
throw err;
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
return wrap(ctxFields, body);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
safely(() => {
|
|
255
|
+
throw err;
|
|
256
|
+
});
|
|
257
|
+
return body();
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
def,
|
|
262
|
+
impl: run
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The {@link telemetry} def factory, augmented with a `.server(def, opts)` binder.
|
|
267
|
+
* Import from `@ayepi/otel/server` in your server entry to bind a def created in a
|
|
268
|
+
* frontend-safe spec.
|
|
269
|
+
*/
|
|
270
|
+
const telemetry = Object.assign(telemetry$1, { server: telemetryServer });
|
|
271
|
+
//#endregion
|
|
272
|
+
export { telemetry };
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ayepi/otel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Observability middleware for @ayepi/core — request/response logging, trace-context enrichment, configurable fields, and per-endpoint overrides",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ClickerMonkey/ayepi.git",
|
|
12
|
+
"directory": "packages/otel"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/ClickerMonkey/ayepi/tree/main/packages/otel#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/ClickerMonkey/ayepi/issues"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"require": {
|
|
30
|
+
"types": "./dist/index.d.cts",
|
|
31
|
+
"default": "./dist/index.cjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"./server": {
|
|
35
|
+
"import": {
|
|
36
|
+
"types": "./dist/server.d.ts",
|
|
37
|
+
"default": "./dist/server.js"
|
|
38
|
+
},
|
|
39
|
+
"require": {
|
|
40
|
+
"types": "./dist/server.d.cts",
|
|
41
|
+
"default": "./dist/server.cjs"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"./package.json": "./package.json"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@ayepi/core": "^0.1.0",
|
|
51
|
+
"@ayepi/log": "^0.1.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
55
|
+
"publint": "^0.3.0",
|
|
56
|
+
"tsdown": "^0.12.0",
|
|
57
|
+
"vitest": "^2.1.8",
|
|
58
|
+
"zod": "^4.4.3",
|
|
59
|
+
"@ayepi/core": "0.1.0",
|
|
60
|
+
"@ayepi/log": "0.1.0"
|
|
61
|
+
},
|
|
62
|
+
"keywords": [
|
|
63
|
+
"ayepi",
|
|
64
|
+
"observability",
|
|
65
|
+
"logging",
|
|
66
|
+
"telemetry",
|
|
67
|
+
"tracing",
|
|
68
|
+
"middleware"
|
|
69
|
+
],
|
|
70
|
+
"scripts": {
|
|
71
|
+
"build": "tsdown",
|
|
72
|
+
"typecheck": "tsc --noEmit",
|
|
73
|
+
"test": "vitest run --coverage",
|
|
74
|
+
"publint": "publint"
|
|
75
|
+
}
|
|
76
|
+
}
|