@unhandled/instrumentation-drizzle 0.0.0-stage → 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.
- package/README.md +73 -2
- package/build/guards.d.ts +3 -0
- package/build/guards.js +2 -0
- package/build/index.d.ts +5 -0
- package/build/index.js +3 -0
- package/build/instrument_client.d.ts +2 -0
- package/build/instrument_client.js +75 -0
- package/build/instrument_driver.d.ts +2 -0
- package/build/instrument_driver.js +95 -0
- package/build/query_span.d.ts +10 -0
- package/build/query_span.js +92 -0
- package/build/sql_parser.d.ts +5 -0
- package/build/sql_parser.js +151 -0
- package/build/transaction_state.d.ts +3 -0
- package/build/transaction_state.js +20 -0
- package/build/types.d.ts +16 -0
- package/build/types.js +1 -0
- package/package.json +50 -3
package/README.md
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @unhandled/instrumentation-drizzle
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Traces [Drizzle ORM](https://orm.drizzle.team) queries and prepared queries as `CLIENT` spans. Needs `drizzle-orm >= 0.28.0` and your own database driver. This package is opt-in: the AdonisJS agent never enables it, and it has no `/integration` entry.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @unhandled/instrumentation-drizzle
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
Initialise OpenTelemetry first, then pick exactly one entry point per database.
|
|
14
|
+
|
|
15
|
+
Wrap the database returned by `drizzle()` (works with any Drizzle driver):
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
19
|
+
import { instrumentDrizzleClient } from '@unhandled/instrumentation-drizzle'
|
|
20
|
+
|
|
21
|
+
export const db = instrumentDrizzleClient(drizzle(pool), { dbSystem: 'postgresql', dbName: 'app' })
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or wrap the node-postgres `Pool` or `Client` before handing it to `drizzle()`:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { instrumentDrizzle } from '@unhandled/instrumentation-drizzle'
|
|
28
|
+
|
|
29
|
+
export const db = drizzle(instrumentDrizzle(pool, { dbSystem: 'postgresql', dbName: 'app' }))
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Do not use both on the same database. Every query would be traced twice, once at the Drizzle layer and once at the driver, with the driver span nested inside the other.
|
|
33
|
+
|
|
34
|
+
## Config
|
|
35
|
+
|
|
36
|
+
| Option | Default | Effect |
|
|
37
|
+
| -------------------- | --------- | --------------------------------------------------------------------------- |
|
|
38
|
+
| `dbSystem` | `unknown` | Value of `db.system.name`: `postgresql`, `mysql`, `sqlite`. Set it. |
|
|
39
|
+
| `dbName` | none | `db.namespace` |
|
|
40
|
+
| `peerName` | none | `server.address`, the database host |
|
|
41
|
+
| `peerPort` | none | `server.port` |
|
|
42
|
+
| `captureQueryText` | `true` | `false` leaves `db.query.text` off every span |
|
|
43
|
+
| `maxQueryTextLength` | `1000` | Query text is cut to this many characters. Operation and table still come from the full SQL. |
|
|
44
|
+
|
|
45
|
+
SQL text can contain sensitive literals. Bound parameters are never recorded, and a failed query's span carries the driver's error message rather than Drizzle's wrapper, which embeds the parameters.
|
|
46
|
+
|
|
47
|
+
## Spans
|
|
48
|
+
|
|
49
|
+
Every span is `CLIENT` kind, named `<OPERATION> <table>` (`SELECT users`), or just the operation when no table can be parsed. A prepared query produces one span per execution.
|
|
50
|
+
|
|
51
|
+
| Attribute | Value |
|
|
52
|
+
| -------------------- | -------------------------------------------------------- |
|
|
53
|
+
| `db.system.name` | `dbSystem` |
|
|
54
|
+
| `db.query.text` | SQL with placeholders, truncated, when captured |
|
|
55
|
+
| `db.operation.name` | Upper-cased first keyword, for example `SELECT` |
|
|
56
|
+
| `db.collection.name` | Target table when it can be parsed |
|
|
57
|
+
| `db.namespace` | `dbName` |
|
|
58
|
+
| `server.address` | `peerName` |
|
|
59
|
+
| `server.port` | `peerPort` |
|
|
60
|
+
| `db.transaction` | `true` for queries inside a transaction |
|
|
61
|
+
| `error.type` | Error class name on failures, plus the recorded exception and `ERROR` status |
|
|
62
|
+
|
|
63
|
+
### Transactions
|
|
64
|
+
|
|
65
|
+
- `instrumentDrizzle` sees what the connection sees, so `BEGIN`, `COMMIT`, `ROLLBACK`, savepoints and every query between them are spans flagged `db.transaction`.
|
|
66
|
+
- `instrumentDrizzleClient` traces queries run through `db.transaction` and flags them. The control statements Drizzle issues itself (`BEGIN`, `COMMIT`, `ROLLBACK`, savepoints) are not traced.
|
|
67
|
+
|
|
68
|
+
### Drizzle's own spans
|
|
69
|
+
|
|
70
|
+
When `@opentelemetry/api` is installed, Drizzle itself emits `drizzle.*` spans under the `drizzle-orm` scope. They are independent of this package and cannot be turned off from here.
|
|
71
|
+
|
|
72
|
+
## Drivers
|
|
73
|
+
|
|
74
|
+
`instrumentDrizzleClient` patches the session behind `db`, so it is driver-agnostic. `instrumentDrizzle` wraps `query()` on a node-postgres `Pool` (including the clients it hands out) or `Client`; any other object throws a `TypeError`.
|
package/build/guards.js
ADDED
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { instrumentDrizzleClient } from "./instrument_client.ts";
|
|
2
|
+
export { instrumentDrizzle } from "./instrument_driver.ts";
|
|
3
|
+
export type { ParsedSql } from "./sql_parser.ts";
|
|
4
|
+
export { parseSql } from "./sql_parser.ts";
|
|
5
|
+
export type { InstrumentDrizzleConfig } from "./types.ts";
|
package/build/index.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { isMethod, isRecord } from "./guards.js";
|
|
3
|
+
import { resolveSettings, runTraced, startQuerySpan } from "./query_span.js";
|
|
4
|
+
import { isTransactionControl } from "./transaction_state.js";
|
|
5
|
+
const EXECUTION_METHODS = ["execute", "all", "get", "values", "run"];
|
|
6
|
+
const instrumentedSessions = new WeakSet();
|
|
7
|
+
const transactionSessions = new AsyncLocalStorage();
|
|
8
|
+
const isSession = (value) => isRecord(value) && isMethod(value.prepareQuery);
|
|
9
|
+
const sessionOf = (db) => (isRecord(db) && isSession(db.session) ? db.session : undefined);
|
|
10
|
+
const sqlOf = (query) => (isRecord(query) && typeof query.sql === "string" ? query.sql : undefined);
|
|
11
|
+
const instrumentPreparedQuery = (prepared, sql, session, settings) => {
|
|
12
|
+
let running = false;
|
|
13
|
+
for (const name of EXECUTION_METHODS) {
|
|
14
|
+
const original = prepared[name];
|
|
15
|
+
if (!isMethod(original)) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
prepared[name] = (...args) => {
|
|
19
|
+
if (running) {
|
|
20
|
+
return Reflect.apply(original, prepared, args);
|
|
21
|
+
}
|
|
22
|
+
const inTransaction = transactionSessions.getStore()?.has(session) === true;
|
|
23
|
+
const query = startQuerySpan(settings, sql, inTransaction);
|
|
24
|
+
running = true;
|
|
25
|
+
try {
|
|
26
|
+
return runTraced(query, () => Reflect.apply(original, prepared, args));
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
running = false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const instrumentSession = (session, settings) => {
|
|
35
|
+
if (instrumentedSessions.has(session)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
instrumentedSessions.add(session);
|
|
39
|
+
const { prepareQuery } = session;
|
|
40
|
+
session.prepareQuery = (...args) => {
|
|
41
|
+
const prepared = Reflect.apply(prepareQuery, session, args);
|
|
42
|
+
const sql = sqlOf(args[0]);
|
|
43
|
+
if (sql !== undefined && isRecord(prepared) && !isTransactionControl(sql)) {
|
|
44
|
+
instrumentPreparedQuery(prepared, sql, session, settings);
|
|
45
|
+
}
|
|
46
|
+
return prepared;
|
|
47
|
+
};
|
|
48
|
+
const { transaction } = session;
|
|
49
|
+
if (!isMethod(transaction)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
session.transaction = (callback, ...rest) => {
|
|
53
|
+
if (!isMethod(callback)) {
|
|
54
|
+
return Reflect.apply(transaction, session, [callback, ...rest]);
|
|
55
|
+
}
|
|
56
|
+
const traced = (tx) => {
|
|
57
|
+
const transactionSession = sessionOf(tx);
|
|
58
|
+
if (!transactionSession) {
|
|
59
|
+
return callback(tx);
|
|
60
|
+
}
|
|
61
|
+
instrumentSession(transactionSession, settings);
|
|
62
|
+
const sessions = new Set([...(transactionSessions.getStore() ?? []), transactionSession]);
|
|
63
|
+
return transactionSessions.run(sessions, () => callback(tx));
|
|
64
|
+
};
|
|
65
|
+
return Reflect.apply(transaction, session, [traced, ...rest]);
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
export const instrumentDrizzleClient = (db, config = {}) => {
|
|
69
|
+
const session = sessionOf(db);
|
|
70
|
+
if (!session) {
|
|
71
|
+
throw new TypeError("instrumentDrizzleClient() expects the database returned by drizzle(); it has no session to instrument.");
|
|
72
|
+
}
|
|
73
|
+
instrumentSession(session, resolveSettings(config));
|
|
74
|
+
return db;
|
|
75
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { context } from "@opentelemetry/api";
|
|
2
|
+
import { isMethod, isRecord } from "./guards.js";
|
|
3
|
+
import { resolveSettings, runTraced, startQuerySpan } from "./query_span.js";
|
|
4
|
+
import { transactionEffect } from "./transaction_state.js";
|
|
5
|
+
const instrumentedClients = new WeakSet();
|
|
6
|
+
const queryTextOf = (query) => {
|
|
7
|
+
if (typeof query === "string") {
|
|
8
|
+
return query;
|
|
9
|
+
}
|
|
10
|
+
return isRecord(query) && typeof query.text === "string" && !isMethod(query.submit) ? query.text : undefined;
|
|
11
|
+
};
|
|
12
|
+
const instrumentQuery = (client, original, settings) => {
|
|
13
|
+
let inTransaction = false;
|
|
14
|
+
client.query = (...args) => {
|
|
15
|
+
const sql = queryTextOf(args[0]);
|
|
16
|
+
if (sql === undefined) {
|
|
17
|
+
return Reflect.apply(original, client, args);
|
|
18
|
+
}
|
|
19
|
+
const effect = transactionEffect(sql);
|
|
20
|
+
const wasInTransaction = inTransaction;
|
|
21
|
+
if (effect === "begin") {
|
|
22
|
+
inTransaction = true;
|
|
23
|
+
}
|
|
24
|
+
const span = startQuerySpan(settings, sql, inTransaction);
|
|
25
|
+
const query = {
|
|
26
|
+
context: span.context,
|
|
27
|
+
end: (error) => {
|
|
28
|
+
if (effect === "begin" && error !== undefined) {
|
|
29
|
+
inTransaction = wasInTransaction;
|
|
30
|
+
}
|
|
31
|
+
if (effect === "end") {
|
|
32
|
+
inTransaction = false;
|
|
33
|
+
}
|
|
34
|
+
span.end(error);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const callback = args.at(-1);
|
|
38
|
+
if (!isMethod(callback)) {
|
|
39
|
+
return runTraced(query, () => Reflect.apply(original, client, args));
|
|
40
|
+
}
|
|
41
|
+
const parent = context.active();
|
|
42
|
+
const traced = (error, ...rest) => {
|
|
43
|
+
query.end(error ?? undefined);
|
|
44
|
+
return context.with(parent, () => Reflect.apply(callback, undefined, [error, ...rest]));
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
return context.with(query.context, () => Reflect.apply(original, client, [...args.slice(0, -1), traced]));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
query.end(error);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
const instrumentClient = (client, settings) => {
|
|
56
|
+
if (!isRecord(client) || instrumentedClients.has(client) || !isMethod(client.query)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
instrumentedClients.add(client);
|
|
60
|
+
instrumentQuery(client, client.query, settings);
|
|
61
|
+
};
|
|
62
|
+
const instrumentPoolConnections = (pool, settings) => {
|
|
63
|
+
const { connect } = pool;
|
|
64
|
+
if (!isMethod(connect)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
pool.connect = (...args) => {
|
|
68
|
+
const callback = args.at(-1);
|
|
69
|
+
if (isMethod(callback)) {
|
|
70
|
+
const instrumented = (error, client, ...rest) => {
|
|
71
|
+
instrumentClient(client, settings);
|
|
72
|
+
return Reflect.apply(callback, undefined, [error, client, ...rest]);
|
|
73
|
+
};
|
|
74
|
+
return Reflect.apply(connect, pool, [...args.slice(0, -1), instrumented]);
|
|
75
|
+
}
|
|
76
|
+
return Promise.resolve(Reflect.apply(connect, pool, args)).then((client) => {
|
|
77
|
+
instrumentClient(client, settings);
|
|
78
|
+
return client;
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const isPgPool = (client) => typeof client.totalCount === "number" && typeof client.idleCount === "number";
|
|
83
|
+
export const instrumentDrizzle = (client, config = {}) => {
|
|
84
|
+
if (!(isRecord(client) && isMethod(client.query))) {
|
|
85
|
+
throw new TypeError("instrumentDrizzle() expects a node-postgres Pool or Client (an object with query()). Use instrumentDrizzleClient() on the drizzle() result for other drivers.");
|
|
86
|
+
}
|
|
87
|
+
const settings = resolveSettings(config);
|
|
88
|
+
if (isPgPool(client)) {
|
|
89
|
+
instrumentPoolConnections(client, settings);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
instrumentClient(client, settings);
|
|
93
|
+
}
|
|
94
|
+
return client;
|
|
95
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Context } from "@opentelemetry/api";
|
|
2
|
+
import type { InstrumentDrizzleConfig, SpanSettings } from "./types.ts";
|
|
3
|
+
export interface QuerySpan {
|
|
4
|
+
context: Context;
|
|
5
|
+
end: (error?: unknown) => void;
|
|
6
|
+
}
|
|
7
|
+
export declare const resolveSettings: (config?: InstrumentDrizzleConfig) => SpanSettings;
|
|
8
|
+
export declare const isPromiseLike: (value: unknown) => value is PromiseLike<unknown>;
|
|
9
|
+
export declare const startQuerySpan: (settings: SpanSettings, sql: string, inTransaction: boolean) => QuerySpan;
|
|
10
|
+
export declare const runTraced: (query: QuerySpan, call: () => unknown) => unknown;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { context, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
2
|
+
import { ATTR } from "@unhandled/protocol";
|
|
3
|
+
import { parseSql } from "./sql_parser.js";
|
|
4
|
+
const INSTRUMENTATION_NAME = "@unhandled/instrumentation-drizzle";
|
|
5
|
+
const INSTRUMENTATION_VERSION = "0.1.0";
|
|
6
|
+
const DEFAULT_MAX_QUERY_TEXT_LENGTH = 1000;
|
|
7
|
+
export const resolveSettings = (config = {}) => ({
|
|
8
|
+
dbSystem: config.dbSystem ?? "unknown",
|
|
9
|
+
dbName: config.dbName,
|
|
10
|
+
peerName: config.peerName,
|
|
11
|
+
peerPort: config.peerPort,
|
|
12
|
+
captureQueryText: config.captureQueryText ?? true,
|
|
13
|
+
maxQueryTextLength: config.maxQueryTextLength ?? DEFAULT_MAX_QUERY_TEXT_LENGTH,
|
|
14
|
+
});
|
|
15
|
+
export const isPromiseLike = (value) => typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
|
|
16
|
+
// Drizzle wraps driver failures in an error whose message embeds the SQL and its bound parameters.
|
|
17
|
+
const unwrapDriverError = (error) => error instanceof Error && "query" in error && "params" in error && error.cause !== undefined ? error.cause : error;
|
|
18
|
+
const failure = (error) => {
|
|
19
|
+
const cause = unwrapDriverError(error);
|
|
20
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
21
|
+
};
|
|
22
|
+
export const startQuerySpan = (settings, sql, inTransaction) => {
|
|
23
|
+
const { operation, collection } = parseSql(sql);
|
|
24
|
+
const attributes = { [ATTR.dbSystemName]: settings.dbSystem };
|
|
25
|
+
if (settings.captureQueryText) {
|
|
26
|
+
attributes[ATTR.dbQueryText] = sql.slice(0, settings.maxQueryTextLength);
|
|
27
|
+
}
|
|
28
|
+
if (operation) {
|
|
29
|
+
attributes[ATTR.dbOperationName] = operation;
|
|
30
|
+
}
|
|
31
|
+
if (collection) {
|
|
32
|
+
attributes[ATTR.dbCollectionName] = collection;
|
|
33
|
+
}
|
|
34
|
+
if (settings.dbName) {
|
|
35
|
+
attributes[ATTR.dbNamespace] = settings.dbName;
|
|
36
|
+
}
|
|
37
|
+
if (settings.peerName) {
|
|
38
|
+
attributes[ATTR.serverAddress] = settings.peerName;
|
|
39
|
+
}
|
|
40
|
+
if (settings.peerPort !== undefined) {
|
|
41
|
+
attributes[ATTR.serverPort] = settings.peerPort;
|
|
42
|
+
}
|
|
43
|
+
if (inTransaction) {
|
|
44
|
+
attributes[ATTR.dbTransaction] = true;
|
|
45
|
+
}
|
|
46
|
+
let name = settings.dbSystem;
|
|
47
|
+
if (operation) {
|
|
48
|
+
name = collection ? `${operation} ${collection}` : operation;
|
|
49
|
+
}
|
|
50
|
+
const parent = context.active();
|
|
51
|
+
const span = trace
|
|
52
|
+
.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION)
|
|
53
|
+
.startSpan(name, { kind: SpanKind.CLIENT, attributes }, parent);
|
|
54
|
+
let ended = false;
|
|
55
|
+
return {
|
|
56
|
+
context: trace.setSpan(parent, span),
|
|
57
|
+
end: (error) => {
|
|
58
|
+
if (ended) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
ended = true;
|
|
62
|
+
if (error !== undefined) {
|
|
63
|
+
const cause = failure(error);
|
|
64
|
+
span.setAttribute(ATTR.errorType, cause.name);
|
|
65
|
+
span.recordException(cause);
|
|
66
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: cause.message });
|
|
67
|
+
}
|
|
68
|
+
span.end();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
export const runTraced = (query, call) => {
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
result = context.with(query.context, call);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
query.end(error);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
if (!isPromiseLike(result)) {
|
|
82
|
+
query.end();
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
return Promise.resolve(result).then((value) => {
|
|
86
|
+
query.end();
|
|
87
|
+
return value;
|
|
88
|
+
}, (error) => {
|
|
89
|
+
query.end(error);
|
|
90
|
+
throw error;
|
|
91
|
+
});
|
|
92
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const WHITESPACE_CHAR_PATTERN = /\s/;
|
|
2
|
+
const STATEMENTS = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE"]);
|
|
3
|
+
const DDL = new Set(["CREATE", "ALTER", "DROP", "TRUNCATE"]);
|
|
4
|
+
const WORD = /[A-Za-z_]/;
|
|
5
|
+
const WORD_PART = /[\w$]/;
|
|
6
|
+
const skipBlanks = (sql, from) => {
|
|
7
|
+
let index = from;
|
|
8
|
+
while (index < sql.length) {
|
|
9
|
+
if (WHITESPACE_CHAR_PATTERN.test(sql[index])) {
|
|
10
|
+
index += 1;
|
|
11
|
+
}
|
|
12
|
+
else if (sql.startsWith("--", index)) {
|
|
13
|
+
const end = sql.indexOf("\n", index);
|
|
14
|
+
index = end === -1 ? sql.length : end + 1;
|
|
15
|
+
}
|
|
16
|
+
else if (sql.startsWith("/*", index)) {
|
|
17
|
+
const end = sql.indexOf("*/", index + 2);
|
|
18
|
+
index = end === -1 ? sql.length : end + 2;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return index;
|
|
25
|
+
};
|
|
26
|
+
const skipQuoted = (sql, from, close) => {
|
|
27
|
+
let index = from + 1;
|
|
28
|
+
while (index < sql.length) {
|
|
29
|
+
if (sql[index] === close) {
|
|
30
|
+
if (sql[index + 1] === close) {
|
|
31
|
+
index += 2;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
return index + 1;
|
|
35
|
+
}
|
|
36
|
+
index += 1;
|
|
37
|
+
}
|
|
38
|
+
return sql.length;
|
|
39
|
+
};
|
|
40
|
+
const readWord = (sql, from) => {
|
|
41
|
+
if (!WORD.test(sql[from] ?? "")) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
let end = from + 1;
|
|
45
|
+
while (end < sql.length && WORD_PART.test(sql[end])) {
|
|
46
|
+
end += 1;
|
|
47
|
+
}
|
|
48
|
+
return { text: sql.slice(from, end), end };
|
|
49
|
+
};
|
|
50
|
+
const findKeyword = (sql, keywords, from) => {
|
|
51
|
+
let depth = 0;
|
|
52
|
+
let index = from;
|
|
53
|
+
while (index < sql.length) {
|
|
54
|
+
const char = sql[index];
|
|
55
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
56
|
+
index = skipQuoted(sql, index, char);
|
|
57
|
+
}
|
|
58
|
+
else if (char === "[") {
|
|
59
|
+
index = skipQuoted(sql, index, "]");
|
|
60
|
+
}
|
|
61
|
+
else if (char === "(") {
|
|
62
|
+
depth += 1;
|
|
63
|
+
index += 1;
|
|
64
|
+
}
|
|
65
|
+
else if (char === ")") {
|
|
66
|
+
depth -= 1;
|
|
67
|
+
index += 1;
|
|
68
|
+
}
|
|
69
|
+
else if (WORD.test(char) && (index === 0 || !WORD_PART.test(sql[index - 1]))) {
|
|
70
|
+
const word = readWord(sql, index);
|
|
71
|
+
if (word && depth === 0 && keywords.has(word.text.toUpperCase())) {
|
|
72
|
+
return word;
|
|
73
|
+
}
|
|
74
|
+
index = word ? word.end : index + 1;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
index += 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
};
|
|
82
|
+
const readIdentifier = (sql, from) => {
|
|
83
|
+
const char = sql[from];
|
|
84
|
+
if (char === '"' || char === "`" || char === "[") {
|
|
85
|
+
const end = skipQuoted(sql, from, char === "[" ? "]" : char);
|
|
86
|
+
return { text: sql.slice(from + 1, end - 1), end };
|
|
87
|
+
}
|
|
88
|
+
return readWord(sql, from);
|
|
89
|
+
};
|
|
90
|
+
const readQualifiedName = (sql, from) => {
|
|
91
|
+
const parts = [];
|
|
92
|
+
let index = skipBlanks(sql, from);
|
|
93
|
+
for (;;) {
|
|
94
|
+
const part = readIdentifier(sql, index);
|
|
95
|
+
if (!part) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
parts.push(part.text);
|
|
99
|
+
const next = skipBlanks(sql, part.end);
|
|
100
|
+
if (sql[next] !== ".") {
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
index = skipBlanks(sql, next + 1);
|
|
104
|
+
}
|
|
105
|
+
return parts.length > 0 ? parts.join(".") : undefined;
|
|
106
|
+
};
|
|
107
|
+
const SKIPPED_MODIFIERS = new Set(["ONLY", "IF", "NOT", "EXISTS"]);
|
|
108
|
+
const readTarget = (sql, from) => {
|
|
109
|
+
let index = skipBlanks(sql, from);
|
|
110
|
+
for (;;) {
|
|
111
|
+
const word = readWord(sql, index);
|
|
112
|
+
if (!(word && SKIPPED_MODIFIERS.has(word.text.toUpperCase()))) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
index = skipBlanks(sql, word.end);
|
|
116
|
+
}
|
|
117
|
+
return readQualifiedName(sql, index);
|
|
118
|
+
};
|
|
119
|
+
const statementStart = (sql, from) => {
|
|
120
|
+
const first = readWord(sql, from);
|
|
121
|
+
if (first?.text.toUpperCase() !== "WITH") {
|
|
122
|
+
return from;
|
|
123
|
+
}
|
|
124
|
+
const main = findKeyword(sql, STATEMENTS, first.end);
|
|
125
|
+
return main ? main.end - main.text.length : from;
|
|
126
|
+
};
|
|
127
|
+
export const parseSql = (sql) => {
|
|
128
|
+
const start = statementStart(sql, skipBlanks(sql, 0));
|
|
129
|
+
const first = readWord(sql, start);
|
|
130
|
+
if (!first) {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
const operation = first.text.toUpperCase();
|
|
134
|
+
const after = first.end;
|
|
135
|
+
if (operation === "SELECT" || operation === "DELETE") {
|
|
136
|
+
const from = findKeyword(sql, new Set(["FROM"]), after);
|
|
137
|
+
return { operation, collection: from ? readTarget(sql, from.end) : undefined };
|
|
138
|
+
}
|
|
139
|
+
if (operation === "INSERT" || operation === "MERGE") {
|
|
140
|
+
const into = findKeyword(sql, new Set(["INTO"]), after);
|
|
141
|
+
return { operation, collection: into ? readTarget(sql, into.end) : undefined };
|
|
142
|
+
}
|
|
143
|
+
if (operation === "UPDATE") {
|
|
144
|
+
return { operation, collection: readTarget(sql, after) };
|
|
145
|
+
}
|
|
146
|
+
if (DDL.has(operation)) {
|
|
147
|
+
const table = findKeyword(sql, new Set(["TABLE"]), after);
|
|
148
|
+
return { operation, collection: table ? readTarget(sql, table.end) : undefined };
|
|
149
|
+
}
|
|
150
|
+
return { operation };
|
|
151
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parseSql } from "./sql_parser.js";
|
|
2
|
+
const ROLLBACK_TO_SAVEPOINT = /^\s*rollback\s+(?:work\s+|transaction\s+)?to\b/i;
|
|
3
|
+
export const transactionEffect = (sql) => {
|
|
4
|
+
const { operation } = parseSql(sql);
|
|
5
|
+
if (operation === "BEGIN" || operation === "START") {
|
|
6
|
+
return "begin";
|
|
7
|
+
}
|
|
8
|
+
if (operation === "COMMIT" || operation === "END") {
|
|
9
|
+
return "end";
|
|
10
|
+
}
|
|
11
|
+
if (operation === "ROLLBACK" && !ROLLBACK_TO_SAVEPOINT.test(sql)) {
|
|
12
|
+
return "end";
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
};
|
|
16
|
+
const CONTROL_OPERATIONS = new Set(["BEGIN", "START", "COMMIT", "END", "ROLLBACK", "ABORT", "SAVEPOINT", "RELEASE"]);
|
|
17
|
+
export const isTransactionControl = (sql) => {
|
|
18
|
+
const { operation } = parseSql(sql);
|
|
19
|
+
return operation !== undefined && CONTROL_OPERATIONS.has(operation);
|
|
20
|
+
};
|
package/build/types.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface InstrumentDrizzleConfig {
|
|
2
|
+
captureQueryText?: boolean;
|
|
3
|
+
dbName?: string;
|
|
4
|
+
dbSystem?: string;
|
|
5
|
+
maxQueryTextLength?: number;
|
|
6
|
+
peerName?: string;
|
|
7
|
+
peerPort?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface SpanSettings {
|
|
10
|
+
captureQueryText: boolean;
|
|
11
|
+
dbName?: string;
|
|
12
|
+
dbSystem: string;
|
|
13
|
+
maxQueryTextLength: number;
|
|
14
|
+
peerName?: string;
|
|
15
|
+
peerPort?: number;
|
|
16
|
+
}
|
package/build/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unhandled/instrumentation-drizzle",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Drizzle ORM query tracing for Unhandled",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/treboryx/unhandled.git",
|
|
8
|
+
"directory": "packages/instrumentation-drizzle"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./build/index.d.ts",
|
|
15
|
+
"default": "./build/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"build"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@opentelemetry/api": "~1.9.1",
|
|
23
|
+
"@opentelemetry/core": "^2.12.0",
|
|
24
|
+
"@unhandled/protocol": "0.0.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@adonisjs/tsconfig": "^2.0.0",
|
|
28
|
+
"@opentelemetry/context-async-hooks": "^2.12.0",
|
|
29
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.223.0",
|
|
30
|
+
"@opentelemetry/sdk-trace-base": "^2.12.0",
|
|
31
|
+
"@types/node": "^25.9.9",
|
|
32
|
+
"@types/pg": "^8.23.1",
|
|
33
|
+
"@unhandled/otlp-receiver": "0.0.0",
|
|
34
|
+
"drizzle-orm": "^0.45.4",
|
|
35
|
+
"pg": "^8.23.1",
|
|
36
|
+
"typescript": "~6.0.2"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"drizzle-orm": ">=0.28.0"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=24.0.0"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc --project tsconfig.build.json",
|
|
49
|
+
"test": "node --test 'tests/**/*.test.ts'",
|
|
50
|
+
"typecheck": "tsrs --noEmit",
|
|
51
|
+
"typecheck:tsc": "tsc --noEmit"
|
|
52
|
+
}
|
|
6
53
|
}
|