Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { defineBuildConfig } from "obuild/config";
export default defineBuildConfig({
entries: [
{ type: "bundle", input: "src/index.ts" },
{ type: "bundle", input: "src/tracing.ts" },
{ type: "transform", input: "src/connectors/", outDir: "dist/connectors" },
{
type: "transform",
Expand Down
2 changes: 2 additions & 0 deletions docs/1.guide/1.index.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ console.log(rows);

## Next steps

:read-more{to="/guide/tracing"}

:read-more{to="/connectors"}

:read-more{to="/integrations"}
53 changes: 53 additions & 0 deletions docs/1.guide/2.tracing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
icon: ph:chart-bar-horizontal-duotone
---

# Tracing

> db0 provides first-class support for tracing capabilities out of the box.

## Overview

db0 uses [tracing channels](https://nodejs.org/api/diagnostics_channel.html) of the diagnostics channel module to emit traceable actions for query operations.

Common use cases for tracing are:

- Query performance monitoring and profiling
- Logging
- Query debugging
- Error tracking

## Query Tracing Channel

db0 uses `db0.query` for the tracing channel name.

To set up tracing, you need to subscribe to the tracing channel. Since query operations are asynchronous, you need to subscribe to the `asyncEnd` event as it signals the completion of the operation.

```ts
import { tracingChannel } from "node:diagnostics_channel";

const queryChannel = tracingChannel("db0.query");

queryChannel.subscribe({
start: (data) => {
console.log("start", data.query);
},
asyncEnd: (data) => {
console.log("end", data.query, data.result);
},
});
```

The event payload contains several properties that can be used to track the query operation:

- `query`: The query string.
- `method`: The method used to execute the query (`exec`, `sql`, `prepare.all`, `prepare.run`, `prepare.get`).
- `dialect`: The dialect of the database connection (e.g. `sqlite`, `postgres`, `mysql`).
- `result`: The result of the query (only available for `asyncEnd` event).
- `error`: The error that occurred during the query operation (only available for `error` and `asyncEnd` events).

## Next steps

:read-more{to="/connectors"}

:read-more{to="/integrations"}
50 changes: 50 additions & 0 deletions examples/tracing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createDatabase } from "../../src";
import { TraceContext, withTracing } from "../../src/tracing";
import { tracingChannel } from "node:diagnostics_channel";

import sqlite from "../../src/connectors/better-sqlite3";

async function main() {
const db = withTracing(createDatabase(sqlite({})));

// Subscribe to tracing events
subscribeToTracing();

await db.sql`create table if not exists users (
id integer primary key autoincrement,
full_name text
)`;

const res = await db.sql`insert into users (full_name) values ('John Doe')`;

console.log({ res });
}

function subscribeToTracing() {
const queryChannel = tracingChannel<TraceContext>("db0.query");

queryChannel.subscribe({
start: (data) => {
console.log("start", data.query);
},
end: (message) => {
console.log("end", message.query);
},
asyncStart: (data) => {
console.log("asyncStart", data.query);
},
asyncEnd: (data) => {
console.log("asyncEnd", data.query, data.result);
},
error: (data) => {
console.log("error", data.error);
},
});
}

// eslint-disable-next-line unicorn/prefer-top-level-await
main().catch((error) => {
console.error(error);
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});
11 changes: 11 additions & 0 deletions examples/tracing/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "db0-with-tracing",
"private": true,
"scripts": {
"start": "jiti ./index.ts"
},
"devDependencies": {
"jiti": "^1.21.0",
"db0": "latest"
}
}
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./tracing": {
"types": "./dist/tracing.d.mts",
"default": "./dist/tracing.mjs"
},
"./connectors/*": {
"types": "./dist/connectors/*.d.ts",
"default": "./dist/connectors/*.mjs"
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

117 changes: 117 additions & 0 deletions src/tracing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type {
Connector,
Database,
Primitive,
SQLDialect,
Statement,
} from "./types.ts";
import { sqlTemplate } from "./template.ts";

export type TracedOperation = "query";

export interface TraceContext {
query: string;
method: "exec" | "sql" | "prepare.all" | "prepare.run" | "prepare.get";
dialect: SQLDialect;
}

type MaybeTracedDatabase<TConnector extends Connector = Connector> =
Database<TConnector> & {
__traced?: boolean;
};

/**
* Wrap a database instance with tracing functionality.
*/
export function withTracing<TConnector extends Connector = Connector>(
db: MaybeTracedDatabase<TConnector>,
): Database<TConnector> {
// Avoids double patching
if (db.__traced) {
return db;
}

const { tracingChannel } =
globalThis.process?.getBuiltinModule?.("node:diagnostics_channel") || {};
if (!tracingChannel) {
return db;
}

const queryChannel = tracingChannel(`db0.query`);

async function tracePromise<T>(
exec: () => Promise<T>,
data: TraceContext,
): Promise<T> {
// TODO: Remove this cast once the @types/node types are updated.
// The @types/node types incorrectly mark tracePromise as returning void,
// but according to the JSDoc and actual implementation, it returns the promise.
// This is fixed in later versions of Node.js.
// See: https://nodejs.org/api/diagnostics_channel.html#channelstracepromisefn-context-thisarg-args
return queryChannel.tracePromise(exec, data) as unknown as Promise<T>;
}

// Use Object.create to preserve getter properties like `dialect` and `disposed`
// The spread operator would evaluate getters at spread-time, making `disposed`
// always return the initial value rather than the current state.
const tracedDb = Object.create(db) as MaybeTracedDatabase<TConnector>;
tracedDb.__traced = true;

tracedDb.exec = (query) =>
tracePromise(() => db.exec(query), {
query,
method: "exec",
dialect: db.dialect,
});

tracedDb.sql = (strings, ...values) =>
tracePromise(() => db.sql(strings, ...values), {
query: sqlTemplate(strings, ...values)[0],
method: "sql",
dialect: db.dialect,
});

class TracedStatement implements Statement {
#statement: Statement;
#query: string;

constructor(statement: Statement, query: string) {
this.#statement = statement;
this.#query = query;
}

private withTrace<T>(
fn: () => Promise<T>,
method: "prepare.all" | "prepare.run" | "prepare.get",
) {
return tracePromise(() => fn(), {
method,
query: this.#query,
dialect: db.dialect,
});
}

bind(...args: Primitive[]) {
return new TracedStatement(this.#statement.bind(...args), this.#query);
}

all(...args: Primitive[]) {
return this.withTrace(() => this.#statement.all(...args), "prepare.all");
}

run(...args: Primitive[]) {
return this.withTrace(() => this.#statement.run(...args), "prepare.run");
}

get(...args: Primitive[]) {
return this.withTrace(() => this.#statement.get(...args), "prepare.get");
}
}

/**
* Prepare needs a special treatment because it returns a statement instance that needs to be patched.
*/
tracedDb.prepare = (query) => new TracedStatement(db.prepare(query), query);

return tracedDb;
}
Loading