agent-telemetry 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -14
- package/package.json +33 -5
- package/src/adapters/express.ts +176 -0
- package/src/adapters/fastify.ts +191 -0
- package/src/adapters/fetch.ts +133 -0
- package/src/adapters/hono.ts +7 -2
- package/src/adapters/inngest.ts +2 -3
- package/src/adapters/prisma.ts +112 -0
- package/src/adapters/supabase.ts +251 -0
- package/src/error.ts +16 -0
- package/src/index.ts +3 -0
- package/src/traceparent.ts +20 -19
- package/src/types.ts +21 -1
- package/src/writer.ts +134 -59
|
@@ -0,0 +1,133 @@
|
|
|
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 { 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>;
|
|
27
|
+
|
|
28
|
+
/** Options for the traced fetch adapter. */
|
|
29
|
+
export interface TracedFetchOptions {
|
|
30
|
+
/** Telemetry instance to emit events through. */
|
|
31
|
+
telemetry: Telemetry<ExternalCallEvent>;
|
|
32
|
+
/** Base fetch implementation. Default: globalThis.fetch. */
|
|
33
|
+
baseFetch?: FetchFn;
|
|
34
|
+
/** Provide trace context for correlating with a parent HTTP request. */
|
|
35
|
+
getTraceContext?: () => { traceId: string; parentSpanId?: string } | undefined;
|
|
36
|
+
/** Guard function — return false to skip tracing. */
|
|
37
|
+
isEnabled?: () => boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
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
|
+
/**
|
|
66
|
+
* Create a traced fetch function that emits external.call telemetry events.
|
|
67
|
+
*
|
|
68
|
+
* The returned function has the same signature as globalThis.fetch.
|
|
69
|
+
* The original input and init are passed through to baseFetch untouched.
|
|
70
|
+
* Non-2xx responses are returned normally (not thrown). Network errors
|
|
71
|
+
* are emitted as status "error" and re-thrown.
|
|
72
|
+
*/
|
|
73
|
+
export function createTracedFetch(options: TracedFetchOptions): FetchFn {
|
|
74
|
+
const { telemetry, baseFetch = globalThis.fetch, getTraceContext, isEnabled } = options;
|
|
75
|
+
|
|
76
|
+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
77
|
+
if (isEnabled && !isEnabled()) {
|
|
78
|
+
return baseFetch(input, init);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { url, method: resolvedMethod } = resolveInput(input);
|
|
82
|
+
const method = init?.method?.toUpperCase() ?? resolvedMethod;
|
|
83
|
+
|
|
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
|
+
}
|
|
93
|
+
|
|
94
|
+
const operation = `${method} ${pathname}`;
|
|
95
|
+
|
|
96
|
+
const ctx = getTraceContext?.();
|
|
97
|
+
const traceId = ctx?.traceId ?? generateTraceId();
|
|
98
|
+
const spanId = generateSpanId();
|
|
99
|
+
|
|
100
|
+
const start = performance.now();
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const response = await baseFetch(input, init);
|
|
104
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
105
|
+
|
|
106
|
+
telemetry.emit({
|
|
107
|
+
kind: "external.call",
|
|
108
|
+
traceId,
|
|
109
|
+
spanId,
|
|
110
|
+
service,
|
|
111
|
+
operation,
|
|
112
|
+
duration_ms,
|
|
113
|
+
status: "success",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return response;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
119
|
+
|
|
120
|
+
telemetry.emit({
|
|
121
|
+
kind: "external.call",
|
|
122
|
+
traceId,
|
|
123
|
+
spanId,
|
|
124
|
+
service,
|
|
125
|
+
operation,
|
|
126
|
+
duration_ms,
|
|
127
|
+
status: "error",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/adapters/hono.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import type { Context, MiddlewareHandler } from "hono";
|
|
23
23
|
import { extractEntities } from "../entities.ts";
|
|
24
|
+
import { toSafeErrorLabel } from "../error.ts";
|
|
24
25
|
import { generateSpanId, generateTraceId } from "../ids.ts";
|
|
25
26
|
import { formatTraceparent, parseTraceparent } from "../traceparent.ts";
|
|
26
27
|
import type { EntityPattern, HttpRequestEvent, Telemetry } from "../types.ts";
|
|
@@ -67,7 +68,7 @@ export function createHonoTrace(options: HonoTraceOptions): MiddlewareHandler {
|
|
|
67
68
|
try {
|
|
68
69
|
await next();
|
|
69
70
|
} catch (err) {
|
|
70
|
-
error = err
|
|
71
|
+
error = toSafeErrorLabel(err);
|
|
71
72
|
throw err;
|
|
72
73
|
} finally {
|
|
73
74
|
const status = error && c.res.status < 400 ? 500 : c.res.status;
|
|
@@ -90,7 +91,11 @@ export function createHonoTrace(options: HonoTraceOptions): MiddlewareHandler {
|
|
|
90
91
|
if (entities) event.entities = entities;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
if (error)
|
|
94
|
+
if (error) {
|
|
95
|
+
event.error = status >= 500 ? `HTTP ${status}` : error;
|
|
96
|
+
} else if (status >= 500) {
|
|
97
|
+
event.error = `HTTP ${status}`;
|
|
98
|
+
}
|
|
94
99
|
|
|
95
100
|
telemetry.emit(event);
|
|
96
101
|
}
|
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,112 @@
|
|
|
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 { generateSpanId, generateTraceId } from "../ids.ts";
|
|
22
|
+
import type { DbQueryEvent, Telemetry } 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?: () => { traceId: string } | 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 traceId = getTraceContext?.()?.traceId ?? generateTraceId();
|
|
71
|
+
const spanId = generateSpanId();
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const result = await query(args);
|
|
75
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
76
|
+
|
|
77
|
+
const event: DbQueryEvent = {
|
|
78
|
+
kind: "db.query",
|
|
79
|
+
traceId,
|
|
80
|
+
spanId,
|
|
81
|
+
provider: "prisma",
|
|
82
|
+
model,
|
|
83
|
+
operation,
|
|
84
|
+
duration_ms,
|
|
85
|
+
status: "success",
|
|
86
|
+
};
|
|
87
|
+
telemetry.emit(event);
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
92
|
+
|
|
93
|
+
const event: DbQueryEvent = {
|
|
94
|
+
kind: "db.query",
|
|
95
|
+
traceId,
|
|
96
|
+
spanId,
|
|
97
|
+
provider: "prisma",
|
|
98
|
+
model,
|
|
99
|
+
operation,
|
|
100
|
+
duration_ms,
|
|
101
|
+
status: "error",
|
|
102
|
+
error: toSafeErrorLabel(err),
|
|
103
|
+
};
|
|
104
|
+
telemetry.emit(event);
|
|
105
|
+
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
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 { 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>;
|
|
32
|
+
|
|
33
|
+
/** Options for the Supabase trace adapter. */
|
|
34
|
+
export interface SupabaseTraceOptions {
|
|
35
|
+
/** Telemetry instance to emit events through. */
|
|
36
|
+
telemetry: Telemetry<SupabaseEvents>;
|
|
37
|
+
/** Base fetch implementation. Default: globalThis.fetch. */
|
|
38
|
+
baseFetch?: FetchFn;
|
|
39
|
+
/** Provide trace context for correlating with a parent HTTP request. */
|
|
40
|
+
getTraceContext?: () => { traceId: string; parentSpanId?: string } | undefined;
|
|
41
|
+
/** Guard function — return false to skip tracing. */
|
|
42
|
+
isEnabled?: () => boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Classification result for a Supabase URL. */
|
|
46
|
+
type Classification =
|
|
47
|
+
| {
|
|
48
|
+
kind: "db.query";
|
|
49
|
+
provider: "supabase";
|
|
50
|
+
model: string;
|
|
51
|
+
operation: string;
|
|
52
|
+
}
|
|
53
|
+
| { kind: "external.call"; service: string; operation: string };
|
|
54
|
+
|
|
55
|
+
/** HTTP method to PostgREST operation mapping. */
|
|
56
|
+
const METHOD_TO_OPERATION: Record<string, string> = {
|
|
57
|
+
GET: "select",
|
|
58
|
+
POST: "insert",
|
|
59
|
+
PATCH: "update",
|
|
60
|
+
PUT: "upsert",
|
|
61
|
+
DELETE: "delete",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// URL pattern regexes — use /v\d+/ to handle future API versions (I5).
|
|
65
|
+
const REST_RE = /\/rest\/v\d+\/([^?/]+)/;
|
|
66
|
+
const AUTH_RE = /\/auth\/v\d+\/(.+)/;
|
|
67
|
+
const STORAGE_RE = /\/storage\/v\d+\/object\/([^/]+)/;
|
|
68
|
+
const FUNCTIONS_RE = /\/functions\/v\d+\/([^?/]+)/;
|
|
69
|
+
|
|
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
|
+
/**
|
|
96
|
+
* Classify a Supabase request URL into the appropriate event type.
|
|
97
|
+
* Uses the URL pathname to determine if it's a PostgREST, Auth,
|
|
98
|
+
* Storage, Functions, or fallback request.
|
|
99
|
+
*/
|
|
100
|
+
function classifyRequest(url: URL, method: string): Classification {
|
|
101
|
+
const pathname = url.pathname;
|
|
102
|
+
|
|
103
|
+
// PostgREST: /rest/v{N}/{table}
|
|
104
|
+
const restMatch = REST_RE.exec(pathname);
|
|
105
|
+
if (restMatch) {
|
|
106
|
+
const table = restMatch[1];
|
|
107
|
+
const operation = METHOD_TO_OPERATION[method] ?? method.toLowerCase();
|
|
108
|
+
return {
|
|
109
|
+
kind: "db.query",
|
|
110
|
+
provider: "supabase",
|
|
111
|
+
model: table,
|
|
112
|
+
operation,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Auth: /auth/v{N}/{endpoint}
|
|
117
|
+
const authMatch = AUTH_RE.exec(pathname);
|
|
118
|
+
if (authMatch) {
|
|
119
|
+
return {
|
|
120
|
+
kind: "external.call",
|
|
121
|
+
service: "supabase-auth",
|
|
122
|
+
operation: authMatch[1],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Storage: /storage/v{N}/object/{bucket}/...
|
|
127
|
+
const storageMatch = STORAGE_RE.exec(pathname);
|
|
128
|
+
if (storageMatch) {
|
|
129
|
+
return {
|
|
130
|
+
kind: "external.call",
|
|
131
|
+
service: "supabase-storage",
|
|
132
|
+
operation: `${method} ${storageMatch[1]}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Functions: /functions/v{N}/{name}
|
|
137
|
+
const functionsMatch = FUNCTIONS_RE.exec(pathname);
|
|
138
|
+
if (functionsMatch) {
|
|
139
|
+
return {
|
|
140
|
+
kind: "external.call",
|
|
141
|
+
service: "supabase-functions",
|
|
142
|
+
operation: functionsMatch[1],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Fallback: unknown path
|
|
147
|
+
return {
|
|
148
|
+
kind: "external.call",
|
|
149
|
+
service: "supabase",
|
|
150
|
+
operation: `${method} ${pathname}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Create a traced fetch function for Supabase that emits telemetry events.
|
|
156
|
+
*
|
|
157
|
+
* The returned function has the same signature as globalThis.fetch.
|
|
158
|
+
* The original input and init are passed through to baseFetch untouched.
|
|
159
|
+
* The Response object is returned as-is — streaming bodies work correctly.
|
|
160
|
+
*/
|
|
161
|
+
export function createSupabaseTrace(options: SupabaseTraceOptions): FetchFn {
|
|
162
|
+
const { telemetry, baseFetch = globalThis.fetch, getTraceContext, isEnabled } = options;
|
|
163
|
+
|
|
164
|
+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
165
|
+
if (isEnabled && !isEnabled()) {
|
|
166
|
+
return baseFetch(input, init);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Extract metadata only — original input is never modified.
|
|
170
|
+
const { url, method: resolvedMethod } = resolveInput(input);
|
|
171
|
+
const method = init?.method?.toUpperCase() ?? resolvedMethod.toUpperCase();
|
|
172
|
+
|
|
173
|
+
let parsed: URL;
|
|
174
|
+
try {
|
|
175
|
+
parsed = new URL(url);
|
|
176
|
+
} catch {
|
|
177
|
+
parsed = new URL(url, "http://localhost");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const classification = classifyRequest(parsed, method);
|
|
181
|
+
|
|
182
|
+
const ctx = getTraceContext?.();
|
|
183
|
+
const traceId = ctx?.traceId ?? generateTraceId();
|
|
184
|
+
const spanId = generateSpanId();
|
|
185
|
+
|
|
186
|
+
const start = performance.now();
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// Pass ORIGINAL input/init to baseFetch unchanged.
|
|
190
|
+
const response = await baseFetch(input, init);
|
|
191
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
192
|
+
|
|
193
|
+
if (classification.kind === "db.query") {
|
|
194
|
+
const event: DbQueryEvent = {
|
|
195
|
+
kind: "db.query",
|
|
196
|
+
traceId,
|
|
197
|
+
spanId,
|
|
198
|
+
provider: classification.provider,
|
|
199
|
+
model: classification.model,
|
|
200
|
+
operation: classification.operation,
|
|
201
|
+
duration_ms,
|
|
202
|
+
status: "success",
|
|
203
|
+
};
|
|
204
|
+
telemetry.emit(event);
|
|
205
|
+
} else {
|
|
206
|
+
const event: ExternalCallEvent = {
|
|
207
|
+
kind: "external.call",
|
|
208
|
+
traceId,
|
|
209
|
+
spanId,
|
|
210
|
+
service: classification.service,
|
|
211
|
+
operation: classification.operation,
|
|
212
|
+
duration_ms,
|
|
213
|
+
status: "success",
|
|
214
|
+
};
|
|
215
|
+
telemetry.emit(event);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return response;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const duration_ms = Math.round(performance.now() - start);
|
|
221
|
+
|
|
222
|
+
if (classification.kind === "db.query") {
|
|
223
|
+
const event: DbQueryEvent = {
|
|
224
|
+
kind: "db.query",
|
|
225
|
+
traceId,
|
|
226
|
+
spanId,
|
|
227
|
+
provider: classification.provider,
|
|
228
|
+
model: classification.model,
|
|
229
|
+
operation: classification.operation,
|
|
230
|
+
duration_ms,
|
|
231
|
+
status: "error",
|
|
232
|
+
error: toSafeErrorLabel(err),
|
|
233
|
+
};
|
|
234
|
+
telemetry.emit(event);
|
|
235
|
+
} else {
|
|
236
|
+
const event: ExternalCallEvent = {
|
|
237
|
+
kind: "external.call",
|
|
238
|
+
traceId,
|
|
239
|
+
spanId,
|
|
240
|
+
service: classification.service,
|
|
241
|
+
operation: classification.operation,
|
|
242
|
+
duration_ms,
|
|
243
|
+
status: "error",
|
|
244
|
+
};
|
|
245
|
+
telemetry.emit(event);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error helpers.
|
|
3
|
+
*
|
|
4
|
+
* Keeps telemetry error fields low-sensitivity by using stable labels
|
|
5
|
+
* (error names) instead of raw exception messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_ERROR_LABEL = "Error";
|
|
9
|
+
const MAX_ERROR_LABEL_LENGTH = 80;
|
|
10
|
+
|
|
11
|
+
export function toSafeErrorLabel(err: unknown): string {
|
|
12
|
+
if (!(err instanceof Error)) return DEFAULT_ERROR_LABEL;
|
|
13
|
+
const name = err.name.trim();
|
|
14
|
+
if (!name) return DEFAULT_ERROR_LABEL;
|
|
15
|
+
return name.slice(0, MAX_ERROR_LABEL_LENGTH);
|
|
16
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -51,6 +51,8 @@ export async function createTelemetry<TEvent extends BaseTelemetryEvent = BaseTe
|
|
|
51
51
|
// Re-export all public types
|
|
52
52
|
export type {
|
|
53
53
|
BaseTelemetryEvent,
|
|
54
|
+
DbEvents,
|
|
55
|
+
DbQueryEvent,
|
|
54
56
|
EntityPattern,
|
|
55
57
|
ExternalCallEvent,
|
|
56
58
|
ExternalEvents,
|
|
@@ -62,6 +64,7 @@ export type {
|
|
|
62
64
|
JobStartEvent,
|
|
63
65
|
JobStepEvent,
|
|
64
66
|
PresetEvents,
|
|
67
|
+
SupabaseEvents,
|
|
65
68
|
Telemetry,
|
|
66
69
|
TelemetryConfig,
|
|
67
70
|
TraceContext,
|
package/src/traceparent.ts
CHANGED
|
@@ -10,15 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
/** Parsed representation of a `traceparent` header. */
|
|
12
12
|
export interface Traceparent {
|
|
13
|
-
version: string
|
|
14
|
-
traceId: string
|
|
15
|
-
parentId: string
|
|
16
|
-
traceFlags: string
|
|
13
|
+
version: string;
|
|
14
|
+
traceId: string;
|
|
15
|
+
parentId: string;
|
|
16
|
+
traceFlags: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const TRACEPARENT_RE = /^([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})
|
|
20
|
-
const ALL_ZEROS_32 =
|
|
21
|
-
const ALL_ZEROS_16 =
|
|
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
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Parse a `traceparent` header value.
|
|
@@ -27,21 +27,22 @@ const ALL_ZEROS_16 = '0'.repeat(16)
|
|
|
27
27
|
* malformed, or violates the W3C spec (e.g. all-zero trace-id/parent-id).
|
|
28
28
|
*/
|
|
29
29
|
export function parseTraceparent(header: string | undefined | null): Traceparent | null {
|
|
30
|
-
if (!header) return null
|
|
30
|
+
if (!header) return null;
|
|
31
31
|
|
|
32
|
-
const match = TRACEPARENT_RE.exec(header.trim().toLowerCase())
|
|
33
|
-
if (!match) return null
|
|
32
|
+
const match = TRACEPARENT_RE.exec(header.trim().toLowerCase());
|
|
33
|
+
if (!match) return null;
|
|
34
34
|
|
|
35
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
|
|
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
40
|
|
|
41
|
-
// W3C spec:
|
|
42
|
-
if (
|
|
41
|
+
// W3C spec: "ff" is invalid as the version, and all-zero IDs are invalid.
|
|
42
|
+
if (version === "ff") return null;
|
|
43
|
+
if (traceId === ALL_ZEROS_32 || parentId === ALL_ZEROS_16) return null;
|
|
43
44
|
|
|
44
|
-
return { version, traceId, parentId, traceFlags }
|
|
45
|
+
return { version, traceId, parentId, traceFlags };
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -51,6 +52,6 @@ export function parseTraceparent(header: string | undefined | null): Traceparent
|
|
|
51
52
|
* @param parentId 16-char lowercase hex parent/span ID
|
|
52
53
|
* @param flags 2-char hex trace flags (default: "01" = sampled)
|
|
53
54
|
*/
|
|
54
|
-
export function formatTraceparent(traceId: string, parentId: string, flags =
|
|
55
|
-
return `00-${traceId}-${parentId}-${flags}
|
|
55
|
+
export function formatTraceparent(traceId: string, parentId: string, flags = "01"): string {
|
|
56
|
+
return `00-${traceId}-${parentId}-${flags}`;
|
|
56
57
|
}
|
package/src/types.ts
CHANGED
|
@@ -81,8 +81,28 @@ export interface ExternalCallEvent extends BaseTelemetryEvent {
|
|
|
81
81
|
/** All external service call events. */
|
|
82
82
|
export type ExternalEvents = ExternalCallEvent;
|
|
83
83
|
|
|
84
|
+
export interface DbQueryEvent extends BaseTelemetryEvent {
|
|
85
|
+
kind: "db.query";
|
|
86
|
+
spanId: string;
|
|
87
|
+
/** Provider identifier (e.g. "prisma", "supabase", "drizzle"). */
|
|
88
|
+
provider: string;
|
|
89
|
+
/** The data entity being operated on — ORM model name or database table. */
|
|
90
|
+
model?: string;
|
|
91
|
+
/** Operation name (e.g. "findMany", "create", "select", "insert"). */
|
|
92
|
+
operation: string;
|
|
93
|
+
duration_ms: number;
|
|
94
|
+
status: "success" | "error";
|
|
95
|
+
error?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** All database query events. */
|
|
99
|
+
export type DbEvents = DbQueryEvent;
|
|
100
|
+
|
|
101
|
+
/** Events emitted by the Supabase adapter (db.query for PostgREST, external.call for auth/storage/functions). */
|
|
102
|
+
export type SupabaseEvents = DbQueryEvent | ExternalCallEvent;
|
|
103
|
+
|
|
84
104
|
/** Union of all preset event types. */
|
|
85
|
-
export type PresetEvents = HttpEvents | JobEvents | ExternalEvents;
|
|
105
|
+
export type PresetEvents = HttpEvents | JobEvents | ExternalEvents | DbEvents;
|
|
86
106
|
|
|
87
107
|
// ============================================================================
|
|
88
108
|
// Entity Extraction
|