agent-telemetry 0.1.0 → 0.3.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, a generic traced fetch wrapper, and browser trace-context helpers.
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. `spanId`/`parentSpanId` fields preserve parent-child relationships inside that trace. 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
 
@@ -157,10 +152,10 @@ app.use('*', trace)
157
152
  The middleware:
158
153
  - Parses the incoming W3C `traceparent` header, or generates a fresh trace ID if absent/invalid
159
154
  - Sets `traceparent` on the response for client-side correlation (format: `00-{traceId}-{spanId}-01`)
160
- - Emits `http.request` events with method, path, status, duration, and extracted entities
155
+ - Emits `http.request` events with method, path, status, duration, extracted entities, and span linkage (`spanId`, `parentSpanId`)
161
156
  - Extracts entity IDs from URL paths — looks for a matching `segment`, then checks if the next segment is a UUID
162
157
 
163
- `getTraceContext(c)` returns `{ _trace: { traceId, parentSpanId } }` for spreading into dispatch payloads. Returns `{}` if no trace middleware is active.
158
+ `getTraceContext(c)` returns `{ _trace: { traceId, parentSpanId, traceFlags } }` for spreading into dispatch payloads. Returns `{}` if no trace middleware is active.
164
159
 
165
160
  ## Inngest Adapter
166
161
 
