autotel-drizzle 0.0.34 → 0.0.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autotel-drizzle",
3
- "version": "0.0.34",
3
+ "version": "0.0.36",
4
4
  "description": "OpenTelemetry instrumentation for Drizzle ORM",
5
5
  "type": "module",
6
6
  "main": "./dist/drizzle.js",
@@ -15,7 +15,6 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "src",
19
18
  "README.md",
20
19
  "skills"
21
20
  ],
@@ -37,7 +36,7 @@
37
36
  "@opentelemetry/api": "^1.9.1",
38
37
  "@opentelemetry/instrumentation": "^0.218.0",
39
38
  "@opentelemetry/semantic-conventions": "^1.41.1",
40
- "autotel": "4.1.0"
39
+ "autotel": "4.2.1"
41
40
  },
42
41
  "peerDependencies": {
43
42
  "drizzle-orm": ">=0.45.2"
@@ -1,105 +0,0 @@
1
- /**
2
- * OpenTelemetry semantic conventions for database operations.
3
- * These constants are shared across all plugins.
4
- */
5
-
6
- // Common database attributes
7
- export const SEMATTRS_DB_SYSTEM = 'db.system' as const;
8
- export const SEMATTRS_DB_SYSTEM_NAME = 'db.system.name' as const;
9
- export const SEMATTRS_DB_OPERATION = 'db.operation' as const;
10
- export const SEMATTRS_DB_STATEMENT = 'db.statement' as const;
11
- export const SEMATTRS_DB_NAME = 'db.name' as const;
12
- export const SEMATTRS_DB_NAMESPACE = 'db.namespace' as const;
13
- export const SEMATTRS_DB_COLLECTION_NAME = 'db.collection.name' as const;
14
- export const SEMATTRS_DB_OPERATION_NAME = 'db.operation.name' as const;
15
- export const SEMATTRS_DB_QUERY_TEXT = 'db.query.text' as const;
16
- export const SEMATTRS_DB_QUERY_SUMMARY = 'db.query.summary' as const;
17
- /**
18
- * sha1 hex of the (parameterised, sanitized) statement text. Use to group
19
- * identical queries in observability tools — the raw `db.statement` is
20
- * usually unique per call due to inline params or comments, so high-
21
- * cardinality grouping by it is unhelpful. Example use: aggregate the top
22
- * 20 queries by total duration by `db.statement.hash`.
23
- */
24
- export const SEMATTRS_DB_STATEMENT_HASH = 'db.statement.hash' as const;
25
-
26
- // MongoDB-specific attributes
27
- export const SEMATTRS_DB_MONGODB_COLLECTION = 'db.mongodb.collection' as const;
28
-
29
- // Network attributes
30
- export const SEMATTRS_NET_PEER_NAME = 'net.peer.name' as const;
31
- export const SEMATTRS_NET_PEER_PORT = 'net.peer.port' as const;
32
-
33
- // Messaging attributes (Kafka, etc.)
34
- export const SEMATTRS_MESSAGING_SYSTEM = 'messaging.system' as const;
35
- export const SEMATTRS_MESSAGING_DESTINATION_NAME =
36
- 'messaging.destination.name' as const;
37
- export const SEMATTRS_MESSAGING_OPERATION = 'messaging.operation' as const;
38
- export const SEMATTRS_MESSAGING_KAFKA_CONSUMER_GROUP =
39
- 'messaging.kafka.consumer.group' as const;
40
- export const SEMATTRS_MESSAGING_KAFKA_PARTITION =
41
- 'messaging.kafka.partition' as const;
42
- export const SEMATTRS_MESSAGING_KAFKA_OFFSET =
43
- 'messaging.kafka.offset' as const;
44
- export const SEMATTRS_MESSAGING_KAFKA_MESSAGE_KEY =
45
- 'messaging.kafka.message.key' as const;
46
-
47
- // Batch lineage attributes
48
- export const SEMATTRS_LINKED_TRACE_ID_COUNT = 'linked_trace_id_count' as const;
49
- export const SEMATTRS_LINKED_TRACE_ID_HASH = 'linked_trace_id_hash' as const;
50
-
51
- // Correlation ID header name
52
- export const CORRELATION_ID_HEADER = 'x-correlation-id' as const;
53
-
54
- // BigQuery-specific attributes (namespaced under gcp.bigquery per OTel spec)
55
- export const SEMATTRS_GCP_BIGQUERY_JOB_ID = 'gcp.bigquery.job.id' as const;
56
- export const SEMATTRS_GCP_BIGQUERY_JOB_LOCATION =
57
- 'gcp.bigquery.job.location' as const;
58
- export const SEMATTRS_GCP_BIGQUERY_PROJECT_ID =
59
- 'gcp.bigquery.project.id' as const;
60
- export const SEMATTRS_GCP_BIGQUERY_DESTINATION_TABLE =
61
- 'gcp.bigquery.destination.table' as const;
62
- export const SEMATTRS_GCP_BIGQUERY_SOURCE_TABLES =
63
- 'gcp.bigquery.source.tables' as const;
64
- export const SEMATTRS_GCP_BIGQUERY_STATEMENT_TYPE =
65
- 'gcp.bigquery.statement_type' as const;
66
- export const SEMATTRS_GCP_BIGQUERY_QUERY_HASH =
67
- 'gcp.bigquery.query.hash' as const;
68
- export const SEMATTRS_GCP_BIGQUERY_ROWS_AFFECTED =
69
- 'gcp.bigquery.rows.affected' as const;
70
- export const SEMATTRS_GCP_BIGQUERY_ROWS_RETURNED =
71
- 'gcp.bigquery.rows.returned' as const;
72
- export const SEMATTRS_GCP_BIGQUERY_SCHEMA_FIELDS =
73
- 'gcp.bigquery.schema.fields' as const;
74
-
75
- // RabbitMQ-specific attributes (aligned with OTel messaging semantic conventions)
76
- export const SEMATTRS_MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY =
77
- 'messaging.rabbitmq.destination.routing_key' as const;
78
- export const SEMATTRS_MESSAGING_RABBITMQ_DESTINATION_EXCHANGE =
79
- 'messaging.rabbitmq.destination.exchange' as const;
80
- export const SEMATTRS_MESSAGING_RABBITMQ_ACK_RESULT =
81
- 'messaging.rabbitmq.ack_result' as const;
82
- export const SEMATTRS_MESSAGING_RABBITMQ_REQUEUE =
83
- 'messaging.rabbitmq.requeue' as const;
84
-
85
- // Messaging attributes (shared across messaging systems)
86
- export const SEMATTRS_MESSAGING_MESSAGE_ID = 'messaging.message.id' as const;
87
- export const SEMATTRS_MESSAGING_MESSAGE_CONVERSATION_ID =
88
- 'messaging.message.conversation_id' as const;
89
- export const SEMATTRS_MESSAGING_CONSUMER_ID = 'messaging.consumer.id' as const;
90
- export const SEMATTRS_MESSAGING_OPERATION_NAME =
91
- 'messaging.operation.name' as const;
92
-
93
- // Kafka batch consumer attributes
94
- export const SEMATTRS_MESSAGING_BATCH_MESSAGE_COUNT =
95
- 'messaging.batch.message_count' as const;
96
- export const SEMATTRS_MESSAGING_KAFKA_BATCH_FIRST_OFFSET =
97
- 'messaging.kafka.batch.first_offset' as const;
98
- export const SEMATTRS_MESSAGING_KAFKA_BATCH_LAST_OFFSET =
99
- 'messaging.kafka.batch.last_offset' as const;
100
- export const SEMATTRS_MESSAGING_KAFKA_BATCH_MESSAGES_PROCESSED =
101
- 'messaging.kafka.batch.messages_processed' as const;
102
- export const SEMATTRS_MESSAGING_KAFKA_BATCH_MESSAGES_FAILED =
103
- 'messaging.kafka.batch.messages_failed' as const;
104
- export const SEMATTRS_MESSAGING_KAFKA_BATCH_PROCESSING_TIME_MS =
105
- 'messaging.kafka.batch.processing_time_ms' as const;
@@ -1,414 +0,0 @@
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
- it('emits db.statement.hash even when statement text is suppressed', async () => {
216
- const client = {
217
- query: vi.fn(async () => ({ rows: [] })),
218
- };
219
-
220
- instrumentDrizzle(client, { captureQueryText: false });
221
- await client.query({ text: 'UPDATE users SET name = $1' });
222
-
223
- const hash = getSpan().attributes['db.statement.hash'];
224
- expect(hash).toMatch(/^[0-9a-f]{16}$/);
225
- });
226
-
227
- it('produces identical db.statement.hash for identical statements', async () => {
228
- const client = {
229
- query: vi.fn(async () => ({ rows: [] })),
230
- };
231
-
232
- instrumentDrizzle(client, {});
233
- await client.query({ text: 'SELECT * FROM users WHERE id = $1' });
234
- await client.query({ text: 'SELECT * FROM users WHERE id = $1' });
235
-
236
- expect(getSpan(0).attributes['db.statement.hash']).toBeDefined();
237
- expect(getSpan(0).attributes['db.statement.hash']).toBe(
238
- getSpan(1).attributes['db.statement.hash'],
239
- );
240
- });
241
-
242
- it('produces different db.statement.hash for different statements', async () => {
243
- const client = {
244
- query: vi.fn(async () => ({ rows: [] })),
245
- };
246
-
247
- instrumentDrizzle(client, {});
248
- await client.query({ text: 'SELECT * FROM users WHERE id = $1' });
249
- await client.query({ text: 'SELECT * FROM accounts WHERE id = $1' });
250
-
251
- expect(getSpan(0).attributes['db.statement.hash']).not.toBe(
252
- getSpan(1).attributes['db.statement.hash'],
253
- );
254
- });
255
- });
256
-
257
- describe('instrumentDrizzleClient', () => {
258
- beforeEach(() => {
259
- spans.length = 0;
260
- tracer.startSpan.mockClear();
261
- runWithSpan.mockClear();
262
- finalizeSpan.mockClear();
263
- });
264
-
265
- it('instruments prepared query helper methods, not just execute', () => {
266
- const prepared = {
267
- all: vi.fn(() => [{ id: 1 }]),
268
- get: vi.fn(() => ({ id: 1 })),
269
- };
270
- const db = {
271
- session: {
272
- prepareQuery: vi.fn(() => prepared),
273
- },
274
- };
275
-
276
- instrumentDrizzleClient(db);
277
-
278
- const preparedQuery = db.session.prepareQuery({
279
- queryString: 'SELECT * FROM users',
280
- });
281
-
282
- const allResult = preparedQuery.all();
283
- const getResult = preparedQuery.get();
284
-
285
- expect(allResult).toEqual([{ id: 1 }]);
286
- expect(getResult).toEqual({ id: 1 });
287
- expect(spans).toHaveLength(2);
288
- expect(getSpan(0).attributes['db.statement']).toBe('SELECT * FROM users');
289
- expect(getSpan(1).attributes['db.operation']).toBe('SELECT');
290
- });
291
-
292
- it('instruments the session but leaves $client untouched', async () => {
293
- const originalClientQuery = vi.fn(async () => ({ rows: ['client'] }));
294
- const db = {
295
- session: {
296
- execute: vi.fn(async () => ({ rows: ['session'] })),
297
- },
298
- $client: {
299
- query: originalClientQuery,
300
- },
301
- };
302
-
303
- instrumentDrizzleClient(db);
304
-
305
- await db.session.execute('INSERT INTO users VALUES (1)');
306
- expect(spans).toHaveLength(1);
307
- expect(getSpan(0).name).toBe('drizzle.insert');
308
-
309
- // $client.query must remain the original reference. Instrumenting it here
310
- // would produce duplicate spans because drizzle's session internally calls
311
- // $client.query from within its own already-traced execute path.
312
- expect(db.$client.query).toBe(originalClientQuery);
313
-
314
- await db.$client.query('SELECT 1');
315
- expect(spans).toHaveLength(1);
316
- });
317
-
318
- it('produces one span when drizzle session.prepareQuery routes through the shared $client', async () => {
319
- // Simulates the real drizzle-orm/node-postgres flow where
320
- // prepared.execute() internally dispatches to db.$client.query().
321
- const client = {
322
- query: vi.fn(async () => ({ rows: [{ id: 1 }] })),
323
- };
324
- const db = {
325
- $client: client,
326
- session: {
327
- prepareQuery: vi.fn((query: { sql: string }) => ({
328
- execute: vi.fn(async () => client.query(query.sql)),
329
- })),
330
- },
331
- };
332
-
333
- instrumentDrizzleClient(db);
334
-
335
- const prepared = db.session.prepareQuery({ sql: 'SELECT 1' });
336
- await prepared.execute();
337
-
338
- // Exactly one autotel span should be created — the one from
339
- // instrumented prepared.execute. The inner $client.query call must
340
- // NOT create its own span.
341
- expect(spans).toHaveLength(1);
342
- expect(getSpan(0).name).toBe('drizzle.select');
343
- expect(client.query).toHaveBeenCalledTimes(1);
344
- });
345
-
346
- it('instruments transaction execute and nested transaction session queries', async () => {
347
- let txRef: any;
348
- const db = {
349
- session: {
350
- transaction: vi.fn(async (callback: (tx: unknown) => unknown) => {
351
- txRef = {
352
- execute: vi.fn(async () => ({ ok: true })),
353
- session: {
354
- query: vi.fn(async () => ({ ok: true })),
355
- },
356
- };
357
-
358
- return callback(txRef);
359
- }),
360
- },
361
- };
362
-
363
- instrumentDrizzleClient(db);
364
-
365
- await db.session.transaction(async (tx: any) => {
366
- await tx.execute({ sql: 'SET LOCAL role app_user' });
367
- await tx.session.query('SELECT 1');
368
- });
369
-
370
- expect(spans).toHaveLength(2);
371
- expect(getSpan(0).attributes['db.transaction']).toBe(true);
372
- expect(getSpan(1).attributes['db.transaction']).toBe(true);
373
- expect(txRef.execute).not.toBeUndefined();
374
- });
375
-
376
- it('preserves sync execution for fallback _.session.execute', () => {
377
- const db = {
378
- _: {
379
- session: {
380
- execute: vi.fn(() => ({ rows: [1] })),
381
- },
382
- },
383
- };
384
-
385
- instrumentDrizzleClient(db);
386
-
387
- const result = db._.session.execute('DELETE FROM users');
388
-
389
- expect(result).toEqual({ rows: [1] });
390
- expect(result).not.toBeInstanceOf(Promise);
391
- expect(getSpan().name).toBe('drizzle.delete');
392
- });
393
-
394
- it('is idempotent when called repeatedly', () => {
395
- const originalClientExecute = vi.fn(async () => ({ rows: [] }));
396
- const db = {
397
- session: {
398
- query: vi.fn(async () => ({ rows: [] })),
399
- },
400
- $client: {
401
- execute: originalClientExecute,
402
- },
403
- };
404
-
405
- instrumentDrizzleClient(db);
406
- const firstSessionQuery = db.session.query;
407
-
408
- instrumentDrizzleClient(db);
409
-
410
- expect(db.session.query).toBe(firstSessionQuery);
411
- // $client.execute is intentionally not wrapped by instrumentDrizzleClient.
412
- expect(db.$client.execute).toBe(originalClientExecute);
413
- });
414
- });
@@ -1,552 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { createHash } from 'node:crypto';
3
- import { SpanKind, trace } from '@opentelemetry/api';
4
- import {
5
- SEMATTRS_DB_NAME,
6
- SEMATTRS_DB_OPERATION,
7
- SEMATTRS_DB_STATEMENT,
8
- SEMATTRS_DB_STATEMENT_HASH,
9
- SEMATTRS_DB_SYSTEM,
10
- SEMATTRS_NET_PEER_NAME,
11
- SEMATTRS_NET_PEER_PORT,
12
- } from '../common/constants';
13
- import { finalizeSpan, runWithSpan } from 'autotel/trace-helpers';
14
-
15
- const DEFAULT_TRACER_NAME = 'autotel-plugins/drizzle';
16
- const DEFAULT_DB_SYSTEM = 'postgresql';
17
- const INSTRUMENTED_FLAG = '__autotelDrizzleInstrumented' as const;
18
- const PREPARED_QUERY_METHODS = [
19
- 'all',
20
- 'execute',
21
- 'get',
22
- 'run',
23
- 'values',
24
- ] as const;
25
-
26
- type QueryCallback = (error: unknown, result: unknown) => void;
27
- type QueryFunction = (...args: any[]) => any;
28
- type AttributeValue = string | number | boolean;
29
- type AttributeMap = Record<string, AttributeValue>;
30
-
31
- interface InstrumentableObject {
32
- [key: string]: any;
33
- [INSTRUMENTED_FLAG]?: true;
34
- }
35
-
36
- interface DrizzleClientLike extends InstrumentableObject {
37
- query?: QueryFunction;
38
- execute?: QueryFunction;
39
- }
40
-
41
- interface DrizzleSessionLike extends InstrumentableObject {
42
- query?: QueryFunction;
43
- execute?: QueryFunction;
44
- prepareQuery?: QueryFunction;
45
- transaction?: QueryFunction;
46
- }
47
-
48
- interface DrizzleDbLike extends InstrumentableObject {
49
- $client?: DrizzleClientLike;
50
- session?: DrizzleSessionLike;
51
- _?: {
52
- session?: DrizzleSessionLike;
53
- [key: string]: any;
54
- };
55
- }
56
-
57
- export interface InstrumentDrizzleConfig {
58
- tracerName?: string;
59
- dbSystem?: string;
60
- dbName?: string;
61
- captureQueryText?: boolean;
62
- maxQueryTextLength?: number;
63
- peerName?: string;
64
- peerPort?: number;
65
- }
66
-
67
- interface ResolvedConfig {
68
- tracerName: string;
69
- dbSystem: string;
70
- dbName?: string;
71
- captureQueryText: boolean;
72
- maxQueryTextLength: number;
73
- peerName?: string;
74
- peerPort?: number;
75
- }
76
-
77
- interface InstrumentationState {
78
- tracer: ReturnType<typeof trace.getTracer>;
79
- config: ResolvedConfig;
80
- }
81
-
82
- interface MethodInstrumentationOptions {
83
- flagSuffix: string;
84
- queryText: (args: any[]) => string | undefined;
85
- callbackStyle?: 'last-arg';
86
- extraAttributes?: AttributeMap;
87
- }
88
-
89
- function resolveConfig(config?: InstrumentDrizzleConfig): ResolvedConfig {
90
- return {
91
- tracerName: config?.tracerName ?? DEFAULT_TRACER_NAME,
92
- dbSystem: config?.dbSystem ?? DEFAULT_DB_SYSTEM,
93
- dbName: config?.dbName,
94
- captureQueryText: config?.captureQueryText ?? true,
95
- maxQueryTextLength: config?.maxQueryTextLength ?? 1000,
96
- peerName: config?.peerName,
97
- peerPort: config?.peerPort,
98
- };
99
- }
100
-
101
- function getState(config?: InstrumentDrizzleConfig): InstrumentationState {
102
- const resolved = resolveConfig(config);
103
- return {
104
- config: resolved,
105
- tracer: trace.getTracer(resolved.tracerName),
106
- };
107
- }
108
-
109
- function getFlagKey(suffix: string): string {
110
- return `${INSTRUMENTED_FLAG}:${suffix}`;
111
- }
112
-
113
- function isObject(value: unknown): value is InstrumentableObject {
114
- return value !== null && typeof value === 'object';
115
- }
116
-
117
- function isPromiseLike<T>(value: T | PromiseLike<T>): value is PromiseLike<T> {
118
- return (
119
- typeof value === 'object' &&
120
- value !== null &&
121
- typeof (value as PromiseLike<T>).then === 'function'
122
- );
123
- }
124
-
125
- function extractQueryText(queryArg: unknown): string | undefined {
126
- if (typeof queryArg === 'string') {
127
- return queryArg;
128
- }
129
-
130
- if (!isObject(queryArg)) {
131
- return undefined;
132
- }
133
-
134
- if (typeof queryArg.sql === 'string') {
135
- return queryArg.sql;
136
- }
137
-
138
- if (typeof queryArg.text === 'string') {
139
- return queryArg.text;
140
- }
141
-
142
- if (typeof queryArg.queryString === 'string') {
143
- return queryArg.queryString;
144
- }
145
-
146
- if (
147
- isObject(queryArg.queryChunks) &&
148
- typeof (queryArg as Record<string, unknown>).sql === 'string'
149
- ) {
150
- return queryArg.sql as string;
151
- }
152
-
153
- return undefined;
154
- }
155
-
156
- function sanitizeQueryText(queryText: string, maxLength: number): string {
157
- if (queryText.length <= maxLength) {
158
- return queryText;
159
- }
160
-
161
- return `${queryText.slice(0, Math.max(0, maxLength))}...`;
162
- }
163
-
164
- /**
165
- * Stable sha1 of a parameterised SQL statement, used as `db.statement.hash`.
166
- * Hashes the full original text (not the truncated form) so the hash is
167
- * identical for queries that only differ in trailing length. We keep this
168
- * cheap (sha1, hex, take 16 chars) — the goal is grouping, not crypto.
169
- */
170
- function hashQueryText(queryText: string): string {
171
- return createHash('sha1').update(queryText).digest('hex').slice(0, 16);
172
- }
173
-
174
- function extractOperation(queryText: string): string | undefined {
175
- const trimmed = queryText.trimStart();
176
- const match = /^(?<operation>\w+)/u.exec(trimmed);
177
- return match?.groups?.operation?.toUpperCase();
178
- }
179
-
180
- function buildSpan(
181
- state: InstrumentationState,
182
- queryText: string | undefined,
183
- extraAttributes?: AttributeMap,
184
- ) {
185
- const operation = queryText ? extractOperation(queryText) : undefined;
186
- const spanName = operation
187
- ? `drizzle.${operation.toLowerCase()}`
188
- : 'drizzle.query';
189
- const span = state.tracer.startSpan(spanName, { kind: SpanKind.CLIENT });
190
-
191
- span.setAttribute(SEMATTRS_DB_SYSTEM, state.config.dbSystem);
192
-
193
- if (operation) {
194
- span.setAttribute(SEMATTRS_DB_OPERATION, operation);
195
- }
196
-
197
- if (state.config.dbName !== undefined) {
198
- span.setAttribute(SEMATTRS_DB_NAME, state.config.dbName);
199
- }
200
-
201
- if (queryText !== undefined) {
202
- // The hash always lives on the span — even when captureQueryText is off
203
- // (e.g. for privacy / size reasons) — so query grouping still works.
204
- span.setAttribute(SEMATTRS_DB_STATEMENT_HASH, hashQueryText(queryText));
205
- }
206
-
207
- if (state.config.captureQueryText && queryText !== undefined) {
208
- span.setAttribute(
209
- SEMATTRS_DB_STATEMENT,
210
- sanitizeQueryText(queryText, state.config.maxQueryTextLength),
211
- );
212
- }
213
-
214
- if (state.config.peerName !== undefined) {
215
- span.setAttribute(SEMATTRS_NET_PEER_NAME, state.config.peerName);
216
- }
217
-
218
- if (state.config.peerPort !== undefined) {
219
- span.setAttribute(SEMATTRS_NET_PEER_PORT, state.config.peerPort);
220
- }
221
-
222
- if (extraAttributes) {
223
- for (const [key, value] of Object.entries(extraAttributes)) {
224
- span.setAttribute(key, value);
225
- }
226
- }
227
-
228
- return span;
229
- }
230
-
231
- function executeWithSpan<T>(span: any, fn: () => T): T {
232
- return runWithSpan(span, () => {
233
- try {
234
- const result = fn();
235
-
236
- if (isPromiseLike(result)) {
237
- return result.then(
238
- (value) => {
239
- finalizeSpan(span);
240
- return value;
241
- },
242
- (error) => {
243
- finalizeSpan(span, error);
244
- throw error;
245
- },
246
- ) as T;
247
- }
248
-
249
- finalizeSpan(span);
250
- return result;
251
- } catch (error) {
252
- finalizeSpan(span, error);
253
- throw error;
254
- }
255
- });
256
- }
257
-
258
- function instrumentMethod(
259
- target: InstrumentableObject,
260
- methodName: string,
261
- state: InstrumentationState,
262
- options: MethodInstrumentationOptions,
263
- ): boolean {
264
- if (typeof target[methodName] !== 'function') {
265
- return false;
266
- }
267
-
268
- const flagKey = getFlagKey(options.flagSuffix);
269
- if (target[flagKey]) {
270
- return false;
271
- }
272
-
273
- const originalMethod = target[methodName] as QueryFunction;
274
-
275
- target[methodName] = function instrumentedMethod(
276
- this: any,
277
- ...incomingArgs: any[]
278
- ) {
279
- const args = [...incomingArgs];
280
- const callback =
281
- options.callbackStyle === 'last-arg' && typeof args.at(-1) === 'function'
282
- ? (args.pop() as QueryCallback)
283
- : undefined;
284
- const span = buildSpan(
285
- state,
286
- options.queryText(args),
287
- options.extraAttributes,
288
- );
289
-
290
- if (callback) {
291
- return runWithSpan(span, () => {
292
- const wrappedCallback: QueryCallback = (error, result) => {
293
- finalizeSpan(span, error);
294
- callback(error, result);
295
- };
296
-
297
- try {
298
- return Reflect.apply(originalMethod, this, [
299
- ...args,
300
- wrappedCallback,
301
- ]);
302
- } catch (error) {
303
- finalizeSpan(span, error);
304
- throw error;
305
- }
306
- });
307
- }
308
-
309
- return executeWithSpan(span, () =>
310
- Reflect.apply(originalMethod, this, args),
311
- );
312
- };
313
-
314
- target[flagKey] = true;
315
- return true;
316
- }
317
-
318
- function instrumentPreparedQuery(
319
- prepared: unknown,
320
- state: InstrumentationState,
321
- querySource: unknown,
322
- extraAttributes?: AttributeMap,
323
- ): boolean {
324
- if (!isObject(prepared)) {
325
- return false;
326
- }
327
-
328
- let instrumented = false;
329
- const queryText = extractQueryText(querySource);
330
-
331
- for (const methodName of PREPARED_QUERY_METHODS) {
332
- instrumented =
333
- instrumentMethod(prepared, methodName, state, {
334
- flagSuffix: `prepared:${methodName}`,
335
- queryText: () => queryText,
336
- extraAttributes,
337
- }) || instrumented;
338
- }
339
-
340
- return instrumented;
341
- }
342
-
343
- function instrumentPrepareQuery(
344
- target: DrizzleSessionLike,
345
- state: InstrumentationState,
346
- extraAttributes?: AttributeMap,
347
- ): boolean {
348
- if (typeof target.prepareQuery !== 'function') {
349
- return false;
350
- }
351
-
352
- const flagKey = getFlagKey('prepareQuery');
353
- if (target[flagKey]) {
354
- return false;
355
- }
356
-
357
- const originalPrepareQuery = target.prepareQuery;
358
-
359
- target.prepareQuery = function instrumentedPrepareQuery(
360
- this: any,
361
- ...prepareArgs: any[]
362
- ) {
363
- const prepared = Reflect.apply(originalPrepareQuery, this, prepareArgs);
364
- instrumentPreparedQuery(prepared, state, prepareArgs[0], extraAttributes);
365
- return prepared;
366
- };
367
-
368
- target[flagKey] = true;
369
- return true;
370
- }
371
-
372
- function instrumentTransactionTarget(
373
- target: unknown,
374
- state: InstrumentationState,
375
- ): boolean {
376
- if (!isObject(target)) {
377
- return false;
378
- }
379
-
380
- const transactionAttributes = { 'db.transaction': true };
381
- let instrumented = false;
382
-
383
- instrumented =
384
- instrumentMethod(target, 'query', state, {
385
- flagSuffix: 'transaction:query',
386
- queryText: (args) => extractQueryText(args[0]),
387
- callbackStyle: 'last-arg',
388
- extraAttributes: transactionAttributes,
389
- }) || instrumented;
390
-
391
- instrumented =
392
- instrumentMethod(target, 'execute', state, {
393
- flagSuffix: 'transaction:execute',
394
- queryText: (args) => extractQueryText(args[0]),
395
- callbackStyle: 'last-arg',
396
- extraAttributes: transactionAttributes,
397
- }) || instrumented;
398
-
399
- instrumented =
400
- instrumentPrepareQuery(
401
- target as DrizzleSessionLike,
402
- state,
403
- transactionAttributes,
404
- ) || instrumented;
405
-
406
- if (isObject(target.session)) {
407
- instrumented =
408
- instrumentTransactionTarget(target.session, state) || instrumented;
409
- }
410
-
411
- if (isObject(target._?.session)) {
412
- instrumented =
413
- instrumentTransactionTarget(target._.session, state) || instrumented;
414
- }
415
-
416
- return instrumented;
417
- }
418
-
419
- function instrumentSession(
420
- session: DrizzleSessionLike,
421
- state: InstrumentationState,
422
- ): boolean {
423
- let instrumented = false;
424
-
425
- instrumented =
426
- instrumentMethod(session, 'query', state, {
427
- flagSuffix: 'session:query',
428
- queryText: (args) => extractQueryText(args[0]),
429
- callbackStyle: 'last-arg',
430
- }) || instrumented;
431
-
432
- instrumented =
433
- instrumentMethod(session, 'execute', state, {
434
- flagSuffix: 'session:execute',
435
- queryText: (args) => extractQueryText(args[0]),
436
- callbackStyle: 'last-arg',
437
- }) || instrumented;
438
-
439
- instrumented = instrumentPrepareQuery(session, state) || instrumented;
440
-
441
- if (typeof session.transaction === 'function') {
442
- const flagKey = getFlagKey('session:transaction');
443
-
444
- if (!session[flagKey]) {
445
- const originalTransaction = session.transaction;
446
-
447
- session.transaction = function instrumentedTransaction(
448
- this: any,
449
- callback: QueryFunction,
450
- ...restArgs: any[]
451
- ) {
452
- if (typeof callback !== 'function') {
453
- return Reflect.apply(originalTransaction, this, [
454
- callback,
455
- ...restArgs,
456
- ]);
457
- }
458
-
459
- const wrappedCallback = (tx: unknown, ...callbackArgs: any[]) => {
460
- instrumentTransactionTarget(tx, state);
461
- return Reflect.apply(callback, this, [tx, ...callbackArgs]);
462
- };
463
-
464
- return Reflect.apply(originalTransaction, this, [
465
- wrappedCallback,
466
- ...restArgs,
467
- ]);
468
- };
469
-
470
- session[flagKey] = true;
471
- instrumented = true;
472
- }
473
- }
474
-
475
- if (instrumented) {
476
- session[INSTRUMENTED_FLAG] = true;
477
- }
478
-
479
- return instrumented;
480
- }
481
-
482
- export function instrumentDrizzle<TClient extends DrizzleClientLike>(
483
- client: TClient,
484
- config?: InstrumentDrizzleConfig,
485
- ): TClient {
486
- if (!client || !isObject(client)) {
487
- return client;
488
- }
489
-
490
- const state = getState(config);
491
- let instrumented = false;
492
-
493
- instrumented =
494
- instrumentMethod(client, 'query', state, {
495
- flagSuffix: 'client:query',
496
- queryText: (args) => extractQueryText(args[0]),
497
- callbackStyle: 'last-arg',
498
- }) || instrumented;
499
-
500
- instrumented =
501
- instrumentMethod(client, 'execute', state, {
502
- flagSuffix: 'client:execute',
503
- queryText: (args) => extractQueryText(args[0]),
504
- callbackStyle: 'last-arg',
505
- }) || instrumented;
506
-
507
- if (instrumented) {
508
- client[INSTRUMENTED_FLAG] = true;
509
- }
510
-
511
- return client;
512
- }
513
-
514
- export function instrumentDrizzleClient<TDb extends DrizzleDbLike>(
515
- db: TDb,
516
- config?: InstrumentDrizzleConfig,
517
- ): TDb {
518
- if (!db || !isObject(db)) {
519
- return db;
520
- }
521
-
522
- if (db[INSTRUMENTED_FLAG]) {
523
- return db;
524
- }
525
-
526
- const state = getState(config);
527
- let instrumented = false;
528
-
529
- instrumented =
530
- instrumentSession(db as unknown as DrizzleSessionLike, state) ||
531
- instrumented;
532
-
533
- if (isObject(db.session)) {
534
- instrumented = instrumentSession(db.session, state) || instrumented;
535
- }
536
-
537
- if (isObject(db._?.session)) {
538
- instrumented = instrumentSession(db._.session, state) || instrumented;
539
- }
540
-
541
- // Intentionally do NOT instrument db.$client here. The raw client (e.g.
542
- // pg.Pool) is the same object that drizzle's session invokes internally from
543
- // its prepared query's execute(). Wrapping both layers produces nested
544
- // duplicate spans for every query. Users who need to trace a standalone
545
- // client without a drizzle wrapper should call `instrumentDrizzle` directly.
546
-
547
- if (instrumented) {
548
- db[INSTRUMENTED_FLAG] = true;
549
- }
550
-
551
- return db;
552
- }
package/src/index.ts DELETED
@@ -1,64 +0,0 @@
1
- /**
2
- * Autotel Drizzle - OpenTelemetry instrumentation for Drizzle ORM
3
- *
4
- * This package provides instrumentation for Drizzle ORM.
5
- *
6
- * Philosophy:
7
- * Only include plugins for libraries that either:
8
- * 1. Have NO official instrumentation (e.g., Drizzle ORM)
9
- * 2. Have BROKEN official instrumentation (e.g., Mongoose in ESM+tsx)
10
- * 3. Add SIGNIFICANT value beyond official packages (e.g., Kafka processing spans)
11
- *
12
- * For databases/ORMs with working official instrumentation, use those directly with the --import pattern:
13
- * - MongoDB: @opentelemetry/instrumentation-mongodb
14
- * - PostgreSQL: @opentelemetry/instrumentation-pg
15
- * - MySQL: @opentelemetry/instrumentation-mysql2
16
- * - Redis: @opentelemetry/instrumentation-redis
17
- * - Kafka: @opentelemetry/instrumentation-kafkajs (use with autotel-plugins/kafka for processing spans)
18
- *
19
- * See: https://github.com/open-telemetry/opentelemetry-js-contrib
20
- *
21
- * @example
22
- * ```typescript
23
- * // Drizzle manual instrumentation
24
- * import { instrumentDrizzleClient } from 'autotel-drizzle';
25
- * import { drizzle } from 'drizzle-orm/node-postgres';
26
- *
27
- * const db = instrumentDrizzleClient(drizzle(pool));
28
- * ```
29
- *
30
- * @packageDocumentation
31
- */
32
-
33
- // Re-export common semantic conventions
34
- export {
35
- SEMATTRS_DB_SYSTEM,
36
- SEMATTRS_DB_SYSTEM_NAME,
37
- SEMATTRS_DB_OPERATION,
38
- SEMATTRS_DB_OPERATION_NAME,
39
- SEMATTRS_DB_STATEMENT,
40
- SEMATTRS_DB_NAME,
41
- SEMATTRS_DB_NAMESPACE,
42
- SEMATTRS_DB_COLLECTION_NAME,
43
- SEMATTRS_DB_QUERY_TEXT,
44
- SEMATTRS_DB_QUERY_SUMMARY,
45
- SEMATTRS_NET_PEER_NAME,
46
- SEMATTRS_NET_PEER_PORT,
47
- SEMATTRS_GCP_BIGQUERY_JOB_ID,
48
- SEMATTRS_GCP_BIGQUERY_JOB_LOCATION,
49
- SEMATTRS_GCP_BIGQUERY_PROJECT_ID,
50
- SEMATTRS_GCP_BIGQUERY_DESTINATION_TABLE,
51
- SEMATTRS_GCP_BIGQUERY_SOURCE_TABLES,
52
- SEMATTRS_GCP_BIGQUERY_STATEMENT_TYPE,
53
- SEMATTRS_GCP_BIGQUERY_QUERY_HASH,
54
- SEMATTRS_GCP_BIGQUERY_ROWS_AFFECTED,
55
- SEMATTRS_GCP_BIGQUERY_ROWS_RETURNED,
56
- SEMATTRS_GCP_BIGQUERY_SCHEMA_FIELDS,
57
- } from './common/constants';
58
-
59
- // Re-export Drizzle plugin
60
- export {
61
- instrumentDrizzle,
62
- instrumentDrizzleClient,
63
- type InstrumentDrizzleConfig,
64
- } from './drizzle';