Skip to content

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
modelThe Model’s class name, such as "Product"
sql, paramsThe statement and its bound values
durationMsHow long it took; in a batch, D1’s own timing for each statement
metaD1’s result metadata (rows read and written, …)
errorD1’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:

WhereKnows the request?
Every query, app-widestatic override onQuery on your app’s base classNo
One request’s queriesconnect(env.DB, { onQuery }), run in withDatabaseYes

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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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.