agent-telemetry 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Lightweight JSONL telemetry for easier AI agent consumption. Zero runtime dependencies.
4
4
 
5
- Writes structured telemetry events to rotating JSONL files in development. Falls back to `console.log` in runtimes without filesystem access (Cloudflare Workers). Includes framework adapters for Hono, Inngest, Express, Fastify, Prisma, Supabase, and a generic traced fetch wrapper.
5
+ Writes structured telemetry events to rotating JSONL files in development. Falls back to `console.log` in runtimes without filesystem access (Cloudflare Workers). Includes framework adapters for Hono, Inngest, Express, Fastify, Prisma, Supabase, a generic traced fetch wrapper, and browser trace-context helpers.
6
6
 
7
7
  ## Install
8
8
 
@@ -47,7 +47,7 @@ Inbound HTTP → Database Queries → External API Calls → Background Jo
47
47
  Fastify storage/functions)
48
48
  ```
49
49
 
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.
50
+ One `traceId` follows a request from the HTTP boundary through database queries, external API calls, and into background job execution. `spanId`/`parentSpanId` fields preserve parent-child relationships inside that trace. HTTP adapters use the [W3C `traceparent`](https://www.w3.org/TR/trace-context/) header for propagation, enabling interop with OpenTelemetry and other standards-compliant tools. Query your JSONL logs by `traceId` to see the full chain.
51
51
 
52
52
  ## Full-Stack Example
53
53
 
@@ -152,10 +152,10 @@ app.use('*', trace)
152
152
  The middleware:
153
153
  - Parses the incoming W3C `traceparent` header, or generates a fresh trace ID if absent/invalid
154
154
  - Sets `traceparent` on the response for client-side correlation (format: `00-{traceId}-{spanId}-01`)
155
- - Emits `http.request` events with method, path, status, duration, and extracted entities
155
+ - Emits `http.request` events with method, path, status, duration, extracted entities, and span linkage (`spanId`, `parentSpanId`)
156
156
  - Extracts entity IDs from URL paths — looks for a matching `segment`, then checks if the next segment is a UUID
157
157
 
158
- `getTraceContext(c)` returns `{ _trace: { traceId, parentSpanId } }` for spreading into dispatch payloads. Returns `{}` if no trace middleware is active.
158
+ `getTraceContext(c)` returns `{ _trace: { traceId, parentSpanId, traceFlags } }` for spreading into dispatch payloads. Returns `{}` if no trace middleware is active.
159
159
 
160
160
  ## Inngest Adapter
161
161
 
@@ -188,17 +188,47 @@ const fetch = createTracedFetch({
188
188
  telemetry,
189
189
  baseFetch: globalThis.fetch, // Optional — default: globalThis.fetch
190
190
  getTraceContext: () => ctx, // Optional — correlate with parent request
191
+ propagateTo: (url) => url.origin === 'https://api.my-app.com', // Optional header allowlist
192
+ onResponseTraceparent: (tp) => { // Optional response callback
193
+ console.log(tp)
194
+ },
191
195
  isEnabled: () => true, // Optional guard
192
196
  })
193
197
 
194
198
  const res = await fetch('https://api.stripe.com/v1/charges', { method: 'POST' })
195
199
  ```
196
200
 
197
- - Emits `external.call` events with `service` (hostname) and `operation` (`METHOD /pathname`)
201
+ - Emits `external.call` events with `service` (hostname), `operation` (`METHOD /pathname`), and span linkage (`spanId`, optional `parentSpanId`)
198
202
  - `duration_ms` measures time-to-headers (TTFB) — the Response body is returned untouched for streaming
199
203
  - Handles all three fetch input types: `string`, `URL`, `Request`
204
+ - Can inject outbound `traceparent` headers using `propagateTo` (default: same-origin only in browser, disabled elsewhere)
200
205
  - Non-2xx responses return normally (not thrown); network errors re-throw after emitting
201
206
 
207
+ ## Browser Trace Context
208
+
209
+ Use the browser helpers to continue the same trace from UI requests into server adapters.
210
+
211
+ ```typescript
212
+ import { createBrowserTraceContext, createBrowserTracedFetch } from 'agent-telemetry/browser'
213
+
214
+ const trace = createBrowserTraceContext({
215
+ // Optional SSR bootstrap: <meta name="traceparent" content="00-...">
216
+ initialTraceparent: document.querySelector('meta[name="traceparent"]')?.getAttribute('content'),
217
+ })
218
+
219
+ const fetch = createBrowserTracedFetch({
220
+ trace,
221
+ // Default is same-origin only. Keep this allowlist strict.
222
+ propagateTo: (url) => url.origin === window.location.origin,
223
+ })
224
+
225
+ await fetch('/api/users')
226
+ ```
227
+
228
+ - `createBrowserTraceContext()` bootstraps from `initialTraceparent`, then `<meta name="traceparent">`, then fresh IDs
229
+ - `createBrowserTracedFetch()` injects W3C `traceparent` on allowed requests and can adopt response `traceparent`
230
+ - `trace.withSpan(name, fn)` creates a child span for user actions and restores the previous parent span after completion
231
+
202
232
  ## Prisma Adapter
203
233
 
204
234
  Traces all Prisma model operations via `$extends()`. No runtime `@prisma/client` import — the extension is structurally compatible.
@@ -213,7 +243,7 @@ const prisma = new PrismaClient().$extends(createPrismaTrace({
213
243
  }))
