Logging
Log the SQL your Worker sends to D1 with an onQuery listener. It’s called once for every statement, writes and batches included, with:
| Field | |
|---|---|
model | The Model’s class name, such as "Product" |
sql, params | The statement and its bound values |
durationMs | How long it took; in a batch, D1’s own timing for each statement |
meta | D1’s result metadata (rows read and written, …) |
error | D1’s error, when it failed |
Send these wherever you like. A listener never changes a query’s outcome: one that throws is logged with console.error, and the query’s result stands.
Set a listener in one of two places:
| Where | Knows the request? | |
|---|---|---|
| Every query, app-wide | static override onQuery on your app’s base class | No |
| One request’s queries | connect(env.DB, { onQuery }), run in withDatabase | Yes |
If both are set, the request’s listener runs first, then the base class’s.
Logging every query
Set onQuery on your app’s base class, in application-record.ts, to see every statement of every Model:
import { Model, modelFor, type QueryListener } from "@forestfuture/d1-record";
import { tables } from "../db/schema";
// src/models/application-record.ts: every query, from every Model, whichever database runs it.
class Base extends Model.Base {
static override binding = "DB";
static override onQuery: QueryListener = ({ model, sql, durationMs, error }) => {
console.log(`${model} ${durationMs.toFixed(1)}ms ${sql}`, error ?? "");
};
}
export const ApplicationRecord = modelFor(tables, Base);It can’t tell requests apart. A Model can set its own static override onQuery, which replaces the base class’s for that Model.
Logging one request’s queries
To group a request’s queries, open its database with a listener that knows the request, and run the request inside withDatabase:
import { connect, withDatabase } from "@forestfuture/d1-record";
// src/worker.ts: one request's queries, tagged with its ID, as JSON lines for Workers Logs.
// `app` stands for what your app exports as its Worker today: a framework's handler, or your own.
export const logged = {
fetch(request, env, ctx) {
const requestId = request.headers.get("cf-ray") ?? crypto.randomUUID();
const db = connect(env.DB, {
onQuery: ({ model, sql, params, durationMs, error }) =>
console.log(
JSON.stringify({
requestId,
model,
ms: Math.round(durationMs),
sql,
params,
error: error instanceof Error ? error.message : undefined,
}),
),
});
// Every query from a Model inside (Product.find, user.save(), …) goes through db.
return withDatabase(db, () => app.fetch(request, env, ctx));
},
} satisfies ExportedHandler<Env>;Logged as JSON, each line can be filtered by requestId in Workers Logs. Turn them on with "observability": { "enabled": true } in your Wrangler config.
TIP
withDatabase needs Node.js compatibility, which is on by default for compatibility dates from 2026-08-04. For an earlier date, add "compatibility_flags": ["nodejs_compat"].
Logging only in development
Check a variable that only .dev.vars sets, such as LOG_SQL=1. For one request’s listener:
import { connect, withDatabase, type QueryListener } from "@forestfuture/d1-record";
// LOG_SQL=1 in .dev.vars: logging in development only, nothing in production.
const onQuery: QueryListener | undefined = env.LOG_SQL
? ({ model, sql, durationMs }) => console.log(`${model} ${durationMs.toFixed(1)}ms ${sql}`)
: undefined;
return withDatabase(connect(env.DB, { onQuery }), () => app.fetch(request, env, ctx));The app-wide listener reads the variable from cloudflare:workers when a query runs:
import { Model, type QueryListener } from "@forestfuture/d1-record";
import { env as workerEnv } from "cloudflare:workers";
// Logs only when LOG_SQL is set (in .dev.vars), read from the Worker's env when a query runs.
class QuietInProduction extends Model.Base {
static override onQuery: QueryListener = ({ model, sql, durationMs }) => {
if ((workerEnv as { LOG_SQL?: string }).LOG_SQL) {
console.log(`${model} ${durationMs.toFixed(1)}ms ${sql}`);
}
};
}Timing queries in the browser
To see a request’s database time in the browser’s developer tools, add it up, and send it as a Server-Timing header:
import { connect, withDatabase } from "@forestfuture/d1-record";
import { User } from "./models";
let queries = 0;
let total = 0;
const db = connect(env.DB, {
onQuery({ durationMs }) {
queries += 1;
total += durationMs;
},
});
const response = await withDatabase(db, async () =>
Response.json(await User.active().toArray()),
);
// Shown in the browser's developer tools, under the request's Timing.
response.headers.append(
"server-timing",
`d1;dur=${total.toFixed(1)};desc="${queries} queries"`,
);
return response;Keeping secrets out of the logs
Listeners see the values of passwords, tokens, keys, and emails as "[FILTERED]", while D1 still gets the real values. Models lists the attributes filtered by default, and everywhere their values are hidden.
Filtering more attributes
Name them on your app’s base class, spreading in the defaults:
import { Model } from "@forestfuture/d1-record";
// src/models/application-record.ts
export class Base extends Model.Base {
// Rails' defaults, plus an IBAN and a date of birth:
static override filterAttributes = [...Model.Base.filterAttributes, "iban", /^dateOfBirth$/];
}A string matches any attribute whose snake_case name contains it, so "iban" also covers ibanNumber. Use a RegExp to match one name exactly.
Seeing a filtered attribute
To see one while debugging, leave it out of the list in development:
import { Model } from "@forestfuture/d1-record";
class Base extends Model.Base {
static override filterAttributes = Model.Base.filterAttributes.filter(
(entry) => entry !== "email",
);
}Filtering sql fragments
A value inside an sql fragment has no attribute, so it’s logged as given. Compare secrets with a condition, such as where({ token }), which is filtered, or leave params out of what you log.
Compared with Rails
Rails filters by its app-wide filter_parameters, against parameter and column names. d1-record keeps the list on the Model (static filterAttributes, inherited from your base class), and matches each entry against the attribute’s snake_case name, so "_key" filters apiKey. A RegExp may match either form. It also hides filtered values in cast errors and in RecordNotFound, which Rails’ filter doesn’t cover.