autotel-drizzle 0.0.1

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,338 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const spans = vi.hoisted(() => [] as MockSpan[]);
4
+ const tracer = vi.hoisted(() => ({
5
+ startSpan: vi.fn((name: string, options: unknown) => {
6
+ const span: MockSpan = {
7
+ name,
8
+ options,
9
+ attributes: {},
10
+ status: undefined,
11
+ ended: false,
12
+ exceptions: [],
13
+ setAttribute: vi.fn((key: string, value: unknown) => {
14
+ span.attributes[key] = value;
15
+ }),
16
+ setStatus: vi.fn((status: unknown) => {
17
+ span.status = status;
18
+ }),
19
+ recordException: vi.fn((error: unknown) => {
20
+ span.exceptions.push(error);
21
+ }),
22
+ end: vi.fn(() => {
23
+ span.ended = true;
24
+ }),
25
+ };
26
+
27
+ spans.push(span);
28
+ return span;
29
+ }),
30
+ }));
31
+ const runWithSpan = vi.hoisted(() =>
32
+ vi.fn((_span: unknown, fn: () => unknown) => fn()),
33
+ );
34
+ const finalizeSpan = vi.hoisted(() =>
35
+ vi.fn((span: MockSpan, error?: unknown) => {
36
+ if (error === undefined) {
37
+ span.setStatus({ code: 'OK' });
38
+ } else {
39
+ span.recordException(error);
40
+ span.setStatus({ code: 'ERROR' });
41
+ }
42
+ span.end();
43
+ }),
44
+ );
45
+
46
+ vi.mock('@opentelemetry/api', async (importOriginal) => {
47
+ const actual = await importOriginal<typeof import('@opentelemetry/api')>();
48
+
49
+ return {
50
+ ...actual,
51
+ trace: {
52
+ ...actual.trace,
53
+ getTracer: vi.fn(() => tracer),
54
+ },
55
+ };
56
+ });
57
+
58
+ vi.mock('autotel/trace-helpers', () => ({
59
+ runWithSpan,
60
+ finalizeSpan,
61
+ }));
62
+
63
+ import {
64
+ instrumentDrizzle,
65
+ instrumentDrizzleClient,
66
+ type InstrumentDrizzleConfig,
67
+ } from './index';
68
+
69
+ interface MockSpan {
70
+ name: string;
71
+ options: unknown;
72
+ attributes: Record<string, unknown>;
73
+ status: unknown;
74
+ ended: boolean;
75
+ exceptions: unknown[];
76
+ setAttribute: ReturnType<typeof vi.fn>;
77
+ setStatus: ReturnType<typeof vi.fn>;
78
+ recordException: ReturnType<typeof vi.fn>;
79
+ end: ReturnType<typeof vi.fn>;
80
+ }
81
+
82
+ function getSpan(index = 0): MockSpan {
83
+ const span = spans[index];
84
+ expect(span).toBeDefined();
85
+ return span as MockSpan;
86
+ }
87
+
88
+ describe('instrumentDrizzle', () => {
89
+ beforeEach(() => {
90
+ spans.length = 0;
91
+ tracer.startSpan.mockClear();
92
+ runWithSpan.mockClear();
93
+ finalizeSpan.mockClear();
94
+ });
95
+
96
+ it('preserves synchronous query return values', () => {
97
+ const client = {
98
+ query: vi.fn(() => ({ rows: [{ id: 1 }] })),
99
+ };
100
+
101
+ instrumentDrizzle(client);
102
+
103
+ const result = client.query('SELECT 1');
104
+
105
+ expect(result).toEqual({ rows: [{ id: 1 }] });
106
+ expect(result).not.toBeInstanceOf(Promise);
107
+ expect(finalizeSpan).toHaveBeenCalledTimes(1);
108
+ expect(getSpan().name).toBe('drizzle.select');
109
+ });
110
+
111
+ it('wraps both query and execute when both methods exist', async () => {
112
+ const client = {
113
+ query: vi.fn(async () => ({ source: 'query' })),
114
+ execute: vi.fn(async () => ({ source: 'execute' })),
115
+ };
116
+
117
+ instrumentDrizzle(client);
118
+ const wrappedQuery = client.query;
119
+ const wrappedExecute = client.execute;
120
+
121
+ instrumentDrizzle(client);
122
+
123
+ expect(client.query).toBe(wrappedQuery);
124
+ expect(client.execute).toBe(wrappedExecute);
125
+
126
+ await client.query('SELECT 1');
127
+ await client.execute({ sql: 'DELETE FROM users' });
128
+
129
+ expect(spans).toHaveLength(2);
130
+ expect(getSpan(0).name).toBe('drizzle.select');
131
+ expect(getSpan(1).name).toBe('drizzle.delete');
132
+ });
133
+
134
+ it('keeps callback-style clients callback-style', async () => {
135
+ const client = {
136
+ query: vi.fn(
137
+ (
138
+ _query: string,
139
+ callback: (error: unknown, result: { ok: true }) => void,
140
+ ) => {
141
+ callback(null, { ok: true });
142
+ return;
143
+ },
144
+ ),
145
+ };
146
+
147
+ instrumentDrizzle(client);
148
+
149
+ await new Promise<void>((resolve) => {
150
+ const result = client.query('SELECT 1', (error, payload) => {
151
+ expect(error).toBeNull();
152
+ expect(payload).toEqual({ ok: true });
153
+ resolve();
154
+ });
155
+
156
+ expect(result).toBeUndefined();
157
+ });
158
+
159
+ expect(finalizeSpan).toHaveBeenCalledWith(getSpan(), null);
160
+ });
161
+
162
+ it('records async failures', async () => {
163
+ const error = new Error('boom');
164
+ const client = {
165
+ query: vi.fn(async () => {
166
+ throw error;
167
+ }),
168
+ };
169
+
170
+ instrumentDrizzle(client);
171
+
172
+ await expect(client.query('SELECT 1')).rejects.toThrow(error);
173
+
174
+ expect(getSpan().exceptions).toContain(error);
175
+ expect(getSpan().status).toEqual({ code: 'ERROR' });
176
+ });
177
+
178
+ it('applies config to captured spans', async () => {
179
+ const client = {
180
+ execute: vi.fn(async () => ({ rows: [] })),
181
+ };
182
+ const config: InstrumentDrizzleConfig = {
183
+ dbSystem: 'mysql',
184
+ dbName: 'app',
185
+ peerName: 'db.example.com',
186
+ peerPort: 3306,
187
+ maxQueryTextLength: 12,
188
+ };
189
+
190
+ instrumentDrizzle(client, config);
191
+ await client.execute('SELECT * FROM very_long_table_name');
192
+
193
+ expect(getSpan().attributes).toMatchObject({
194
+ 'db.system': 'mysql',
195
+ 'db.name': 'app',
196
+ 'net.peer.name': 'db.example.com',
197
+ 'net.peer.port': 3306,
198
+ 'db.operation': 'SELECT',
199
+ 'db.statement': 'SELECT * FRO...',
200
+ });
201
+ });
202
+
203
+ it('skips db.statement when query capture is disabled', async () => {
204
+ const client = {
205
+ query: vi.fn(async () => ({ rows: [] })),
206
+ };
207
+
208
+ instrumentDrizzle(client, { captureQueryText: false });
209
+ await client.query({ text: 'UPDATE users SET name = $1' });
210
+
211
+ expect(getSpan().attributes['db.operation']).toBe('UPDATE');
212
+ expect(getSpan().attributes['db.statement']).toBeUndefined();
213
+ });
214
+ });
215
+
216
+ describe('instrumentDrizzleClient', () => {
217
+ beforeEach(() => {
218
+ spans.length = 0;
219
+ tracer.startSpan.mockClear();
220
+ runWithSpan.mockClear();
221
+ finalizeSpan.mockClear();
222
+ });
223
+
224
+ it('instruments prepared query helper methods, not just execute', () => {
225
+ const prepared = {
226
+ all: vi.fn(() => [{ id: 1 }]),
227
+ get: vi.fn(() => ({ id: 1 })),
228
+ };
229
+ const db = {
230
+ session: {
231
+ prepareQuery: vi.fn(() => prepared),
232
+ },
233
+ };
234
+
235
+ instrumentDrizzleClient(db);
236
+
237
+ const preparedQuery = db.session.prepareQuery({
238
+ queryString: 'SELECT * FROM users',
239
+ });
240
+
241
+ const allResult = preparedQuery.all();
242
+ const getResult = preparedQuery.get();
243
+
244
+ expect(allResult).toEqual([{ id: 1 }]);
245
+ expect(getResult).toEqual({ id: 1 });
246
+ expect(spans).toHaveLength(2);
247
+ expect(getSpan(0).attributes['db.statement']).toBe('SELECT * FROM users');
248
+ expect(getSpan(1).attributes['db.operation']).toBe('SELECT');
249
+ });
250
+
251
+ it('instruments multiple surfaces on the same db instance', async () => {
252
+ const db = {
253
+ session: {
254
+ execute: vi.fn(async () => ({ rows: ['session'] })),
255
+ },
256
+ $client: {
257
+ query: vi.fn(async () => ({ rows: ['client'] })),
258
+ },
259
+ };
260
+
261
+ instrumentDrizzleClient(db);
262
+
263
+ await db.session.execute('INSERT INTO users VALUES (1)');
264
+ await db.$client.query('SELECT 1');
265
+
266
+ expect(spans).toHaveLength(2);
267
+ expect(getSpan(0).name).toBe('drizzle.insert');
268
+ expect(getSpan(1).name).toBe('drizzle.select');
269
+ });
270
+
271
+ it('instruments transaction execute and nested transaction session queries', async () => {
272
+ let txRef: any;
273
+ const db = {
274
+ session: {
275
+ transaction: vi.fn(async (callback: (tx: unknown) => unknown) => {
276
+ txRef = {
277
+ execute: vi.fn(async () => ({ ok: true })),
278
+ session: {
279
+ query: vi.fn(async () => ({ ok: true })),
280
+ },
281
+ };
282
+
283
+ return callback(txRef);
284
+ }),
285
+ },
286
+ };
287
+
288
+ instrumentDrizzleClient(db);
289
+
290
+ await db.session.transaction(async (tx: any) => {
291
+ await tx.execute({ sql: 'SET LOCAL role app_user' });
292
+ await tx.session.query('SELECT 1');
293
+ });
294
+
295
+ expect(spans).toHaveLength(2);
296
+ expect(getSpan(0).attributes['db.transaction']).toBe(true);
297
+ expect(getSpan(1).attributes['db.transaction']).toBe(true);
298
+ expect(txRef.execute).not.toBeUndefined();
299
+ });
300
+
301
+ it('preserves sync execution for fallback _.session.execute', () => {
302
+ const db = {
303
+ _: {
304
+ session: {
305
+ execute: vi.fn(() => ({ rows: [1] })),
306
+ },
307
+ },
308
+ };
309
+
310
+ instrumentDrizzleClient(db);
311
+
312
+ const result = db._.session.execute('DELETE FROM users');
313
+
314
+ expect(result).toEqual({ rows: [1] });
315
+ expect(result).not.toBeInstanceOf(Promise);
316
+ expect(getSpan().name).toBe('drizzle.delete');
317
+ });
318
+
319
+ it('is idempotent when called repeatedly', () => {
320
+ const db = {
321
+ session: {
322
+ query: vi.fn(async () => ({ rows: [] })),
323
+ },
324
+ $client: {
325
+ execute: vi.fn(async () => ({ rows: [] })),
326
+ },
327
+ };
328
+
329
+ instrumentDrizzleClient(db);
330
+ const firstSessionQuery = db.session.query;
331
+ const firstClientExecute = db.$client.execute;
332
+
333
+ instrumentDrizzleClient(db);
334
+
335
+ expect(db.session.query).toBe(firstSessionQuery);
336
+ expect(db.$client.execute).toBe(firstClientExecute);
337
+ });
338
+ });