Logging and observability
Query timings, slow-query warnings, error and raw SQL logs, transaction events, request correlation, and OpenTelemetry spans built on hooks.
better-drizzle has no built-in logger. It gives you client hooks around every operation, raw SQL call, and transaction. Every recipe on this page is a few hooks that you write once and pass to better(...). At the end, the same hooks are packaged as a plugin.
A client with no hooks skips the hook machinery entirely. Each hook you add costs an await per call, so register only the ones you use.
Query logging with duration
Hooks do not carry a timer, so you add one. The before hook and the after hook of one call receive the same ctx.args object, even after plugin transforms. That makes ctx.args a key for a WeakMap:
import { better } from 'better-drizzle';
type Meta = { requestId?: string; userId?: string };
type OperationContext = {
action: string;
table: string;
args: unknown;
meta?: Meta;
};
const started = new WeakMap<object, number>();
const start = (ctx: OperationContext) => {
started.set(ctx.args as object, performance.now());
};
const elapsed = (args: unknown) => {
const at = started.get(args as object);
if (at === undefined) return undefined;
started.delete(args as object);
return performance.now() - at;
};
const logOperation = (ctx: OperationContext) => {
console.info('db', {
action: ctx.action,
table: ctx.table,
ms: elapsed(ctx.args),
requestId: ctx.meta?.requestId,
});
};
export const client = better<typeof schema, Meta>(db, {
schema,
hooks: {
beforeQuery: start,
afterQuery: logOperation,
beforeCreate: start,
afterCreate: logOperation,
beforeUpdate: start,
afterUpdate: logOperation,
beforeDelete: start,
afterDelete: logOperation,
},
});Notes:
actiontells operations apart within one hook:findMany,count,createMany,updateEach, and so on.upsertonly fires the create hooks.- The
WeakMapdoes not hold on to failed calls: if an operation throws and no after hook runs, the entry is garbage-collected with itsargs. Two concurrent calls that pass the very same args object would share an entry, so build args per call. - The time covers the database work, including relation loading. Plugin before hooks and transforms run earlier, so they are not included.
.explain()does not run query hooks, so plans you inspect do not show up in these logs.
Slow-query warnings
Use the same timer and log at a higher level above a threshold:
const SLOW_MS = 200;
const logOperation = (ctx: OperationContext) => {
const ms = elapsed(ctx.args);
if (ms === undefined) return;
const entry = {
action: ctx.action,
table: ctx.table,
ms: Math.round(ms),
requestId: ctx.meta?.requestId,
};
if (ms >= SLOW_MS) console.warn('slow db operation', entry);
else console.debug('db', entry);
};Keep argument values out of the log line. ctx.args.where and ctx.args.data often hold emails, tokens, or other personal data. To find out why a query is slow, run it again with .explain().
Error logging
onError fires when an operation fails, when a CRUD or query hook throws, and when .throw() finds no row. ctx.error is always a BetterDrizzleError:
import { type BetterDrizzleError, better } from 'better-drizzle';
export const client = better<typeof schema, Meta>(db, {
schema,
hooks: {
// ...timing hooks from above
onError(ctx) {
const error = ctx.error as BetterDrizzleError;
console.error('db error', {
action: ctx.action,
table: ctx.table,
stage: ctx.stage,
hookName: ctx.hookName,
code: error.code,
status: error.status,
message: error.message,
ms: elapsed(ctx.args),
requestId: ctx.meta?.requestId,
});
},
},
});| Field | Meaning |
|---|---|
stage | 'beforeHook', 'operation', or 'afterHook' |
hookName | the hook that threw, such as beforeCreate; unset when the operation itself failed |
error.code | a BetterDrizzleErrorCode, such as RESULT_NOT_FOUND or OPERATION_ERROR |
error.driver, error.constraint, error.sqlState | filled in when the error came from the database driver |
error.toJSON() returns all of these fields as a plain object, which structured loggers can take as is. Errors thrown inside onError are swallowed, so a broken logger does not change the result of the call.
onError does not cover raw SQL or transactions. They have their own hooks, shown below.
Raw SQL logging
beforeRaw, afterRaw, and onRawError wrap $raw, $executeRaw, and $rawUnsafe. All three receive the same ctx.rawOptions object for one call, so it can key the timer:
const rawStarted = new WeakMap<object, number>();
const rawElapsed = (key: object) => {
const at = rawStarted.get(key);
rawStarted.delete(key);
return at === undefined ? undefined : performance.now() - at;
};
export const client = better<typeof schema, Meta>(db, {
schema,
hooks: {
beforeRaw(ctx) {
rawStarted.set(ctx.rawOptions, performance.now());
},
afterRaw(ctx) {
console.info('raw sql', {
action: ctx.action,
name: ctx.name,
query: ctx.query,
rows:
ctx.action === 'executeRaw'
? ctx.result.rowsAffected
: ctx.result.length,
ms: rawElapsed(ctx.rawOptions),
requestId: ctx.meta?.requestId,
});
},
onRawError(ctx) {
console.error('raw sql failed', {
action: ctx.action,
name: ctx.name,
query: ctx.query,
error: ctx.error,
ms: rawElapsed(ctx.rawOptions),
});
},
},
});ctx.query is the rendered SQL with $1, $2 placeholders. Parameter values are not in it, so it is safe to log. Pass name (and, on PostgreSQL, comment) in the raw options to make log lines searchable:
const rows = await client.$raw(sql`select id from users where active = ${true}`, {
name: 'active-user-ids',
});Transaction lifecycle
The four transaction hooks give you start, commit, rollback, and failure. For one transaction, all of them receive the same ctx.client (the transaction client), which keys the timer:
const txStarted = new WeakMap<object, number>();
const txElapsed = (key: object) => {
const at = txStarted.get(key);
txStarted.delete(key);
return at === undefined ? undefined : performance.now() - at;
};
export const client = better<typeof schema, Meta>(db, {
schema,
hooks: {
beforeTransaction(ctx) {
txStarted.set(ctx.client, performance.now());
},
afterTransactionCommit(ctx) {
console.info('tx commit', {
name: ctx.name,
depth: ctx.depth,
attempt: ctx.attempt,
ms: txElapsed(ctx.client),
requestId: ctx.meta?.requestId,
});
},
onTransactionError(ctx) {
console.error('tx error', { name: ctx.name, error: ctx.error });
},
afterTransactionRollback(ctx) {
console.warn('tx rollback', {
name: ctx.name,
depth: ctx.depth,
attempt: ctx.attempt,
reason: ctx.reason instanceof Error ? ctx.reason.message : ctx.reason,
ms: txElapsed(ctx.client),
});
},
},
});depthis1for the outer transaction and higher for savepoints.attemptstarts at1and goes up with each retry. Each attempt gets its own transaction client, so each is timed separately.- When the callback throws,
onTransactionErrorfires first, thenafterTransactionRollback. An explicittx.rollback(reason)fires onlyafterTransactionRollback, with aBetterDrizzleTransactionRollbackErrorasreason. - Operations inside the transaction still go through the CRUD hooks, with
ctx.isInTransactionset totrue.
Request correlation
Pass a request id in meta and every hook sees it. $withContext(...) sets it once per request instead of on every call:
export const handle = async (request: Request) => {
const scoped = client.$withContext({
requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(),
});
const user = await scoped.users.findUnique({ where: { id: 1 } });
const { count } = await scoped.posts.updateMany({
where: { authorId: 1 },
data: { archived: true },
});
return Response.json({ user, archived: count });
};The scoped meta reaches CRUD hooks, onError, raw hooks, and transaction hooks, including operations on the transaction client. Per-call meta and transaction(..., { meta }) are shallow-merged on top, so a single call can add or override keys. See framework integration for middleware in Next.js, Hono, Express, and Fastify.
OpenTelemetry spans
The same keys work for spans. This example uses @opentelemetry/api and leaves exporting to whatever SDK your application already sets up:
import { type Span, SpanStatusCode, trace } from '@opentelemetry/api';
import type { BetterDrizzleError } from 'better-drizzle';
const tracer = trace.getTracer('app-db');
const spans = new WeakMap<object, Span>();
type OperationContext = { action: string; table: string; args: unknown };
export const startSpan = (ctx: OperationContext) => {
const span = tracer.startSpan(`${ctx.action} ${ctx.table}`, {
attributes: {
'db.system.name': 'postgresql',
'db.collection.name': ctx.table,
'db.operation.name': ctx.action,
},
});
spans.set(ctx.args as object, span);
};
export const endSpan = (ctx: OperationContext) => {
const span = spans.get(ctx.args as object);
if (!span) return;
spans.delete(ctx.args as object);
span.end();
};
export const failSpan = (ctx: OperationContext & { error: unknown }) => {
const span = spans.get(ctx.args as object);
if (!span) return;
const error = ctx.error as BetterDrizzleError;
spans.delete(ctx.args as object);
span.recordException(error);
span.setAttribute('error.type', error.code);
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
span.end();
};import { endSpan, failSpan, startSpan } from './db-tracing';
export const client = better(db, {
schema,
hooks: {
beforeQuery: startSpan,
afterQuery: endSpan,
beforeCreate: startSpan,
afterCreate: endSpan,
beforeUpdate: startSpan,
afterUpdate: endSpan,
beforeDelete: startSpan,
afterDelete: endSpan,
onError: failSpan,
},
});tracer.startSpan(...)uses the active context as the parent. With a context manager registered (the Node SDK registers one), database spans nest under the current request span.- The attribute names follow the OpenTelemetry database semantic conventions. Match the version your backend expects, and set
db.system.nameto your database. - For raw SQL, key the span by
ctx.rawOptionsand add'db.query.text': ctx.query. For transactions, key it byctx.client. onErroralso fires for.throw()after a read has already finished. By then the span has ended and been removed from the map, sofailSpanreturns early.
SQL text from Drizzle
CRUD hooks work at the operation level: action, table, args, and result. They do not show the generated SQL. When you need the statements themselves, turn on Drizzle's own logger on the database you pass to better(...):
const db = drizzle(pool, {
schema,
logger: {
logQuery(query, params) {
console.debug('sql', { query, params: params.length });
},
},
});logger: true prints every query with its parameters. Avoid that in production if parameters can hold personal data.
Packaging as a plugin
Once the hooks settle, move them into a plugin so every service installs the same thing with one line. Plugin hooks receive kind (the operation), table, args, and meta, like client hooks:
import { definePlugin } from 'better-drizzle/plugins';
type Entry = Record<string, unknown>;
type Options = {
slowMs?: number;
log?: (level: 'info' | 'warn' | 'error', message: string, entry: Entry) => void;
};
export const observability = ({
slowMs = 200,
log = (level, message, entry) => console[level](message, entry),
}: Options = {}) => {
const started = new WeakMap<object, number>();
const start = (key: object) => {
started.set(key, performance.now());
};
const stop = (key: object) => {
const at = started.get(key);
started.delete(key);
return at === undefined ? undefined : performance.now() - at;
};
const finish = (ctx: {
kind: string;
table: string;
args: unknown;
meta?: Record<string, unknown>;
}) => {
const ms = stop(ctx.args as object);
if (ms === undefined) return;
log(ms >= slowMs ? 'warn' : 'info', 'db', {
kind: ctx.kind,
table: ctx.table,
ms: Math.round(ms),
requestId: ctx.meta?.requestId,
});
};
return definePlugin({
id: 'app/observability',
hooks: {
beforeQuery(ctx) {
start(ctx.args);
},
afterQuery: finish,
beforeCreate(ctx) {
start(ctx.args);
},
afterCreate: finish,
beforeUpdate(ctx) {
start(ctx.args);
},
afterUpdate: finish,
beforeDelete(ctx) {
start(ctx.args);
},
afterDelete: finish,
beforeRaw(ctx) {
start(ctx.rawOptions);
},
afterRaw(ctx) {
log('info', 'raw sql', {
name: ctx.name,
query: ctx.query,
ms: stop(ctx.rawOptions),
requestId: ctx.meta?.requestId,
});
},
onRawError(ctx) {
log('error', 'raw sql failed', {
name: ctx.name,
query: ctx.query,
error: ctx.error,
ms: stop(ctx.rawOptions),
});
},
beforeTransaction(ctx) {
start(ctx.client);
},
afterTransactionCommit(ctx) {
log('info', 'tx commit', {
depth: ctx.depth,
attempt: ctx.attempt,
ms: stop(ctx.client),
});
},
afterTransactionRollback(ctx) {
log('warn', 'tx rollback', {
depth: ctx.depth,
attempt: ctx.attempt,
ms: stop(ctx.client),
});
},
},
});
};export const client = better(db, {
schema,
plugins: [observability({ slowMs: 100 })],
});Differences from client hooks:
- Before hooks must return nothing. In a plugin, a value returned from
beforeCreateorbeforeUpdatereplaces the write data, and a value returned frombeforeQueryorbeforeDeletereplaces the result and skips the database call. Use block bodies, as above, so the hooks returnundefined. - No
onError. Plugins cannot observe failed CRUD operations. Keep anonErrorclient hook next to the plugin for error logs. Raw and transaction failures are covered byonRawErrorandonTransactionError. $withoutPlugins()skips it. Calls made through$withoutPlugins()are not logged by the plugin. Client hooks still see them.- Order matters. Plugins run in
pluginsarray order. Put this one first so its timer also covers the other plugins' before hooks and transforms.