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 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
+ }
@@ -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
+ }