agent-telemetry 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -16
- package/package.json +37 -5
- package/src/adapters/express.ts +188 -0
- package/src/adapters/fastify.ts +207 -0
- package/src/adapters/fetch.ts +135 -0
- package/src/adapters/hono.ts +24 -13
- package/src/adapters/inngest.ts +2 -3
- package/src/adapters/prisma.ts +118 -0
- package/src/adapters/supabase.ts +239 -0
- package/src/browser.ts +184 -0
- package/src/error.ts +16 -0
- package/src/fetch-utils.ts +67 -0
- package/src/index.ts +3 -0
- package/src/trace-context.ts +41 -0
- package/src/traceparent.ts +20 -19
- package/src/types.ts +25 -1
- package/src/writer.ts +134 -59
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traced Fetch Adapter
|
|
3
|
+
*
|
|
4
|
+
* Wraps fetch with telemetry for external service calls. Does NOT monkey-patch
|
|
5
|
+
* the global — returns a new function with identical semantics.
|
|
6
|
+
*
|
|
7
|
+
* duration_ms measures time-to-headers (TTFB), not total transfer time.
|
|
8
|
+
* The Response object is returned untouched — streaming bodies work correctly.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createTelemetry, type ExternalEvents } from 'agent-telemetry'
|
|
13
|
+
* import { createTracedFetch } from 'agent-telemetry/fetch'
|
|
14
|
+
*
|
|
15
|
+
* const telemetry = await createTelemetry<ExternalEvents>()
|
|
16
|
+
* const fetch = createTracedFetch({ telemetry })
|
|
17
|
+
*
|
|
18
|
+
* const res = await fetch('https://api.example.com/users')
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
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";
|
|
34
|
+
|
|
35
|
+
/** Options for the traced fetch adapter. */
|
|
36
|
+
export interface TracedFetchOptions {
|
|
37
|
+
/** Telemetry instance to emit events through. */
|
|
38
|
+
telemetry: Telemetry<ExternalCallEvent>;
|
|
39
|
+
/** Base fetch implementation. Default: globalThis.fetch. */
|
|
40
|
+
baseFetch?: FetchFn;
|
|
41
|
+
/** Provide trace context for correlating with a parent HTTP request. */
|
|
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;
|
|
47
|
+
/** Guard function — return false to skip tracing. */
|
|
48
|
+
isEnabled?: () => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a traced fetch function that emits external.call telemetry events.
|
|
53
|
+
*
|
|
54
|
+
* The returned function has the same signature as globalThis.fetch.
|
|
55
|
+
* Request inputs are only cloned when header propagation is enabled.
|
|
56
|
+
* Non-2xx responses are returned normally (not thrown). Network errors
|
|
57
|
+
* are emitted as status "error" and re-thrown.
|
|
58
|
+
*/
|
|
59
|
+
export function createTracedFetch(options: TracedFetchOptions): FetchFn {
|
|
60
|
+
const {
|
|
61
|
+
telemetry,
|
|
62
|
+
baseFetch = globalThis.fetch,
|
|
63
|
+
getTraceContext,
|
|
64
|
+
propagateTo,
|
|
65
|
+
onResponseTraceparent,
|
|
66
|
+
isEnabled,
|
|
67
|
+
} = options;
|
|
68
|
+
|
|
69
|
+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
70
|
+
if (isEnabled && !isEnabled()) {
|
|
71
|
+
return baseFetch(input, init);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { url, method: resolvedMethod } = resolveInput(input);
|
|
75
|
+
const method = init?.method?.toUpperCase() ?? resolvedMethod.toUpperCase();
|
|
76
|
+
const parsedUrl = resolveUrl(url);
|
|
77
|
+
|
|
78
|
+
const service = parsedUrl.hostname;
|
|
79
|
+
const pathname = parsedUrl.pathname;
|
|
80
|
+
|
|
81
|
+
const operation = `${method} ${pathname}`;
|
|
82
|
+
|
|
83
|
+
const ctx = getTraceContext?.();
|
|
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 };
|
|
95
|
+
|
|
96
|
+
const start = performance.now();
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const response = await baseFetch(outbound.input, outbound.init);
|
|
100
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
101
|
+
const responseTraceparent = response.headers.get("traceparent");
|
|
102
|
+
if (responseTraceparent) {
|
|
103
|
+
onResponseTraceparent?.(responseTraceparent);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
telemetry.emit({
|
|
107
|
+
kind: "external.call",
|
|
108
|
+
traceId: span.traceId,
|
|
109
|
+
spanId: span.spanId,
|
|
110
|
+
parentSpanId: span.parentSpanId,
|
|
111
|
+
service,
|
|
112
|
+
operation,
|
|
113
|
+
duration_ms,
|
|
114
|
+
status: "success",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return response;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
120
|
+
|
|
121
|
+
telemetry.emit({
|
|
122
|
+
kind: "external.call",
|
|
123
|
+
traceId: span.traceId,
|
|
124
|
+
spanId: span.spanId,
|
|
125
|
+
parentSpanId: span.parentSpanId,
|
|
126
|
+
service,
|
|
127
|
+
operation,
|
|
128
|
+
duration_ms,
|
|
129
|
+
status: "error",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
package/src/adapters/hono.ts
CHANGED
|
@@ -21,8 +21,10 @@
|
|
|
21
21
|
|
|
22
22
|
import type { Context, MiddlewareHandler } from "hono";
|
|
23
23
|
import { extractEntities } from "../entities.ts";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { toSafeErrorLabel } from "../error.ts";
|
|
25
|
+
import { generateSpanId } from "../ids.ts";
|
|
26
|
+
import { startSpanFromTraceparent } from "../trace-context.ts";
|
|
27
|
+
import { formatTraceparent } from "../traceparent.ts";
|
|
26
28
|
import type { EntityPattern, HttpRequestEvent, Telemetry } from "../types.ts";
|
|
27
29
|
|
|
28
30
|
/** Options for Hono trace middleware. */
|
|
@@ -38,6 +40,7 @@ export interface HonoTraceOptions {
|
|
|
38
40
|
/** Hono variable keys for trace storage. */
|
|
39
41
|
const TRACE_ID_VAR = "traceId" as const;
|
|
40
42
|
const SPAN_ID_VAR = "spanId" as const;
|
|
43
|
+
const TRACE_FLAGS_VAR = "traceFlags" as const;
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
46
|
* Create Hono middleware that traces HTTP requests.
|
|
@@ -54,12 +57,11 @@ export function createHonoTrace(options: HonoTraceOptions): MiddlewareHandler {
|
|
|
54
57
|
return next();
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
const
|
|
58
|
-
const traceId = parsed?.traceId ?? generateTraceId();
|
|
59
|
-
const spanId = generateSpanId();
|
|
60
|
+
const span = startSpanFromTraceparent(c.req.header("traceparent"));
|
|
60
61
|
|
|
61
|
-
c.set(TRACE_ID_VAR, traceId);
|
|
62
|
-
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);
|
|
63
65
|
|
|
64
66
|
const start = performance.now();
|
|
65
67
|
let error: string | undefined;
|
|
@@ -67,18 +69,20 @@ export function createHonoTrace(options: HonoTraceOptions): MiddlewareHandler {
|
|
|
67
69
|
try {
|
|
68
70
|
await next();
|
|
69
71
|
} catch (err) {
|
|
70
|
-
error = err
|
|
72
|
+
error = toSafeErrorLabel(err);
|
|
71
73
|
throw err;
|
|
72
74
|
} finally {
|
|
73
75
|
const status = error && c.res.status < 400 ? 500 : c.res.status;
|
|
74
76
|
const duration_ms = Math.round(performance.now() - start);
|
|
75
77
|
const path = c.req.path;
|
|
76
78
|
|
|
77
|
-
c.header("traceparent", formatTraceparent(traceId, spanId));
|
|
79
|
+
c.header("traceparent", formatTraceparent(span.traceId, span.spanId, span.traceFlags));
|
|
78
80
|
|
|
79
81
|
const event: HttpRequestEvent = {
|
|
80
82
|
kind: "http.request",
|
|
81
|
-
traceId,
|
|
83
|
+
traceId: span.traceId,
|
|
84
|
+
spanId: span.spanId,
|
|
85
|
+
parentSpanId: span.parentSpanId,
|
|
82
86
|
method: c.req.method,
|
|
83
87
|
path,
|
|
84
88
|
status,
|
|
@@ -90,7 +94,11 @@ export function createHonoTrace(options: HonoTraceOptions): MiddlewareHandler {
|
|
|
90
94
|
if (entities) event.entities = entities;
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
if (error)
|
|
97
|
+
if (error) {
|
|
98
|
+
event.error = status >= 500 ? `HTTP ${status}` : error;
|
|
99
|
+
} else if (status >= 500) {
|
|
100
|
+
event.error = `HTTP ${status}`;
|
|
101
|
+
}
|
|
94
102
|
|
|
95
103
|
telemetry.emit(event);
|
|
96
104
|
}
|
|
@@ -112,9 +120,12 @@ export function createHonoTrace(options: HonoTraceOptions): MiddlewareHandler {
|
|
|
112
120
|
*/
|
|
113
121
|
export function getTraceContext(
|
|
114
122
|
c: Context,
|
|
115
|
-
):
|
|
123
|
+
):
|
|
124
|
+
| { _trace: { traceId: string; parentSpanId: string; traceFlags?: string } }
|
|
125
|
+
| Record<string, never> {
|
|
116
126
|
const traceId = c.get(TRACE_ID_VAR) as string | undefined;
|
|
117
127
|
const spanId = c.get(SPAN_ID_VAR) as string | undefined;
|
|
128
|
+
const traceFlags = c.get(TRACE_FLAGS_VAR) as string | undefined;
|
|
118
129
|
if (!traceId) return {};
|
|
119
|
-
return { _trace: { traceId, parentSpanId: spanId ?? generateSpanId() } };
|
|
130
|
+
return { _trace: { traceId, parentSpanId: spanId ?? generateSpanId(), traceFlags } };
|
|
120
131
|
}
|
package/src/adapters/inngest.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { InngestMiddleware } from "inngest";
|
|
20
20
|
import { extractEntitiesFromEvent } from "../entities.ts";
|
|
21
|
+
import { toSafeErrorLabel } from "../error.ts";
|
|
21
22
|
import { generateSpanId, generateTraceId } from "../ids.ts";
|
|
22
23
|
import type {
|
|
23
24
|
JobDispatchEvent,
|
|
@@ -86,9 +87,7 @@ export function createInngestTrace(options: InngestTraceOptions): InngestMiddlew
|
|
|
86
87
|
runId,
|
|
87
88
|
duration_ms,
|
|
88
89
|
status: hasError ? "error" : "success",
|
|
89
|
-
error: hasError
|
|
90
|
-
? ((result.error as Error)?.message ?? String(result.error))
|
|
91
|
-
: undefined,
|
|
90
|
+
error: hasError ? toSafeErrorLabel(result.error) : undefined,
|
|
92
91
|
};
|
|
93
92
|
telemetry.emit(endEvent);
|
|
94
93
|
},
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma Adapter
|
|
3
|
+
*
|
|
4
|
+
* Creates a Prisma client extension that emits db.query telemetry events
|
|
5
|
+
* for all model operations. Uses $extends({ query }) — no $use() middleware.
|
|
6
|
+
*
|
|
7
|
+
* No runtime import of @prisma/client — the extension object is structurally
|
|
8
|
+
* compatible with PrismaClient.$extends().
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createTelemetry, type DbEvents } from 'agent-telemetry'
|
|
13
|
+
* import { createPrismaTrace } from 'agent-telemetry/prisma'
|
|
14
|
+
*
|
|
15
|
+
* const telemetry = await createTelemetry<DbEvents>()
|
|
16
|
+
* const prisma = new PrismaClient().$extends(createPrismaTrace({ telemetry }))
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { toSafeErrorLabel } from "../error.ts";
|
|
21
|
+
import { startSpan } from "../trace-context.ts";
|
|
22
|
+
import type { DbQueryEvent, Telemetry, TraceContext } from "../types.ts";
|
|
23
|
+
|
|
24
|
+
/** Options for the Prisma trace extension. */
|
|
25
|
+
export interface PrismaTraceOptions {
|
|
26
|
+
/** Telemetry instance to emit events through. */
|
|
27
|
+
telemetry: Telemetry<DbQueryEvent>;
|
|
28
|
+
/** Guard function — return false to skip tracing. */
|
|
29
|
+
isEnabled?: () => boolean;
|
|
30
|
+
/** Provide parent trace context for correlating with an incoming request. */
|
|
31
|
+
getTraceContext?: () => TraceContext | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Callback params passed by Prisma's $allOperations hook. */
|
|
35
|
+
interface AllOperationsParams {
|
|
36
|
+
model: string;
|
|
37
|
+
operation: string;
|
|
38
|
+
args: unknown;
|
|
39
|
+
query: (args: unknown) => Promise<unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Shape returned by createPrismaTrace, compatible with PrismaClient.$extends(). */
|
|
43
|
+
export interface PrismaTraceExtension {
|
|
44
|
+
query: {
|
|
45
|
+
$allModels: {
|
|
46
|
+
$allOperations(params: AllOperationsParams): Promise<unknown>;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a Prisma client extension that traces all model queries.
|
|
53
|
+
*
|
|
54
|
+
* Returns a plain object compatible with `PrismaClient.$extends()`.
|
|
55
|
+
* Emits a db.query event for every model operation with timing,
|
|
56
|
+
* status, and optional trace context correlation.
|
|
57
|
+
*/
|
|
58
|
+
export function createPrismaTrace(options: PrismaTraceOptions): PrismaTraceExtension {
|
|
59
|
+
const { telemetry, isEnabled, getTraceContext } = options;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
query: {
|
|
63
|
+
$allModels: {
|
|
64
|
+
async $allOperations({ model, operation, args, query }) {
|
|
65
|
+
if (isEnabled && !isEnabled()) {
|
|
66
|
+
return query(args);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const start = performance.now();
|
|
70
|
+
const ctx = getTraceContext?.();
|
|
71
|
+
const span = startSpan({
|
|
72
|
+
traceId: ctx?.traceId,
|
|
73
|
+
parentSpanId: ctx?.parentSpanId,
|
|
74
|
+
traceFlags: ctx?.traceFlags,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const result = await query(args);
|
|
79
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
80
|
+
|
|
81
|
+
const event: DbQueryEvent = {
|
|
82
|
+
kind: "db.query",
|
|
83
|
+
traceId: span.traceId,
|
|
84
|
+
spanId: span.spanId,
|
|
85
|
+
parentSpanId: span.parentSpanId,
|
|
86
|
+
provider: "prisma",
|
|
87
|
+
model,
|
|
88
|
+
operation,
|
|
89
|
+
duration_ms,
|
|
90
|
+
status: "success",
|
|
91
|
+
};
|
|
92
|
+
telemetry.emit(event);
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
97
|
+
|
|
98
|
+
const event: DbQueryEvent = {
|
|
99
|
+
kind: "db.query",
|
|
100
|
+
traceId: span.traceId,
|
|
101
|
+
spanId: span.spanId,
|
|
102
|
+
parentSpanId: span.parentSpanId,
|
|
103
|
+
provider: "prisma",
|
|
104
|
+
model,
|
|
105
|
+
operation,
|
|
106
|
+
duration_ms,
|
|
107
|
+
status: "error",
|
|
108
|
+
error: toSafeErrorLabel(err),
|
|
109
|
+
};
|
|
110
|
+
telemetry.emit(event);
|
|
111
|
+
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase Adapter
|
|
3
|
+
*
|
|
4
|
+
* Creates a traced fetch function for Supabase's createClient({ global: { fetch } }).
|
|
5
|
+
* Parses Supabase URL patterns to emit rich, service-aware telemetry:
|
|
6
|
+
* - PostgREST calls -> db.query events with table/operation
|
|
7
|
+
* - Auth/Storage/Functions calls -> external.call events with service context
|
|
8
|
+
*
|
|
9
|
+
* Each fetch invocation emits one event. Supabase's built-in retry logic
|
|
10
|
+
* will generate separate events per retry — each is a real network call.
|
|
11
|
+
*
|
|
12
|
+
* duration_ms measures time-to-headers (TTFB). Response is returned untouched.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { createClient } from '@supabase/supabase-js'
|
|
17
|
+
* import { createTelemetry, type SupabaseEvents } from 'agent-telemetry'
|
|
18
|
+
* import { createSupabaseTrace } from 'agent-telemetry/supabase'
|
|
19
|
+
*
|
|
20
|
+
* const telemetry = await createTelemetry<SupabaseEvents>()
|
|
21
|
+
* const tracedFetch = createSupabaseTrace({ telemetry })
|
|
22
|
+
* const supabase = createClient(url, key, { global: { fetch: tracedFetch } })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { toSafeErrorLabel } from "../error.ts";
|
|
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";
|
|
38
|
+
|
|
39
|
+
/** Options for the Supabase trace adapter. */
|
|
40
|
+
export interface SupabaseTraceOptions {
|
|
41
|
+
/** Telemetry instance to emit events through. */
|
|
42
|
+
telemetry: Telemetry<SupabaseEvents>;
|
|
43
|
+
/** Base fetch implementation. Default: globalThis.fetch. */
|
|
44
|
+
baseFetch?: FetchFn;
|
|
45
|
+
/** Provide trace context for correlating with a parent HTTP request. */
|
|
46
|
+
getTraceContext?: () => TraceContext | undefined;
|
|
47
|
+
/** Guard function — return false to skip tracing. */
|
|
48
|
+
isEnabled?: () => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Classification result for a Supabase URL. */
|
|
52
|
+
type Classification =
|
|
53
|
+
| {
|
|
54
|
+
kind: "db.query";
|
|
55
|
+
provider: "supabase";
|
|
56
|
+
model: string;
|
|
57
|
+
operation: string;
|
|
58
|
+
}
|
|
59
|
+
| { kind: "external.call"; service: string; operation: string };
|
|
60
|
+
|
|
61
|
+
/** HTTP method to PostgREST operation mapping. */
|
|
62
|
+
const METHOD_TO_OPERATION: Record<string, string> = {
|
|
63
|
+
GET: "select",
|
|
64
|
+
POST: "insert",
|
|
65
|
+
PATCH: "update",
|
|
66
|
+
PUT: "upsert",
|
|
67
|
+
DELETE: "delete",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// URL pattern regexes — use /v\d+/ to handle future API versions (I5).
|
|
71
|
+
const REST_RE = /\/rest\/v\d+\/([^?/]+)/;
|
|
72
|
+
const AUTH_RE = /\/auth\/v\d+\/(.+)/;
|
|
73
|
+
const STORAGE_RE = /\/storage\/v\d+\/object\/([^/]+)/;
|
|
74
|
+
const FUNCTIONS_RE = /\/functions\/v\d+\/([^?/]+)/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Classify a Supabase request URL into the appropriate event type.
|
|
78
|
+
* Uses the URL pathname to determine if it's a PostgREST, Auth,
|
|
79
|
+
* Storage, Functions, or fallback request.
|
|
80
|
+
*/
|
|
81
|
+
function classifyRequest(url: URL, method: string): Classification {
|
|
82
|
+
const pathname = url.pathname;
|
|
83
|
+
|
|
84
|
+
// PostgREST: /rest/v{N}/{table}
|
|
85
|
+
const restMatch = REST_RE.exec(pathname);
|
|
86
|
+
if (restMatch) {
|
|
87
|
+
const table = restMatch[1];
|
|
88
|
+
const operation = METHOD_TO_OPERATION[method] ?? method.toLowerCase();
|
|
89
|
+
return {
|
|
90
|
+
kind: "db.query",
|
|
91
|
+
provider: "supabase",
|
|
92
|
+
model: table,
|
|
93
|
+
operation,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Auth: /auth/v{N}/{endpoint}
|
|
98
|
+
const authMatch = AUTH_RE.exec(pathname);
|
|
99
|
+
if (authMatch) {
|
|
100
|
+
return {
|
|
101
|
+
kind: "external.call",
|
|
102
|
+
service: "supabase-auth",
|
|
103
|
+
operation: authMatch[1],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Storage: /storage/v{N}/object/{bucket}/...
|
|
108
|
+
const storageMatch = STORAGE_RE.exec(pathname);
|
|
109
|
+
if (storageMatch) {
|
|
110
|
+
return {
|
|
111
|
+
kind: "external.call",
|
|
112
|
+
service: "supabase-storage",
|
|
113
|
+
operation: `${method} ${storageMatch[1]}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Functions: /functions/v{N}/{name}
|
|
118
|
+
const functionsMatch = FUNCTIONS_RE.exec(pathname);
|
|
119
|
+
if (functionsMatch) {
|
|
120
|
+
return {
|
|
121
|
+
kind: "external.call",
|
|
122
|
+
service: "supabase-functions",
|
|
123
|
+
operation: functionsMatch[1],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fallback: unknown path
|
|
128
|
+
return {
|
|
129
|
+
kind: "external.call",
|
|
130
|
+
service: "supabase",
|
|
131
|
+
operation: `${method} ${pathname}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create a traced fetch function for Supabase that emits telemetry events.
|
|
137
|
+
*
|
|
138
|
+
* The returned function has the same signature as globalThis.fetch.
|
|
139
|
+
* The original input and init are passed through to baseFetch untouched.
|
|
140
|
+
* The Response object is returned as-is — streaming bodies work correctly.
|
|
141
|
+
*/
|
|
142
|
+
export function createSupabaseTrace(options: SupabaseTraceOptions): FetchFn {
|
|
143
|
+
const { telemetry, baseFetch = globalThis.fetch, getTraceContext, isEnabled } = options;
|
|
144
|
+
|
|
145
|
+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
146
|
+
if (isEnabled && !isEnabled()) {
|
|
147
|
+
return baseFetch(input, init);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Extract metadata only — original input is never modified.
|
|
151
|
+
const { url, method: resolvedMethod } = resolveInput(input);
|
|
152
|
+
const method = init?.method?.toUpperCase() ?? resolvedMethod.toUpperCase();
|
|
153
|
+
|
|
154
|
+
let parsed: URL;
|
|
155
|
+
try {
|
|
156
|
+
parsed = new URL(url);
|
|
157
|
+
} catch {
|
|
158
|
+
parsed = new URL(url, "http://localhost");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const classification = classifyRequest(parsed, method);
|
|
162
|
+
|
|
163
|
+
const ctx = getTraceContext?.();
|
|
164
|
+
const span = startSpan({
|
|
165
|
+
traceId: ctx?.traceId,
|
|
166
|
+
parentSpanId: ctx?.parentSpanId,
|
|
167
|
+
traceFlags: ctx?.traceFlags,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const start = performance.now();
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
// Pass ORIGINAL input/init to baseFetch unchanged.
|
|
174
|
+
const response = await baseFetch(input, init);
|
|
175
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
176
|
+
|
|
177
|
+
if (classification.kind === "db.query") {
|
|
178
|
+
const event: DbQueryEvent = {
|
|
179
|
+
kind: "db.query",
|
|
180
|
+
traceId: span.traceId,
|
|
181
|
+
spanId: span.spanId,
|
|
182
|
+
parentSpanId: span.parentSpanId,
|
|
183
|
+
provider: classification.provider,
|
|
184
|
+
model: classification.model,
|
|
185
|
+
operation: classification.operation,
|
|
186
|
+
duration_ms,
|
|
187
|
+
status: "success",
|
|
188
|
+
};
|
|
189
|
+
telemetry.emit(event);
|
|
190
|
+
} else {
|
|
191
|
+
const event: ExternalCallEvent = {
|
|
192
|
+
kind: "external.call",
|
|
193
|
+
traceId: span.traceId,
|
|
194
|
+
spanId: span.spanId,
|
|
195
|
+
parentSpanId: span.parentSpanId,
|
|
196
|
+
service: classification.service,
|
|
197
|
+
operation: classification.operation,
|
|
198
|
+
duration_ms,
|
|
199
|
+
status: "success",
|
|
200
|
+
};
|
|
201
|
+
telemetry.emit(event);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return response;
|
|
205
|
+
} catch (err) {
|
|
206
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
207
|
+
|
|
208
|
+
if (classification.kind === "db.query") {
|
|
209
|
+
const event: DbQueryEvent = {
|
|
210
|
+
kind: "db.query",
|
|
211
|
+
traceId: span.traceId,
|
|
212
|
+
spanId: span.spanId,
|
|
213
|
+
parentSpanId: span.parentSpanId,
|
|
214
|
+
provider: classification.provider,
|
|
215
|
+
model: classification.model,
|
|
216
|
+
operation: classification.operation,
|
|
217
|
+
duration_ms,
|
|
218
|
+
status: "error",
|
|
219
|
+
error: toSafeErrorLabel(err),
|
|
220
|
+
};
|
|
221
|
+
telemetry.emit(event);
|
|
222
|
+
} else {
|
|
223
|
+
const event: ExternalCallEvent = {
|
|
224
|
+
kind: "external.call",
|
|
225
|
+
traceId: span.traceId,
|
|
226
|
+
spanId: span.spanId,
|
|
227
|
+
parentSpanId: span.parentSpanId,
|
|
228
|
+
service: classification.service,
|
|
229
|
+
operation: classification.operation,
|
|
230
|
+
duration_ms,
|
|
231
|
+
status: "error",
|
|
232
|
+
};
|
|
233
|
+
telemetry.emit(event);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|