@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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/ayepi-otel.md +427 -0
  3. 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 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.
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.1.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.1.0",
51
- "@ayepi/log": "^0.1.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.1.0",
60
- "@ayepi/log": "0.1.0"
60
+ "@ayepi/core": "0.2.0",
61
+ "@ayepi/log": "0.2.0"
61
62
  },
62
63
  "keywords": [
63
64
  "ayepi",