agent-telemetry 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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # agent-telemetry
2
2
 
3
- Lightweight JSONL telemetry for AI agent backends. Zero runtime dependencies.
3
+ Lightweight JSONL telemetry for easier AI agent consumption. Zero runtime dependencies.
4
4
 
5
- Writes structured telemetry events to rotating JSONL files in development. Falls back to `console.log` in runtimes without filesystem access (Cloudflare Workers). Includes framework adapters for [Hono](https://hono.dev) and [Inngest](https://inngest.com).
5
+ Writes structured telemetry events to rotating JSONL files in development. Falls back to `console.log` in runtimes without filesystem access (Cloudflare Workers). Includes framework adapters for Hono, Inngest, Express, Fastify, Prisma, Supabase, and a generic traced fetch wrapper.
6
6
 
7
7
  ## Install
8
8
 
@@ -38,21 +38,16 @@ Each call to `emit()` appends a JSON line to `logs/telemetry.jsonl` with an auto
38
38
 
39
39
  ## How It Works
40
40
 
41
- The library connects three layers of tracing HTTP requests, event dispatch, and background jobs — through a shared `traceId`:
41
+ The library connects every layer of your stack through a shared `traceId`:
42
42
 
43
43
  ```
44
- BrowserHTTP RequestHono Middleware (parses/generates traceparent)
45
-
46
- getTraceContext(c) → { _trace: { traceId, parentSpanId } }
47
-
48
- inngest.send({ data: { ...payload, ...getTraceContext(c) } })
49
-
50
- Inngest Middleware (reads _trace from event data)
51
-
52
- job.start / job.end (same traceId)
44
+ Inbound HTTP Database Queries → External API Calls Background Jobs
45
+ Hono Prisma Traced Fetch Inngest
46
+ Express Supabase (PostgREST) Supabase (auth/
47
+ Fastify storage/functions)
53
48
  ```
54
49
 
55
- One `traceId` follows a request from the HTTP boundary through dispatch into background job execution. The Hono adapter uses the [W3C `traceparent`](https://www.w3.org/TR/trace-context/) header for propagation, enabling interop with OpenTelemetry and other standards-compliant tools. Query your JSONL logs by `traceId` to see the full chain.
50
+ One `traceId` follows a request from the HTTP boundary through database queries, external API calls, and into background job execution. HTTP adapters use the [W3C `traceparent`](https://www.w3.org/TR/trace-context/) header for propagation, enabling interop with OpenTelemetry and other standards-compliant tools. Query your JSONL logs by `traceId` to see the full chain.
56
51
 
57
52
  ## Full-Stack Example
58
53
 
@@ -182,6 +177,120 @@ The middleware:
182
177
  - Reads `traceId` from the `_trace` field in `event.data` (set by `getTraceContext()` at the dispatch site)
183
178
  - Generates a new `traceId` when no `_trace` is present, so every function run is always traceable
184
179
 
180
+ ## Fetch Adapter
181
+
182
+ Wraps any `fetch` call with telemetry. Does not monkey-patch the global — returns a new function with identical semantics.
183
+
184
+ ```typescript
185
+ import { createTracedFetch } from 'agent-telemetry/fetch'
186
+
187
+ const fetch = createTracedFetch({
188
+ telemetry,
189
+ baseFetch: globalThis.fetch, // Optional — default: globalThis.fetch
190
+ getTraceContext: () => ctx, // Optional — correlate with parent request
191
+ isEnabled: () => true, // Optional guard
192
+ })
193
+
194
+ const res = await fetch('https://api.stripe.com/v1/charges', { method: 'POST' })
195
+ ```
196
+
197
+ - Emits `external.call` events with `service` (hostname) and `operation` (`METHOD /pathname`)
198
+ - `duration_ms` measures time-to-headers (TTFB) — the Response body is returned untouched for streaming
199
+ - Handles all three fetch input types: `string`, `URL`, `Request`
200
+ - Non-2xx responses return normally (not thrown); network errors re-throw after emitting
201
+
202
+ ## Prisma Adapter
203
+
204
+ Traces all Prisma model operations via `$extends()`. No runtime `@prisma/client` import — the extension is structurally compatible.
205
+
206
+ ```typescript
207
+ import { createPrismaTrace } from 'agent-telemetry/prisma'
208
+
209
+ const prisma = new PrismaClient().$extends(createPrismaTrace({
210
+ telemetry,
211
+ getTraceContext: () => ctx, // Optional — correlate with parent request
212
+ isEnabled: () => true, // Optional guard
213
+ }))
214
+ ```
215
+
216
+ - Emits `db.query` events with `provider: "prisma"`, `model` (e.g. `"User"`), and `operation` (e.g. `"findMany"`)
217
+ - Requires Prisma 5.0.0+ (stable `$extends` API)
218
+ - No access to raw SQL at the query extension level — model and operation names only
219
+
220
+ ## Express Adapter
221
+
222
+ Standard Express middleware with the same tracing pattern as Hono. No `express` or `@types/express` runtime dependency.
223
+
224
+ ```typescript
225
+ import { createExpressTrace, getTraceContext } from 'agent-telemetry/express'
226
+
227
+ app.use(createExpressTrace({
228
+ telemetry,
229
+ entityPatterns: [
230
+ { segment: 'users', key: 'userId' },
231
+ ],
232
+ isEnabled: () => true,
233
+ }))
234
+
235
+ app.post('/api/users/:id', (req, res) => {
236
+ // Propagate trace context to downstream services
237
+ const ctx = getTraceContext(req)
238
+ res.json({ ok: true })
239
+ })
240
+ ```
241
+
242
+ - Emits `http.request` events with method, path (query string stripped), status, duration, entities
243
+ - Parses/sets W3C `traceparent` header for propagation
244
+ - Uses `req.route.path` for parameterized patterns (e.g. `/users/:id`), falls back to `req.originalUrl`
245
+ - Handles both `res.on("finish")` and `res.on("close")` to capture aborted requests
246
+
247
+ ## Fastify Adapter
248
+
249
+ Fastify plugin using `onRequest`/`onResponse` hooks. No `fastify` runtime dependency — uses `Symbol.for("skip-override")` instead of `fastify-plugin`.
250
+
251
+ ```typescript
252
+ import { createFastifyTrace, getTraceContext } from 'agent-telemetry/fastify'
253
+
254
+ app.register(createFastifyTrace({
255
+ telemetry,
256
+ entityPatterns: [
257
+ { segment: 'users', key: 'userId' },
258
+ ],
259
+ isEnabled: () => true,
260
+ }))
261
+ ```
262
+
263
+ - Emits `http.request` events using `reply.elapsedTime` for high-resolution duration
264
+ - Strips query strings from emitted `path` values
265
+ - Parses/sets W3C `traceparent` header for propagation
266
+ - Uses `request.routeOptions.url` for parameterized route patterns
267
+ - Requires Fastify 4.0.0+ (`reply.elapsedTime` not available in 3.x)
268
+
269
+ ## Supabase Adapter
270
+
271
+ A traced `fetch` that parses Supabase URL patterns to emit rich, service-aware telemetry. PostgREST calls become `db.query` events; auth/storage/functions calls become `external.call` events.
272
+
273
+ ```typescript
274
+ import { createClient } from '@supabase/supabase-js'
275
+ import { createSupabaseTrace } from 'agent-telemetry/supabase'
276
+
277
+ const tracedFetch = createSupabaseTrace({ telemetry })
278
+ const supabase = createClient(url, key, { global: { fetch: tracedFetch } })
279
+ ```
280
+
281
+ URL pattern classification:
282
+
283
+ | Pattern | Event | Fields |
284
+ |---------|-------|--------|
285
+ | `/rest/v{N}/{table}` | `db.query` | `model: table`, `operation: select\|insert\|update\|delete` |
286
+ | `/auth/v{N}/{endpoint}` | `external.call` | `service: "supabase-auth"` |
287
+ | `/storage/v{N}/object/{bucket}` | `external.call` | `service: "supabase-storage"` |
288
+ | `/functions/v{N}/{name}` | `external.call` | `service: "supabase-functions"` |
289
+
290
+ - Each `fetch` invocation emits one event — Supabase's built-in retry logic generates separate events per retry
291
+ - Realtime (WebSocket) subscriptions are not intercepted (they don't use `fetch`)
292
+ - Uses `Telemetry<SupabaseEvents>` (`DbQueryEvent | ExternalCallEvent` union)
293
+
185
294
  ## Configuration
186
295
 
187
296
  ```typescript
@@ -210,6 +319,8 @@ const telemetry = await createTelemetry({
210
319
  | `HttpEvents` | `http.request` | HTTP request/response telemetry |
211
320
  | `JobEvents` | `job.start`, `job.end`, `job.dispatch`, `job.step` | Background job lifecycle |
212
321
  | `ExternalEvents` | `external.call` | External service calls |
322
+ | `DbEvents` | `db.query` | Database query telemetry |
323
+ | `SupabaseEvents` | `db.query`, `external.call` | Supabase-specific union |
213
324
  | `PresetEvents` | All of the above | Combined preset union |
214
325
 
215
326
  ## Utilities
@@ -251,7 +362,7 @@ The writer automatically detects the runtime environment:
251
362
 
252
363
  Detection happens once during `createTelemetry()` — it probes the filesystem by creating the log directory and verifying it exists. Cloudflare's `nodejs_compat` stubs succeed silently on `mkdirSync` but fail the existence check, triggering the console fallback.
253
364
 
254
- The returned `emit()` function is synchronous and **never throws**, even with malformed data or filesystem errors. Telemetry must not crash the host application.
365
+ The returned `emit()` function is synchronous, non-blocking, and **never throws**, even with malformed data or filesystem errors. Telemetry must not crash the host application.
255
366
 
256
367
  ## License
257
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-telemetry",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Lightweight JSONL telemetry for AI agent backends. Zero deps, framework adapters included.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,11 +15,29 @@
15
15
  "./inngest": {
16
16
  "import": "./src/adapters/inngest.ts",
17
17
  "types": "./src/adapters/inngest.ts"
18
+ },
19
+ "./fetch": {
20
+ "import": "./src/adapters/fetch.ts",
21
+ "types": "./src/adapters/fetch.ts"
22
+ },
23
+ "./prisma": {
24
+ "import": "./src/adapters/prisma.ts",
25
+ "types": "./src/adapters/prisma.ts"
26
+ },
27
+ "./express": {
28
+ "import": "./src/adapters/express.ts",
29
+ "types": "./src/adapters/express.ts"
30
+ },
31
+ "./fastify": {
32
+ "import": "./src/adapters/fastify.ts",
33
+ "types": "./src/adapters/fastify.ts"
34
+ },
35
+ "./supabase": {
36
+ "import": "./src/adapters/supabase.ts",
37
+ "types": "./src/adapters/supabase.ts"
18
38
  }
19
39
  },
20
- "files": [
21
- "src"
22
- ],
40
+ "files": ["src"],
23
41
  "scripts": {
24
42
  "test": "bun test",
25
43
  "typecheck": "tsc --noEmit",
@@ -27,10 +45,14 @@
27
45
  "check": "bun run typecheck && bun run lint && bun test"
28
46
  },
29
47
  "peerDependencies": {
48
+ "fastify": ">=4.0.0",
30
49
  "hono": ">=4.0.0",
31
50
  "inngest": ">=3.0.0"
32
51
  },
33
52
  "peerDependenciesMeta": {
53
+ "fastify": {
54
+ "optional": true
55
+ },
34
56
  "hono": {
35
57
  "optional": true
36
58
  },
@@ -41,6 +63,7 @@
41
63
  "devDependencies": {
42
64
  "@biomejs/biome": "^1.9.0",
43
65
  "@types/bun": "latest",
66
+ "fastify": "^5.7.4",
44
67
  "hono": "^4.7.0",
45
68
  "inngest": "^3.0.0",
46
69
  "typescript": "^5.7.0"
@@ -58,7 +81,12 @@
58
81
  "agent",
59
82
  "ai",
60
83
  "tracing",
84
+ "express",
85
+ "fastify",
86
+ "fetch",
61
87
  "hono",
62
- "inngest"
88
+ "inngest",
89
+ "prisma",
90
+ "supabase"
63
91
  ]
64
92
  }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Express Adapter
3
+ *
4
+ * Creates Express middleware that traces HTTP requests. Emits http.request
5
+ * telemetry events with method, path, status, duration, and extracted entities.
6
+ *
7
+ * No runtime import of express — uses inline types for req/res/next.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { createTelemetry, type HttpEvents } from 'agent-telemetry'
12
+ * import { createExpressTrace, getTraceContext } from 'agent-telemetry/express'
13
+ *
14
+ * const telemetry = await createTelemetry<HttpEvents>()
15
+ * app.use(createExpressTrace({ telemetry }))
16
+ * ```
17
+ */
18
+
19
+ import { extractEntities } from "../entities.ts";
20
+ import { generateSpanId, generateTraceId } from "../ids.ts";
21
+ import { formatTraceparent, parseTraceparent } from "../traceparent.ts";
22
+ import type { EntityPattern, HttpRequestEvent, Telemetry } from "../types.ts";
23
+
24
+ // ============================================================================
25
+ // Inline Express Types (no runtime import of express)
26
+ // ============================================================================
27
+
28
+ interface ExpressRequest {
29
+ method: string;
30
+ originalUrl: string;
31
+ url: string;
32
+ headers: Record<string, string | string[] | undefined>;
33
+ route?: { path?: string };
34
+ }
35
+
36
+ interface ExpressResponse {
37
+ statusCode: number;
38
+ setHeader(name: string, value: string): void;
39
+ on(event: string, listener: () => void): void;
40
+ }
41
+
42
+ type ExpressNextFunction = (err?: unknown) => void;
43
+
44
+ /** Express middleware function signature. */
45
+ export type ExpressMiddleware = (
46
+ req: ExpressRequest,
47
+ res: ExpressResponse,
48
+ next: ExpressNextFunction,
49
+ ) => void;
50
+
51
+ // ============================================================================
52
+ // Request-Scoped Trace Storage
53
+ // ============================================================================
54
+
55
+ const traceStore = new WeakMap<object, { traceId: string; spanId: string }>();
56
+
57
+ function stripQueryAndFragment(url: string): string {
58
+ const queryIdx = url.indexOf("?");
59
+ const hashIdx = url.indexOf("#");
60
+ const cutIdx =
61
+ queryIdx === -1 ? hashIdx : hashIdx === -1 ? queryIdx : Math.min(queryIdx, hashIdx);
62
+ const clean = cutIdx === -1 ? url : url.slice(0, cutIdx);
63
+ return clean || "/";
64
+ }
65
+
66
+ // ============================================================================
67
+ // Options
68
+ // ============================================================================
69
+
70
+ /** Options for Express trace middleware. */
71
+ export interface ExpressTraceOptions {
72
+ /** Telemetry instance to emit events through. */
73
+ telemetry: Telemetry<HttpRequestEvent>;
74
+ /** Entity patterns for extracting IDs from URL paths. */
75
+ entityPatterns?: EntityPattern[];
76
+ /** Guard function — return false to skip tracing for a request. */
77
+ isEnabled?: () => boolean;
78
+ }
79
+
80
+ // ============================================================================
81
+ // Middleware Factory
82
+ // ============================================================================
83
+
84
+ /**
85
+ * Create Express middleware that traces HTTP requests.
86
+ *
87
+ * Generates a traceId per request (or propagates a valid incoming
88
+ * `traceparent`), stores it on a WeakMap keyed by the request object,
89
+ * sets the `traceparent` response header, and emits an http.request
90
+ * event on completion.
91
+ *
92
+ * Listens on both `"finish"` and `"close"` response events with an
93
+ * emit-once guard to handle aborted requests without double-emission.
94
+ */
95
+ export function createExpressTrace(options: ExpressTraceOptions): ExpressMiddleware {
96
+ const { telemetry, entityPatterns, isEnabled } = options;
97
+
98
+ return (req, res, next) => {
99
+ if (isEnabled && !isEnabled()) {
100
+ next();
101
+ return;
102
+ }
103
+
104
+ const incoming = Array.isArray(req.headers.traceparent)
105
+ ? req.headers.traceparent[0]
106
+ : req.headers.traceparent;
107
+ const parsed = parseTraceparent(incoming);
108
+ const traceId = parsed?.traceId ?? generateTraceId();
109
+ const spanId = generateSpanId();
110
+
111
+ traceStore.set(req, { traceId, spanId });
112
+ res.setHeader("traceparent", formatTraceparent(traceId, spanId));
113
+
114
+ const start = performance.now();
115
+ let emitted = false;
116
+
117
+ const emitOnce = () => {
118
+ if (emitted) return;
119
+ emitted = true;
120
+
121
+ const duration_ms = Math.round(performance.now() - start);
122
+ const requestPath = stripQueryAndFragment(req.originalUrl || req.url || "/");
123
+ const path = req.route?.path ?? requestPath;
124
+
125
+ const event: HttpRequestEvent = {
126
+ kind: "http.request",
127
+ traceId,
128
+ method: req.method,
129
+ path,
130
+ status: res.statusCode,
131
+ duration_ms,
132
+ };
133
+
134
+ if (entityPatterns) {
135
+ const entities = extractEntities(requestPath, entityPatterns);
136
+ if (entities) event.entities = entities;
137
+ }
138
+
139
+ if (res.statusCode >= 500) {
140
+ event.error = `HTTP ${res.statusCode}`;
141
+ }
142
+
143
+ telemetry.emit(event);
144
+ };
145
+
146
+ res.on("finish", emitOnce);
147
+ res.on("close", emitOnce);
148
+
149
+ next();
150
+ };
151
+ }
152
+
153
+ // ============================================================================
154
+ // Trace Context Accessor
155
+ // ============================================================================
156
+
157
+ /**
158
+ * Get trace context from an Express request object.
159
+ *
160
+ * Returns an object with `_trace` suitable for spreading into event
161
+ * dispatch payloads to propagate the trace across async boundaries.
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * app.post('/api/process', (req, res) => {
166
+ * await queue.send({ ...payload, ...getTraceContext(req) })
167
+ * })
168
+ * ```
169
+ */
170
+ export function getTraceContext(
171
+ req: object,
172
+ ): { _trace: { traceId: string; parentSpanId: string } } | Record<string, never> {
173
+ const stored = traceStore.get(req);
174
+ if (!stored) return {};
175
+ return { _trace: { traceId: stored.traceId, parentSpanId: stored.spanId } };
176
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Fastify Adapter
3
+ *
4
+ * Creates a Fastify plugin that traces HTTP requests via onRequest/onResponse
5
+ * hooks. Uses reply.elapsedTime for high-resolution duration measurement.
6
+ *
7
+ * No runtime import of fastify -- uses inline types and Symbol.for("skip-override")
8
+ * instead of the fastify-plugin package.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { createTelemetry, type HttpEvents } from 'agent-telemetry'
13
+ * import { createFastifyTrace, getTraceContext } from 'agent-telemetry/fastify'
14
+ *
15
+ * const telemetry = await createTelemetry<HttpEvents>()
16
+ * app.register(createFastifyTrace({ telemetry }))
17
+ * ```
18
+ */
19
+
20
+ import { extractEntities } from "../entities.ts";
21
+ import { generateSpanId, generateTraceId } from "../ids.ts";
22
+ import { formatTraceparent, parseTraceparent } from "../traceparent.ts";
23
+ import type { EntityPattern, HttpRequestEvent, Telemetry } from "../types.ts";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Inline Fastify types (no runtime import)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ interface FastifyRequest {
30
+ method: string;
31
+ url: string;
32
+ headers: Record<string, string | string[] | undefined>;
33
+ routeOptions?: { url?: string };
34
+ }
35
+
36
+ interface FastifyReply {
37
+ statusCode: number;
38
+ elapsedTime: number;
39
+ header(name: string, value: string): FastifyReply;
40
+ }
41
+
42
+ interface FastifyInstance {
43
+ addHook(
44
+ name: "onRequest",
45
+ hook: (request: FastifyRequest, reply: FastifyReply, done: () => void) => void,
46
+ ): void;
47
+ addHook(
48
+ name: "onResponse",
49
+ hook: (request: FastifyRequest, reply: FastifyReply, done: () => void) => void,
50
+ ): void;
51
+ }
52
+
53
+ type FastifyPluginCallback = ((
54
+ instance: FastifyInstance,
55
+ opts: Record<string, unknown>,
56
+ done: () => void,
57
+ ) => void) & {
58
+ [key: symbol]: unknown;
59
+ };
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Trace storage (keyed on Fastify request wrapper, not request.raw)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const traceStore = new WeakMap<object, { traceId: string; spanId: string }>();
66
+
67
+ function stripQueryAndFragment(url: string): string {
68
+ const queryIdx = url.indexOf("?");
69
+ const hashIdx = url.indexOf("#");
70
+ const cutIdx =
71
+ queryIdx === -1 ? hashIdx : hashIdx === -1 ? queryIdx : Math.min(queryIdx, hashIdx);
72
+ const clean = cutIdx === -1 ? url : url.slice(0, cutIdx);
73
+ return clean || "/";
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Options
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** Options for the Fastify trace plugin. */
81
+ export interface FastifyTraceOptions {
82
+ /** Telemetry instance to emit events through. */
83
+ telemetry: Telemetry<HttpRequestEvent>;
84
+ /** Entity patterns for extracting IDs from URL paths. */
85
+ entityPatterns?: EntityPattern[];
86
+ /** Guard function -- return false to skip tracing for a request. */
87
+ isEnabled?: () => boolean;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Plugin factory
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Create a Fastify plugin that traces HTTP requests.
96
+ *
97
+ * Registers onRequest and onResponse hooks. The onRequest hook generates
98
+ * a traceId (or propagates a valid incoming `traceparent`), stores it in
99
+ * a WeakMap keyed on the Fastify request object, and sets the response
100
+ * `traceparent` header. The onResponse hook emits an http.request event
101
+ * using `reply.elapsedTime` for high-resolution duration.
102
+ */
103
+ export function createFastifyTrace(options: FastifyTraceOptions): FastifyPluginCallback {
104
+ const { telemetry, entityPatterns, isEnabled } = options;
105
+
106
+ const plugin = (instance: FastifyInstance, _opts: Record<string, unknown>, done: () => void) => {
107
+ instance.addHook("onRequest", (request, reply, hookDone) => {
108
+ if (isEnabled && !isEnabled()) {
109
+ hookDone();
110
+ return;
111
+ }
112
+
113
+ const incoming = Array.isArray(request.headers.traceparent)
114
+ ? request.headers.traceparent[0]
115
+ : request.headers.traceparent;
116
+ const parsed = parseTraceparent(incoming);
117
+ const traceId = parsed?.traceId ?? generateTraceId();
118
+ const spanId = generateSpanId();
119
+
120
+ traceStore.set(request, { traceId, spanId });
121
+ reply.header("traceparent", formatTraceparent(traceId, spanId));
122
+ hookDone();
123
+ });
124
+
125
+ instance.addHook("onResponse", (request, reply, hookDone) => {
126
+ const trace = traceStore.get(request);
127
+ if (!trace) {
128
+ hookDone();
129
+ return;
130
+ }
131
+
132
+ const requestPath = stripQueryAndFragment(request.url);
133
+ const path = request.routeOptions?.url ?? requestPath;
134
+ const duration_ms = Math.round(reply.elapsedTime);
135
+
136
+ const event: HttpRequestEvent = {
137
+ kind: "http.request",
138
+ traceId: trace.traceId,
139
+ method: request.method,
140
+ path,
141
+ status: reply.statusCode,
142
+ duration_ms,
143
+ };
144
+
145
+ if (entityPatterns) {
146
+ // Extract entities from the actual URL (with real IDs),
147
+ // not the parameterized route pattern
148
+ const entities = extractEntities(requestPath, entityPatterns);
149
+ if (entities) event.entities = entities;
150
+ }
151
+
152
+ telemetry.emit(event);
153
+ hookDone();
154
+ });
155
+
156
+ done();
157
+ };
158
+
159
+ // Fastify encapsulation decorators (replaces fastify-plugin dependency)
160
+ const decorated = plugin as FastifyPluginCallback;
161
+ decorated[Symbol.for("skip-override")] = true;
162
+ decorated[Symbol.for("fastify.display-name")] = "agent-telemetry";
163
+
164
+ return decorated;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Trace context accessor
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Get trace context from a Fastify request object.
173
+ *
174
+ * Returns an object with `_trace` suitable for spreading into event
175
+ * dispatch payloads to propagate the trace across async boundaries.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * app.post('/api/process', async (request, reply) => {
180
+ * await queue.send({ ...payload, ...getTraceContext(request) })
181
+ * })
182
+ * ```
183
+ */
184
+ export function getTraceContext(
185
+ request: unknown,
186
+ ): { _trace: { traceId: string; parentSpanId: string } } | Record<string, never> {
187
+ if (!request || typeof request !== "object") return {};
188
+ const trace = traceStore.get(request);
189
+ if (!trace) return {};
190
+ return { _trace: { traceId: trace.traceId, parentSpanId: trace.spanId } };
191
+ }