agent-telemetry 0.1.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/LICENSE +21 -0
- package/README.md +258 -0
- package/package.json +64 -0
- package/src/adapters/hono.ts +120 -0
- package/src/adapters/inngest.ts +128 -0
- package/src/entities.ts +84 -0
- package/src/ids.ts +16 -0
- package/src/index.ts +74 -0
- package/src/traceparent.ts +56 -0
- package/src/types.ts +134 -0
- package/src/writer.ts +131 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brannon Lucas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# agent-telemetry
|
|
2
|
+
|
|
3
|
+
Lightweight JSONL telemetry for AI agent backends. Zero runtime dependencies.
|
|
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).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add agent-telemetry
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> **Node.js users:** This package ships TypeScript source (no build step). You'll need a bundler that handles `.ts` imports (esbuild, tsup, Vite, etc.).
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { createTelemetry, generateTraceId, type PresetEvents } from 'agent-telemetry'
|
|
19
|
+
|
|
20
|
+
// createTelemetry is async (one-time runtime probe). The returned emit() is synchronous.
|
|
21
|
+
const telemetry = await createTelemetry<PresetEvents>()
|
|
22
|
+
|
|
23
|
+
telemetry.emit({
|
|
24
|
+
kind: 'http.request',
|
|
25
|
+
traceId: generateTraceId(),
|
|
26
|
+
method: 'GET',
|
|
27
|
+
path: '/api/health',
|
|
28
|
+
status: 200,
|
|
29
|
+
duration_ms: 12,
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Each call to `emit()` appends a JSON line to `logs/telemetry.jsonl` with an auto-injected `timestamp`:
|
|
34
|
+
|
|
35
|
+
```jsonl
|
|
36
|
+
{"kind":"http.request","traceId":"0a1b2c3d4e5f67890a1b2c3d4e5f6789","method":"GET","path":"/api/health","status":200,"duration_ms":12,"timestamp":"2026-02-24T21:00:00.000Z"}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## How It Works
|
|
40
|
+
|
|
41
|
+
The library connects three layers of tracing — HTTP requests, event dispatch, and background jobs — through a shared `traceId`:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Browser → HTTP Request → Hono 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)
|
|
53
|
+
```
|
|
54
|
+
|
|
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.
|
|
56
|
+
|
|
57
|
+
## Full-Stack Example
|
|
58
|
+
|
|
59
|
+
Create **one** telemetry instance and share it across both adapters:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// lib/telemetry.ts
|
|
63
|
+
import { createTelemetry, type PresetEvents } from 'agent-telemetry'
|
|
64
|
+
|
|
65
|
+
export const telemetry = await createTelemetry<PresetEvents>()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
// server.ts
|
|
70
|
+
import { Hono } from 'hono'
|
|
71
|
+
import { Inngest } from 'inngest'
|
|
72
|
+
import { createHonoTrace, getTraceContext } from 'agent-telemetry/hono'
|
|
73
|
+
import { createInngestTrace } from 'agent-telemetry/inngest'
|
|
74
|
+
import { telemetry } from './lib/telemetry'
|
|
75
|
+
|
|
76
|
+
// --- HTTP tracing ---
|
|
77
|
+
const trace = createHonoTrace({
|
|
78
|
+
telemetry,
|
|
79
|
+
entityPatterns: [
|
|
80
|
+
{ segment: 'users', key: 'userId' },
|
|
81
|
+
{ segment: 'posts', key: 'postId' },
|
|
82
|
+
],
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const app = new Hono()
|
|
86
|
+
app.use('*', trace)
|
|
87
|
+
|
|
88
|
+
// Propagate traceId into background job dispatch
|
|
89
|
+
app.post('/api/users/:id/process', async (c) => {
|
|
90
|
+
await inngest.send({
|
|
91
|
+
name: 'app/user.process',
|
|
92
|
+
data: { userId: c.req.param('id'), ...getTraceContext(c) },
|
|
93
|
+
})
|
|
94
|
+
return c.json({ ok: true })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// --- Background job tracing ---
|
|
98
|
+
const inngestTrace = createInngestTrace({
|
|
99
|
+
telemetry,
|
|
100
|
+
entityKeys: ['userId', 'postId'],
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const inngest = new Inngest({ id: 'my-app', middleware: [inngestTrace] })
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
This produces a correlated trace:
|
|
107
|
+
```jsonl
|
|
108
|
+
{"kind":"http.request","traceId":"aabb...","method":"POST","path":"/api/users/550e8400-e29b-41d4-a716-446655440000/process","status":200,"duration_ms":45,"entities":{"userId":"550e8400-e29b-41d4-a716-446655440000"},"timestamp":"..."}
|
|
109
|
+
{"kind":"job.dispatch","traceId":"aabb...","parentSpanId":"cc11...","eventName":"app/user.process","entities":{"userId":"550e8400-e29b-41d4-a716-446655440000"},"timestamp":"..."}
|
|
110
|
+
{"kind":"job.start","traceId":"aabb...","spanId":"dd22...","functionId":"process-user","timestamp":"..."}
|
|
111
|
+
{"kind":"job.end","traceId":"aabb...","spanId":"dd22...","functionId":"process-user","duration_ms":230,"status":"success","timestamp":"..."}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
All four events share the same `traceId`. Filter with `jq 'select(.traceId == "aabb...")'` to see the full chain.
|
|
115
|
+
|
|
116
|
+
## Custom Events
|
|
117
|
+
|
|
118
|
+
Extend the type system with your own event kinds:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
import { createTelemetry, type HttpEvents, type JobEvents } from 'agent-telemetry'
|
|
122
|
+
|
|
123
|
+
type MyEvents = HttpEvents | JobEvents | {
|
|
124
|
+
kind: 'custom.checkout'
|
|
125
|
+
traceId: string
|
|
126
|
+
orderId: string
|
|
127
|
+
amount: number
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const telemetry = await createTelemetry<MyEvents>()
|
|
131
|
+
|
|
132
|
+
telemetry.emit({
|
|
133
|
+
kind: 'custom.checkout',
|
|
134
|
+
traceId: 'trace-1',
|
|
135
|
+
orderId: 'order-abc',
|
|
136
|
+
amount: 4999,
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Hono Adapter
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { createHonoTrace, getTraceContext } from 'agent-telemetry/hono'
|
|
144
|
+
|
|
145
|
+
const trace = createHonoTrace({
|
|
146
|
+
telemetry,
|
|
147
|
+
entityPatterns: [ // Extract entity IDs from URL path segments
|
|
148
|
+
{ segment: 'users', key: 'userId' },
|
|
149
|
+
{ segment: 'posts', key: 'postId' },
|
|
150
|
+
],
|
|
151
|
+
isEnabled: () => true, // Guard function (default: () => true)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
app.use('*', trace)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The middleware:
|
|
158
|
+
- Parses the incoming W3C `traceparent` header, or generates a fresh trace ID if absent/invalid
|
|
159
|
+
- 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
|
|
161
|
+
- Extracts entity IDs from URL paths — looks for a matching `segment`, then checks if the next segment is a UUID
|
|
162
|
+
|
|
163
|
+
`getTraceContext(c)` returns `{ _trace: { traceId, parentSpanId } }` for spreading into dispatch payloads. Returns `{}` if no trace middleware is active.
|
|
164
|
+
|
|
165
|
+
## Inngest Adapter
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { createInngestTrace } from 'agent-telemetry/inngest'
|
|
169
|
+
|
|
170
|
+
const trace = createInngestTrace({
|
|
171
|
+
telemetry,
|
|
172
|
+
name: 'my-app/trace', // Middleware name (default: 'agent-telemetry/trace')
|
|
173
|
+
entityKeys: ['userId', 'orderId'], // Keys to extract from event.data (default: [])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const inngest = new Inngest({ id: 'my-app', middleware: [trace] })
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The middleware:
|
|
180
|
+
- Emits `job.start` and `job.end` events for function lifecycle (with duration and error tracking)
|
|
181
|
+
- Emits `job.dispatch` events for outgoing event sends
|
|
182
|
+
- Reads `traceId` from the `_trace` field in `event.data` (set by `getTraceContext()` at the dispatch site)
|
|
183
|
+
- Generates a new `traceId` when no `_trace` is present, so every function run is always traceable
|
|
184
|
+
|
|
185
|
+
## Configuration
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
const telemetry = await createTelemetry({
|
|
189
|
+
logDir: 'logs', // Directory for log files (default: 'logs')
|
|
190
|
+
filename: 'telemetry.jsonl', // Log filename (default: 'telemetry.jsonl')
|
|
191
|
+
maxSize: 5_000_000, // Max file size before rotation (default: 5MB)
|
|
192
|
+
maxBackups: 3, // Number of rotated backups (default: 3)
|
|
193
|
+
prefix: '[TEL]', // Console fallback prefix (default: '[TEL]')
|
|
194
|
+
isEnabled: () => true, // Guard function (default: () => true)
|
|
195
|
+
})
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
When `isEnabled` returns `false`, `emit()` is a no-op. Useful for environment-based guards:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
const telemetry = await createTelemetry({
|
|
202
|
+
isEnabled: () => process.env.NODE_ENV === 'development',
|
|
203
|
+
})
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Preset Event Types
|
|
207
|
+
|
|
208
|
+
| Type | Events | Description |
|
|
209
|
+
|------|--------|-------------|
|
|
210
|
+
| `HttpEvents` | `http.request` | HTTP request/response telemetry |
|
|
211
|
+
| `JobEvents` | `job.start`, `job.end`, `job.dispatch`, `job.step` | Background job lifecycle |
|
|
212
|
+
| `ExternalEvents` | `external.call` | External service calls |
|
|
213
|
+
| `PresetEvents` | All of the above | Combined preset union |
|
|
214
|
+
|
|
215
|
+
## Utilities
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
import {
|
|
219
|
+
generateTraceId,
|
|
220
|
+
generateSpanId,
|
|
221
|
+
extractEntities,
|
|
222
|
+
extractEntitiesFromEvent,
|
|
223
|
+
} from 'agent-telemetry'
|
|
224
|
+
|
|
225
|
+
generateTraceId() // → '0a1b2c3d4e5f67890a1b2c3d4e5f6789' (32 hex chars)
|
|
226
|
+
generateSpanId() // → '0a1b2c3d4e5f6789' (16 hex chars)
|
|
227
|
+
|
|
228
|
+
// Extract entity IDs from URL paths (matches UUID segments only)
|
|
229
|
+
extractEntities('/api/users/550e8400-e29b-41d4-a716-446655440000/posts/6ba7b810-9dad-11d1-80b4-00c04fd430c8', [
|
|
230
|
+
{ segment: 'users', key: 'userId' },
|
|
231
|
+
{ segment: 'posts', key: 'postId' },
|
|
232
|
+
])
|
|
233
|
+
// → { userId: '550e8400-...', postId: '6ba7b810-...' }
|
|
234
|
+
|
|
235
|
+
extractEntities('/api/users/john', [{ segment: 'users', key: 'userId' }])
|
|
236
|
+
// → undefined (non-UUID values are skipped)
|
|
237
|
+
|
|
238
|
+
// Extract entity IDs from event data objects
|
|
239
|
+
extractEntitiesFromEvent({ userId: 'abc', count: 5 }, ['userId', 'postId'])
|
|
240
|
+
// → { userId: 'abc' }
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Runtime Detection
|
|
244
|
+
|
|
245
|
+
The writer automatically detects the runtime environment:
|
|
246
|
+
|
|
247
|
+
| Runtime | Behavior |
|
|
248
|
+
|---------|----------|
|
|
249
|
+
| **Bun / Node.js** | Writes to filesystem with size-based rotation |
|
|
250
|
+
| **Cloudflare Workers** | Falls back to `console.log` with `[TEL]` prefix |
|
|
251
|
+
|
|
252
|
+
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
|
+
|
|
254
|
+
The returned `emit()` function is synchronous and **never throws**, even with malformed data or filesystem errors. Telemetry must not crash the host application.
|
|
255
|
+
|
|
256
|
+
## License
|
|
257
|
+
|
|
258
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-telemetry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight JSONL telemetry for AI agent backends. Zero deps, framework adapters included.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"./hono": {
|
|
12
|
+
"import": "./src/adapters/hono.ts",
|
|
13
|
+
"types": "./src/adapters/hono.ts"
|
|
14
|
+
},
|
|
15
|
+
"./inngest": {
|
|
16
|
+
"import": "./src/adapters/inngest.ts",
|
|
17
|
+
"types": "./src/adapters/inngest.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"lint": "biome check .",
|
|
27
|
+
"check": "bun run typecheck && bun run lint && bun test"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"hono": ">=4.0.0",
|
|
31
|
+
"inngest": ">=3.0.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"hono": {
|
|
35
|
+
"optional": true
|
|
36
|
+
},
|
|
37
|
+
"inngest": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@biomejs/biome": "^1.9.0",
|
|
43
|
+
"@types/bun": "latest",
|
|
44
|
+
"hono": "^4.7.0",
|
|
45
|
+
"inngest": "^3.0.0",
|
|
46
|
+
"typescript": "^5.7.0"
|
|
47
|
+
},
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"author": "Brannon Lucas",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/brannonlucas/agent-telemetry.git"
|
|
53
|
+
},
|
|
54
|
+
"keywords": [
|
|
55
|
+
"telemetry",
|
|
56
|
+
"observability",
|
|
57
|
+
"jsonl",
|
|
58
|
+
"agent",
|
|
59
|
+
"ai",
|
|
60
|
+
"tracing",
|
|
61
|
+
"hono",
|
|
62
|
+
"inngest"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono Adapter
|
|
3
|
+
*
|
|
4
|
+
* Creates Hono middleware that generates trace IDs per request, emits
|
|
5
|
+
* http.request telemetry events, and provides getTraceContext() for
|
|
6
|
+
* injecting trace context into downstream dispatches.
|
|
7
|
+
*
|
|
8
|
+
* Uses the W3C `traceparent` header for trace propagation.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createTelemetry, type HttpEvents } from 'agent-telemetry'
|
|
13
|
+
* import { createHonoTrace, getTraceContext } from 'agent-telemetry/hono'
|
|
14
|
+
*
|
|
15
|
+
* const telemetry = await createTelemetry<HttpEvents>()
|
|
16
|
+
* const trace = createHonoTrace({ telemetry })
|
|
17
|
+
*
|
|
18
|
+
* app.use('*', trace)
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
23
|
+
import { extractEntities } from "../entities.ts";
|
|
24
|
+
import { generateSpanId, generateTraceId } from "../ids.ts";
|
|
25
|
+
import { formatTraceparent, parseTraceparent } from "../traceparent.ts";
|
|
26
|
+
import type { EntityPattern, HttpRequestEvent, Telemetry } from "../types.ts";
|
|
27
|
+
|
|
28
|
+
/** Options for Hono trace middleware. */
|
|
29
|
+
export interface HonoTraceOptions {
|
|
30
|
+
/** Telemetry instance to emit events through. */
|
|
31
|
+
telemetry: Telemetry<HttpRequestEvent>;
|
|
32
|
+
/** Entity patterns for extracting IDs from URL paths. */
|
|
33
|
+
entityPatterns?: EntityPattern[];
|
|
34
|
+
/** Guard function — return false to skip tracing for a request. */
|
|
35
|
+
isEnabled?: () => boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Hono variable keys for trace storage. */
|
|
39
|
+
const TRACE_ID_VAR = "traceId" as const;
|
|
40
|
+
const SPAN_ID_VAR = "spanId" as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create Hono middleware that traces HTTP requests.
|
|
44
|
+
*
|
|
45
|
+
* Generates a traceId per request (or propagates a valid incoming `traceparent`),
|
|
46
|
+
* stores it on the Hono context, sets the `traceparent` response header, and emits
|
|
47
|
+
* an http.request event on completion.
|
|
48
|
+
*/
|
|
49
|
+
export function createHonoTrace(options: HonoTraceOptions): MiddlewareHandler {
|
|
50
|
+
const { telemetry, entityPatterns, isEnabled } = options;
|
|
51
|
+
|
|
52
|
+
return async (c, next) => {
|
|
53
|
+
if (isEnabled && !isEnabled()) {
|
|
54
|
+
return next();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const parsed = parseTraceparent(c.req.header("traceparent"));
|
|
58
|
+
const traceId = parsed?.traceId ?? generateTraceId();
|
|
59
|
+
const spanId = generateSpanId();
|
|
60
|
+
|
|
61
|
+
c.set(TRACE_ID_VAR, traceId);
|
|
62
|
+
c.set(SPAN_ID_VAR, spanId);
|
|
63
|
+
|
|
64
|
+
const start = performance.now();
|
|
65
|
+
let error: string | undefined;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await next();
|
|
69
|
+
} catch (err) {
|
|
70
|
+
error = err instanceof Error ? err.message : "Unknown error";
|
|
71
|
+
throw err;
|
|
72
|
+
} finally {
|
|
73
|
+
const status = error && c.res.status < 400 ? 500 : c.res.status;
|
|
74
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
75
|
+
const path = c.req.path;
|
|
76
|
+
|
|
77
|
+
c.header("traceparent", formatTraceparent(traceId, spanId));
|
|
78
|
+
|
|
79
|
+
const event: HttpRequestEvent = {
|
|
80
|
+
kind: "http.request",
|
|
81
|
+
traceId,
|
|
82
|
+
method: c.req.method,
|
|
83
|
+
path,
|
|
84
|
+
status,
|
|
85
|
+
duration_ms,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (entityPatterns) {
|
|
89
|
+
const entities = extractEntities(path, entityPatterns);
|
|
90
|
+
if (entities) event.entities = entities;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (error) event.error = error;
|
|
94
|
+
|
|
95
|
+
telemetry.emit(event);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get trace context from a Hono request context.
|
|
102
|
+
*
|
|
103
|
+
* Returns an object with `_trace` suitable for spreading into event dispatch
|
|
104
|
+
* payloads to propagate the trace across async boundaries.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* app.post('/api/process', async (c) => {
|
|
109
|
+
* await queue.send({ ...payload, ...getTraceContext(c) })
|
|
110
|
+
* })
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function getTraceContext(
|
|
114
|
+
c: Context,
|
|
115
|
+
): { _trace: { traceId: string; parentSpanId: string } } | Record<string, never> {
|
|
116
|
+
const traceId = c.get(TRACE_ID_VAR) as string | undefined;
|
|
117
|
+
const spanId = c.get(SPAN_ID_VAR) as string | undefined;
|
|
118
|
+
if (!traceId) return {};
|
|
119
|
+
return { _trace: { traceId, parentSpanId: spanId ?? generateSpanId() } };
|
|
120
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inngest Adapter
|
|
3
|
+
*
|
|
4
|
+
* Creates Inngest middleware that emits job.start/job.end lifecycle events
|
|
5
|
+
* and job.dispatch events for outgoing event sends.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { createTelemetry, type JobEvents } from 'agent-telemetry'
|
|
10
|
+
* import { createInngestTrace } from 'agent-telemetry/inngest'
|
|
11
|
+
*
|
|
12
|
+
* const telemetry = await createTelemetry<JobEvents>()
|
|
13
|
+
* const trace = createInngestTrace({ telemetry })
|
|
14
|
+
*
|
|
15
|
+
* const inngest = new Inngest({ id: 'my-app', middleware: [trace] })
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { InngestMiddleware } from "inngest";
|
|
20
|
+
import { extractEntitiesFromEvent } from "../entities.ts";
|
|
21
|
+
import { generateSpanId, generateTraceId } from "../ids.ts";
|
|
22
|
+
import type {
|
|
23
|
+
JobDispatchEvent,
|
|
24
|
+
JobEndEvent,
|
|
25
|
+
JobEvents,
|
|
26
|
+
JobStartEvent,
|
|
27
|
+
Telemetry,
|
|
28
|
+
} from "../types.ts";
|
|
29
|
+
|
|
30
|
+
/** Options for the Inngest trace middleware. */
|
|
31
|
+
export interface InngestTraceOptions {
|
|
32
|
+
/** Telemetry instance to emit events through. */
|
|
33
|
+
telemetry: Telemetry<JobEvents>;
|
|
34
|
+
/** Middleware name. Default: "agent-telemetry/trace". */
|
|
35
|
+
name?: string;
|
|
36
|
+
/** Keys to extract as entities from event data. Default: []. */
|
|
37
|
+
entityKeys?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create Inngest middleware that traces function runs and event dispatches.
|
|
42
|
+
*
|
|
43
|
+
* Hooks:
|
|
44
|
+
* - onFunctionRun: emits job.start on entry, job.end on completion
|
|
45
|
+
* - onSendEvent: emits job.dispatch for outgoing events with _trace context
|
|
46
|
+
*/
|
|
47
|
+
export function createInngestTrace(options: InngestTraceOptions): InngestMiddleware.Any {
|
|
48
|
+
const { telemetry, name = "agent-telemetry/trace", entityKeys = [] } = options;
|
|
49
|
+
|
|
50
|
+
return new InngestMiddleware({
|
|
51
|
+
name,
|
|
52
|
+
init() {
|
|
53
|
+
return {
|
|
54
|
+
onFunctionRun({ ctx, fn }) {
|
|
55
|
+
const eventData = (ctx.event.data ?? {}) as Record<string, unknown>;
|
|
56
|
+
const trace = eventData._trace as { traceId: string; parentSpanId: string } | undefined;
|
|
57
|
+
|
|
58
|
+
const traceId = trace?.traceId ?? generateTraceId();
|
|
59
|
+
const spanId = generateSpanId();
|
|
60
|
+
const runId = ctx.runId;
|
|
61
|
+
const functionId = fn.id("");
|
|
62
|
+
const entities =
|
|
63
|
+
entityKeys.length > 0 ? extractEntitiesFromEvent(eventData, entityKeys) : undefined;
|
|
64
|
+
const start = Date.now();
|
|
65
|
+
|
|
66
|
+
const startEvent: JobStartEvent = {
|
|
67
|
+
kind: "job.start",
|
|
68
|
+
traceId,
|
|
69
|
+
spanId,
|
|
70
|
+
functionId,
|
|
71
|
+
runId,
|
|
72
|
+
entities,
|
|
73
|
+
};
|
|
74
|
+
telemetry.emit(startEvent);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
finished({ result }) {
|
|
78
|
+
const duration_ms = Date.now() - start;
|
|
79
|
+
const hasError = result.error != null;
|
|
80
|
+
|
|
81
|
+
const endEvent: JobEndEvent = {
|
|
82
|
+
kind: "job.end",
|
|
83
|
+
traceId,
|
|
84
|
+
spanId,
|
|
85
|
+
functionId,
|
|
86
|
+
runId,
|
|
87
|
+
duration_ms,
|
|
88
|
+
status: hasError ? "error" : "success",
|
|
89
|
+
error: hasError
|
|
90
|
+
? ((result.error as Error)?.message ?? String(result.error))
|
|
91
|
+
: undefined,
|
|
92
|
+
};
|
|
93
|
+
telemetry.emit(endEvent);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
onSendEvent() {
|
|
99
|
+
return {
|
|
100
|
+
transformInput({ payloads }) {
|
|
101
|
+
for (const payload of payloads) {
|
|
102
|
+
const data = ((payload as { data?: unknown }).data ?? {}) as Record<
|
|
103
|
+
string,
|
|
104
|
+
unknown
|
|
105
|
+
>;
|
|
106
|
+
const trace = data._trace as { traceId: string; parentSpanId: string } | undefined;
|
|
107
|
+
|
|
108
|
+
if (trace) {
|
|
109
|
+
const dispatchEvent: JobDispatchEvent = {
|
|
110
|
+
kind: "job.dispatch",
|
|
111
|
+
traceId: trace.traceId,
|
|
112
|
+
parentSpanId: trace.parentSpanId,
|
|
113
|
+
eventName: (payload as { name: string }).name,
|
|
114
|
+
entities:
|
|
115
|
+
entityKeys.length > 0
|
|
116
|
+
? extractEntitiesFromEvent(data, entityKeys)
|
|
117
|
+
: undefined,
|
|
118
|
+
};
|
|
119
|
+
telemetry.emit(dispatchEvent);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
package/src/entities.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Extraction
|
|
3
|
+
*
|
|
4
|
+
* Configurable extraction of entity IDs from URL paths and event payloads.
|
|
5
|
+
* Users provide their own patterns — no framework-specific defaults.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EntityPattern } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const UUID_RE = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract entity IDs from a URL path using the provided patterns.
|
|
14
|
+
*
|
|
15
|
+
* Scans path segments for pattern matches. When a segment matches a pattern's
|
|
16
|
+
* `segment` value, the following segment is tested against UUID format and
|
|
17
|
+
* captured under the pattern's `key`.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* const patterns = [
|
|
22
|
+
* { segment: 'users', key: 'userId' },
|
|
23
|
+
* { segment: 'posts', key: 'postId' },
|
|
24
|
+
* ]
|
|
25
|
+
* extractEntities(
|
|
26
|
+
* '/api/users/550e8400-e29b-41d4-a716-446655440000/posts/6ba7b810-9dad-11d1-80b4-00c04fd430c8',
|
|
27
|
+
* patterns,
|
|
28
|
+
* )
|
|
29
|
+
* // → { userId: '550e8400-e29b-41d4-a716-446655440000', postId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8' }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function extractEntities(
|
|
33
|
+
path: string,
|
|
34
|
+
patterns: EntityPattern[],
|
|
35
|
+
): Record<string, string> | undefined {
|
|
36
|
+
const segments = path.split("/");
|
|
37
|
+
const entities: Record<string, string> = {};
|
|
38
|
+
let found = false;
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
41
|
+
for (const pattern of patterns) {
|
|
42
|
+
if (segments[i] === pattern.segment) {
|
|
43
|
+
const next = segments[i + 1];
|
|
44
|
+
if (next && UUID_RE.test(next)) {
|
|
45
|
+
entities[pattern.key] = next;
|
|
46
|
+
found = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return found ? entities : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract entity IDs from an event data payload.
|
|
57
|
+
*
|
|
58
|
+
* Scans the data object for string values at the specified keys.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* extractEntitiesFromEvent({ userId: 'abc', count: 5 }, ['userId', 'postId'])
|
|
63
|
+
* // → { userId: 'abc' }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function extractEntitiesFromEvent(
|
|
67
|
+
data: Record<string, unknown> | undefined,
|
|
68
|
+
keys: string[],
|
|
69
|
+
): Record<string, string> | undefined {
|
|
70
|
+
if (!data) return undefined;
|
|
71
|
+
|
|
72
|
+
const entities: Record<string, string> = {};
|
|
73
|
+
let found = false;
|
|
74
|
+
|
|
75
|
+
for (const key of keys) {
|
|
76
|
+
const val = data[key];
|
|
77
|
+
if (typeof val === "string") {
|
|
78
|
+
entities[key] = val;
|
|
79
|
+
found = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return found ? entities : undefined;
|
|
84
|
+
}
|
package/src/ids.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ID Generation
|
|
3
|
+
*
|
|
4
|
+
* Trace and span ID generators using crypto.randomUUID().
|
|
5
|
+
* Compatible with Node.js, Bun, and Cloudflare Workers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Generate a 32-char hex trace ID (UUID v4 without dashes). */
|
|
9
|
+
export function generateTraceId(): string {
|
|
10
|
+
return crypto.randomUUID().replaceAll("-", "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Generate a 16-char hex span ID (first half of a trace ID). */
|
|
14
|
+
export function generateSpanId(): string {
|
|
15
|
+
return crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-telemetry
|
|
3
|
+
*
|
|
4
|
+
* Lightweight JSONL telemetry for AI agent backends.
|
|
5
|
+
* Zero runtime dependencies. Framework adapters for Hono and Inngest.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { createTelemetry, type PresetEvents } from 'agent-telemetry'
|
|
10
|
+
*
|
|
11
|
+
* const telemetry = await createTelemetry<PresetEvents>()
|
|
12
|
+
* telemetry.emit({ kind: 'http.request', traceId: '...', method: 'GET', path: '/', status: 200, duration_ms: 12 })
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { BaseTelemetryEvent, Telemetry, TelemetryConfig } from "./types.ts";
|
|
17
|
+
import { createWriter } from "./writer.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a telemetry instance.
|
|
21
|
+
*
|
|
22
|
+
* Async because runtime detection (filesystem probe) happens once at startup.
|
|
23
|
+
* The returned `emit()` function is synchronous and never throws.
|
|
24
|
+
*/
|
|
25
|
+
export async function createTelemetry<TEvent extends BaseTelemetryEvent = BaseTelemetryEvent>(
|
|
26
|
+
config?: TelemetryConfig,
|
|
27
|
+
): Promise<Telemetry<TEvent>> {
|
|
28
|
+
const isEnabled = config?.isEnabled ?? (() => true);
|
|
29
|
+
|
|
30
|
+
const writer = await createWriter({
|
|
31
|
+
logDir: config?.logDir,
|
|
32
|
+
filename: config?.filename,
|
|
33
|
+
maxSize: config?.maxSize,
|
|
34
|
+
maxBackups: config?.maxBackups,
|
|
35
|
+
prefix: config?.prefix,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
emit(event: TEvent): void {
|
|
40
|
+
try {
|
|
41
|
+
if (!isEnabled()) return;
|
|
42
|
+
const line = JSON.stringify({ ...event, timestamp: new Date().toISOString() });
|
|
43
|
+
writer.write(line);
|
|
44
|
+
} catch {
|
|
45
|
+
// emit never throws — telemetry must not crash the host application
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Re-export all public types
|
|
52
|
+
export type {
|
|
53
|
+
BaseTelemetryEvent,
|
|
54
|
+
EntityPattern,
|
|
55
|
+
ExternalCallEvent,
|
|
56
|
+
ExternalEvents,
|
|
57
|
+
HttpEvents,
|
|
58
|
+
HttpRequestEvent,
|
|
59
|
+
JobDispatchEvent,
|
|
60
|
+
JobEndEvent,
|
|
61
|
+
JobEvents,
|
|
62
|
+
JobStartEvent,
|
|
63
|
+
JobStepEvent,
|
|
64
|
+
PresetEvents,
|
|
65
|
+
Telemetry,
|
|
66
|
+
TelemetryConfig,
|
|
67
|
+
TraceContext,
|
|
68
|
+
} from "./types.ts";
|
|
69
|
+
|
|
70
|
+
// Re-export utilities
|
|
71
|
+
export { generateSpanId, generateTraceId } from "./ids.ts";
|
|
72
|
+
export { extractEntities, extractEntitiesFromEvent } from "./entities.ts";
|
|
73
|
+
export { formatTraceparent, parseTraceparent } from "./traceparent.ts";
|
|
74
|
+
export type { Traceparent } from "./traceparent.ts";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W3C Trace Context — traceparent header parsing and formatting.
|
|
3
|
+
*
|
|
4
|
+
* Implements the `traceparent` header format defined in
|
|
5
|
+
* https://www.w3.org/TR/trace-context/#traceparent-header
|
|
6
|
+
*
|
|
7
|
+
* Format: {version}-{trace-id}-{parent-id}-{trace-flags}
|
|
8
|
+
* Example: 00-4bf92f3577b86cd56163f2543210c4a0-00f067aa0ba902b7-01
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Parsed representation of a `traceparent` header. */
|
|
12
|
+
export interface Traceparent {
|
|
13
|
+
version: string
|
|
14
|
+
traceId: string
|
|
15
|
+
parentId: string
|
|
16
|
+
traceFlags: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TRACEPARENT_RE = /^([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})$/
|
|
20
|
+
const ALL_ZEROS_32 = '0'.repeat(32)
|
|
21
|
+
const ALL_ZEROS_16 = '0'.repeat(16)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a `traceparent` header value.
|
|
25
|
+
*
|
|
26
|
+
* Returns the parsed components, or `null` if the header is missing,
|
|
27
|
+
* malformed, or violates the W3C spec (e.g. all-zero trace-id/parent-id).
|
|
28
|
+
*/
|
|
29
|
+
export function parseTraceparent(header: string | undefined | null): Traceparent | null {
|
|
30
|
+
if (!header) return null
|
|
31
|
+
|
|
32
|
+
const match = TRACEPARENT_RE.exec(header.trim().toLowerCase())
|
|
33
|
+
if (!match) return null
|
|
34
|
+
|
|
35
|
+
// Captures are guaranteed by the regex match above
|
|
36
|
+
const version = match[1] as string
|
|
37
|
+
const traceId = match[2] as string
|
|
38
|
+
const parentId = match[3] as string
|
|
39
|
+
const traceFlags = match[4] as string
|
|
40
|
+
|
|
41
|
+
// W3C spec: all-zero trace-id and parent-id are invalid
|
|
42
|
+
if (traceId === ALL_ZEROS_32 || parentId === ALL_ZEROS_16) return null
|
|
43
|
+
|
|
44
|
+
return { version, traceId, parentId, traceFlags }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Format a `traceparent` header value from components.
|
|
49
|
+
*
|
|
50
|
+
* @param traceId 32-char lowercase hex trace ID
|
|
51
|
+
* @param parentId 16-char lowercase hex parent/span ID
|
|
52
|
+
* @param flags 2-char hex trace flags (default: "01" = sampled)
|
|
53
|
+
*/
|
|
54
|
+
export function formatTraceparent(traceId: string, parentId: string, flags = '01'): string {
|
|
55
|
+
return `00-${traceId}-${parentId}-${flags}`
|
|
56
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for agent-telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Provides base event types, preset event families (HttpEvents, JobEvents,
|
|
5
|
+
* ExternalEvents), configuration interfaces, and the Telemetry handle type.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Base Event
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
/** Fields present on every telemetry event after emission. */
|
|
13
|
+
export interface BaseTelemetryEvent {
|
|
14
|
+
kind: string;
|
|
15
|
+
traceId: string;
|
|
16
|
+
timestamp?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Preset Event Families
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
export interface HttpRequestEvent extends BaseTelemetryEvent {
|
|
24
|
+
kind: "http.request";
|
|
25
|
+
method: string;
|
|
26
|
+
path: string;
|
|
27
|
+
status: number;
|
|
28
|
+
duration_ms: number;
|
|
29
|
+
entities?: Record<string, string>;
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** All HTTP-related events. */
|
|
34
|
+
export type HttpEvents = HttpRequestEvent;
|
|
35
|
+
|
|
36
|
+
export interface JobStartEvent extends BaseTelemetryEvent {
|
|
37
|
+
kind: "job.start";
|
|
38
|
+
spanId: string;
|
|
39
|
+
functionId: string;
|
|
40
|
+
runId?: string;
|
|
41
|
+
entities?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface JobEndEvent extends BaseTelemetryEvent {
|
|
45
|
+
kind: "job.end";
|
|
46
|
+
spanId: string;
|
|
47
|
+
functionId: string;
|
|
48
|
+
runId?: string;
|
|
49
|
+
duration_ms: number;
|
|
50
|
+
status: "success" | "error";
|
|
51
|
+
error?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface JobDispatchEvent extends BaseTelemetryEvent {
|
|
55
|
+
kind: "job.dispatch";
|
|
56
|
+
parentSpanId: string;
|
|
57
|
+
eventName: string;
|
|
58
|
+
entities?: Record<string, string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface JobStepEvent extends BaseTelemetryEvent {
|
|
62
|
+
kind: "job.step";
|
|
63
|
+
spanId: string;
|
|
64
|
+
stepId: string;
|
|
65
|
+
duration_ms: number;
|
|
66
|
+
status: "success" | "error";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** All background job events. */
|
|
70
|
+
export type JobEvents = JobStartEvent | JobEndEvent | JobDispatchEvent | JobStepEvent;
|
|
71
|
+
|
|
72
|
+
export interface ExternalCallEvent extends BaseTelemetryEvent {
|
|
73
|
+
kind: "external.call";
|
|
74
|
+
spanId: string;
|
|
75
|
+
service: string;
|
|
76
|
+
operation: string;
|
|
77
|
+
duration_ms: number;
|
|
78
|
+
status: "success" | "error";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** All external service call events. */
|
|
82
|
+
export type ExternalEvents = ExternalCallEvent;
|
|
83
|
+
|
|
84
|
+
/** Union of all preset event types. */
|
|
85
|
+
export type PresetEvents = HttpEvents | JobEvents | ExternalEvents;
|
|
86
|
+
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// Entity Extraction
|
|
89
|
+
// ============================================================================
|
|
90
|
+
|
|
91
|
+
/** A pattern for extracting entity IDs from URL path segments. */
|
|
92
|
+
export interface EntityPattern {
|
|
93
|
+
/** The URL path segment that precedes the entity ID (e.g. "users"). */
|
|
94
|
+
segment: string;
|
|
95
|
+
/** The key name for the extracted ID (e.g. "userId"). */
|
|
96
|
+
key: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// Configuration
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
/** Configuration for the telemetry writer. */
|
|
104
|
+
export interface TelemetryConfig {
|
|
105
|
+
/** Directory for log files. Default: "logs" relative to cwd. */
|
|
106
|
+
logDir?: string;
|
|
107
|
+
/** Log filename (without directory). Default: "telemetry.jsonl". */
|
|
108
|
+
filename?: string;
|
|
109
|
+
/** Max file size in bytes before rotation. Default: 5_000_000 (5MB). */
|
|
110
|
+
maxSize?: number;
|
|
111
|
+
/** Number of rotated backup files to keep. Default: 3. */
|
|
112
|
+
maxBackups?: number;
|
|
113
|
+
/** Prefix for console.log fallback lines. Default: "[TEL]". */
|
|
114
|
+
prefix?: string;
|
|
115
|
+
/** Guard function — return false to disable emission. Default: () => true. */
|
|
116
|
+
isEnabled?: () => boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The telemetry handle returned by createTelemetry(). */
|
|
120
|
+
export interface Telemetry<TEvent extends BaseTelemetryEvent = PresetEvents> {
|
|
121
|
+
/** Emit a telemetry event. Synchronous. Never throws. */
|
|
122
|
+
emit: (event: TEvent) => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Trace Context
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
/** Trace context passed between HTTP requests and background jobs. */
|
|
130
|
+
export interface TraceContext {
|
|
131
|
+
traceId: string;
|
|
132
|
+
parentSpanId: string;
|
|
133
|
+
traceFlags?: string;
|
|
134
|
+
}
|
package/src/writer.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL Writer
|
|
3
|
+
*
|
|
4
|
+
* Writes telemetry events to a JSONL file with size-based rotation.
|
|
5
|
+
* Auto-detects runtime: uses filesystem when available (Node/Bun),
|
|
6
|
+
* falls back to console.log with a configurable prefix (Cloudflare Workers).
|
|
7
|
+
*
|
|
8
|
+
* The writer is initialized asynchronously (runtime probe), but the returned
|
|
9
|
+
* write function is synchronous and never throws.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface WriterConfig {
|
|
13
|
+
logDir: string
|
|
14
|
+
filename: string
|
|
15
|
+
maxSize: number
|
|
16
|
+
maxBackups: number
|
|
17
|
+
prefix: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Writer {
|
|
21
|
+
write: (line: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULTS: WriterConfig = {
|
|
25
|
+
logDir: 'logs',
|
|
26
|
+
filename: 'telemetry.jsonl',
|
|
27
|
+
maxSize: 5_000_000,
|
|
28
|
+
maxBackups: 3,
|
|
29
|
+
prefix: '[TEL]',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a writer that appends JSONL lines to a file with rotation.
|
|
34
|
+
* Falls back to console.log if the filesystem is unavailable.
|
|
35
|
+
*/
|
|
36
|
+
export async function createWriter(config?: Partial<WriterConfig>): Promise<Writer> {
|
|
37
|
+
const cfg: WriterConfig = {
|
|
38
|
+
logDir: config?.logDir ?? DEFAULTS.logDir,
|
|
39
|
+
filename: config?.filename ?? DEFAULTS.filename,
|
|
40
|
+
maxSize: config?.maxSize ?? DEFAULTS.maxSize,
|
|
41
|
+
maxBackups: config?.maxBackups ?? DEFAULTS.maxBackups,
|
|
42
|
+
prefix: config?.prefix ?? DEFAULTS.prefix,
|
|
43
|
+
}
|
|
44
|
+
const writeToConsole = (line: string): void => {
|
|
45
|
+
// biome-ignore lint/suspicious/noConsole: intentional fallback for runtimes without filesystem
|
|
46
|
+
console.log(`${cfg.prefix} ${line}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const fs = await import('node:fs')
|
|
51
|
+
const path = await import('node:path')
|
|
52
|
+
|
|
53
|
+
const logDir = path.resolve(cfg.logDir)
|
|
54
|
+
const logFile = path.join(logDir, cfg.filename)
|
|
55
|
+
|
|
56
|
+
// Probe: verify filesystem actually works
|
|
57
|
+
// (Cloudflare's nodejs_compat stubs succeed silently)
|
|
58
|
+
fs.mkdirSync(logDir, { recursive: true })
|
|
59
|
+
if (!fs.existsSync(logDir)) {
|
|
60
|
+
throw new Error('Filesystem probe failed')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let useConsoleFallback = false
|
|
64
|
+
|
|
65
|
+
const rotate = (): void => {
|
|
66
|
+
if (!fs.existsSync(logFile)) return
|
|
67
|
+
|
|
68
|
+
if (cfg.maxBackups <= 0) {
|
|
69
|
+
fs.unlinkSync(logFile)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const oldestBackup = `${logFile}.${cfg.maxBackups}`
|
|
74
|
+
if (fs.existsSync(oldestBackup)) {
|
|
75
|
+
fs.unlinkSync(oldestBackup)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (let i = cfg.maxBackups - 1; i >= 1; i--) {
|
|
79
|
+
const from = `${logFile}.${i}`
|
|
80
|
+
const to = `${logFile}.${i + 1}`
|
|
81
|
+
if (fs.existsSync(from)) {
|
|
82
|
+
fs.renameSync(from, to)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fs.renameSync(logFile, `${logFile}.1`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
write(line: string) {
|
|
91
|
+
if (useConsoleFallback) {
|
|
92
|
+
writeToConsole(line)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const lineWithNewline = `${line}\n`
|
|
98
|
+
const incomingSize = Buffer.byteLength(lineWithNewline)
|
|
99
|
+
let currentSize = 0
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
currentSize = fs.statSync(logFile).size
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const isEnoent =
|
|
105
|
+
typeof err === 'object' &&
|
|
106
|
+
err !== null &&
|
|
107
|
+
'code' in err &&
|
|
108
|
+
(err as { code?: unknown }).code === 'ENOENT'
|
|
109
|
+
if (!isEnoent) {
|
|
110
|
+
throw err
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (cfg.maxSize > 0 && currentSize + incomingSize > cfg.maxSize) {
|
|
115
|
+
rotate()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fs.appendFileSync(logFile, lineWithNewline)
|
|
119
|
+
} catch {
|
|
120
|
+
useConsoleFallback = true
|
|
121
|
+
writeToConsole(line)
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// Import failed or filesystem probe failed — console fallback
|
|
127
|
+
return {
|
|
128
|
+
write: writeToConsole,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|