mirror of
https://github.com/zoriya/drizzle-otel.git
synced 2026-06-04 19:45:35 +00:00
Drizzle ORM telemetry package
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kubiks/otel-drizzle": patch
|
||||
---
|
||||
|
||||
update package.json
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.turbo
|
||||
tsconfig.tsbuildinfo
|
||||
dist
|
||||
dist-site
|
||||
@@ -0,0 +1 @@
|
||||
18.19.0
|
||||
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
Vendored
+15
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
@@ -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.
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+3887
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
- 'tests/*'
|
||||
@@ -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
@@ -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}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user