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 +125 -14
- package/package.json +33 -5
- package/src/adapters/express.ts +176 -0
- package/src/adapters/fastify.ts +191 -0
- package/src/adapters/fetch.ts +133 -0
- package/src/adapters/hono.ts +7 -2
- package/src/adapters/inngest.ts +2 -3
- package/src/adapters/prisma.ts +112 -0
- package/src/adapters/supabase.ts +251 -0
- package/src/error.ts +16 -0
- package/src/index.ts +3 -0
- package/src/traceparent.ts +20 -19
- package/src/types.ts +21 -1
- package/src/writer.ts +134 -59
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# agent-telemetry
|
|
2
2
|
|
|
3
|
-
Lightweight JSONL telemetry for AI agent
|
|
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
|
|
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
|
|
41
|
+
The library connects every layer of your stack through a shared `traceId`:
|
|
42
42
|
|
|
43
43
|
```
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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.
|
|
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
|
+
}
|