@@ -182,6 +177,150 @@ 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
+ propagateTo: (url) => url.origin === 'https://api.my-app.com', // Optional header allowlist
192
+ onResponseTraceparent: (tp) => { // Optional response callback
193
+ console.log(tp)
194
+ },
195
+ isEnabled: () => true, // Optional guard
196
+ })
197
+
198
+ const res = await fetch('https://api.stripe.com/v1/charges', { method: 'POST' })
199
+ ```
200
+
201
+ - Emits `external.call` events with `service` (hostname), `operation` (`METHOD /pathname`), and span linkage (`spanId`, optional `parentSpanId`)
202
+ - `duration_ms` measures time-to-headers (TTFB) — the Response body is returned untouched for streaming
203
+ - Handles all three fetch input types: `string`, `URL`, `Request`
204
+ - Can inject outbound `traceparent` headers using `propagateTo` (default: same-origin only in browser, disabled elsewhere)
205
+ - Non-2xx responses return normally (not thrown); network errors re-throw after emitting
206
+
207
+ ## Browser Trace Context
208
+
209
+ Use the browser helpers to continue the same trace from UI requests into server adapters.
210
+
211
+ ```typescript
212
+ import { createBrowserTraceContext, createBrowserTracedFetch } from 'agent-telemetry/browser'
213
+
214
+ const trace = createBrowserTraceContext({
215
+ // Optional SSR bootstrap: <meta name="traceparent" content="00-...">
216
+ initialTraceparent: document.querySelector('meta[name="traceparent"]')?.getAttribute('content'),
217
+ })
218
+
219
+ const fetch = createBrowserTracedFetch({
220
+ trace,
221
+ // Default is same-origin only. Keep this allowlist strict.
222
+ propagateTo: (url) => url.origin === window.location.origin,
223
+ })
224
+
225
+ await fetch('/api/users')
226
+ ```
227
+
228
+ - `createBrowserTraceContext()` bootstraps from `initialTraceparent`, then `<meta name="traceparent">`, then fresh IDs
229
+ - `createBrowserTracedFetch()` injects W3C `traceparent` on allowed requests and can adopt response `traceparent`
230
+ - `trace.withSpan(name, fn)` creates a child span for user actions and restores the previous parent span after completion
231
+
232
+ ## Prisma Adapter
233
+
234
+ Traces all Prisma model operations via `$extends()`. No runtime `@prisma/client` import — the extension is structurally compatible.
235
+
236
+ ```typescript
237
+ import { createPrismaTrace } from 'agent-telemetry/prisma'
238
+
239
+ const prisma = new PrismaClient().$extends(createPrismaTrace({
240
+ telemetry,
241
+ getTraceContext: () => ctx, // Optional — correlate with parent request
242
+ isEnabled: () => true, // Optional guard
243
+ }))
244
+ ```
245
+
246
+ - Emits `db.query` events with `provider: "prisma"`, `model` (e.g. `"User"`), `operation` (e.g. `"findMany"`), and span linkage (`spanId`, optional `parentSpanId`)
247
+ - Requires Prisma 5.0.0+ (stable `$extends` API)
248
+ - No access to raw SQL at the query extension level — model and operation names only
249
+
250
+ ## Express Adapter
251
+
252
+ Standard Express middleware with the same tracing pattern as Hono. No `express` or `@types/express` runtime dependency.
253
+
254
+ ```typescript
255
+ import { createExpressTrace, getTraceContext } from 'agent-telemetry/express'
256
+
257
+ app.use(createExpressTrace({
258
+ telemetry,
259
+ entityPatterns: [
260
+ { segment: 'users', key: 'userId' },
261
+ ],
262
+ isEnabled: () => true,
263
+ }))
264
+
265
+ app.post('/api/users/:id', (req, res) => {
266
+ // Propagate trace context to downstream services
267
+ const ctx = getTraceContext(req)
268
+ res.json({ ok: true })
269
+ })
270
+ ```
271
+
272
+ - Emits `http.request` events with method, path (query string stripped), status, duration, entities, and span linkage
273
+ - Parses/sets W3C `traceparent` header for propagation
274
+ - Uses `req.route.path` for parameterized patterns (e.g. `/users/:id`), falls back to `req.originalUrl`
275
+ - Handles both `res.on("finish")` and `res.on("close")` to capture aborted requests
276
+
277
+ ## Fastify Adapter
278
+
279
+ Fastify plugin using `onRequest`/`onResponse` hooks. No `fastify` runtime dependency — uses `Symbol.for("skip-override")` instead of `fastify-plugin`.
280
+
281
+ ```typescript
282
+ import { createFastifyTrace, getTraceContext } from 'agent-telemetry/fastify'
283
+
284
+ app.register(createFastifyTrace({
285
+ telemetry,
286
+ entityPatterns: [
287
+ { segment: 'users', key: 'userId' },
288
+ ],
289
+ isEnabled: () => true,
290
+ }))
291
+ ```
292
+
293
+ - Emits `http.request` events using `reply.elapsedTime` for high-resolution duration, including span linkage
294
+ - Strips query strings from emitted `path` values
295
+ - Parses/sets W3C `traceparent` header for propagation
296
+ - Uses `request.routeOptions.url` for parameterized route patterns
297
+ - Requires Fastify 4.0.0+ (`reply.elapsedTime` not available in 3.x)
298
+
299
+ ## Supabase Adapter
300
+
301
+ 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.
302
+
303
+ ```typescript
304
+ import { createClient } from '@supabase/supabase-js'
305
+ import { createSupabaseTrace } from 'agent-telemetry/supabase'
306
+
307
+ const tracedFetch = createSupabaseTrace({ telemetry })
308
+ const supabase = createClient(url, key, { global: { fetch: tracedFetch } })
309
+ ```
310
+
311
+ URL pattern classification:
312
+
313
+ | Pattern | Event | Fields |
314
+ |---------|-------|--------|
315
+ | `/rest/v{N}/{table}` | `db.query` | `model: table`, `operation: select\|insert\|update\|delete` |
316
+ | `/auth/v{N}/{endpoint}` | `external.call` | `service: "supabase-auth"` |
317
+ | `/storage/v{N}/object/{bucket}` | `external.call` | `service: "supabase-storage"` |
318
+ | `/functions/v{N}/{name}` | `external.call` | `service: "supabase-functions"` |
319
+
320
+ - Each `fetch` invocation emits one event — Supabase's built-in retry logic generates separate events per retry
321
+ - Realtime (WebSocket) subscriptions are not intercepted (they don't use `fetch`)
322
+ - Uses `Telemetry<SupabaseEvents>` (`DbQueryEvent | ExternalCallEvent` union)
323
+
185
324
  ## Configuration
186
325
 
187
326
  ```typescript
@@ -210,6 +349,8 @@ const telemetry = await createTelemetry({
210
349
  | `HttpEvents` | `http.request` | HTTP request/response telemetry |
211
350
  | `JobEvents` | `job.start`, `job.end`, `job.dispatch`, `job.step` | Background job lifecycle |
212
351
  | `ExternalEvents` | `external.call` | External service calls |
352
+ | `DbEvents` | `db.query` | Database query telemetry |
353
+ | `SupabaseEvents` | `db.query`, `external.call` | Supabase-specific union |
213
354
  | `PresetEvents` | All of the above | Combined preset union |
214
355
 
215
356
  ## Utilities
@@ -251,7 +392,7 @@ The writer automatically detects the runtime environment:
251
392
 
252
393
  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
394
 
254
- The returned `emit()` function is synchronous and **never throws**, even with malformed data or filesystem errors. Telemetry must not crash the host application.
395
+ 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
396
 
256
397
  ## License
257
398
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-telemetry",
3
- "version": "0.1.0",
3
+ "version": "0.3.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,33 @@
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"
38
+ },
39
+ "./browser": {
40
+ "import": "./src/browser.ts",
41
+ "types": "./src/browser.ts"
18
42
  }
19
43
  },
