Techniques
Logging
Log through the injected LOGGER, rely on built-in credential redaction, and switch to JSON structured output with LoggingModule.
On this page 14 sections
Every Knead application has one logger, provided under the LOGGER token from @knead-js/core.
The framework writes to it (the listening address, unhandled request errors, WebSocket and
GraphQL problems) and so do your providers. Out of the box it is a ConsoleLogger; importing
LoggingModule.forRoot() from @knead-js/logging replaces it everywhere with a JsonLogger
that writes one JSON object per line. Whatever logger is configured, credentials are redacted
before they are written.
#Log from a provider
Inject LOGGER and call one of its four methods, debug, info, warn or error. Each takes
a message and any number of extra values (meta):
import { Injectable, inject, LOGGER } from '@knead-js/core';
export interface Order {
id: string;
customerId: string;
totalCents: number;
}
export class PaymentDeclinedError extends Error {
override name = 'PaymentDeclinedError';
}
@Injectable()
export class OrdersService {
readonly #log = inject(LOGGER);
place(order: Order): void {
this.#log.info('order placed', { orderId: order.id, totalCents: order.totalCents });
}
charge(order: Order, error: unknown): void {
// Pass the error itself as meta: the logger decides how much of it to write.
this.#log.error('payment failed', error, { orderId: order.id });
}
}LOGGER is typed with the Logger interface, so code that logs does not depend on which logger
is configured. Keep the message a fixed string and put the variable parts in meta objects: a
structured logger turns them into separate fields that you can search and aggregate.
Do not call console.* directly. Those calls bypass the configured logger, its redaction and
its JSON format, and tests cannot capture them.
#The default logger
Without LoggingModule, LOGGER is a ConsoleLogger, one instance shared by every module. It
writes through console.debug, console.info, console.warn and console.error, prefixing the
message with [knead]:
[knead] connected to redis://***@cache:6379\nforged {
password: "***",
user: "ada",
}That line came from
info('connected to redis://app:hunter2@cache:6379\nforged', { password: 'hunter2', user: 'ada' }).
The ConsoleLogger redacts credentials in the message and in every meta value, and escapes
control characters (CR, LF, ESC and the rest of U+0000 to U+001F, plus DEL) in the message and in
string meta, so a logged value cannot start a forged line. The originals are never modified, and
a failing console never throws into your code. Pass another prefix to the constructor:
new ConsoleLogger('orders') writes [orders] ready.
#JSON logs with LoggingModule
For production, where logs are collected and parsed, import LoggingModule.forRoot() once, in
the root module:
import { Module } from '@knead-js/core';
import { LoggingModule } from '@knead-js/logging';
import { OrdersService } from './orders.service.js';
@Module({
imports: [
LoggingModule.forRoot({
name: 'orders-api',
fields: { service: 'orders' },
}),
],
providers: [OrdersService],
})
export class AppModule {}bun add @knead-js/loggingLoggingModule is global and makes a JsonLogger the LOGGER of every module, including the
framework's own. The OrdersService above then writes:
{"time":"2026-10-17T09:30:00.000Z","level":"info","logger":"orders-api","msg":"order placed","service":"orders","orderId":"ord_1042","totalCents":4999}Every record is one line: time (ISO 8601), level, logger (the name option) and msg
come first, then the logger's fields, then the entry's meta. Debug and info lines go to stdout,
warn and error lines to stderr.
Import forRoot() exactly once. Importing the bare LoggingModule fails startup with
LoggingConfigError: Import LoggingModule.forRoot(), not LoggingModule, and importing two
separate forRoot() results fails with
LoggingModule.forRoot() is imported 2 times; import it once, in the root module, because
modules would disagree on which logger they see. The same forRoot() value imported from two
places counts once.
#How meta becomes fields
JsonLogger sorts each meta argument by what it is:
- A plain object is merged into the record as fields. When two objects set the same key, the later one wins.
- The first
Errorbecomes theerrfield. - Anything else (strings, numbers, arrays, later errors) is collected, in order, in a
metaarray.
So warn('retrying', { attempt: 2 }, new Error('timeout'), 'upstream', 7, { attempt: 3 }, new Error('second'))
outside development writes:
{"time":"2026-10-17T09:30:00.000Z","level":"warn","logger":"knead","msg":"retrying","attempt":3,"err":"Error: timeout","meta":["upstream",7,"Error: second"]}Fields and meta can never set time, level, logger, msg, err or meta: those keys are
dropped from them. __proto__, constructor and prototype keys are dropped everywhere, so a
record parsed downstream cannot pollute a prototype.
Values are converted to plain JSON data on the way:
| Value | Written as |
|---|---|
bigint | A string: 10n is "10". |
undefined, functions, symbols | Left out (inside an array, null). |
Date and objects with toJSON() | Their toJSON() result. |
| Class instances | Their own enumerable properties: new Point(1, 2) is {"x":1,"y":2}. |
Map, Headers | An object of their entries, keys redacted. |
Set | An array. |
Typed arrays, ArrayBuffer | "[Uint8Array 16 bytes]". |
| A value already on the current path | "[Circular]". |
| A value that throws when read | "[unreadable]". |
An Error anywhere | A string, as described in errors in logs. |
#Child loggers
To add the same fields to every entry of a component, wrap the injected logger with
childLogger():
import { Injectable, inject, LOGGER } from '@knead-js/core';
import { childLogger } from '@knead-js/logging';
@Injectable()
export class InvoiceJob {
// Every entry from this class carries `component: 'invoices'`.
readonly #log = childLogger(inject(LOGGER), { component: 'invoices' });
run(batchId: string, invoices: number): void {
const batchLog = childLogger(this.#log, { batchId });
batchLog.info('batch started', { invoices });
batchLog.info('batch finished');
}
}With a JsonLogger named billing, run('b_17', 120) writes:
{"time":"2026-10-17T09:30:00.000Z","level":"info","logger":"billing","msg":"batch started","component":"invoices","batchId":"b_17","invoices":120}
{"time":"2026-10-17T09:30:00.000Z","level":"info","logger":"billing","msg":"batch finished","component":"invoices","batchId":"b_17"}childLogger(logger, fields) works with any Logger, which is why it is the call to use in
providers. On a JsonLogger it returns logger.child(fields): the same level, name, sink and
development flag, with fields added to (or replacing) the parent's fields. On any other logger
(a ConsoleLogger, or the MemoryLogger of a test) it passes the fields as the first meta
argument of every call, merging the fields of nested children, so tests see them too. Either way,
child fields are redacted once, when the child is created, and cannot set the reserved keys.
#Redaction
Knead redacts credentials before anything reaches a log, whatever logger is configured: the
ConsoleLogger and JsonLogger redact everything they write, and the framework's own error
logging redacts the message and error before it calls LOGGER, so even a custom logger receives
redacted data. Both rules come from @knead-js/core and are exported, so a custom logger can
apply them too.
#Credentials in text
redactCredentials(text) replaces four kinds of credential with ***:
- URL userinfo:
redis://app:hunter2@cache:6379becomesredis://***@cache:6379, for any scheme. The userinfo runs to the last@within the first 1024 characters of the URL, because passwords often contain an unencoded@or/(postgres://app:p@ss/w0rd@db/shopbecomespostgres://***@db/shop). Anhttp(s)URL that startshost[:port]/keeps an@in its path (https://example.com/users/@adais unchanged), but credential URLs nested in it are still redacted. The scheme starts at its first letter, so-redis://u:p@his redacted too. - Secret
key=valuepairs: the value of any pair whose key is sensitive, with the same rules as sensitive keys in objects (client_secret,x-api-key,SharedAccessKey,sig, ...). An unquoted value ends at&,;or whitespace, so/callback?code=1&client_secret=abc&state=2becomes/callback?code=1&client_secret=***&state=2andDB_PASSWORD=hunter2becomesDB_PASSWORD=***; a quoted one runs to its closing quote (password="hunter 2"becomespassword="***"). - Secret
key: valuepairs: JSON ({"password":"x"}becomes{"password":"***"}), YAML and inspected objects. Akey:with an unquoted key counts only at the start of a line or after{or,, so "Invalid token: expired" stays readable. AnAuthorization:orCookie:line loses its whole value whatever its shape (Cookie: ***,Authorization: Token ***). BearerandBasiccredentials, in any case:Bearer eyJhbGciOi.payload.sigbecomesBearer ***. Only credential-shaped values are replaced (8 or more token characters, or a:or=between token characters), so prose such as "a basic mistake" stays readable.
#Sensitive keys in objects
redactValue(value) returns a redacted copy of objects, arrays and errors, walking up to 4
levels deep:
- Every string is passed through
redactCredentials(), keys included. - The value of a sensitive key becomes
***, whatever the value. The exact keys areauthorization,cookie,set-cookie,password,passwd,pwd,secret,token,apikey,api_key,accesstoken,refreshtoken,accountkey,privatekeyandconnectionstring, in any case, andpass,passphrase,jwt,sig,accesskeyandsharedaccesskeyonce lowercased and stripped of-and_. - A key that contains one of the words
password,passwd,pwd,secret,token,apikey,accountkey,privatekey,connectionstring,authorization,cookie,credentialorsessionid, once lowercased and stripped of-and_, is sensitive too (db_password,x-api-key,clientSecret,AWS_SECRET_ACCESS_KEY,sessionId). For these keys, numbers and booleans are kept, somaxTokens: 100stays readable, whiletoken: 42(an exact key) becomes***. - A
URLis walked as itshref, aRequestorResponseas its method or status, URL and headers, andHeaders,Mapand other objects withentries()ortoJSON()as their entries. - Other class instances are walked as their own properties, into a copy with the same prototype. Dates and binary data are kept as they are.
- Cycles become
[Circular], and objects deeper than 4 levels become[Object](or[Array]). At most 20000 strings and objects are walked per value; the rest become[Truncated].
The original value is never modified, and a value with nothing to redact is returned as is.
#Escaping
JsonLogger relies on JSON escaping: newlines and other control characters inside strings are
escaped by JSON.stringify, and so are U+2028 and U+2029 (which JSON.stringify leaves alone but
some log readers treat as line breaks). Every record stays on one line, so a logged value cannot
forge another record. For interpolating a client-controlled value into a message with any logger,
logValue(value) from @knead-js/core caps it at 1024 characters and escapes control
characters.
#Errors in logs
An error's stack, cause chain and properties often carry request data: SQL with parameters, URLs
with tokens, response bodies. JsonLogger therefore writes errors in full only in development:
- Outside development, an error is written as
Name: messageonly (core'ssummarizeError()): the message is redacted, capped at 500 characters and control characters are escaped. No stack, cause or properties. - In development, an error is written as its redacted stack, then its own enumerable
properties as redacted JSON on an indented line, then each cause as
Caused by: ..., up to 16 errors (a longer chain ends withCaused by: … (more causes omitted)).AggregateErrorerrors are included as anerrorsproperty.
For an error with a code and a password property and a cause, a JsonLogger writes
outside development:
{"time":"2026-10-17T09:30:00.000Z","level":"error","logger":"knead","msg":"query failed","err":"Error: query failed for postgres://***@db/shop"}and in development, err holds (shown here with its newlines expanded):
Error: query failed for postgres://***@db/shop
at run (db.ts:10:5)
{"code":"ECONNRESET","password":"***"}
Caused by: Error: connection reset
at socket (net.ts:3:1)"Development" is the application's development flag: KneadFactory.create(root, { development }),
which defaults to NODE_ENV === 'development'. LoggingModule passes that flag to its
JsonLogger unless you set the development option yourself.
Pass errors to the logger as meta rather than building messages from them
(`failed: ${error}`). The logger can then apply the rules above; a message string is only
redacted, never shortened.
#Framework error lines
For a route that throws, the HTTP pipeline logs the error under a fresh correlation id, which it
also returns in the 500 response. With LoggingModule outside development the line is:
{"time":"2026-10-17T09:30:00.000Z","level":"error","logger":"knead","msg":"Unhandled error on GET /reports (correlation id 0f5e9c1a-8f0b-4b8e-9f43-2d7e6c1b5a90): Error: replica postgres://***@db/shop is down"}In development the message ends at the correlation id and the error follows as an Error, so it
becomes err with its full stack and causes. When the error held a credential, the pipeline
passes a copy whose message and stack are already redacted, so the line has the same shape: for
the route above, err starts with Error: replica postgres://***@db/shop is down, followed by
the stack, and the password appears nowhere in the line.
To log your own errors this way (with a correlation id, full in development and summarized
otherwise), use the ErrorLog provider or logError() from @knead-js/http; see the
HTTP reference.
#Levels
The four levels, from lowest to highest, are debug, info, warn and error. A JsonLogger
writes entries at or above its level option, which defaults to debug in development and
info otherwise. The ConsoleLogger writes every level and leaves filtering to the console.
#Where the logger comes from
One LOGGER is chosen for the whole application, by these rules from highest to lowest
precedence:
- An
overridesentry forLOGGERpassed toKneadFactory.create(). - The
loggeroption ofKneadFactory.create(), which replaces every module'sLOGGER, global and module-local providers alike. - A
LOGGERexported by a global module, such asLoggingModule.forRoot(), used by every module (a module that registers its ownLOGGERprovider keeps that one for its own providers). - The default
ConsoleLogger.
The logger option is the way to pick a logger in the bootstrap code:
import { KneadFactory } from '@knead-js/http';
import { JsonLogger } from '@knead-js/logging';
import { AppModule } from './app.module.js';
export function createApp() {
// The logger option is the LOGGER of every module, whatever the modules provide.
return KneadFactory.create(AppModule, { logger: new JsonLogger({ name: 'orders-api' }) });
}Any global module can provide the logger, for example a ConsoleLogger with another prefix:
import { ConsoleLogger, LOGGER, Module } from '@knead-js/core';
/** A global module's LOGGER is used by every module of the application. */
@Module({
global: true,
providers: [{ provide: LOGGER, useValue: new ConsoleLogger('orders') }],
exports: [LOGGER],
})
export class OrdersLoggerModule {}app.logger on the created application is the chosen logger. @knead-js/testing
uses rule 2: compile() passes a MemoryLogger as the logger option, so a test captures every
log entry even when the application imports LoggingModule. See
testing logs.
#Custom sinks and standalone use
The write option replaces stdout and stderr. It receives each record as one line of JSON
without the trailing newline, and its level:
import type { LogLevel } from '@knead-js/core';
import { LoggingModule } from '@knead-js/logging';
/** Buffers records and ships them in batches. */
export class LogShipper {
readonly pending: { line: string; level: LogLevel }[] = [];
// `line` is one record of JSON without the trailing newline.
write = (line: string, level: LogLevel): void => {
this.pending.push({ line, level });
};
async flush(): Promise<void> {
const body = this.pending.splice(0).map((record) => `${record.line}\n`);
if (body.length > 0) {
await fetch('https://logs.example.com/ingest', { method: 'POST', body: body.join('') });
}
}
}
export const shipper = new LogShipper();
export const Logging = LoggingModule.forRoot({ write: shipper.write });A write that throws, or returns a promise that rejects, is ignored: a lost log line never fails
the code that logged it. The default sink also ignores stream errors such as EPIPE (the reading
end of a pipe closed) by adding a no-op 'error' listener to stdout and stderr on first use.
JsonLogger does not need a module. A script or worker outside a Knead application can create
one directly:
import { JsonLogger } from '@knead-js/logging';
/** A logger for a script that runs outside a Knead application. */
export const log = new JsonLogger({ name: 'reindex', level: 'warn' });All options are listed on the @knead-js/logging reference page.