zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Server framework

LoggingSupported

There is no Logger service and no bundled logger — zero runtime dependencies. What you get is the two seams worth logging at: an interceptor around a handler, and a Driver wrapper around every query. Both take the sink as an argument, so tests assert on records instead of scraping stdout.

Structured, not printf#

export interface Sink {
  (record: Readonly<Record<string, unknown>>): void;
}

export const jsonLines: Sink = record => {
  console.log(JSON.stringify({ ts: new Date().toISOString(), ...record }));
};

One JSON object per line. Every log platform ingests it, every field is queryable, and nothing needs a regex to parse. A formatted string is a field you cannot filter on.

Request logging#

import type { Interceptor } from '@zmdb/web/middleware';

export function requestLog(sink: Sink): Interceptor {
  return {
    async intercept(ctx, next) {
      const started = performance.now();
      try {
        const result = await next();
        sink({ level: 'info', method: ctx.method, path: ctx.path, ms: round(performance.now() - started) });
        return result;
      } catch (error) {
        sink({
          level: 'error',
          method: ctx.method,
          path: ctx.path,
          ms: round(performance.now() - started),
          err: errorName(error),
        });
        throw error;
      }
    },
  };
}

Two corrections to the obvious version of this. next() resolves to the handler's return value, not a WebResponse — there is no result.status to log, because the router assigns the status after the chain is done. And Ctx has no route field: it carries params, body, query, headers, method, path and optional span. ctx.path is the concrete path, /users/42; the span is explicit trace context, not a general state bag.

⚠️ Warning

The router does not call runChain, so an interceptor registered on a controller does nothing — invoke the chain in the handler, or log in your adapter instead. See Request Lifecycle.

Logging in the adapter instead#

The adapter sees the status, the byte count and every request including the 404s, which makes it the better place for access logging:

import { bodyText } from '@zmdb/web';

createServer(async (req, res) => {
  const started = performance.now();
  const requestId = req.headers['x-request-id'] ?? randomUUID();
  const out = await app.handle(await webRequest(req));
  jsonLines({
    level: out.status >= 500 ? 'error' : 'info',
    requestId,
    method: req.method,
    path: (req.url ?? '/').split('?')[0],
    status: out.status,
    ms: round(performance.now() - started),
  });
  res.writeHead(out.status, { ...out.headers, 'x-request-id': String(requestId) }).end(await bodyText(out));
});

Echo the request id back to correlate logs. Distributed traces use the W3C traceparent and optional tracestate carrier instead. The custom logger buffers a streamed body; toNodeHandler preserves backpressure and cancellation.

webRequest(req) is the WebRequest the adapter builds itself — there is no toWebRequest to import; it is written out in Request Lifecycle.

📝 Note

ctx.path is high-cardinality: /users/1, /users/2, … Fine in logs, wrong for metrics — label a counter with the route pattern from getRoutes, or you will create a time series per user id and take your metrics backend down.

Logging queries#

A Driver wrapper covers handlers, workers and CLI scripts alike, because it sits under all of them:

import { type Driver } from '@zmdb/orm';

export function loggingDriver(inner: Driver, sink: Sink): Driver {
  return {
    ...inner,
    async execute(query, options) {
      const started = performance.now();
      try {
        const rows = await inner.execute(query, options);
        sink({
          level: 'debug',
          sql: query.text,
          params: query.parameters.length,
          rows: rows.length,
          ms: round(performance.now() - started),
        });
        return rows;
      } catch (error) {
        sink({ level: 'error', sql: query.text, params: query.parameters.length, err: errorName(error) });
        throw error;
      }
    },
  };
}

query.text is safe to log: it contains placeholders ($1, ?) and never the values, because the compiler never interpolates. query.parameters is the opposite — log the count, or the types, never the contents.

⚠️ Warning

Query parameters are the user's data: email addresses, tokens, the plaintext of whatever you are about to hash. The same applies to request bodies, the authorization header, cookie, and any upstream response body. Logs are replicated, retained for years, and readable by more people than your database — a console.log(ctx.body) added during debugging is a data breach that passes code review because it looks like debugging.

Redact by allow-list, not deny-list — log the fields you chose, rather than removing the ones you remembered.

Levels, and what to put at each#

LevelContent
errora request failed in a way that needs a human; always with a request id
warna degraded path that succeeded — a cache miss storm, a retried call
infoone line per request, plus significant state changes
debugqueries, payload shapes; off in production

Log an error once, where you handle it. Logging at every frame on the way up turns one incident into thirty lines and makes the rate meaningless.

Injecting the sink#

export const SINK = createToken<Sink>('SINK');

@Module({ providers: [{ token: SINK, useValue: jsonLines }], controllers: [PostsController] })
export class AppModule {}
await using app = createTestApp(AppModule, {
  overrides: [{ token: SINK, useValue: record => records.push(record) }],
});

expect(records.at(-1)).toMatchObject({ level: 'error', status: 500 });

Which makes logging _testable_: the assertion that an error path actually logs is the assertion nobody writes, and it is the one that matters at 3am.

Design notes#

needs trace correlation.

---

See also: Observability · Tracing · Interceptors