20
- "files": [
21
- "src"
22
- ],
44
+ "files": ["src"],
23
45
  "scripts": {
24
46
  "test": "bun test",
25
47
  "typecheck": "tsc --noEmit",
@@ -27,10 +49,14 @@
27
49
  "check": "bun run typecheck && bun run lint && bun test"
28
50
  },
29
51
  "peerDependencies": {
52
+ "fastify": ">=4.0.0",
30
53
  "hono": ">=4.0.0",
31
54
  "inngest": ">=3.0.0"
32
55
  },
33
56
  "peerDependenciesMeta": {
57
+ "fastify": {
58
+ "optional": true
59
+ },
34
60
  "hono": {
35
61
  "optional": true
36
62
  },
@@ -41,6 +67,7 @@
41
67
  "devDependencies": {
42
68
  "@biomejs/biome": "^1.9.0",
43
69
  "@types/bun": "latest",
70
+ "fastify": "^5.7.4",
44
71
  "hono": "^4.7.0",
45
72
  "inngest": "^3.0.0",
46
73
  "typescript": "^5.7.0"
@@ -58,7 +85,12 @@
58
85
  "agent",
59
86
  "ai",
60
87
  "tracing",
88
+ "express",
89
+ "fastify",
90
+ "fetch",
61
91
  "hono",
62
- "inngest"
92
+ "inngest",
93
+ "prisma",
94
+ "supabase"
63
95
  ]
64
96
  }
