@ayepi/otel 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/ayepi-otel.md +427 -0
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -105,7 +105,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
|
|
|
105
105
|
|
|
106
106
|
- [`ayepi-otel.md`](./ayepi-otel.md)
|
|
107
107
|
|
|
108
|
-
They
|
|
108
|
+
They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/otel).
|
|
109
109
|
|
|
110
110
|
## License
|
|
111
111
|
|
package/ayepi-otel.md
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ayepi-otel.md — reference for `@ayepi/otel`, written for coding agents.
|
|
3
|
+
|
|
4
|
+
Copy this file into any project that depends on `@ayepi/otel` (e.g. into your repo's
|
|
5
|
+
`docs/` or `.claude/` directory) and reference it from your agents and slash commands.
|
|
6
|
+
It documents the public API, the patterns the package expects, and how it works under the
|
|
7
|
+
hood, with copy-pasteable examples. Keep it in sync with the installed package version.
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# `@ayepi/otel`
|
|
11
|
+
|
|
12
|
+
Telemetry middleware for [`@ayepi/core`](https://www.npmjs.com/package/@ayepi/core). It
|
|
13
|
+
does two independent, optional things per request:
|
|
14
|
+
|
|
15
|
+
1. **Enriches the [`@ayepi/log`](https://www.npmjs.com/package/@ayepi/log) trace
|
|
16
|
+
context** — the whole `io.next()` (the rest of the middleware chain **and** the
|
|
17
|
+
handler) runs inside a `logWith({...})`, so every inner `logger.*` call inherits the
|
|
18
|
+
chosen fields (and any thrown error is tagged with them). This is the same mechanism as
|
|
19
|
+
`@ayepi/log`'s `logMiddleware`; `telemetry` is its observability-focused sibling.
|
|
20
|
+
2. **Emits a request and/or response log line** with a configurable field selection and
|
|
21
|
+
level. `duration` and `error` are reliable; `status` is best-effort; response `size` is
|
|
22
|
+
rarely derivable (see [Honest limits](#honest-limits-from-a-middleware-vantage)).
|
|
23
|
+
|
|
24
|
+
It is **transport-neutral**. `name`/`method`/`path` come from the matched route (`io.route`),
|
|
25
|
+
not the URL, so they are correct over both HTTP and WebSocket; and over ws the per-call
|
|
26
|
+
request id is the **frame id** (`io.ws.id`) — the real per-call correlation id, not the
|
|
27
|
+
shared upgrade request.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pnpm add @ayepi/otel @ayepi/core @ayepi/log
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
It ships as a **def / impl split**:
|
|
34
|
+
|
|
35
|
+
- `@ayepi/otel` (frontend-safe — **no** `node:crypto`, **no** `@ayepi/log`) exports
|
|
36
|
+
`telemetry(opts?)`, a middleware **def factory**. The def declares the contract that goes
|
|
37
|
+
in the spec; it contributes nothing to the payload and carries no behaviour. A spec that
|
|
38
|
+
imports only this entry is safe to bundle for the frontend.
|
|
39
|
+
- `@ayepi/otel/server` (the only entry pulling in `node:crypto` + `@ayepi/log`) augments
|
|
40
|
+
`telemetry` with **`.server(def, opts)`**, which binds the implementation. **All**
|
|
41
|
+
behaviour options live here. Bind the pair with `implement(api).middleware(...)`.
|
|
42
|
+
|
|
43
|
+
Cross-reference: middleware composition (def vs impl, `requires`, `StackCtx`, `use(...)` /
|
|
44
|
+
`.group()` / `.endpoint()` / `.with()`), the `implement(api)` builder, and short-circuit semantics are
|
|
45
|
+
documented in **`ayepi-core-middleware.md`**; the trace-context model (`logWith`, context
|
|
46
|
+
inheritance, error tagging) and the `Logger` interface are in **`ayepi-log.md`** — read both
|
|
47
|
+
alongside this file.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## At a glance
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// shared.ts — frontend-safe
|
|
55
|
+
import { telemetry } from '@ayepi/otel'
|
|
56
|
+
import { spec } from '@ayepi/core'
|
|
57
|
+
|
|
58
|
+
const tel = telemetry() // a def with sensible-default behaviour once bound on the server
|
|
59
|
+
|
|
60
|
+
export const api = spec({ endpoints: { ...tel.group({ getUser, listUsers }) } })
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// server.ts — binds behaviour, imports node deps
|
|
65
|
+
import { telemetry } from '@ayepi/otel/server'
|
|
66
|
+
import { implement } from '@ayepi/core'
|
|
67
|
+
import { api, tel } from './shared'
|
|
68
|
+
|
|
69
|
+
const app = implement(api)
|
|
70
|
+
.middleware(telemetry.server(tel)) // defaults
|
|
71
|
+
.server()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
With defaults, for every request:
|
|
75
|
+
|
|
76
|
+
- `logWith` pushes `{ requestId, method, path }` — inherited by every inner `logger.*`.
|
|
77
|
+
- a `request` line is logged at `info` with `{ method, path, requestId }`.
|
|
78
|
+
- a `response` line is logged at `info` with `{ status, duration }` on success, or at
|
|
79
|
+
`error` with `{ status }` + the serialized error on failure (then the error is
|
|
80
|
+
**rethrown** — telemetry never swallows).
|
|
81
|
+
|
|
82
|
+
The `telemetry()` **def provides nothing** to the handler context; it only logs and
|
|
83
|
+
establishes trace context. Compose the def like any middleware: `.endpoint()`, `.group()`,
|
|
84
|
+
`use(...)` / `.with(...)`, `.path(...)`. The behaviour above is configured entirely on the
|
|
85
|
+
matching `telemetry.server(def, opts)` impl.
|
|
86
|
+
|
|
87
|
+
> **Every middleware in a chain must be bound.** `implement(api)` is a chainable builder;
|
|
88
|
+
> bind a def → impl pair with `.middleware(def, impl)` or `.middleware(boundPair)` (where
|
|
89
|
+
> `telemetry.server(def, opts)` returns the bound pair). If any middleware reachable from the
|
|
90
|
+
> spec is left unbound, `.server()` throws.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## The three field sets
|
|
95
|
+
|
|
96
|
+
There are three independently-configured selections, all set on **`.server`**. Each is a
|
|
97
|
+
flag object where `true` includes a field and omitted/`false` excludes it.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
telemetry.server(tel, {
|
|
101
|
+
context: { requestId: true, method: true, path: true }, // → logWith (inherited by inner logs)
|
|
102
|
+
request: { method: true, path: true, requestId: true }, // → the request line (or `false`)
|
|
103
|
+
response: { status: true, duration: true }, // → the response line (or `false`)
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Defaults:**
|
|
108
|
+
|
|
109
|
+
| set | default | disable with |
|
|
110
|
+
| ---------- | ---------------------------------------- | ---------------- |
|
|
111
|
+
| `context` | `{ requestId: true, method: true, path: true }` | `context: {}` |
|
|
112
|
+
| `request` | `{ method: true, path: true, requestId: true }` | `request: false` |
|
|
113
|
+
| `response` | `{ status: true, duration: true }` | `response: false`|
|
|
114
|
+
|
|
115
|
+
`request: false` / `response: false` skip that log line entirely (the context enrichment
|
|
116
|
+
still happens). `context: {}` enriches nothing but still logs the lines.
|
|
117
|
+
|
|
118
|
+
### Request fields
|
|
119
|
+
|
|
120
|
+
Computed once per invocation. `name`/`method`/`path` come from the matched **route**
|
|
121
|
+
(`io.route`) — transport-neutral and correct over both HTTP and ws. The header-derived
|
|
122
|
+
fields read the HTTP / upgrade `Request` (`io.req`).
|
|
123
|
+
|
|
124
|
+
| field | source |
|
|
125
|
+
| ----------- | ------------------------------------------------------------------------- |
|
|
126
|
+
| `name` | the instance's resolved `name` (an `overrides` entry may rename it) |
|
|
127
|
+
| `requestId` | see [Request id](#request-id) |
|
|
128
|
+
| `method` | `io.route.method` — omitted on an `event` route (events have no method) |
|
|
129
|
+
| `path` | `io.route.path` — omitted on an `event` route (events have no path) |
|
|
130
|
+
| `transport` | `io.transport` (`'http'` or `'ws'`) |
|
|
131
|
+
| `ip` | first hop of `X-Forwarded-For`, else `X-Real-IP` (omitted if neither) |
|
|
132
|
+
| `size` | `Content-Length` as a number (omitted if absent or non-numeric) |
|
|
133
|
+
| `traceId` | `X-Trace-Id` (omitted if absent) |
|
|
134
|
+
|
|
135
|
+
Fields whose value is `undefined` (e.g. `ip` with no headers, or `method`/`path` on an
|
|
136
|
+
event route) are dropped from the bag — they never appear as `ip=undefined`.
|
|
137
|
+
|
|
138
|
+
### Response fields
|
|
139
|
+
|
|
140
|
+
| field | source |
|
|
141
|
+
| ---------- | -------------------------------------------------------------------------- |
|
|
142
|
+
| `status` | best-effort — see [Honest limits](#honest-limits-from-a-middleware-vantage)|
|
|
143
|
+
| `duration` | `now() - start` in ms (reliable) |
|
|
144
|
+
| `type` | `'json' \| 'multi' \| 'stream' \| 'response' \| 'empty' \| 'error'` |
|
|
145
|
+
| `error` | the serialized thrown error (error path only) |
|
|
146
|
+
| `size` | only from a short-circuit `Response`'s `Content-Length` (else omitted) |
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Context enrichment (the `@ayepi/log` integration)
|
|
151
|
+
|
|
152
|
+
The middleware (once bound via `.server`) wraps the entire downstream chain + handler in
|
|
153
|
+
`logWith(contextFields, () => io.next())`. Because `@ayepi/log` stores context in
|
|
154
|
+
`AsyncLocalStorage`, **any** `logger.*` call made anywhere inside the request inherits those
|
|
155
|
+
fields:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// shared.ts
|
|
159
|
+
const tel = telemetry()
|
|
160
|
+
const api = spec({ endpoints: { getUser: tel.endpoint({ response: User }) } })
|
|
161
|
+
|
|
162
|
+
// server.ts
|
|
163
|
+
const app = implement(api)
|
|
164
|
+
.middleware(telemetry.server(tel, { context: { requestId: true } }))
|
|
165
|
+
.handlers({
|
|
166
|
+
getUser: ({ data }) => {
|
|
167
|
+
logger.info('loading user', { id: data.id }) // → record carries requestId too
|
|
168
|
+
return loadUser(data.id)
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
.server()
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
A rejection thrown anywhere downstream is also tagged with the context (per `@ayepi/log`'s
|
|
175
|
+
error-context mechanism), so an error logged higher up still carries `requestId`/`path`.
|
|
176
|
+
|
|
177
|
+
`extra` (a `.server` option) adds static or `requires`-derived fields to **every** bag
|
|
178
|
+
(context + request line), at lowest precedence. The `requires` deps are declared on the
|
|
179
|
+
**def** (`telemetry({ requires: [auth] })`), and their context is typed in `extra`:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
// shared.ts
|
|
183
|
+
const auth = authMiddleware() // a def
|
|
184
|
+
const tel = telemetry({ requires: [auth] }) // ctx.user becomes available to the impl
|
|
185
|
+
|
|
186
|
+
// server.ts
|
|
187
|
+
telemetry.server(tel, {
|
|
188
|
+
extra: (ctx, req) => ({ userId: ctx.user.id, host: new URL(req.url).host }),
|
|
189
|
+
})
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Request id
|
|
195
|
+
|
|
196
|
+
Resolution order (first hit wins). Resolution runs in the server impl (`node:crypto` is a
|
|
197
|
+
`/server`-only dependency):
|
|
198
|
+
|
|
199
|
+
1. a `requestId: (req) => string` `.server` option, if provided;
|
|
200
|
+
2. **`io.ws.id`** — the ws **frame id** (present only over ws; the real per-call
|
|
201
|
+
correlation id);
|
|
202
|
+
3. the `X-Request-ID` request header;
|
|
203
|
+
4. a generated UUID (`node:crypto`'s `randomUUID`).
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
telemetry.server(tel, {
|
|
207
|
+
requestId: (req) => req.headers.get('x-correlation-id') ?? crypto.randomUUID(),
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Put `requestId` in the `context` set (the default does) so the resolved id is visible to
|
|
212
|
+
every downstream log and propagates to any service you call.
|
|
213
|
+
|
|
214
|
+
> **WebSocket frame id is first-class.** A ws *call* arrives as a JSON frame with its own
|
|
215
|
+
> `id`. `@ayepi/core` now threads that frame id into the middleware as `io.ws.id` (alongside
|
|
216
|
+
> `io.transport === 'ws'`), so over ws each call gets a **real per-call request id** —
|
|
217
|
+
> distinct from the connection's HTTP upgrade request, which is shared by every call on the
|
|
218
|
+
> socket. No special configuration is needed; the precedence above picks it up automatically.
|
|
219
|
+
|
|
220
|
+
### Echoing the request id
|
|
221
|
+
|
|
222
|
+
Set `echoRequestId` (a `.server` option) to write the resolved request id back onto the
|
|
223
|
+
response (via `io.setHeader`):
|
|
224
|
+
|
|
225
|
+
- `false` (default) — do nothing;
|
|
226
|
+
- `true` — echo on the `x-request-id` header;
|
|
227
|
+
- a string — echo on that header name.
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
telemetry.server(tel, { echoRequestId: true }) // → response header `x-request-id: <id>`
|
|
231
|
+
telemetry.server(tel, { echoRequestId: 'x-correlation-id' }) // → custom header name
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Over HTTP this becomes a response header; over ws it is collected on the result frame (see
|
|
235
|
+
`@ayepi/core`'s `io.setHeader` semantics).
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Per-endpoint overrides
|
|
240
|
+
|
|
241
|
+
There are two ways to tune individual routes.
|
|
242
|
+
|
|
243
|
+
### 1. The `overrides` map (keyed by route name, a `.server` option)
|
|
244
|
+
|
|
245
|
+
The matched route name (`io.route.name`, i.e. the endpoint/event key in your `spec`) is
|
|
246
|
+
reachable at runtime, so a single `telemetry(...)` def — bound by one `.server` impl — can
|
|
247
|
+
carry per-route tweaks in an `overrides` map on `.server`. The matching entry is
|
|
248
|
+
shallow-merged over the base per-call config at call time. Overridable per route: `name`,
|
|
249
|
+
`level`, `context`, `request`, `response`, `echoRequestId`.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
// shared.ts
|
|
253
|
+
const tel = telemetry()
|
|
254
|
+
const api = spec({ endpoints: { ...tel.group({ getUser, upload, health }) } })
|
|
255
|
+
|
|
256
|
+
// server.ts
|
|
257
|
+
telemetry.server(tel, {
|
|
258
|
+
request: { method: true, path: true, requestId: true },
|
|
259
|
+
overrides: {
|
|
260
|
+
upload: { name: 'upload', request: { name: true, method: true, path: true, ip: true, size: true } },
|
|
261
|
+
health: { request: false, response: false }, // stay quiet on the health check
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Routes without an entry use the base config unchanged. (The plumbing options — `logger`,
|
|
267
|
+
`logWith`, `now`, `extra` — are impl-wide and are **not** overridable per route; `requires`
|
|
268
|
+
is declared on the def.)
|
|
269
|
+
|
|
270
|
+
### 2. A tailored def + impl per endpoint/group
|
|
271
|
+
|
|
272
|
+
The idiomatic ayepi way still works — **attach a tailored `telemetry(...)` def to the
|
|
273
|
+
specific endpoint or group, and bind a matching `.server` impl.** Use the `name` option (a
|
|
274
|
+
def option) as your human label (emit it via the `name` field):
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
// shared.ts
|
|
278
|
+
const base = telemetry() // standard fields everywhere
|
|
279
|
+
const noisy = telemetry({ name: 'upload' }) // verbose, only for uploads
|
|
280
|
+
|
|
281
|
+
const api = spec({
|
|
282
|
+
endpoints: {
|
|
283
|
+
...base.group({ getUser, listUsers }),
|
|
284
|
+
upload: noisy.endpoint({ files: { file: z.instanceof(File) } }),
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// server.ts — one impl per def
|
|
289
|
+
implement(api)
|
|
290
|
+
.middleware(telemetry.server(base))
|
|
291
|
+
.middleware(telemetry.server(noisy, {
|
|
292
|
+
request: { name: true, method: true, path: true, ip: true, size: true },
|
|
293
|
+
}))
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The def composes with `use(...)` (or the equivalent `.with(...)` method) too — e.g.
|
|
297
|
+
`use(tel, auth, rateLimit)` with `const tel = telemetry()`.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Honest limits from a middleware vantage
|
|
302
|
+
|
|
303
|
+
A middleware observes the request **before** and the handler result **after**, but it sits
|
|
304
|
+
*upstream* of where `@ayepi/core` serializes the result into the wire `Response`. That
|
|
305
|
+
bounds what `status` and `size` can be:
|
|
306
|
+
|
|
307
|
+
- **`duration`** — reliable. Measured around `io.next()` with the injected clock.
|
|
308
|
+
- **`error`** — reliable. The thrown value is serialized via `@ayepi/log`.
|
|
309
|
+
- **`status`** — best-effort, derived from the handler result the middleware sees:
|
|
310
|
+
- a thrown `ApiError` → `error.status`; any other thrown value → `500`;
|
|
311
|
+
- a multi-status result `{ status, data }` → that `status`;
|
|
312
|
+
- a middleware short-circuit `Response` → its `.status`;
|
|
313
|
+
- everything else (plain object, stream, `undefined`) → `200`.
|
|
314
|
+
|
|
315
|
+
This is the *intended* status, but it does **not** account for response transforms applied
|
|
316
|
+
after the middleware returns (e.g. a `204` for an empty body, a `206` for a served byte
|
|
317
|
+
range). For exact wire status, use access logging at the HTTP adapter (Node/Bun/Deno)
|
|
318
|
+
instead.
|
|
319
|
+
- **`size`** — only derivable when a middleware **short-circuits with a `Response`** that
|
|
320
|
+
carries `Content-Length`. A normal handler result is serialized *downstream* of this
|
|
321
|
+
middleware, so its byte size is not visible here; `size` is simply omitted in that case.
|
|
322
|
+
It is opt-in for exactly this reason. For accurate response sizes, measure at the adapter.
|
|
323
|
+
- **`type`** — `'response'` (short-circuit `Response`), `'multi'` (`{ status, data }`),
|
|
324
|
+
`'stream'` (async-iterable result), `'empty'` (`undefined`/`null`), `'json'` (anything
|
|
325
|
+
else), or `'error'` on the failure path.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Testing pattern
|
|
330
|
+
|
|
331
|
+
Inject a capturing logger (a custom transport that records the built records), the same
|
|
332
|
+
logger's `logWith`, and a fixed clock so `duration` is exact — all on the `.server` impl:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
import { createLogger } from '@ayepi/log'
|
|
336
|
+
import { telemetry } from '@ayepi/otel/server'
|
|
337
|
+
|
|
338
|
+
const records: LogRecord[] = []
|
|
339
|
+
const logger = createLogger({ level: 'debug', transports: [{ name: 'cap', write: (r) => void records.push(r) }] })
|
|
340
|
+
|
|
341
|
+
const tel = telemetry() // the def
|
|
342
|
+
const api = spec({ endpoints: { e: tel.endpoint({ response: z.object({ ok: z.boolean() }) }) } })
|
|
343
|
+
|
|
344
|
+
const app = implement(api)
|
|
345
|
+
.middleware(telemetry.server(tel, {
|
|
346
|
+
logger,
|
|
347
|
+
logWith: logger.logWith, // enrich the SAME logger the handler uses
|
|
348
|
+
now: (() => { let t = 1000; return () => (t += 25) - 25 })(), // 1000 then 1025 → duration 25
|
|
349
|
+
}))
|
|
350
|
+
.handlers({ e: () => { logger.info('inner'); return { ok: true } } })
|
|
351
|
+
.server()
|
|
352
|
+
|
|
353
|
+
await app.fetch(new Request('http://t/e', { method: 'POST', headers: { 'x-request-id': 'rid' } }))
|
|
354
|
+
// records: the 'request' line, the handler's 'inner' (carrying requestId='rid'), the 'response' line
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Full options reference
|
|
360
|
+
|
|
361
|
+
The options are split across the two entries: the **def factory** takes only the
|
|
362
|
+
frontend-safe contract; **all** behaviour options live on `.server`.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
// @ayepi/otel — the def factory (frontend-safe)
|
|
366
|
+
function telemetry<R extends readonly AnyMiddleware[] = readonly []>(
|
|
367
|
+
opts?: TelemetryDefOptions<R>,
|
|
368
|
+
): TelemetryDef<R>
|
|
369
|
+
|
|
370
|
+
interface TelemetryDefOptions<R extends readonly AnyMiddleware[]> {
|
|
371
|
+
requires?: R // middleware deps; their ctx is typed in `.server`'s `extra`
|
|
372
|
+
name?: string // def name + default `name` field value (default 'otel')
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
// @ayepi/otel/server — augments telemetry with `.server(def, opts)`
|
|
378
|
+
telemetry.server: <R extends readonly AnyMiddleware[]>(
|
|
379
|
+
def: TelemetryDef<R>,
|
|
380
|
+
opts?: TelemetryServerOptions<R>,
|
|
381
|
+
) => BoundMiddleware // pass to implement(api).middleware(...)
|
|
382
|
+
|
|
383
|
+
interface TelemetryServerOptions<R extends readonly AnyMiddleware[]> {
|
|
384
|
+
level?: Level // level of both lines (default 'info')
|
|
385
|
+
|
|
386
|
+
context?: RequestFieldFlags // → logWith (default { requestId, method, path })
|
|
387
|
+
request?: RequestFieldFlags | false // request line (default { method, path, requestId })
|
|
388
|
+
response?: ResponseFieldFlags | false // response line (default { status, duration })
|
|
389
|
+
|
|
390
|
+
overrides?: Record<string, PerCallOptions> // per-route tweaks, keyed by io.route.name (shallow-merged)
|
|
391
|
+
|
|
392
|
+
requestId?: (req: Request) => string // override id resolution (else io.ws.id → X-Request-ID → uuid)
|
|
393
|
+
echoRequestId?: boolean | string // echo id on the response: true → 'x-request-id', string → that header
|
|
394
|
+
extra?: (ctx: StackCtx<R>, req: Request) => Record<string, unknown> // merged into every bag
|
|
395
|
+
|
|
396
|
+
logger?: Logger // emitter (default @ayepi/log default logger)
|
|
397
|
+
logWith?: <T>(add: object, inner: () => T) => T // context pusher (default @ayepi/log default logWith)
|
|
398
|
+
onError?: (err: unknown) => void // observe a telemetry failure (off by default)
|
|
399
|
+
now?: () => number // ms clock (default Date.now)
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
> **Telemetry is fail-open.** A throw in *your* `extra`/`logWith` or in a log call never
|
|
404
|
+
> breaks the request — the handler runs and its result/error pass through untouched; only
|
|
405
|
+
> the logging is skipped. Pass `onError` to observe those swallowed failures (off by default;
|
|
406
|
+
> a throwing `onError` is itself ignored).
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
|
|
410
|
+
// the per-route slice an `overrides` entry may set:
|
|
411
|
+
interface PerCallOptions {
|
|
412
|
+
name?: string; level?: Level
|
|
413
|
+
context?: RequestFieldFlags; request?: RequestFieldFlags | false; response?: ResponseFieldFlags | false
|
|
414
|
+
echoRequestId?: boolean | string
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
interface RequestFieldFlags { name?; requestId?; method?; path?; transport?; ip?; size?; traceId?: boolean }
|
|
418
|
+
interface ResponseFieldFlags { status?; duration?; type?; error?; size?: boolean }
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
> Tip: pass a specific logger's `logWith` (not a different logger's) so the context you push
|
|
422
|
+
> is the context your inner `logger.*` calls read. Mismatching them silently drops the
|
|
423
|
+
> enrichment.
|
|
424
|
+
|
|
425
|
+
## License
|
|
426
|
+
|
|
427
|
+
MIT © Philip Diffenderfer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ayepi/otel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Observability middleware for @ayepi/core — request/response logging, trace-context enrichment, configurable fields, and per-endpoint overrides",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"type": "module",
|
|
19
19
|
"sideEffects": false,
|
|
20
20
|
"files": [
|
|
21
|
-
"dist"
|
|
21
|
+
"dist",
|
|
22
|
+
"ayepi-*.md"
|
|
22
23
|
],
|
|
23
24
|
"exports": {
|
|
24
25
|
".": {
|
|
@@ -47,8 +48,8 @@
|
|
|
47
48
|
"node": ">=18"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
50
|
-
"@ayepi/core": "^0.
|
|
51
|
-
"@ayepi/log": "^0.
|
|
51
|
+
"@ayepi/core": "^0.2.0",
|
|
52
|
+
"@ayepi/log": "^0.2.0"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@vitest/coverage-v8": "^2.1.8",
|
|
@@ -56,8 +57,8 @@
|
|
|
56
57
|
"tsdown": "^0.12.0",
|
|
57
58
|
"vitest": "^2.1.8",
|
|
58
59
|
"zod": "^4.4.3",
|
|
59
|
-
"@ayepi/core": "0.
|
|
60
|
-
"@ayepi/log": "0.
|
|
60
|
+
"@ayepi/core": "0.2.0",
|
|
61
|
+
"@ayepi/log": "0.2.0"
|
|
61
62
|
},
|
|
62
63
|
"keywords": [
|
|
63
64
|
"ayepi",
|