Drizzle ORM telemetry package

This commit is contained in:
Alex Holovach
2025-10-02 15:28:26 -05:00
parent c102381031
commit b385007e44
29 changed files with 5035 additions and 2 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "kubiks-inc/otel" }],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"snapshot": {
"useCalculatedVersion": true
}
}
+5
View File
@@ -0,0 +1,5 @@
---
"@kubiks/otel-drizzle": patch
---
update package.json
+43
View File
@@ -0,0 +1,43 @@
name: Release
on:
push:
branches:
- main
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
token: ${{ secrets.PAT_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
- uses: actions/setup-node@v3
with:
node-version-file: ".node-version"
cache: "pnpm"
- name: Install Dependencies
run: pnpm install
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
publish: pnpm release
version: pnpm version-packages
setupGitUser: true
createGithubReleases: true
commit: Version Packages
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+33
View File
@@ -0,0 +1,33 @@
name: Type Check
on:
push:
branches: [main, v1.x]
pull_request:
branches: [main, v1.x]
jobs:
test:
name: "type-check"
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup pnpm
uses: pnpm/action-setup@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Run type-check
run: pnpm type-check
+36
View File
@@ -0,0 +1,36 @@
name: Unit Tests
on:
push:
branches: [main, v1.x]
pull_request:
branches: [main, v1.x]
jobs:
test:
name: "unit tests"
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup pnpm
uses: pnpm/action-setup@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Run unit tests
run: pnpm unit-test
+5
View File
@@ -0,0 +1,5 @@
node_modules
.turbo
tsconfig.tsbuildinfo
dist
dist-site
+1
View File
@@ -0,0 +1 @@
18.19.0
+3
View File
@@ -0,0 +1,3 @@
dist
pnpm-lock.yaml
pnpm-workspace.yaml
+15
View File
@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Sample app",
"type": "node-terminal",
"request": "launch",
"cwd": "${workspaceFolder}/apps/sample",
"command": "pnpm dev"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+5 -1
View File
@@ -1 +1,5 @@
# otel
# OpenTelemetry for Next.js ecosystem
The released packages:
- [`@kubiks/otel-drizzle`](./packages/otel-drizzle/README.md)
Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

+30
View File
@@ -0,0 +1,30 @@
{
"name": "otel",
"version": "1.0.0",
"private": true,
"description": "",
"scripts": {
"build": "turbo build",
"changeset": "changeset add",
"dev": "turbo --filter sample dev",
"e2e-test": "turbo e2e-test",
"release": "pnpm build && changeset publish ${TAG:+--tag $TAG}",
"type-check": "turbo --continue type-check",
"unit-test": "turbo unit-test",
"version-packages": "changeset version && pnpm i --no-frozen-lockfile && git add ."
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@9.4.0",
"engines": {
"node": ">=18"
},
"type": "module",
"devDependencies": {
"@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "^2.27.1",
"prettier": "^3.1.1",
"turbo": "^1.11.3"
}
}
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "kubiks/otel" }],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
@@ -0,0 +1,11 @@
---
"@kubiks/otel-drizzle": major
---
Initial release of @kubiks/otel-drizzle - OpenTelemetry instrumentation for Drizzle ORM
- Automatic span creation for all database queries
- Support for PostgreSQL, MySQL, and SQLite
- Configurable query text capture and truncation
- Full OpenTelemetry semantic conventions support
- Zero-configuration setup with one line of code
+31
View File
@@ -0,0 +1,31 @@
# @kubiks/otel-drizzle
## 2.0.0
### Major Changes
- [`7abe73d`](https://github.com/kubiks-inc/otel/commit/7abe73d58ed133fae975684e3493ea83218dde97) Thanks [@alex-holovach](https://github.com/alex-holovach)! - Initial release of @kubiks/otel-drizzle - OpenTelemetry instrumentation for Drizzle ORM
- Automatic span creation for all database queries
- Support for PostgreSQL, MySQL, and SQLite
- Configurable query text capture and truncation
- Full OpenTelemetry semantic conventions support
- Zero-configuration setup with one line of code
## 1.0.0
### Major Changes
- Initial release of Drizzle ORM instrumentation package
- Automatic tracing for all Drizzle database queries
- Support for PostgreSQL, MySQL, and SQLite
- Configurable query text capture with sanitization
- Full OpenTelemetry semantic conventions compliance
- Comprehensive test coverage
### Features
- Network peer attributes (`net.peer.name` and `net.peer.port`) for better observability
- Configurable database connection information in spans
- Proper span status codes (OK/ERROR) following OpenTelemetry standards
- Exception recording with full stack traces
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 Vercel
Copyright (c) 2025 Kubiks
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+74
View File
@@ -0,0 +1,74 @@
# @kubiks/otel-drizzle
OpenTelemetry instrumentation for [Drizzle ORM](https://orm.drizzle.team/). Add distributed tracing to your database queries with a single line of code.
## Installation
```bash
npm install @kubiks/otel-drizzle
# or
pnpm add @kubiks/otel-drizzle
# or
yarn add @kubiks/otel-drizzle
```
**Peer Dependencies:** `@opentelemetry/api` >= 1.9.0, `drizzle-orm` >= 0.28.0
## Usage
Simply wrap your Drizzle client with `instrumentDrizzle()`:
```typescript
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { instrumentDrizzle } from "@kubiks/otel-drizzle";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = instrumentDrizzle(drizzle(pool));
// That's it! All queries are now traced automatically
const users = await db.select().from(usersTable);
```
### Optional Configuration
```typescript
instrumentDrizzle(db, {
dbSystem: "postgresql", // Database type (default: 'postgresql')
dbName: "myapp", // Database name for spans
captureQueryText: true, // Include SQL in traces (default: true)
maxQueryTextLength: 1000, // Max SQL length (default: 1000)
});
```
### Works with Any Database
```typescript
// PostgreSQL
import { drizzle } from "drizzle-orm/node-postgres";
const db = instrumentDrizzle(drizzle(pool));
// MySQL
import { drizzle } from "drizzle-orm/mysql2";
const db = instrumentDrizzle(drizzle(connection), { dbSystem: "mysql" });
// SQLite
import { drizzle } from "drizzle-orm/better-sqlite3";
const db = instrumentDrizzle(drizzle(sqlite), { dbSystem: "sqlite" });
```
## What You Get
Each database query automatically creates a span with:
- **Span name**: `drizzle.select`, `drizzle.insert`, `drizzle.update`, etc.
- **SQL operation**: Extracted from query (SELECT, INSERT, UPDATE, DELETE)
- **Full SQL query**: Captured and sanitized (configurable)
- **Error tracking**: Exceptions are recorded with stack traces
- **Database metadata**: System, name, host, and port information
Follows [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/database/) for database instrumentation.
## License
MIT
+70
View File
@@ -0,0 +1,70 @@
import { stat } from "node:fs/promises";
import type { Plugin } from "esbuild";
import { build } from "esbuild";
const MINIFY = true;
const SOURCEMAP = true;
const MAX_SIZE = 50_000; // 50KB max for instrumentation package
type ExternalPluginFactory = (external: string[]) => Plugin;
const externalCjsToEsmPlugin: ExternalPluginFactory = (external) => ({
name: "external",
setup(builder): void {
const escape = (text: string): string =>
`^${text.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}$`;
const filter = new RegExp(external.map(escape).join("|"));
builder.onResolve({ filter: /.*/, namespace: "external" }, (args) => ({
path: args.path,
external: true,
}));
builder.onResolve({ filter }, (args) => ({
path: args.path,
namespace: "external",
}));
builder.onLoad({ filter: /.*/, namespace: "external" }, (args) => ({
contents: `export * from ${JSON.stringify(args.path)}`,
}));
},
});
/** Adds support for require, __filename, and __dirname to ESM / Node. */
const esmNodeSupportBanner = {
js: `import { fileURLToPath } from 'url';
import { createRequire as topLevelCreateRequire } from 'module';
import _nPath from 'path'
const require = topLevelCreateRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = _nPath.dirname(__filename);`,
};
const peerDependencies = ["@opentelemetry/api", "drizzle-orm"];
async function buildAll(): Promise<void> {
await build({
platform: "node",
format: "esm",
splitting: false,
entryPoints: ["src/index.ts"],
outdir: "dist",
bundle: true,
minify: MINIFY,
sourcemap: SOURCEMAP,
banner: esmNodeSupportBanner,
external: peerDependencies,
plugins: [externalCjsToEsmPlugin(peerDependencies)],
});
// Check max size.
const outputFile = "dist/index.js";
const s = await stat(outputFile);
if (s.size > MAX_SIZE) {
// eslint-disable-next-line no-console
console.error(
`${outputFile}: the size of ${s.size} is over the maximum allowed size of ${MAX_SIZE}`,
);
process.exit(1);
}
}
void buildAll();
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
{
"name": "@kubiks/otel-drizzle",
"version": "2.0.0",
"private": false,
"publishConfig": {
"access": "public"
},
"description": "OpenTelemetry instrumentation for Drizzle ORM",
"author": "Kubiks",
"license": "MIT",
"repository": "kubiks/otel",
"sideEffects": false,
"type": "module",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/types/index.d.ts",
"files": [
"dist",
"LICENSE",
"README.md"
],
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"scripts": {
"build": "pnpm clean && pnpm build-only && pnpm build-types",
"build-only": "pnpm tsx build.ts",
"build-types": "tsc --noEmit false --declaration --emitDeclarationOnly --stripInternal --declarationDir dist/types src/index.ts",
"clean": "rimraf dist",
"prepublishOnly": "pnpm build",
"type-check": "tsc --noEmit",
"unit-test": "vitest --run",
"unit-test-watch": "vitest"
},
"dependencies": {},
"devDependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-trace-base": "^2.1.0",
"@types/node": "18.15.11",
"@types/pg": "^8.11.10",
"drizzle-orm": "^0.36.4",
"esbuild": "^0.19.4",
"postgres": "^3.4.5",
"rimraf": "3.0.2",
"tsx": "^4.6.2",
"typescript": "^5",
"vitest": "0.33.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <2.0.0",
"drizzle-orm": ">=0.28.0"
}
}
+298
View File
@@ -0,0 +1,298 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SpanStatusCode, trace } from "@opentelemetry/api";
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import { instrumentDrizzle, type InstrumentDrizzleConfig } from "./index";
interface MockDrizzleClient {
query: (...args: any[]) => unknown;
}
describe("instrumentDrizzle", () => {
let provider: BasicTracerProvider;
let exporter: InMemorySpanExporter;
beforeEach(() => {
exporter = new InMemorySpanExporter();
provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
});
afterEach(async () => {
await provider.shutdown();
exporter.reset();
trace.disable();
});
it("wraps the query method only once", () => {
const client: MockDrizzleClient = {
query: vi.fn(),
};
const instrumented = instrumentDrizzle(client);
expect(instrumented.query).not.toBeUndefined();
const wrappedQuery = instrumented.query;
instrumentDrizzle(client);
expect(instrumented.query).toBe(wrappedQuery);
});
it("records a successful query", async () => {
const client: MockDrizzleClient = {
query: vi.fn(() => Promise.resolve({ rows: [{ id: 1 }] })),
};
instrumentDrizzle(client);
const result = await client.query("select 1");
expect(result).toEqual({ rows: [{ id: 1 }] });
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
const span = spans[0];
if (!span) {
throw new Error("Expected a recorded span");
}
expect(span.name).toBe("drizzle.select");
expect(span.attributes["db.statement"]).toBe("select 1");
expect(span.attributes["db.operation"]).toBe("SELECT");
expect(span.attributes["db.system"]).toBe("postgresql");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("records errors and propagates them", async () => {
const error = new Error("boom");
const client: MockDrizzleClient = {
query: vi.fn(() => Promise.reject(error)),
};
instrumentDrizzle(client);
await expect(client.query("select 1" as unknown)).rejects.toThrow(error);
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
const span = spans[0];
if (!span) {
throw new Error("Expected a recorded span");
}
expect(span.status.code).toBe(SpanStatusCode.ERROR);
expect(span.events.some((event) => event.name === "exception")).toBe(true);
});
it("supports callback-based queries", () => {
return new Promise<void>((resolve) => {
const client: MockDrizzleClient = {
query: vi.fn((query: unknown, cb: (err: unknown, res: unknown) => void) => {
cb(null, { ok: true });
return undefined;
}),
};
instrumentDrizzle(client);
const returnValue = client.query("select 1", (err: unknown, result: unknown) => {
expect(err).toBeNull();
expect(result).toEqual({ ok: true });
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
resolve();
});
expect(returnValue).toBeUndefined();
});
});
it("respects custom configuration", async () => {
const client: MockDrizzleClient = {
query: vi.fn(() => Promise.resolve({ rows: [] })),
};
const config: InstrumentDrizzleConfig = {
dbSystem: "mysql",
dbName: "test_db",
captureQueryText: true,
};
instrumentDrizzle(client, config);
await client.query("SELECT * FROM users");
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
const span = spans[0];
if (!span) {
throw new Error("Expected a recorded span");
}
expect(span.attributes["db.system"]).toBe("mysql");
expect(span.attributes["db.name"]).toBe("test_db");
expect(span.attributes["db.statement"]).toBe("SELECT * FROM users");
});
it("includes network peer attributes when configured", async () => {
const client: MockDrizzleClient = {
query: vi.fn(() => Promise.resolve({ rows: [] })),
};
instrumentDrizzle(client, {
peerName: 'db.example.com',
peerPort: 5432,
});
await client.query("SELECT 1");
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
const span = spans[0];
if (!span) {
throw new Error("Expected a recorded span");
}
expect(span.attributes["net.peer.name"]).toBe("db.example.com");
expect(span.attributes["net.peer.port"]).toBe(5432);
});
it("truncates long query text", async () => {
const client: MockDrizzleClient = {
query: vi.fn(() => Promise.resolve({ rows: [] })),
};
const longQuery = `SELECT ${"a, ".repeat(1000)}b FROM table`;
instrumentDrizzle(client, { maxQueryTextLength: 50 });
await client.query(longQuery);
const spans = exporter.getFinishedSpans();
const span = spans[0];
if (!span) {
throw new Error("Expected a recorded span");
}
const statement = span.attributes["db.statement"] as string;
expect(statement.length).toBe(53); // 50 + "..."
expect(statement.endsWith("...")).toBe(true);
});
it("handles query objects with sql property", async () => {
const client: MockDrizzleClient = {
query: vi.fn(() => Promise.resolve({ rows: [] })),
};
instrumentDrizzle(client);
await client.query({ sql: "INSERT INTO users VALUES ($1)", params: ["test"] });
const spans = exporter.getFinishedSpans();
const span = spans[0];
if (!span) {
throw new Error("Expected a recorded span");
}
expect(span.name).toBe("drizzle.insert");
expect(span.attributes["db.operation"]).toBe("INSERT");
expect(span.attributes["db.statement"]).toBe("INSERT INTO users VALUES ($1)");
});
it("handles query objects with text property", async () => {
const client: MockDrizzleClient = {
query: vi.fn(() => Promise.resolve({ rows: [] })),
};
instrumentDrizzle(client);
await client.query({ text: "UPDATE users SET name = $1", values: ["test"] });
const spans = exporter.getFinishedSpans();
const span = spans[0];
if (!span) {
throw new Error("Expected a recorded span");
}
expect(span.name).toBe("drizzle.update");
expect(span.attributes["db.operation"]).toBe("UPDATE");
});
it("does not capture query text when disabled", async () => {
const client: MockDrizzleClient = {
query: vi.fn(() => Promise.resolve({ rows: [] })),
};
instrumentDrizzle(client, { captureQueryText: false });
await client.query("SELECT * FROM users");
const spans = exporter.getFinishedSpans();
const span = spans[0];
if (!span) {
throw new Error("Expected a recorded span");
}
expect(span.attributes["db.statement"]).toBeUndefined();
expect(span.attributes["db.operation"]).toBe("SELECT");
});
it("handles synchronous errors", () => {
const error = new Error("sync error");
const client: MockDrizzleClient = {
query: vi.fn(() => {
throw error;
}),
};
instrumentDrizzle(client);
expect(() => client.query("SELECT 1")).toThrow(error);
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0]?.status.code).toBe(SpanStatusCode.ERROR);
});
it("handles callback errors", () => {
return new Promise<void>((resolve) => {
const error = new Error("callback error");
const client: MockDrizzleClient = {
query: vi.fn((query: unknown, cb: (err: unknown, res: unknown) => void) => {
cb(error, null);
return undefined;
}),
};
instrumentDrizzle(client);
client.query("SELECT 1", (err: unknown) => {
expect(err).toBe(error);
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0]?.status.code).toBe(SpanStatusCode.ERROR);
resolve();
});
});
});
it("returns client unchanged if query is not a function", () => {
const client = { query: "not a function" } as any;
const result = instrumentDrizzle(client);
expect(result).toBe(client);
expect(result.query).toBe("not a function");
});
it("returns client unchanged if client is null", () => {
const result = instrumentDrizzle(null as any);
expect(result).toBeNull();
});
});
+298
View File
@@ -0,0 +1,298 @@
import {
context,
SpanKind,
SpanStatusCode,
trace,
type Span,
} from "@opentelemetry/api";
const DEFAULT_TRACER_NAME = "@kubiks/otel-drizzle";
const DEFAULT_DB_SYSTEM = "postgresql";
const INSTRUMENTED_FLAG = "__kubiksOtelDrizzleInstrumented" as const;
// Semantic conventions for database attributes
export const SEMATTRS_DB_SYSTEM = "db.system";
export const SEMATTRS_DB_OPERATION = "db.operation";
export const SEMATTRS_DB_STATEMENT = "db.statement";
export const SEMATTRS_DB_NAME = "db.name";
// Semantic conventions for network attributes
export const SEMATTRS_NET_PEER_NAME = "net.peer.name";
export const SEMATTRS_NET_PEER_PORT = "net.peer.port";
type QueryCallback = (error: unknown, result: unknown) => void;
type QueryFunction = (...args: unknown[]) => unknown;
interface DrizzleClientLike {
query: QueryFunction;
[INSTRUMENTED_FLAG]?: true;
}
/**
* Configuration options for Drizzle instrumentation.
*/
export interface InstrumentDrizzleConfig {
/**
* Custom tracer name. Defaults to "\@kubiks/otel-drizzle".
*/
tracerName?: string;
/**
* Database system identifier (e.g., "postgresql", "mysql", "sqlite").
* Defaults to "postgresql".
*/
dbSystem?: string;
/**
* Database name to include in spans.
*/
dbName?: string;
/**
* Whether to capture full SQL query text in spans.
* Defaults to true.
*/
captureQueryText?: boolean;
/**
* Maximum length for captured query text. Queries longer than this
* will be truncated. Defaults to 1000 characters.
*/
maxQueryTextLength?: number;
/**
* Remote hostname or IP address of the database server.
* Example: "db.example.com" or "192.168.1.100"
*/
peerName?: string;
/**
* Remote port number of the database server.
* Example: 5432 for PostgreSQL, 3306 for MySQL
*/
peerPort?: number;
}
/**
* Extracts SQL query text from various query argument formats.
*/
function extractQueryText(queryArg: unknown): string | undefined {
if (typeof queryArg === "string") {
return queryArg;
}
if (queryArg && typeof queryArg === "object") {
// PostgreSQL-style query object
if (typeof (queryArg as { text?: unknown }).text === "string") {
return (queryArg as { text: string }).text;
}
// MySQL/generic-style query object
if (typeof (queryArg as { sql?: unknown }).sql === "string") {
return (queryArg as { sql: string }).sql;
}
// Drizzle SQL object
if (
typeof (queryArg as { queryChunks?: unknown }).queryChunks === "object"
) {
// Drizzle query objects may have complex structure, try to extract meaningful info
const drizzleQuery = queryArg as Record<string, unknown>;
if (typeof drizzleQuery.sql === "string") {
return drizzleQuery.sql;
}
}
}
return undefined;
}
/**
* Sanitizes and truncates query text for safe inclusion in spans.
*/
function sanitizeQueryText(queryText: string, maxLength: number): string {
if (queryText.length <= maxLength) {
return queryText;
}
return `${queryText.substring(0, maxLength)}...`;
}
/**
* Extracts the SQL operation (SELECT, INSERT, etc.) from query text.
*/
function extractOperation(queryText: string): string | undefined {
const trimmed = queryText.trimStart();
const match = /^(?<op>\w+)/u.exec(trimmed);
return match?.groups?.op?.toUpperCase();
}
/**
* Finalizes a span with status, timing, and optional error.
*/
function finalizeSpan(span: Span, error?: unknown): void {
if (error) {
if (error instanceof Error) {
span.recordException(error);
} else {
span.recordException(new Error(String(error)));
}
span.setStatus({ code: SpanStatusCode.ERROR });
} else {
span.setStatus({ code: SpanStatusCode.OK });
}
span.end();
}
/**
* Instruments a Drizzle database client with OpenTelemetry tracing.
*
* This function wraps the client's `query` method to automatically create
* spans for each database operation. It supports both promise-based and
* callback-based query patterns.
*
* The instrumentation is idempotent - calling it multiple times on the same
* client will only instrument it once.
*
* @typeParam TClient - The type of the Drizzle client
* @param client - The Drizzle client instance to instrument
* @param config - Optional configuration for instrumentation behavior
* @returns The instrumented client (same instance, modified in place)
*
* @example
* ```typescript
* import { drizzle } from 'drizzle-orm/node-postgres';
* import { Pool } from 'pg';
* import { instrumentDrizzle } from '@kubiks/otel-drizzle';
*
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
* const db = drizzle(pool);
*
* // Instrument with defaults
* instrumentDrizzle(db);
*
* // Or with custom configuration
* instrumentDrizzle(db, {
* dbSystem: 'postgresql',
* dbName: 'myapp',
* captureQueryText: true,
* maxQueryTextLength: 500,
* peerName: 'db.example.com',
* peerPort: 5432,
* });
* ```
*/
export function instrumentDrizzle<TClient extends DrizzleClientLike>(
client: TClient,
config?: InstrumentDrizzleConfig,
): TClient {
if (!client) {
return client;
}
if (typeof client.query !== "function") {
return client;
}
if (client[INSTRUMENTED_FLAG]) {
return client;
}
const {
tracerName = DEFAULT_TRACER_NAME,
dbSystem = DEFAULT_DB_SYSTEM,
dbName,
captureQueryText = true,
maxQueryTextLength = 1000,
peerName,
peerPort,
} = config ?? {};
const tracer = trace.getTracer(tracerName);
const originalQuery = client.query;
const instrumentedQuery: QueryFunction = function instrumented(
this: unknown,
...incomingArgs: unknown[]
) {
const args = [...incomingArgs];
let callback: QueryCallback | undefined;
// Detect callback pattern
if (typeof args[args.length - 1] === "function") {
callback = args.pop() as QueryCallback;
}
// Extract query information
const queryText = extractQueryText(args[0]);
const operation = queryText ? extractOperation(queryText) : undefined;
const spanName = operation
? `drizzle.${operation.toLowerCase()}`
: "drizzle.query";
// Start span
const span = tracer.startSpan(spanName, { kind: SpanKind.CLIENT });
span.setAttribute(SEMATTRS_DB_SYSTEM, dbSystem);
if (operation) {
span.setAttribute(SEMATTRS_DB_OPERATION, operation);
}
if (dbName) {
span.setAttribute(SEMATTRS_DB_NAME, dbName);
}
if (captureQueryText && queryText !== undefined) {
const sanitized = sanitizeQueryText(queryText, maxQueryTextLength);
span.setAttribute(SEMATTRS_DB_STATEMENT, sanitized);
}
if (peerName) {
span.setAttribute(SEMATTRS_NET_PEER_NAME, peerName);
}
if (peerPort) {
span.setAttribute(SEMATTRS_NET_PEER_PORT, peerPort);
}
const activeContext = trace.setSpan(context.active(), span);
// Callback-based pattern
if (callback) {
return context.with(activeContext, () => {
const wrappedCallback: QueryCallback = (err, result) => {
finalizeSpan(span, err);
if (callback) {
callback(err, result);
}
};
try {
return originalQuery.apply(this, [...args, wrappedCallback]);
} catch (error) {
finalizeSpan(span, error);
throw error;
}
});
}
// Promise-based pattern
return context.with(activeContext, () => {
try {
const result = originalQuery.apply(this, args);
return Promise.resolve(result)
.then((value) => {
finalizeSpan(span);
return value;
})
.catch((error) => {
finalizeSpan(span, error);
throw error;
});
} catch (error) {
finalizeSpan(span, error);
throw error;
}
});
};
client[INSTRUMENTED_FLAG] = true;
client.query = instrumentedQuery;
return client;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"incremental": true,
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+3887
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
packages:
- 'apps/*'
- 'packages/*'
- 'tests/*'
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"outDir": "dist",
"target": "es5",
"lib": ["esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"paths": {
"#/*": ["./*"]
}
},
"exclude": ["node_modules", "dist"]
}
+46
View File
@@ -0,0 +1,46 @@
{
"pipeline": {
"//#prettier-script": {
"outputs": ["node_modules/.cache/prettier/.prettier-cache"]
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "dist-site/**"]
},
"conformance": {
"dependsOn": ["^build"]
},
"dev": {
"dependsOn": ["^build"],
"cache": false
},
"dev-turbo": {
"dependsOn": ["^build"],
"cache": false
},
"dev-webpack": {
"dependsOn": ["^build"],
"cache": false
},
"lint-copy": {
"inputs": ["**/*.{md,mdx}"]
},
"build-release-packages": {
"dependsOn": ["@kubiks/otel-drizzle"]
},
"root-conformance": {},
"//#root-conformance": {},
"type-check": {
"dependsOn": ["^build"],
"outputs": ["**/node_modules/.cache/tsbuildinfo.json"]
},
"e2e-test": {
"dependsOn": ["^build"],
"inputs": ["**/*.{js,jsx,ts,tsx}"]
},
"unit-test": {
"dependsOn": ["^build"],
"inputs": ["**/*.{js,jsx,ts,tsx}"]
}
}
}