Skip to content

Logging

This page wires an application’s logs: the failures the framework cannot answer to a client, a line for every request, and the request id that joins them. The framework has no logger and takes none; where a line goes and what it contains is up to the application. The examples use pino, and any logger that takes an object works the same way.

Most failures become a response: a thrown HttpError is a 404 or a 409, a validation failure is a 422, and the access log shows them with their status. Some failures have no response to become — an error no onError hook mapped, an afterResponse hook that threw after the response was sent, a broken stream. By default they are printed with console.error. Given reportError, createApp sends them there instead:

import type { FailureReport } from "@tetsujs/core";
import { createApp } from "@tetsujs/core";
import { requestId } from "@tetsujs/request-id";
import pino from "pino";
const
const logger: pino.Logger<never, boolean>
logger
= pino();
const
const reportError: ({ source, error, ctx }: FailureReport<{
requestId?: string;
}>) => void
reportError
= ({
source: FailureSource
source
,
error: unknown

What was thrown, exactly as it was thrown: not formatted, not truncated, so a logger's redaction sees the fields it knows.

error
,
ctx: {
requestId?: string;
} | undefined

The context of the request the failure belongs to.

Absent where there is no request: a WebSocket event, a shutdown.

ctx
}: FailureReport<{
requestId?: string | undefined
requestId
?: string }>) =>
const logger: pino.Logger<never, boolean>
logger
.
error: pino.LogFn
<{
err: unknown;
source: FailureSource;
requestId: string | undefined;
}, "tetsu">(obj: {
err: unknown;
source: FailureSource;
requestId: string | undefined;
} & pino.LogFnFields, msg?: "tetsu" | undefined) => void (+2 overloads)
error
({
err: unknown
err
:
error: unknown

What was thrown, exactly as it was thrown: not formatted, not truncated, so a logger's redaction sees the fields it knows.

error
,
source: FailureSource
source
,
requestId: string | undefined
requestId
:
ctx: {
requestId?: string;
} | undefined

The context of the request the failure belongs to.

Absent where there is no request: a WebSocket event, a shutdown.

ctx
?.
requestId?: string | undefined
requestId
}, "tetsu");
createApp({
hooks: { beforeParse: [requestId()] },
reportError,
routes: object

The topology: a group, a controller, or an array of either.

routes
,
});

error is what was thrown, exactly as it was thrown; see Redaction for why that matters. source says what failed. ctx is the request’s context, typed from the application’s hooks, each field optional, and absent where there was no request. Errors lists every source.

The receiver is called and never awaited, so a slow log shipper does not slow down a response.

The same function serves the rest of the process: onShutdownSignals takes it as its reportError option, and a background job calls it with a source of its own, such as { source: "job", error }.

@tetsujs/request-log writes request logs. accessLog() is an afterResponse hook that writes one record as each response goes out, 404s and failures included. arrivalLog() is a beforeParse hook that writes one as each request comes in:

import { createApp } from "@tetsujs/core";
import { requestId } from "@tetsujs/request-id";
import { accessLog, arrivalLog } from "@tetsujs/request-log";
const id = requestId();
const arrived = arrivalLog({
write?: ((record: ArrivalRecord) => void) | undefined

Where the line goes. console.log by default.

Takes the record rather than a string so a structured logger can be handed the fields as they are.

write
: (
record: ArrivalRecord
record
) =>
const logger: pino.Logger<never, boolean>
logger
.
info: pino.LogFn
<ArrivalRecord, "request received">(obj: ArrivalRecord & pino.LogFnFields, msg?: "request received" | undefined) => void (+2 overloads)
info
(
record: ArrivalRecord
record
, "request received") });
const finished = accessLog({
write?: ((record: AccessRecord) => void) | undefined

Where the line goes. console.log by default.

Takes the record rather than a string so a structured logger can be handed the fields as they are.

write
: (
record: AccessRecord
record
) =>
const logger: pino.Logger<never, boolean>
logger
.
info: pino.LogFn
<AccessRecord, "request finished">(obj: AccessRecord & pino.LogFnFields, msg?: "request finished" | undefined) => void (+2 overloads)
info
(
record: AccessRecord
record
, "request finished") });
createApp({
hooks: { beforeParse: [id, arrived], afterResponse: [finished] },
routes: object

The topology: a group, a controller, or an array of either.

routes
,
});

