---
url: https://docs.forestfuture.dev/d1-record/guide/logging.md
description: >-
  See the SQL your Worker sends to D1: every query app-wide, one request's
  queries with its ID, only in development, in Workers Logs, and as
  Server-Timing.
---

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

```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](https://developers.cloudflare.com/workers/observability/logs/workers-logs/). Turn them on with `"observability": { "enabled": true }` in your Wrangler config.

::: warning 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](./models#filtering-attributes) 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.

::: rails 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.
:::
