silgi 0.1.0-beta.3 → 0.1.0-beta.4

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.
@@ -0,0 +1,286 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ //#region src/integrations/drizzle/index.ts
3
+ /**
4
+ * Silgi + Drizzle ORM tracing integration.
5
+ *
6
+ * Wraps Drizzle session methods to intercept every query and record
7
+ * spans in silgi analytics. Uses AsyncLocalStorage to bridge request
8
+ * context to the DB layer.
9
+ *
10
+ * Patching priority (inspired by @kubiks/otel-drizzle):
11
+ * 1. `db.session.prepareQuery` — wrap returned prepared.execute (main ORM path)
12
+ * 2. `db.session.query` — for direct string queries
13
+ * 3. `db.session.transaction` — re-instrument tx session per transaction call
14
+ * 4. `db.$client.query` or `db.$client.execute` — fallback to raw driver
15
+ * 5. `db._.session.execute` — deep internal fallback
16
+ *
17
+ * Instance patching, NOT prototype patching. Idempotent via flag.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { instrumentDrizzle, withSilgiCtx } from 'silgi/drizzle'
22
+ *
23
+ * const db = instrumentDrizzle(drizzle(url, { schema }), {
24
+ * dbName: 'ecommerce',
25
+ * peerName: 'db.example.com',
26
+ * peerPort: 5432,
27
+ * })
28
+ *
29
+ * const listUsers = s.$resolve(async ({ ctx }) => {
30
+ * return withSilgiCtx(ctx, () => db.select().from(users))
31
+ * })
32
+ * ```
33
+ */
34
+ const ctxStorage = new AsyncLocalStorage();
35
+ const INSTRUMENTED = "__silgiDrizzleInstrumented";
36
+ const DEFAULT_DB_SYSTEM = "postgresql";
37
+ const DEFAULT_MAX_QUERY_LENGTH = 1e3;
38
+ /**
39
+ * Instrument a Drizzle db instance to record query spans in silgi analytics.
40
+ * Returns the same db instance (mutated). Safe to call multiple times.
41
+ */
42
+ function instrumentDrizzle(db, config) {
43
+ if (!db || db[INSTRUMENTED]) return db;
44
+ const cfg = resolveConfig(config);
45
+ let instrumented = false;
46
+ const session = db.session ?? db._?.session;
47
+ if (session) instrumented = patchSession(session, cfg, false);
48
+ if (!instrumented && db.$client) instrumented = patchRawClient(db.$client, cfg);
49
+ if (!instrumented && db._?.session && typeof db._.session.execute === "function") instrumented = patchSessionExecute(db._.session, cfg);
50
+ if (!instrumented) {
51
+ console.warn("[silgi/drizzle] Could not find any patchable method — skipping instrumentation");
52
+ return db;
53
+ }
54
+ db[INSTRUMENTED] = true;
55
+ return db;
56
+ }
57
+ /**
58
+ * Run a function with silgi context available to instrumented Drizzle instances.
59
+ * All Drizzle queries inside `fn` will be recorded as trace spans.
60
+ */
61
+ function withSilgiCtx(ctx, fn) {
62
+ return ctxStorage.run(ctx, fn);
63
+ }
64
+ function resolveConfig(config) {
65
+ return {
66
+ dbSystem: config?.dbSystem ?? DEFAULT_DB_SYSTEM,
67
+ dbName: config?.dbName,
68
+ captureQueryText: config?.captureQueryText !== false,
69
+ maxQueryTextLength: config?.maxQueryTextLength ?? DEFAULT_MAX_QUERY_LENGTH,
70
+ peerName: config?.peerName,
71
+ peerPort: config?.peerPort
72
+ };
73
+ }
74
+ /**
75
+ * Patch session.prepareQuery, session.query, and session.transaction.
76
+ * Returns true if any method was patched.
77
+ */
78
+ function patchSession(session, cfg, isTx) {
79
+ const flagSuffix = isTx ? "_tx" : "";
80
+ if (session[INSTRUMENTED + flagSuffix]) return true;
81
+ let patched = false;
82
+ if (typeof session.prepareQuery === "function") {
83
+ const originalPrepareQuery = session.prepareQuery.bind(session);
84
+ session.prepareQuery = function patchedPrepareQuery(...args) {
85
+ const prepared = originalPrepareQuery.apply(this, args);
86
+ if (!prepared || typeof prepared.execute !== "function") return prepared;
87
+ const reqTrace = ctxStorage.getStore()?.__analyticsTrace;
88
+ if (!reqTrace) return prepared;
89
+ const queryText = extractQueryText(args[0]) ?? prepared.rawQueryConfig?.text ?? prepared.queryConfig?.text ?? null;
90
+ const originalExecute = prepared.execute.bind(prepared);
91
+ prepared.execute = function tracedExecute(...execArgs) {
92
+ return traceExecution(reqTrace, cfg, queryText, isTx, originalExecute, this, execArgs);
93
+ };
94
+ return prepared;
95
+ };
96
+ patched = true;
97
+ }
98
+ if (typeof session.query === "function") {
99
+ const originalQuery = session.query.bind(session);
100
+ session.query = function patchedQuery(queryString, params) {
101
+ const reqTrace = ctxStorage.getStore()?.__analyticsTrace;
102
+ if (!reqTrace) return originalQuery.call(this, queryString, params);
103
+ return traceExecution(reqTrace, cfg, queryString ?? null, isTx, originalQuery, this, [queryString, params]);
104
+ };
105
+ patched = true;
106
+ }
107
+ if (!isTx && typeof session.transaction === "function") {
108
+ const originalTransaction = session.transaction.bind(session);
109
+ session.transaction = function patchedTransaction(callback, txConfig) {
110
+ return originalTransaction.call(this, (tx) => {
111
+ const txSession = tx.session ?? tx;
112
+ if (txSession && typeof txSession.prepareQuery === "function") patchSession(txSession, cfg, true);
113
+ return callback(tx);
114
+ }, txConfig);
115
+ };
116
+ patched = true;
117
+ }
118
+ if (patched) session[INSTRUMENTED + flagSuffix] = true;
119
+ return patched;
120
+ }
121
+ /**
122
+ * Patch $client.query or $client.execute as fallback for raw driver access.
123
+ */
124
+ function patchRawClient(client, cfg) {
125
+ if (!client || client[INSTRUMENTED]) return false;
126
+ const methodName = typeof client.query === "function" ? "query" : typeof client.execute === "function" ? "execute" : null;
127
+ if (!methodName) return false;
128
+ const originalMethod = client[methodName].bind(client);
129
+ client[methodName] = function patchedClientMethod(...args) {
130
+ const reqTrace = ctxStorage.getStore()?.__analyticsTrace;
131
+ if (!reqTrace) return originalMethod.apply(this, args);
132
+ return traceExecution(reqTrace, cfg, extractQueryText(args[0]) ?? null, false, originalMethod, this, args);
133
+ };
134
+ client[INSTRUMENTED] = true;
135
+ return true;
136
+ }
137
+ /**
138
+ * Patch db._.session.execute as deep internal fallback.
139
+ */
140
+ function patchSessionExecute(session, cfg) {
141
+ if (session[INSTRUMENTED]) return false;
142
+ const originalExecute = session.execute.bind(session);
143
+ session.execute = function patchedDeepExecute(...args) {
144
+ const reqTrace = ctxStorage.getStore()?.__analyticsTrace;
145
+ if (!reqTrace) return originalExecute.apply(this, args);
146
+ return traceExecution(reqTrace, cfg, extractQueryText(args[0]) ?? null, false, originalExecute, this, args);
147
+ };
148
+ session[INSTRUMENTED] = true;
149
+ return true;
150
+ }
151
+ /**
152
+ * Execute a function and record a trace span with timing, attributes, and error handling.
153
+ * Handles both sync and async (Promise) return values.
154
+ */
155
+ function traceExecution(reqTrace, cfg, queryText, isTx, fn, thisArg, args) {
156
+ const spanName = buildSpanName(queryText, isTx);
157
+ const attributes = buildAttributes(cfg, queryText, isTx);
158
+ const start = performance.now();
159
+ try {
160
+ const result = fn.apply(thisArg, args);
161
+ if (result instanceof Promise) return result.then((value) => {
162
+ pushSpan(reqTrace, spanName, start, queryText, cfg, attributes, void 0);
163
+ return value;
164
+ }, (error) => {
165
+ pushSpan(reqTrace, spanName, start, queryText, cfg, attributes, error);
166
+ throw error;
167
+ });
168
+ pushSpan(reqTrace, spanName, start, queryText, cfg, attributes, void 0);
169
+ return result;
170
+ } catch (error) {
171
+ pushSpan(reqTrace, spanName, start, queryText, cfg, attributes, error);
172
+ throw error;
173
+ }
174
+ }
175
+ function pushSpan(reqTrace, name, start, queryText, cfg, attributes, error) {
176
+ const detail = cfg.captureQueryText && queryText ? truncateQuery(queryText, cfg.maxQueryTextLength) : void 0;
177
+ reqTrace.spans.push({
178
+ name,
179
+ kind: "db",
180
+ durationMs: round(performance.now() - start),
181
+ startOffsetMs: round(start - reqTrace.t0),
182
+ detail,
183
+ attributes,
184
+ error: error ? formatError(error) : void 0
185
+ });
186
+ }
187
+ function buildAttributes(cfg, queryText, isTx) {
188
+ const attrs = { "db.system": cfg.dbSystem };
189
+ if (cfg.dbName) attrs["db.name"] = cfg.dbName;
190
+ if (cfg.peerName) attrs["net.peer.name"] = cfg.peerName;
191
+ if (cfg.peerPort) attrs["net.peer.port"] = cfg.peerPort;
192
+ if (isTx) attrs["db.transaction"] = true;
193
+ if (queryText) {
194
+ const op = extractOperationName(queryText);
195
+ if (op) attrs["db.operation"] = op;
196
+ if (cfg.captureQueryText) attrs["db.statement"] = truncateQuery(queryText, cfg.maxQueryTextLength);
197
+ }
198
+ return attrs;
199
+ }
200
+ function buildSpanName(queryText, isTx) {
201
+ const prefix = isTx ? "db.tx" : "db";
202
+ if (!queryText) return `${prefix}.query`;
203
+ const opInfo = extractOperationInfo(queryText);
204
+ if (!opInfo) return `${prefix}.query`;
205
+ return opInfo.table ? `${prefix}.${opInfo.op}.${opInfo.table}` : `${prefix}.${opInfo.op}`;
206
+ }
207
+ /**
208
+ * Extract SQL text from various query argument formats:
209
+ * - Plain string
210
+ * - { sql: string }
211
+ * - { text: string }
212
+ * - { queryString: string }
213
+ * - { queryChunks: ..., sql: string }
214
+ */
215
+ function extractQueryText(queryArg) {
216
+ if (typeof queryArg === "string") return queryArg;
217
+ if (queryArg && typeof queryArg === "object") {
218
+ const q = queryArg;
219
+ if (typeof q.sql === "string") return q.sql;
220
+ if (typeof q.text === "string") return q.text;
221
+ if (typeof q.queryString === "string") return q.queryString;
222
+ if (typeof q.queryChunks === "object" && typeof q.sql === "string") return q.sql;
223
+ }
224
+ return null;
225
+ }
226
+ function truncateQuery(text, maxLength) {
227
+ if (text.length <= maxLength) return text;
228
+ return text.slice(0, maxLength) + "...";
229
+ }
230
+ /**
231
+ * Parse the SQL operation and target table from query text.
232
+ * Returns lowercase op name and table for span naming.
233
+ */
234
+ function extractOperationInfo(sql) {
235
+ const upper = sql.trimStart().toUpperCase();
236
+ if (upper.startsWith("SELECT")) return {
237
+ op: "select",
238
+ table: matchTable(sql, /from\s+"?(\w+)"?/i)
239
+ };
240
+ if (upper.startsWith("INSERT")) return {
241
+ op: "insert",
242
+ table: matchTable(sql, /into\s+"?(\w+)"?/i)
243
+ };
244
+ if (upper.startsWith("UPDATE")) return {
245
+ op: "update",
246
+ table: matchTable(sql, /update\s+"?(\w+)"?/i)
247
+ };
248
+ if (upper.startsWith("DELETE")) return {
249
+ op: "delete",
250
+ table: matchTable(sql, /from\s+"?(\w+)"?/i)
251
+ };
252
+ if (upper.startsWith("BEGIN") || upper.startsWith("START TRANSACTION")) return {
253
+ op: "begin",
254
+ table: null
255
+ };
256
+ if (upper.startsWith("COMMIT")) return {
257
+ op: "commit",
258
+ table: null
259
+ };
260
+ if (upper.startsWith("ROLLBACK")) return {
261
+ op: "rollback",
262
+ table: null
263
+ };
264
+ return null;
265
+ }
266
+ /**
267
+ * Extract uppercase operation name for the db.operation attribute.
268
+ */
269
+ function extractOperationName(sql) {
270
+ const trimmed = sql.trimStart();
271
+ const match = /^(\w+)/u.exec(trimmed);
272
+ return match ? match[1].toUpperCase() : null;
273
+ }
274
+ function matchTable(sql, pattern) {
275
+ const m = sql.match(pattern);
276
+ return m ? m[1] : null;
277
+ }
278
+ function formatError(error) {
279
+ if (error instanceof Error) return error.stack ?? `${error.name}: ${error.message}`;
280
+ return String(error);
281
+ }
282
+ function round(n) {
283
+ return Math.round(n * 100) / 100;
284
+ }
285
+ //#endregion
286
+ export { instrumentDrizzle, withSilgiCtx };
@@ -23,7 +23,11 @@ interface TraceSpan {
23
23
  durationMs: number;
24
24
  startOffsetMs?: number;
25
25
  detail?: string;
26
+ input?: unknown;
27
+ output?: unknown;
26
28
  error?: string;
29
+ /** Structured key-value attributes (db.name, auth.operation, user.id, etc.) */
30
+ attributes?: Record<string, string | number | boolean>;
27
31
  }