An access record looks like this:

{
"method": "GET",
"path": "/notes/42",
"route": "/notes/:id",
"status": 200,
"durationMs": 12.418,
"requestId": "0b7c6f0e-…"
}

route is the route that answered, and is absent on a 404. When something was thrown, the record adds thrown with the error’s name, never its message; the full error goes to reportError, joined by the request id. When the client left before the response was ready, it adds aborted: true.

The arrival line is for requests that never finish — a handler that hangs, a process that dies half-way — and leave no access record. It doubles the lines, so skip it behind a proxy that already logs every arrival.

Neither record holds headers, bodies or query strings; path is the one field that carries what the client sent, so keep secrets out of URLs. A route that needs more in its line — a user id, a tenant — logs it itself. @tetsujs/request-log lists every field.

Both hooks write on the request’s own path, so a write that blocks delays the response. Use a logger that does not wait on its output, such as pino(pino.destination({ sync: false })), and call logger.flush() among the closers when the process stops.

The same records feed request metrics.

requestId() gives every request an id: ctx.requestId in the context, and an x-request-id header on the response. Mount it before the request logs, and they pick it up; reportError reads it from ctx?.requestId. The arrival line, the access line and a failure report of one request then share one id, and a client reporting a problem can quote it from the response header.

Order matters: a hook sees only what the hooks before it contributed. See @tetsujs/request-id.

Code that never receives ctx — a repository several calls down — can still log with the id, through AsyncLocalStorage:

import { AsyncLocalStorage } from "node:async_hooks";
import { hook, type Requires } from "@tetsujs/core";
const
const store: AsyncLocalStorage<{
requestId: string;
}>
store
= new AsyncLocalStorage<{
requestId: string
requestId
: string }>();
export const scope = hook.beforeParse((
ctx: Requires<{
requestId: string;
}>
ctx
: Requires<{
requestId: string
requestId
: string }>) => {
const store: AsyncLocalStorage<{
requestId: string;
}>
store
.enterWith({
requestId: string
requestId
:
ctx: Requires<{
requestId: string;
}>
ctx
.
requestId: string
requestId
});
});
export const
const current: () => {
requestId: string;
} | undefined
current
= () =>
const store: AsyncLocalStorage<{
requestId: string;
}>
store
.getStore();

Mount scope on the application, after requestId(), so that every request has it, a 404 included: createApp({ hooks: { beforeParse: [id, scope] }, routes }). Placed before id, it does not compile. It uses enterWith rather than run because a hook is not handed the rest of the request as a callback.

With pino, a mixin puts the id on every line the application writes:

import pino from "pino";
const
const logger: pino.Logger<never, boolean>
logger
= pino({
mixin?: pino.MixinFn<never> | undefined

If provided, the mixin function is called each time one of the active logging methods is called. The function must synchronously return an object. The properties of the returned object will be added to the logged JSON.

mixin
: () => ({ ...
const current: () => {
requestId: string;
} | undefined
current
() }) });

Return a copy, as above: pino merges each line’s fields into the object mixin returns, so the stored object itself would carry one line’s fields into the next. Keep the store to ids and trace labels. What a decision depends on — a user, a role — belongs in ctx, where the compiler checks that it is there.

reportError receives the error as it was thrown. A database error may carry the query parameters; a broken response contract carries the validator’s issues, and some validators quote the rejected value. The framework formats none of it into a string, so the logger’s own redaction still sees those fields:

const
const logger: pino.Logger<never, boolean>
logger
= pino({
redact?: string[] | pino.redactOptions | undefined

As an array, the redact option specifies paths that should have their values redacted from any log output.

Each path must be a string using a syntax which corresponds to JavaScript dot and bracket notation.

If an object is supplied, three options can be specified:

paths (String[]): Required. An array of paths
censor (String): Optional. A value to overwrite key which are to be redacted. Default: '[Redacted]'
remove (Boolean): Optional. Instead of censoring the value, remove both the key and the value. Default: false

redact
: ["err.issues", "err.parameters"],
});

pino’s error serializer keeps an error’s own fields, such as issues on a ResponseContractError or a parameters field a database driver adds, and redact replaces them with [Redacted].

The request logs need none of this: their records hold no headers, bodies or query strings, and thrown is only a name.