Logging
Log query timings, slow queries, errors, and raw SQL with client hooks.
Use these recipes when you need query timings, slow-query warnings, or error logs. better-drizzle has no built-in logger: each recipe is a few client hooks passed to better(...). For request correlation and OpenTelemetry, see tracing; to ship the hooks as one reusable plugin, see observability 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';
export type Meta = { requestId?: string; userId?: string };
export 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());
};
export 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 relations, Meta>(db, {
hooks: {
beforeQuery: start,
afterQuery: logOperation,
beforeCreate: start,
afterCreate: logOperation,
beforeUpdate: start,
afterUpdate: logOperation,
beforeDelete: start,
afterDelete: logOperation,
},
});Slow-query warnings
Use the same timer and log at a higher level above a threshold:
import { elapsed, type OperationContext } from './lib/db';
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';
import { elapsed, type Meta } from './lib/db';
export const client = better<typeof relations, Meta>(db, {
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:
import type { Meta } from './lib/db';
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 relations, Meta>(db, {
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',
});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({
client: pool,
relations,
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.