28
32
  interface ErrorEntry {
29
33
  id: number;
@@ -110,11 +114,22 @@ interface AnalyticsSnapshot {
110
114
  timeSeries: TimeWindow[];
111
115
  }
112
116
  declare class RequestTrace {
113
- #private;
114
117
  spans: TraceSpan[];
118
+ /** Procedure-level input — set via `setProcedureInput()` or `trace(..., { procedure: { input } })` */
119
+ procedureInput: unknown;
120
+ /** Procedure-level output — set via `setProcedureOutput()` or `trace(..., { procedure: { output } })` */
121
+ procedureOutput: unknown;
122
+ /** @internal Start time — used by integrations (drizzle etc.) for span offset calculation */
123
+ readonly t0: number;
115
124
  trace<T>(name: string, fn: () => T | Promise<T>, opts?: {
116
125
  kind?: SpanKind;
117
126
  detail?: string;
127
+ input?: unknown;
128
+ output?: unknown | ((result: T) => unknown);
129
+ procedure?: {
130
+ input?: unknown;
131
+ output?: unknown | ((result: T) => unknown);
132
+ };
118
133
  }): Promise<T>;
119
134
  totalByKind(kind: SpanKind): number;
120
135
  }
@@ -134,6 +149,12 @@ declare class RequestTrace {
134
149
  declare function trace<T>(ctx: Record<string, unknown>, name: string, fn: () => T | Promise<T>, opts?: {
135
150
  kind?: SpanKind;
136
151
  detail?: string;
152
+ input?: unknown;
153
+ output?: unknown | ((result: T) => unknown);
154
+ procedure?: {
155
+ input?: unknown;
156
+ output?: unknown | ((result: T) => unknown);
157
+ };
137
158
  }): Promise<T>;
138
159
  declare class AnalyticsCollector {
139
160
  #private;
@@ -65,7 +65,12 @@ function generateRequestId() {
65
65
  }
66
66
  var RequestTrace = class {
67
67
  spans = [];
68
- #t0 = performance.now();
68
+ /** Procedure-level input — set via `setProcedureInput()` or `trace(..., { procedure: { input } })` */
69
+ procedureInput = void 0;
70
+ /** Procedure-level output — set via `setProcedureOutput()` or `trace(..., { procedure: { output } })` */
71
+ procedureOutput = void 0;
72
+ /** @internal Start time — used by integrations (drizzle etc.) for span offset calculation */
73
+ t0 = performance.now();
69
74
  async trace(name, fn, opts) {
70
75
  const start = performance.now();
71
76
  const kind = opts?.kind ?? guessKind(name);
@@ -75,19 +80,28 @@ var RequestTrace = class {
75
80
  name,
76
81
  kind,
77
82
  durationMs: round(performance.now() - start),
78
- startOffsetMs: round(start - this.#t0),
79
- detail: opts?.detail
83
+ startOffsetMs: round(start - this.t0),
84
+ detail: opts?.detail,
85
+ input: opts?.input,
86
+ output: typeof opts?.output === "function" ? opts.output(result) : opts?.output
80
87
  });
88
+ if (opts?.procedure) {
89
+ if (opts.procedure.input !== void 0) this.procedureInput = opts.procedure.input;
90
+ const po = opts.procedure.output;
91
+ if (po !== void 0) this.procedureOutput = typeof po === "function" ? po(result) : po;
92
+ }
81
93
  return result;
82
94
  } catch (err) {
83
95
  this.spans.push({
84
96
  name,
85
97
  kind,
86
98
  durationMs: round(performance.now() - start),
87
- startOffsetMs: round(start - this.#t0),
99
+ startOffsetMs: round(start - this.t0),
88
100
  detail: opts?.detail,
101
+ input: opts?.input,
89
102
  error: err instanceof Error ? err.message : String(err)
90
103
  });
104
+ if (opts?.procedure?.input !== void 0) this.procedureInput = opts.procedure.input;
91
105
  throw err;
92
106
  }
93
107
  }
@@ -277,7 +291,7 @@ var RequestAccumulator = class {
277
291
  #collector;
278
292
  constructor(request, collector) {
279
293
  this.requestId = generateRequestId();
280
- this.#t0 = performance.now();
294
+ this.t0 = performance.now();
281
295
  this.#request = request;
282
296
  this.#collector = collector;
283
297
  const existing = parseCookie(request.headers.get("cookie"), SESSION_COOKIE);
@@ -300,7 +314,7 @@ var RequestAccumulator = class {
300
314
  /** Flush with response headers extracted from the actual Response object. */
301
315
  flushWithResponse(res) {
302
316
  if (this.#procedures.length === 0) return;
303
- const durationMs = round(performance.now() - this.#t0);
317
+ const durationMs = round(performance.now() - this.t0);
304
318
  const headers = {};
305
319
  this.#request.headers.forEach((v, k) => {
306
320
  headers[k] = k === "authorization" || k === "cookie" ? "[REDACTED]" : v;