214
244
  ```
215
245
 
216
- - Emits `db.query` events with `provider: "prisma"`, `model` (e.g. `"User"`), and `operation` (e.g. `"findMany"`)
246
+ - Emits `db.query` events with `provider: "prisma"`, `model` (e.g. `"User"`), `operation` (e.g. `"findMany"`), and span linkage (`spanId`, optional `parentSpanId`)
217
247
  - Requires Prisma 5.0.0+ (stable `$extends` API)
218
248
  - No access to raw SQL at the query extension level — model and operation names only
219
249
 
@@ -239,7 +269,7 @@ app.post('/api/users/:id', (req, res) => {
239
269
  })
240
270
  ```
241
271
 
242
- - Emits `http.request` events with method, path (query string stripped), status, duration, entities
272
+ - Emits `http.request` events with method, path (query string stripped), status, duration, entities, and span linkage
243
273
  - Parses/sets W3C `traceparent` header for propagation
244
274
  - Uses `req.route.path` for parameterized patterns (e.g. `/users/:id`), falls back to `req.originalUrl`
245
275
  - Handles both `res.on("finish")` and `res.on("close")` to capture aborted requests
@@ -260,7 +290,7 @@ app.register(createFastifyTrace({
260
290
  }))
261
291
  ```
262
292
 
263
- - Emits `http.request` events using `reply.elapsedTime` for high-resolution duration
293
+ - Emits `http.request` events using `reply.elapsedTime` for high-resolution duration, including span linkage
264
294
  - Strips query strings from emitted `path` values
265
295
  - Parses/sets W3C `traceparent` header for propagation
266
296
  - Uses `request.routeOptions.url` for parameterized route patterns
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-telemetry",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Lightweight JSONL telemetry for AI agent backends. Zero deps, framework adapters included.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -35,6 +35,10 @@
35
35
  "./supabase": {
36
36
  "import": "./src/adapters/supabase.ts",
37
37
  "types": "./src/adapters/supabase.ts"
38
+ },
39
+ "./browser": {
40
+ "import": "./src/browser.ts",
41
+ "types": "./src/browser.ts"
38
42
  }
39
43
  },
40
44
  "files": ["src"],
@@ -17,8 +17,8 @@
17
17
  */
18
18
 
19
19
  import { extractEntities } from "../entities.ts";
20
- import { generateSpanId, generateTraceId } from "../ids.ts";
21
- import { formatTraceparent, parseTraceparent } from "../traceparent.ts";
20
+ import { startSpanFromTraceparent } from "../trace-context.ts";
21
+ import { formatTraceparent } from "../traceparent.ts";
22
22
  import type { EntityPattern, HttpRequestEvent, Telemetry } from "../types.ts";
23
23
 
24
24
  // ============================================================================
@@ -52,7 +52,7 @@ export type ExpressMiddleware = (
52
52
  // Request-Scoped Trace Storage
53
53
  // ============================================================================
54
54
 
55
- const traceStore = new WeakMap<object, { traceId: string; spanId: string }>();
55
+ const traceStore = new WeakMap<object, { traceId: string; spanId: string; traceFlags: string }>();
56
56
 
57
57
  function stripQueryAndFragment(url: string): string {
58
58
  const queryIdx = url.indexOf("?");
@@ -104,12 +104,14 @@ export function createExpressTrace(options: ExpressTraceOptions): ExpressMiddlew
104
104
  const incoming = Array.isArray(req.headers.traceparent)
105
105
  ? req.headers.traceparent[0]
106
106
  : req.headers.traceparent;
107
- const parsed = parseTraceparent(incoming);
108
- const traceId = parsed?.traceId ?? generateTraceId();
109
- const spanId = generateSpanId();
107
+ const span = startSpanFromTraceparent(incoming);
110
108
 
111
- traceStore.set(req, { traceId, spanId });
112
- res.setHeader("traceparent", formatTraceparent(traceId, spanId));
109
+ traceStore.set(req, {
110
+ traceId: span.traceId,
111
+ spanId: span.spanId,
112
+ traceFlags: span.traceFlags,
113
+ });
114
+ res.setHeader("traceparent", formatTraceparent(span.traceId, span.spanId, span.traceFlags));
113
115
 
114
116
  const start = performance.now();
115
117
  let emitted = false;
@@ -124,7 +126,9 @@ export function createExpressTrace(options: ExpressTraceOptions): ExpressMiddlew
124
126
 
125
127
  const event: HttpRequestEvent = {
126
128
  kind: "http.request",
127
- traceId,
129
+ traceId: span.traceId,
130
+ spanId: span.spanId,
131
+ parentSpanId: span.parentSpanId,
128
132
  method: req.method,
129
133
  path,
130
134
  status: res.statusCode,
@@ -169,8 +173,16 @@ export function createExpressTrace(options: ExpressTraceOptions): ExpressMiddlew
169
173
  */
170
174
  export function getTraceContext(
171
175
  req: object,
172
- ): { _trace: { traceId: string; parentSpanId: string } } | Record<string, never> {
176
+ ):
177
+ | { _trace: { traceId: string; parentSpanId: string; traceFlags?: string } }
178
+ | Record<string, never> {
173
179
  const stored = traceStore.get(req);
174
180
  if (!stored) return {};
175
- return { _trace: { traceId: stored.traceId, parentSpanId: stored.spanId } };
181
+ return {
182
+ _trace: {
183
+ traceId: stored.traceId,
184
+ parentSpanId: stored.spanId,
185
+ traceFlags: stored.traceFlags,
186
+ },
187
+ };
176
188
  }
@@ -18,8 +18,8 @@
18
18
  */
19
19
 
20
20
  import { extractEntities } from "../entities.ts";
21
- import { generateSpanId, generateTraceId } from "../ids.ts";
22
- import { formatTraceparent, parseTraceparent } from "../traceparent.ts";
21
+ import { startSpanFromTraceparent } from "../trace-context.ts";
22
+ import { formatTraceparent } from "../traceparent.ts";
23
23
  import type { EntityPattern, HttpRequestEvent, Telemetry } from "../types.ts";
24
24
 
25
25
  // ---------------------------------------------------------------------------
@@ -62,7 +62,10 @@ type FastifyPluginCallback = ((
62
62
  // Trace storage (keyed on Fastify request wrapper, not request.raw)
63
63
  // ---------------------------------------------------------------------------
64
64
 
65
- const traceStore = new WeakMap<object, { traceId: string; spanId: string }>();
65
+ const traceStore = new WeakMap<
66
+ object,
67
+ { traceId: string; spanId: string; parentSpanId?: string; traceFlags: string }
68
+ >();
66
69
 
67
70
  function stripQueryAndFragment(url: string): string {
68
71
  const queryIdx = url.indexOf("?");
@@ -113,12 +116,15 @@ export function createFastifyTrace(options: FastifyTraceOptions): FastifyPluginC
113
116
  const incoming = Array.isArray(request.headers.traceparent)
114
117
  ? request.headers.traceparent[0]
115
118
  : 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));
119
+ const span = startSpanFromTraceparent(incoming);
120
+
121
+ traceStore.set(request, {
122
+ traceId: span.traceId,
123
+ spanId: span.spanId,
124
+ parentSpanId: span.parentSpanId,
125
+ traceFlags: span.traceFlags,
126
+ });
127
+ reply.header("traceparent", formatTraceparent(span.traceId, span.spanId, span.traceFlags));
122
128
  hookDone();
123
129
  });
124
130
 
@@ -136,6 +142,8 @@ export function createFastifyTrace(options: FastifyTraceOptions): FastifyPluginC
136
142
  const event: HttpRequestEvent = {
137
143
  kind: "http.request",
138
144
  traceId: trace.traceId,
145
+ spanId: trace.spanId,
146
+ parentSpanId: trace.parentSpanId,
139
147
  method: request.method,
140
148
  path,
141
149
  status: reply.statusCode,
@@ -183,9 +191,17 @@ export function createFastifyTrace(options: FastifyTraceOptions): FastifyPluginC
183
191
  */
184
192
  export function getTraceContext(
185
193
  request: unknown,
186
- ): { _trace: { traceId: string; parentSpanId: string } } | Record<string, never> {
194
+ ):
195
+ | { _trace: { traceId: string; parentSpanId: string; traceFlags?: string } }
196
+ | Record<string, never> {
187
197
  if (!request || typeof request !== "object") return {};
188
198
  const trace = traceStore.get(request);
189
199
  if (!trace) return {};
190
- return { _trace: { traceId: trace.traceId, parentSpanId: trace.spanId } };
200
+ return {
201
+ _trace: {
202
+ traceId: trace.traceId,
203
+ parentSpanId: trace.spanId,
204
+ traceFlags: trace.traceFlags,
205
+ },
206
+ };
191
207
  }
@@ -19,11 +19,18 @@
19
19
  * ```