@@ -0,0 +1,188 @@
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 { startSpanFromTraceparent } from "../trace-context.ts";
21
+ import { formatTraceparent } 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; traceFlags: 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 span = startSpanFromTraceparent(incoming);
108
+
109
+ traceStore.set(req, {
110
+ traceId: span.traceId,
111
+ spanId: span.spanId,
112
+ traceFlags: span.traceFlags,
113
+ });
114
+ res.setHeader("traceparent", formatTraceparent(span.traceId, span.spanId, span.traceFlags));
115
+
116
+ const start = performance.now();
117
+ let emitted = false;
118
+
119
+ const emitOnce = () => {
120
+ if (emitted) return;
121
+ emitted = true;
122
+
123
+ const duration_ms = Math.round(performance.now() - start);
124
+ const requestPath = stripQueryAndFragment(req.originalUrl || req.url || "/");
125
+ const path = req.route?.path ?? requestPath;
126
+
127
+ const event: HttpRequestEvent = {
128
+ kind: "http.request",
129
+ traceId: span.traceId,
130
+ spanId: span.spanId,
131
+ parentSpanId: span.parentSpanId,
132
+ method: req.method,
133
+ path,
134
+ status: res.statusCode,
135
+ duration_ms,
136
+ };
137
+
138
+ if (entityPatterns) {
139
+ const entities = extractEntities(requestPath, entityPatterns);
140
+ if (entities) event.entities = entities;
141
+ }
142
+
143
+ if (res.statusCode >= 500) {
144
+ event.error = `HTTP ${res.statusCode}`;
145
+ }
146
+
147
+ telemetry.emit(event);
148
+ };
149
+
150
+ res.on("finish", emitOnce);
151
+ res.on("close", emitOnce);
152
+
153
+ next();
154
+ };
155
+ }
156
+
157
+ // ============================================================================
158
+ // Trace Context Accessor
159
+ // ============================================================================
160
+
161
+ /**
162
+ * Get trace context from an Express request object.
163
+ *
164
+ * Returns an object with `_trace` suitable for spreading into event
165
+ * dispatch payloads to propagate the trace across async boundaries.
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * app.post('/api/process', (req, res) => {
170
+ * await queue.send({ ...payload, ...getTraceContext(req) })
171
+ * })
172
+ * ```
173
+ */
174
+ export function getTraceContext(
175
+ req: object,
176
+ ):
177
+ | { _trace: { traceId: string; parentSpanId: string; traceFlags?: string } }
178
+ | Record<string, never> {
179
+ const stored = traceStore.get(req);
180
+ if (!stored) return {};
181
+ return {
182
+ _trace: {
183
+ traceId: stored.traceId,
184
+ parentSpanId: stored.spanId,
185
+ traceFlags: stored.traceFlags,
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,207 @@
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 { startSpanFromTraceparent } from "../trace-context.ts";
22
+ import { formatTraceparent } 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<
66
+ object,
67
+ { traceId: string; spanId: string; parentSpanId?: string; traceFlags: string }
68
+ >();
69
+
70
+ function stripQueryAndFragment(url: string): string {
71
+ const queryIdx = url.indexOf("?");
72
+ const hashIdx = url.indexOf("#");
73
+ const cutIdx =
74
+ queryIdx === -1 ? hashIdx : hashIdx === -1 ? queryIdx : Math.min(queryIdx, hashIdx);
75
+ const clean = cutIdx === -1 ? url : url.slice(0, cutIdx);
76
+ return clean || "/";
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Options
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /** Options for the Fastify trace plugin. */
84
+ export interface FastifyTraceOptions {
85
+ /** Telemetry instance to emit events through. */
86
+ telemetry: Telemetry<HttpRequestEvent>;
87
+ /** Entity patterns for extracting IDs from URL paths. */
88
+ entityPatterns?: EntityPattern[];
89
+ /** Guard function -- return false to skip tracing for a request. */
90
+ isEnabled?: () => boolean;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Plugin factory
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Create a Fastify plugin that traces HTTP requests.
99
+ *
100
+ * Registers onRequest and onResponse hooks. The onRequest hook generates
101
+ * a traceId (or propagates a valid incoming `traceparent`), stores it in
102
+ * a WeakMap keyed on the Fastify request object, and sets the response
103
+ * `traceparent` header. The onResponse hook emits an http.request event
104
+ * using `reply.elapsedTime` for high-resolution duration.
105
+ */
106
+ export function createFastifyTrace(options: FastifyTraceOptions): FastifyPluginCallback {
107
+ const { telemetry, entityPatterns, isEnabled } = options;
108
+
109
+ const plugin = (instance: FastifyInstance, _opts: Record<string, unknown>, done: () => void) => {
110
+ instance.addHook("onRequest", (request, reply, hookDone) => {
111
+ if (isEnabled && !isEnabled()) {
112
+ hookDone();
113
+ return;
114
+ }
115
+
116
+ const incoming = Array.isArray(request.headers.traceparent)
117
+ ? request.headers.traceparent[0]
118
+ : request.headers.traceparent;
119
+ const span = startSpanFromTraceparent(incoming);
120
+
121
+ traceStore.set(request, {
122
+ traceId: span.traceId,
123
+ spanId: span.spanId,
124
+ parentSpanId: span.parentSpanId,
125
+ traceFlags: span.traceFlags,
126
+ });
127
+ reply.header("traceparent", formatTraceparent(span.traceId, span.spanId, span.traceFlags));
128
+ hookDone();
129
+ });
130
+
131
+ instance.addHook("onResponse", (request, reply, hookDone) => {
132
+ const trace = traceStore.get(request);
133
+ if (!trace) {
134
+ hookDone();
135
+ return;
136
+ }
137
+
138
+ const requestPath = stripQueryAndFragment(request.url);
139
+ const path = request.routeOptions?.url ?? requestPath;
140
+ const duration_ms = Math.round(reply.elapsedTime);
141
+
142
+ const event: HttpRequestEvent = {
143
+ kind: "http.request",
144
+ traceId: trace.traceId,
145
+ spanId: trace.spanId,
146
+ parentSpanId: trace.parentSpanId,
147
+ method: request.method,
148
+ path,
149
+ status: reply.statusCode,
150
+ duration_ms,
151
+ };
152
+
153
+ if (entityPatterns) {
154
+ // Extract entities from the actual URL (with real IDs),
155
+ // not the parameterized route pattern
156
+ const entities = extractEntities(requestPath, entityPatterns);
157
+ if (entities) event.entities = entities;
158
+ }
159
+
160
+ telemetry.emit(event);
161
+ hookDone();
162
+ });
163
+
164
+ done();
165
+ };
166
+
167
+ // Fastify encapsulation decorators (replaces fastify-plugin dependency)
168
+ const decorated = plugin as FastifyPluginCallback;
169
+ decorated[Symbol.for("skip-override")] = true;
170
+ decorated[Symbol.for("fastify.display-name")] = "agent-telemetry";
171
+
172
+ return decorated;
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Trace context accessor
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Get trace context from a Fastify request object.
181
+ *
182
+ * Returns an object with `_trace` suitable for spreading into event
183
+ * dispatch payloads to propagate the trace across async boundaries.
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * app.post('/api/process', async (request, reply) => {
188
+ * await queue.send({ ...payload, ...getTraceContext(request) })
189
+ * })
190
+ * ```
191
+ */
192
+ export function getTraceContext(
193
+ request: unknown,
194
+ ):
195
+ | { _trace: { traceId: string; parentSpanId: string; traceFlags?: string } }
196
+ | Record<string, never> {
197
+ if (!request || typeof request !== "object") return {};
198
+ const trace = traceStore.get(request);
199
+ if (!trace) return {};
200
+ return {
201
+ _trace: {
202
+ traceId: trace.traceId,
203
+ parentSpanId: trace.spanId,
204
+ traceFlags: trace.traceFlags,
205
+ },
206
+ };
207
+ }