20
20
  */
21
21
 
22
- import { generateSpanId, generateTraceId } from "../ids.ts";
23
- import type { ExternalCallEvent, Telemetry } from "../types.ts";
24
-
25
- /** Callable fetch signature (without static properties like `preconnect`). */
26
- export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
22
+ import {
23
+ type FetchFn,
24
+ defaultPropagateTo,
25
+ injectTraceparent,
26
+ resolveInput,
27
+ resolveUrl,
28
+ } from "../fetch-utils.ts";
29
+ import { startSpan } from "../trace-context.ts";
30
+ import { formatTraceparent } from "../traceparent.ts";
31
+ import type { ExternalCallEvent, Telemetry, TraceContext } from "../types.ts";
32
+
33
+ export type { FetchFn } from "../fetch-utils.ts";
27
34
 
28
35
  /** Options for the traced fetch adapter. */
29
36
  export interface TracedFetchOptions {
@@ -32,46 +39,32 @@ export interface TracedFetchOptions {
32
39
  /** Base fetch implementation. Default: globalThis.fetch. */
33
40
  baseFetch?: FetchFn;
34
41
  /** Provide trace context for correlating with a parent HTTP request. */
35
- getTraceContext?: () => { traceId: string; parentSpanId?: string } | undefined;
42
+ getTraceContext?: () => TraceContext | undefined;
43
+ /** Predicate controlling where to forward `traceparent` headers. */
44
+ propagateTo?: (url: URL) => boolean;
45
+ /** Optional callback invoked when responses include `traceparent`. */
46
+ onResponseTraceparent?: (traceparent: string) => void;
36
47
  /** Guard function — return false to skip tracing. */
37
48
  isEnabled?: () => boolean;
38
49
  }
39
50
 
40
- /**
41
- * Extract URL metadata from the three fetch input types.
42
- * This is metadata-only — the original input is never modified.
43
- */
44
- function resolveInput(input: RequestInfo | URL): {
45
- url: string;
46
- method: string;
47
- } {
48
- if (input instanceof Request) {
49
- return { url: input.url, method: input.method };
50
- }
51
- if (input instanceof URL) {
52
- return { url: input.href, method: "GET" };
53
- }
54
- // string — try absolute first, then relative with localhost fallback
55
- try {
56
- return { url: new URL(input).href, method: "GET" };
57
- } catch {
58
- return {
59
- url: new URL(input, "http://localhost").href,
60
- method: "GET",
61
- };
62
- }
63
- }
64
-
65
51
  /**
66
52
  * Create a traced fetch function that emits external.call telemetry events.
67
53
  *
68
54
  * The returned function has the same signature as globalThis.fetch.
69
- * The original input and init are passed through to baseFetch untouched.
55
+ * Request inputs are only cloned when header propagation is enabled.
70
56
  * Non-2xx responses are returned normally (not thrown). Network errors
71
57
  * are emitted as status "error" and re-thrown.
72
58
  */
73
59
  export function createTracedFetch(options: TracedFetchOptions): FetchFn {
74
- const { telemetry, baseFetch = globalThis.fetch, getTraceContext, isEnabled } = options;
60
+ const {
61
+ telemetry,
62
+ baseFetch = globalThis.fetch,
63
+ getTraceContext,
64
+ propagateTo,
65
+ onResponseTraceparent,
66
+ isEnabled,
67
+ } = options;
75
68
 
76
69
  return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
77
70
  if (isEnabled && !isEnabled()) {
@@ -79,34 +72,42 @@ export function createTracedFetch(options: TracedFetchOptions): FetchFn {
79
72
  }
80
73
 
81
74
  const { url, method: resolvedMethod } = resolveInput(input);
82
- const method = init?.method?.toUpperCase() ?? resolvedMethod;
75
+ const method = init?.method?.toUpperCase() ?? resolvedMethod.toUpperCase();
76
+ const parsedUrl = resolveUrl(url);
83
77
 
84
- let service = "unknown";
85
- let pathname = "/";
86
- try {
87
- const parsed = new URL(url);
88
- service = parsed.hostname;
89
- pathname = parsed.pathname;
90
- } catch {
91
- // keep defaults
92
- }
78
+ const service = parsedUrl.hostname;
79
+ const pathname = parsedUrl.pathname;
93
80
 
94
81
  const operation = `${method} ${pathname}`;
95
82
 
96
83
  const ctx = getTraceContext?.();
97
- const traceId = ctx?.traceId ?? generateTraceId();
98
- const spanId = generateSpanId();
84
+ const span = startSpan({
85
+ traceId: ctx?.traceId,
86
+ parentSpanId: ctx?.parentSpanId,
87
+ traceFlags: ctx?.traceFlags,
88
+ });
89
+ const traceparent = formatTraceparent(span.traceId, span.spanId, span.traceFlags);
90
+
91
+ const shouldPropagate = (propagateTo ?? defaultPropagateTo)(parsedUrl);
92
+ const outbound = shouldPropagate
93
+ ? injectTraceparent(input, init, traceparent)
94
+ : { input, init };
99
95
 
100
96
  const start = performance.now();
101
97
 
102
98
  try {
103
- const response = await baseFetch(input, init);
99
+ const response = await baseFetch(outbound.input, outbound.init);
104
100
  const duration_ms = Math.round(performance.now() - start);
101
+ const responseTraceparent = response.headers.get("traceparent");
102
+ if (responseTraceparent) {
103
+ onResponseTraceparent?.(responseTraceparent);
104
+ }
105
105
 
106
106
  telemetry.emit({
107
107
  kind: "external.call",
108
- traceId,
109
- spanId,
108
+ traceId: span.traceId,
109
+ spanId: span.spanId,
110
+ parentSpanId: span.parentSpanId,
110
111
  service,
111
112
  operation,
112
113
  duration_ms,
@@ -119,8 +120,9 @@ export function createTracedFetch(options: TracedFetchOptions): FetchFn {
119
120
 
120
121
  telemetry.emit({
121
122
  kind: "external.call",
122
- traceId,
123
- spanId,
123
+ traceId: span.traceId,
124
+ spanId: span.spanId,
125
+ parentSpanId: span.parentSpanId,
124
126
  service,
125
127
  operation,
126
128
  duration_ms,
@@ -22,8 +22,9 @@
22
22
  import type { Context, MiddlewareHandler } from "hono";
23
23
  import { extractEntities } from "../entities.ts";
24
24
  import { toSafeErrorLabel } from "../error.ts";
25
- import { generateSpanId, generateTraceId } from "../ids.ts";
26
- import { formatTraceparent, parseTraceparent } from "../traceparent.ts";
25
+ import { generateSpanId } from "../ids.ts";
26
+ import { startSpanFromTraceparent } from "../trace-context.ts";
27
+ import { formatTraceparent } from "../traceparent.ts";
27
28
  import type { EntityPattern, HttpRequestEvent, Telemetry } from "../types.ts";
28
29
 
29
30
  /** Options for Hono trace middleware. */
@@ -39,6 +40,7 @@ export interface HonoTraceOptions {
39
40
  /** Hono variable keys for trace storage. */
40
41
  const TRACE_ID_VAR = "traceId" as const;
41
42
  const SPAN_ID_VAR = "spanId" as const;
43
+ const TRACE_FLAGS_VAR = "traceFlags" as const;
42
44
 
43
45
  /**
44
46
  * Create Hono middleware that traces HTTP requests.
@@ -55,12 +57,11 @@ export function createHonoTrace(options: HonoTraceOptions): MiddlewareHandler {
55
57
  return next();
56
58
  }
57
59
 
58
- const parsed = parseTraceparent(c.req.header("traceparent"));
59
- const traceId = parsed?.traceId ?? generateTraceId();
60
- const spanId = generateSpanId();
60
+ const span = startSpanFromTraceparent(c.req.header("traceparent"));
61
61
 
62
- c.set(TRACE_ID_VAR, traceId);
63
- c.set(SPAN_ID_VAR, spanId);
62
+ c.set(TRACE_ID_VAR, span.traceId);
63
+ c.set(SPAN_ID_VAR, span.spanId);
64
+ c.set(TRACE_FLAGS_VAR, span.traceFlags);
64
65
 
65
66
  const start = performance.now();
66
67
  let error: string | undefined;
@@ -75,11 +76,13 @@ export function createHonoTrace(options: HonoTraceOptions): MiddlewareHandler {
75
76
  const duration_ms = Math.round(performance.now() - start);
76
77
  const path = c.req.path;
77
78
 
78
- c.header("traceparent", formatTraceparent(traceId, spanId));
79
+ c.header("traceparent", formatTraceparent(span.traceId, span.spanId, span.traceFlags));
79
80
 
80
81
  const event: HttpRequestEvent = {
81
82
  kind: "http.request",
82
- traceId,
83
+ traceId: span.traceId,
84
+ spanId: span.spanId,
85
+ parentSpanId: span.parentSpanId,
83
86
  method: c.req.method,
84
87
  path,
85
88
  status,
@@ -117,9 +120,12 @@ export function createHonoTrace(options: HonoTraceOptions): MiddlewareHandler {
117
120
  */
118
121
  export function getTraceContext(
119
122
  c: Context,
120
- ): { _trace: { traceId: string; parentSpanId: string } } | Record<string, never> {
123
+ ):
124
+ | { _trace: { traceId: string; parentSpanId: string; traceFlags?: string } }
125
+ | Record<string, never> {
121
126
  const traceId = c.get(TRACE_ID_VAR) as string | undefined;
122
127
  const spanId = c.get(SPAN_ID_VAR) as string | undefined;
128
+ const traceFlags = c.get(TRACE_FLAGS_VAR) as string | undefined;
123
129
  if (!traceId) return {};
124
- return { _trace: { traceId, parentSpanId: spanId ?? generateSpanId() } };
130
+ return { _trace: { traceId, parentSpanId: spanId ?? generateSpanId(), traceFlags } };
125
131
  }
@@ -18,8 +18,8 @@
18
18
  */
19
19
 
20
20
  import { toSafeErrorLabel } from "../error.ts";
21
- import { generateSpanId, generateTraceId } from "../ids.ts";
22
- import type { DbQueryEvent, Telemetry } from "../types.ts";
21
+ import { startSpan } from "../trace-context.ts";
22
+ import type { DbQueryEvent, Telemetry, TraceContext } from "../types.ts";
23
23
 
24
24
  /** Options for the Prisma trace extension. */
25
25
  export interface PrismaTraceOptions {
@@ -28,7 +28,7 @@ export interface PrismaTraceOptions {
28
28
  /** Guard function — return false to skip tracing. */
29
29
  isEnabled?: () => boolean;
30
30
  /** Provide parent trace context for correlating with an incoming request. */
31
- getTraceContext?: () => { traceId: string } | undefined;
31
+ getTraceContext?: () => TraceContext | undefined;
32
32
  }
33
33
 
34
34
  /** Callback params passed by Prisma's $allOperations hook. */
@@ -67,8 +67,12 @@ export function createPrismaTrace(options: PrismaTraceOptions): PrismaTraceExten
67
67
  }
68
68
 
69
69
  const start = performance.now();
70
- const traceId = getTraceContext?.()?.traceId ?? generateTraceId();
71
- const spanId = generateSpanId();
70
+ const ctx = getTraceContext?.();
71
+ const span = startSpan({
72
+ traceId: ctx?.traceId,
73
+ parentSpanId: ctx?.parentSpanId,
74
+ traceFlags: ctx?.traceFlags,
75
+ });
72
76
 
73
77
  try {
74
78
  const result = await query(args);
@@ -76,8 +80,9 @@ export function createPrismaTrace(options: PrismaTraceOptions): PrismaTraceExten
76
80
 
77
81
  const event: DbQueryEvent = {
78
82
  kind: "db.query",
79
- traceId,
80
- spanId,
83
+ traceId: span.traceId,
84
+ spanId: span.spanId,
85
+ parentSpanId: span.parentSpanId,
81
86
  provider: "prisma",
82
87
  model,
83
88
  operation,
@@ -92,8 +97,9 @@ export function createPrismaTrace(options: PrismaTraceOptions): PrismaTraceExten
92
97
 
93
98
  const event: DbQueryEvent = {
94
99
  kind: "db.query",
95
- traceId,
96
- spanId,
100
+ traceId: span.traceId,
101
+ spanId: span.spanId,
102
+ parentSpanId: span.parentSpanId,
97
103
  provider: "prisma",
98
104
  model,
99
105
  operation,
@@ -24,11 +24,17 @@
24
24
  */
25
25
 
26
26
  import { toSafeErrorLabel } from "../error.ts";
27
- import { generateSpanId, generateTraceId } from "../ids.ts";
28
- import type { DbQueryEvent, ExternalCallEvent, SupabaseEvents, Telemetry } from "../types.ts";
29
-
30
- /** Callable fetch signature (without static properties like `preconnect`). */
31
- export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
27
+ import { type FetchFn, resolveInput } from "../fetch-utils.ts";
28
+ import { startSpan } from "../trace-context.ts";
29
+ import type {
30
+ DbQueryEvent,
31
+ ExternalCallEvent,
32
+ SupabaseEvents,
33
+ Telemetry,
34
+ TraceContext,
35
+ } from "../types.ts";
36
+
37
+ export type { FetchFn } from "../fetch-utils.ts";
32
38
 
33
39
  /** Options for the Supabase trace adapter. */
34
40
  export interface SupabaseTraceOptions {
@@ -37,7 +43,7 @@ export interface SupabaseTraceOptions {
37
43
  /** Base fetch implementation. Default: globalThis.fetch. */
38
44
  baseFetch?: FetchFn;
39
45
  /** Provide trace context for correlating with a parent HTTP request. */
40
- getTraceContext?: () => { traceId: string; parentSpanId?: string } | undefined;
46
+ getTraceContext?: () => TraceContext | undefined;
41
47
  /** Guard function — return false to skip tracing. */
42
48
  isEnabled?: () => boolean;
43
49
  }
@@ -67,31 +73,6 @@ const AUTH_RE = /\/auth\/v\d+\/(.+)/;
67
73
  const STORAGE_RE = /\/storage\/v\d+\/object\/([^/]+)/;
68
74
  const FUNCTIONS_RE = /\/functions\/v\d+\/([^?/]+)/;
69
75
 
70
- /**
71
- * Extract URL metadata from the three fetch input types.
72
- * This is metadata-only — the original input is never modified (C3/C4).
73
- */
74
- function resolveInput(input: RequestInfo | URL): {
75
- url: string;
76
- method: string;
77
- } {
78
- if (input instanceof Request) {
79
- return { url: input.url, method: input.method };
80
- }
81
- if (input instanceof URL) {
82
- return { url: input.href, method: "GET" };
83
- }
84
- // string
85
- try {
86
- return { url: new URL(input).href, method: "GET" };
87
- } catch {
88
- return {
89
- url: new URL(input, "http://localhost").href,
90
- method: "GET",
91
- };
92
- }
93
- }
94
-
95
76
  /**
96
77
  * Classify a Supabase request URL into the appropriate event type.
97
78
  * Uses the URL pathname to determine if it's a PostgREST, Auth,
@@ -180,8 +161,11 @@ export function createSupabaseTrace(options: SupabaseTraceOptions): FetchFn {
180
161
  const classification = classifyRequest(parsed, method);
181
162
 
182
163
  const ctx = getTraceContext?.();
183
- const traceId = ctx?.traceId ?? generateTraceId();
184
- const spanId = generateSpanId();
164
+ const span = startSpan({
165
+ traceId: ctx?.traceId,
166
+ parentSpanId: ctx?.parentSpanId,
167
+ traceFlags: ctx?.traceFlags,
168
+ });
185
169
 
186
170
  const start = performance.now();
187
171
 
@@ -193,8 +177,9 @@ export function createSupabaseTrace(options: SupabaseTraceOptions): FetchFn {
193
177
  if (classification.kind === "db.query") {
194
178
  const event: DbQueryEvent = {
195
179
  kind: "db.query",
196
- traceId,
197
- spanId,
180
+ traceId: span.traceId,
181
+ spanId: span.spanId,
182
+ parentSpanId: span.parentSpanId,
198
183
  provider: classification.provider,
199
184
  model: classification.model,
200
185
  operation: classification.operation,
@@ -205,8 +190,9 @@ export function createSupabaseTrace(options: SupabaseTraceOptions): FetchFn {
205
190
  } else {
206
191
  const event: ExternalCallEvent = {
207
192
  kind: "external.call",
208
- traceId,
209
- spanId,
193
+ traceId: span.traceId,
194
+ spanId: span.spanId,
195
+ parentSpanId: span.parentSpanId,
210
196
  service: classification.service,
211
197
  operation: classification.operation,
212
198
  duration_ms,
@@ -222,8 +208,9 @@ export function createSupabaseTrace(options: SupabaseTraceOptions): FetchFn {
222
208
  if (classification.kind === "db.query") {
223
209
  const event: DbQueryEvent = {
224
210
  kind: "db.query",
225
- traceId,
226
- spanId,
211
+ traceId: span.traceId,
212
+ spanId: span.spanId,
213
+ parentSpanId: span.parentSpanId,
227
214
  provider: classification.provider,
228
215
  model: classification.model,
229
216
  operation: classification.operation,
@@ -235,8 +222,9 @@ export function createSupabaseTrace(options: SupabaseTraceOptions): FetchFn {
235
222
  } else {
236
223
  const event: ExternalCallEvent = {
237
224
  kind: "external.call",
238
- traceId,
239
- spanId,
225
+ traceId: span.traceId,
226
+ spanId: span.spanId,
227
+ parentSpanId: span.parentSpanId,
240
228
  service: classification.service,
241
229
  operation: classification.operation,
242
230
  duration_ms,
package/src/browser.ts ADDED
@@ -0,0 +1,184 @@
1
+ import {
2
+ type FetchFn,
3
+ defaultPropagateTo,
4
+ injectTraceparent,
5
+ resolveInput,
6
+ resolveUrl,
7
+ } from "./fetch-utils.ts";
8
+ import { generateSpanId, generateTraceId } from "./ids.ts";
9
+ import { normalizeTraceFlags, startSpan } from "./trace-context.ts";
10
+ import { formatTraceparent, parseTraceparent } from "./traceparent.ts";
11
+ import type { TraceContext } from "./types.ts";
12
+
13
+ export type { FetchFn } from "./fetch-utils.ts";
14
+
15
+ interface BrowserTraceState {
16
+ traceId: string;
17
+ parentSpanId: string;
18
+ traceFlags: string;
19
+ }
20
+
21
+ function readMetaTraceparent(metaName: string): string | undefined {
22
+ if (typeof document === "undefined") return undefined;
23
+ const value = document.querySelector(`meta[name="${metaName}"]`)?.getAttribute("content");
24
+ return value ?? undefined;
25
+ }
26
+
27
+ function toTraceContext(state: BrowserTraceState): TraceContext {
28
+ return {
29
+ traceId: state.traceId,
30
+ parentSpanId: state.parentSpanId,
31
+ traceFlags: state.traceFlags,
32
+ };
33
+ }
34
+
35
+ /** Browser trace context manager for request propagation. */
36
+ export interface BrowserTraceContext {
37
+ /** Get the current trace context for child operations. */
38
+ getTraceContext(): TraceContext;
39
+ /** Get a serialized `traceparent` for the current context. */
40
+ getTraceparent(): string;
41
+ /** Replace the current trace context. */
42
+ setTraceContext(context: TraceContext): void;
43
+ /** Parse and adopt an incoming `traceparent` header value. */
44
+ updateFromTraceparent(traceparent: string | null | undefined): boolean;
45
+ /**
46
+ * Run work under a child span.
47
+ * The callback receives a context whose `parentSpanId` is the created span ID.
48
+ */
49
+ withSpan<T>(
50
+ name: string,
51
+ run: (context: TraceContext & { spanId: string; name: string }) => Promise<T> | T,
52
+ ): Promise<T>;
53
+ }
54
+
55
+ export interface BrowserTraceContextOptions {
56
+ /** Optional bootstrap header value (e.g. from SSR). */
57
+ initialTraceparent?: string | null;
58
+ /** Meta tag name used for bootstrap lookup. Default: "traceparent". */
59
+ metaName?: string;
60
+ }
61
+
62
+ /**
63
+ * Create browser trace context.
64
+ *
65
+ * Bootstrap order:
66
+ * 1) options.initialTraceparent
67
+ * 2) <meta name="traceparent" content="...">
68
+ * 3) fresh trace/span IDs
69
+ */
70
+ export function createBrowserTraceContext(
71
+ options: BrowserTraceContextOptions = {},
72
+ ): BrowserTraceContext {
73
+ const metaName = options.metaName ?? "traceparent";
74
+ const bootstrap = options.initialTraceparent ?? readMetaTraceparent(metaName);
75
+ const parsed = parseTraceparent(bootstrap);
76
+
77
+ const state: BrowserTraceState = {
78
+ traceId: parsed?.traceId ?? generateTraceId(),
79
+ parentSpanId: parsed?.parentId ?? generateSpanId(),
80
+ traceFlags: normalizeTraceFlags(parsed?.traceFlags),
81
+ };
82
+
83
+ const api: BrowserTraceContext = {
84
+ getTraceContext() {
85
+ return toTraceContext(state);
86
+ },
87
+ getTraceparent() {
88
+ return formatTraceparent(state.traceId, state.parentSpanId, state.traceFlags);
89
+ },
90
+ setTraceContext(context) {
91
+ state.traceId = context.traceId;
92
+ state.parentSpanId = context.parentSpanId;
93
+ state.traceFlags = normalizeTraceFlags(context.traceFlags);
94
+ },
95
+ updateFromTraceparent(traceparent) {
96
+ const incoming = parseTraceparent(traceparent);
97
+ if (!incoming) return false;
98
+ state.traceId = incoming.traceId;
99
+ state.parentSpanId = incoming.parentId;
100
+ state.traceFlags = normalizeTraceFlags(incoming.traceFlags);
101
+ return true;
102
+ },
103
+ async withSpan(name, run) {
104
+ const currentParent = state.parentSpanId;
105
+ const span = startSpan({
106
+ traceId: state.traceId,
107
+ parentSpanId: currentParent,
108
+ traceFlags: state.traceFlags,
109
+ });
110
+
111
+ state.parentSpanId = span.spanId;
112
+ try {
113
+ return await run({
114
+ traceId: span.traceId,
115
+ parentSpanId: span.spanId,
116
+ traceFlags: span.traceFlags,
117
+ spanId: span.spanId,
118
+ name,
119
+ });
120
+ } finally {
121
+ state.parentSpanId = currentParent;
122
+ }
123
+ },
124
+ };
125
+
126
+ return api;
127
+ }
128
+
129
+ export interface BrowserTracedFetchOptions {
130
+ /** Base fetch implementation. Default: globalThis.fetch. */
131
+ baseFetch?: FetchFn;
132
+ /** Shared trace context manager. If omitted, a new one is created. */
133
+ trace?: BrowserTraceContext;
134
+ /** Predicate controlling where to forward `traceparent`. Default: same-origin only. */
135
+ propagateTo?: (url: URL) => boolean;
136
+ /** Whether to adopt response `traceparent` headers. Default: true. */
137
+ updateContextFromResponse?: boolean;
138
+ }
139
+
140
+ /**
141
+ * Create a browser fetch wrapper that injects W3C `traceparent`.
142
+ *
143
+ * By default it only propagates headers to same-origin URLs.
144
+ */
145
+ export function createBrowserTracedFetch(options: BrowserTracedFetchOptions = {}): FetchFn {
146
+ const {
147
+ baseFetch = globalThis.fetch,
148
+ trace = createBrowserTraceContext(),
149
+ propagateTo = defaultPropagateTo,
150
+ updateContextFromResponse = true,
151
+ } = options;
152
+
153
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
154
+ const { url } = resolveInput(input);
155
+ const parsedUrl = resolveUrl(url);
156
+
157
+ const ctx = trace.getTraceContext();
158
+ const span = startSpan({
159
+ traceId: ctx.traceId,
160
+ parentSpanId: ctx.parentSpanId,
161
+ traceFlags: ctx.traceFlags,
162
+ });
163
+ const traceparent = formatTraceparent(span.traceId, span.spanId, span.traceFlags);
164
+
165
+ const outbound = propagateTo(parsedUrl)
166
+ ? injectTraceparent(input, init, traceparent)
167
+ : { input, init };
168
+
169
+ const response = await baseFetch(outbound.input, outbound.init);
170
+
171
+ if (updateContextFromResponse) {
172
+ const responseTraceparent = response.headers.get("traceparent");
173
+ if (!responseTraceparent || !trace.updateFromTraceparent(responseTraceparent)) {
174
+ trace.setTraceContext({
175
+ traceId: span.traceId,
176
+ parentSpanId: span.spanId,
177
+ traceFlags: span.traceFlags,
178
+ });
179
+ }
180
+ }
181
+
182
+ return response;
183
+ };
184
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared fetch utilities for adapters that wrap fetch.
3
+ *
4
+ * Used by the traced fetch adapter, Supabase adapter, and browser module
5
+ * to avoid duplicating URL resolution, traceparent injection, and
6
+ * origin detection logic.
7
+ */
8
+
9
+ /** Callable fetch signature (without static properties like `preconnect`). */
10
+ export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
11
+
12
+ export function getLocationOrigin(): string | undefined {
13
+ const globalWithLocation = globalThis as { location?: { origin?: string } };
14
+ return globalWithLocation.location?.origin;
15
+ }
16
+
17
+ export function resolveUrl(url: string): URL {
18
+ const base = getLocationOrigin() ?? "http://localhost";
19
+ return new URL(url, base);
20
+ }
21
+
22
+ export function defaultPropagateTo(url: URL): boolean {
23
+ const origin = getLocationOrigin();
24
+ return origin != null && url.origin === origin;
25
+ }
26
+
27
+ /**
28
+ * Extract URL metadata from the three fetch input types.
29
+ * This is metadata-only — the original input is never modified.
30
+ */
31
+ export function resolveInput(input: RequestInfo | URL): {
32
+ url: string;
33
+ method: string;
34
+ } {
35
+ if (input instanceof Request) {
36
+ return { url: input.url, method: input.method };
37
+ }
38
+ if (input instanceof URL) {
39
+ return { url: input.href, method: "GET" };
40
+ }
41
+ // string — try absolute first, then relative with location-aware fallback
42
+ try {
43
+ return { url: new URL(input).href, method: "GET" };
44
+ } catch {
45
+ return {
46
+ url: resolveUrl(input).href,
47
+ method: "GET",
48
+ };
49
+ }
50
+ }
51
+
52
+ export function injectTraceparent(
53
+ input: RequestInfo | URL,
54
+ init: RequestInit | undefined,
55
+ traceparent: string,
56
+ ): { input: RequestInfo | URL; init: RequestInit | undefined } {
57
+ if (input instanceof Request) {
58
+ const request = new Request(input, init);
59
+ const headers = new Headers(request.headers);
60
+ headers.set("traceparent", traceparent);
61
+ return { input: new Request(request, { headers }), init: undefined };
62
+ }
63
+
64
+ const headers = new Headers(init?.headers);
65
+ headers.set("traceparent", traceparent);
66
+ return { input, init: { ...init, headers } };
67
+ }
@@ -0,0 +1,41 @@
1
+ import { generateSpanId, generateTraceId } from "./ids.ts";
2
+ import { parseTraceparent } from "./traceparent.ts";
3
+
4
+ const TRACE_FLAGS_RE = /^[\da-f]{2}$/;
5
+
6
+ export function normalizeTraceFlags(traceFlags: string | undefined): string {
7
+ if (!traceFlags) return "01";
8
+ const normalized = traceFlags.toLowerCase();
9
+ return TRACE_FLAGS_RE.test(normalized) ? normalized : "01";
10
+ }
11
+
12
+ export interface SpanStartOptions {
13
+ traceId?: string;
14
+ parentSpanId?: string;
15
+ traceFlags?: string;
16
+ }
17
+
18
+ export interface SpanContext {
19
+ traceId: string;
20
+ spanId: string;
21
+ parentSpanId?: string;
22
+ traceFlags: string;
23
+ }
24
+
25
+ export function startSpan(options: SpanStartOptions = {}): SpanContext {
26
+ return {
27
+ traceId: options.traceId ?? generateTraceId(),
28
+ spanId: generateSpanId(),
29
+ parentSpanId: options.parentSpanId,
30
+ traceFlags: normalizeTraceFlags(options.traceFlags),
31
+ };
32
+ }
33
+
34
+ export function startSpanFromTraceparent(header: string | null | undefined): SpanContext {
35
+ const parsed = parseTraceparent(header);
36
+ return startSpan({
37
+ traceId: parsed?.traceId,
38
+ parentSpanId: parsed?.parentId,
39
+ traceFlags: parsed?.traceFlags,
40
+ });
41
+ }
package/src/types.ts CHANGED
@@ -22,6 +22,8 @@ export interface BaseTelemetryEvent {
22
22
 
23
23
  export interface HttpRequestEvent extends BaseTelemetryEvent {
24
24
  kind: "http.request";
25
+ spanId?: string;
26
+ parentSpanId?: string;
25
27
  method: string;
26
28
  path: string;
27
29
  status: number;
@@ -72,6 +74,7 @@ export type JobEvents = JobStartEvent | JobEndEvent | JobDispatchEvent | JobStep
72
74
  export interface ExternalCallEvent extends BaseTelemetryEvent {
73
75
  kind: "external.call";
74
76
  spanId: string;
77
+ parentSpanId?: string;
75
78
  service: string;
76
79
  operation: string;
77
80
  duration_ms: number;
@@ -84,6 +87,7 @@ export type ExternalEvents = ExternalCallEvent;
84
87
  export interface DbQueryEvent extends BaseTelemetryEvent {
85
88
  kind: "db.query";
86
89
  spanId: string;
90
+ parentSpanId?: string;
87
91
  /** Provider identifier (e.g. "prisma", "supabase", "drizzle"). */
88
92
  provider: string;
89
93
  /** The data entity being operated on — ORM model name or database table. */