@ddd-ts-kit/winston
v0.15.0
Published
Adapter of the kernel Logger port on winston, with trace and execution correlation on every line.
Readme
@ddd-ts-kit/winston
The kernel Logger port on winston: JSON lines on the
standard output, the correlation fields of the work in progress on every line, credentials
redacted, a flush for the shutdown. Nothing in domain/ or application/ imports this
package, or logs anything: the composition root binds the adapter, and infrastructure,
presentation and the aspects write through the port.
npm i @ddd-ts-kit/winston winstonWritten against winston 3.19.
Composition root
// platform/observability/create-logger.ts
import type { ExecutionScope, LogLevel } from '@ddd-ts-kit/kernel';
import { traceLogFields } from '@ddd-ts-kit/tracing';
import { createWinstonLogger, type WinstonLogger } from '@ddd-ts-kit/winston';
export interface LogSettings {
readonly level: LogLevel; // LOG_LEVEL
readonly format: 'json' | 'pretty'; // LOG_FORMAT
readonly service: string;
readonly environment: string;
readonly scrubMessages: boolean; // LOG_SCRUB_MESSAGES
}
export function createLogger(scope: ExecutionScope, settings: LogSettings): WinstonLogger {
return createWinstonLogger({ ...settings, fields: () => traceLogFields(scope) });
}// main.ts
import { tracing } from '@/platform/tracing'; // first
import { AsyncLocalStorageExecutionScope } from '@ddd-ts-kit/kernel';
import { createApplication, kernelModule } from '@ddd-ts-kit/di';
const scope = new AsyncLocalStorageExecutionScope();
const logger = createLogger(scope, config.log);
const app = createApplication({
modules: [kernelModule({ executionScope: scope, logger }), ...contexts],
});
process.once('SIGTERM', () => void shutdown());
async function shutdown(): Promise<void> {
await app.stop(); // flushes the error reporter, then the logger, last: that ends the winston logger
await tracing.shutdown();
}init --logger winston writes this create-logger.ts (it answers a NoopLogger when given no
settings, which is how the tools and the tests stay silent); platform/lay-foundations.ts
calls it once and composeApplication hands the logger to kernelModule({ logger }).
One line of the application, as it is written:
{"actorId":"user-1","context":"shipping","correlationId":"9f2c","environment":"production","level":"info","message":"order shipped","origin":"http","service":"shop-api","span_id":"00f067aa0ba902b7","tenantId":"acme","time":"2026-03-01T10:00:00.000Z","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}Correlation. fields is read at each line that is written, by a winston format: no call
site passes an id. traceLogFields(scope) answers the trace and span ids inside a span, and
inside the execution scope the correlation id, the causation id, the origin, the actor and the
tenant. This package does not depend on the tracing package: any () => LogFields fits,
() => logFieldsOf(scope.current()) included. A function that throws gives no field, never a
lost line. The fields of the line win over those of a child, which win over fields: the
format only fills the keys nobody gave. StdoutLogger and @ddd-ts-kit/pino differ on the
second step (there fields win over a child), so do not bind in a child a key that fields
answers if the logger may be swapped.
An instance of your own. The project that already configures winston keeps doing so, and takes the formats it wants:
import { CREDENTIAL_KEYS } from '@ddd-ts-kit/kernel';
import winston from 'winston';
import { WinstonLogger, correlationFields, redactCredentials } from '@ddd-ts-kit/winston';
const instance = winston.createLogger({
level: 'info',
format: winston.format.combine(
correlationFields(() => traceLogFields(scope)),
redactCredentials([...CREDENTIAL_KEYS, 'iban']), // after the formats that add fields
winston.format.timestamp(),
winston.format.json(),
),
transports: [new winston.transports.Console()],
});
const logger = new WinstonLogger(instance);createWinstonLogger writes the instant under time, the level by its name and the message
under message, like the StdoutLogger of the aspects package and like @ddd-ts-kit/pino:
one dashboard reads the three. The keys of a line come out sorted: winston's JSON format
serializes with a stable stringifier.
Development mode
createWinstonLogger({ level: 'debug', format: 'pretty', fields: () => traceLogFields(scope) });10:00:00.000 info order shipped { context: 'shipping', service: 'shop-api', correlationId: '9f2c' }The pretty format is made of winston's own combinators (colorize, printf), in the
process: no other package, the local time of day, the level in colour, the fields as Node
prints an object, a stack over several lines. Choose the format from configuration
(LOG_FORMAT=pretty in .env, json everywhere else).
Privacy
Log lines are where personal data leaks: they are written everywhere, read by everyone and kept for long. Three things are done in the adapter, so that no call site has to remember them.
Credentials are redacted by a winston format, in the fields of a line, of a child and of
fields alike. The value of these keys is replaced by [redacted], at the top of a line and
one level below (headers.authorization, body.password):
password, authorization, cookie, set-cookie, token, secret, apiKey, api_key,
access_token, refresh_token, accessToken, refreshToken, client_secret
redact: ['iban', 'ssn'] adds to the list. Names are matched exactly, case included, and
nothing is looked for deeper than one level: log the part you mean, not a whole request. The
objects you pass are never written to: a nested one is copied when a key has to be hidden.
An error passed as a field (logger.error('payment failed', { error }): the LogValue of
the port admits an Error) is written as describeError of the kernel describes it, the same
from every logger of the port: its class as type, its code when
it is a DomainError, its message, its own stack, then its cause the same way, up to
eight deep. Each error of a chain is written once. Errors are looked for among the fields of
the line, not inside nested objects. CREDENTIAL_KEYS, REDACTED and SCRUBBED_MESSAGE are
those of the kernel too.
Messages of unexpected errors can be dropped. The message of an error is the first place
where personal data shows up: a driver quotes the row it refused. The default keeps it, because
a log line that says [message removed] costs every investigation. With one option only the
messages the project wrote itself survive, those of its DomainErrors; class, code, stack and
cause remain for the others, and the stack loses its first line, which repeats the message:
createWinstonLogger({ scrubMessages: true }); // or new WinstonLogger(instance, { scrubMessages: true })This is the scrubMessages every logger of the port takes (StdoutLogger, pino, winston,
InMemoryLogger) and the one of @ddd-ts-kit/sentry, with the same rule, the same
SCRUBBED_MESSAGE and the same trade-off: turn it on when the logs leave your hands or when no
operator should read that data there. What the message of a log line itself says stays yours:
write order shipped and put the identifiers in fields.
The kit itself never hands an error to a logger: the logged aspect, loggingMiddleware and
the job scheduler write errorType and errorCode only (identifyError of the kernel), never
a message, whatever scrubMessages says, and the error goes to the ErrorReporter. The
description above is for the error your own code chooses to log:
logger.error('payment failed', { error }).
Levels
| Port | winston (npm levels) | |
|---|---|---|
| debug | debug | below it, winston also has silly |
| info | info | the default threshold; http and verbose stand between info and debug |
| warn | warn | |
| error | error | |
The names are the same in the cli levels. An instance on the syslog levels has no warn:
say where it goes, or the line is dropped, since winston does not write at a level it does
not know.
new WinstonLogger(instance, { levels: { warn: 'warning' } });A line below the threshold costs one comparison: the adapter asks isLevelEnabled first,
because winston alone would run every format before its transports drop the line. The
threshold of an instance changed while running (instance.level = 'debug') is followed by the
adapter and by its children. isLevelEnabled(level) of the port answers the same question for
the mapped level: ask it before building fields that cost. Once the logger was flushed it
answers false for every level.
child(fields) is a winston child: the fields are bound once, and winston merges them under
the fields of each line. Three things differ from what winston does when called directly, all
on purpose: a field named level or message never replaces the level or the message of the
line; the message is never read as a format string (%s); and with createWinstonLogger a
field named time is overwritten by the instant of the line. One thing is winston's:
service, environment and base are its defaultMeta, which wins over a field of the
same name on a line or in a child, where the other two loggers of the kit let the line and
the child win.
Flush
flush(): Promise<void> ends the winston logger and resolves once each of its transports has
written what it was handed (their finish event). It never rejects, and a second call answers
the same promise. app.stop() calls it, last, after the modules stopped and the error reporter
was flushed: stopping logs too. Without the application of the di package, call it yourself at
that place.
The port calls a flush once, at shutdown, and leaves what is logged afterwards unspecified.
Here the flush is final: a winston logger does not reopen, so what is logged afterwards,
through this logger or one of its children, is dropped without an error, and isLevelEnabled
answers false. Whatever must be logged about the shutdown is logged before app.stop()
returns. A transport over a stream you opened yourself
(a socket, a file stream given to the Stream transport) has handed its lines to that stream
when flush() resolves: ending that stream is yours.
Performance
winston writes each line through a Node stream, a chain of formats and its transports, all in
the process and in the call: about 7 microseconds a line where @ddd-ts-kit/pino takes 3
(Node 24, a child, four correlation fields, three fields, a sink that discards), and more
with each format you add. Each line costs here one copy of its fields, one merge per level of
child, the format reading fields, the redaction pass over the top-level values, the instant,
and JSON.stringify through a safe serializer. That is nothing at a few hundred lines per
second and visible at tens of thousands: there, lower the level or take @ddd-ts-kit/pino.
Nothing is buffered by the adapter, so nothing is lost on a crash beyond what the transport
itself had not written. Log calls never throw: fields that cannot be read or serialized (a
getter that throws) give the line without them, marked logError: "unserializable fields",
and createWinstonLogger holds its formats to the same rule inside the stream.
Which one
Take StdoutLogger of the aspects package when JSON lines on the standard output are all you
need: no dependency, the same line shape. Take @ddd-ts-kit/pino when logging volume matters
or when the framework already speaks pino (Fastify). Take winston when the team already runs
its transports and formats (files with rotation, syslog, a vendor transport) and wants to keep
them. The three implement one port and are held by one contract suite: swapping is one line of
the composition root.
NestJS
Nest has its own LoggerService, used by the framework for its own messages and by
new Logger(context) in Nest classes. Application code of the kit never needs it: handlers and
adapters receive the kernel Logger. To send what Nest itself logs through the same instance,
hand it a bridge of a few lines; the kit ships none, since it would bind this package to Nest:
const nest = logger.child({ context: 'nest' });
const bridge: LoggerService = {
log: (message: unknown) => nest.info(String(message)),
warn: (message: unknown) => nest.warn(String(message)),
error: (message: unknown) => nest.error(String(message)),
debug: (message: unknown) => nest.debug(String(message)),
};
const app = await NestFactory.create(AppModule, { logger: bridge });nest-winston is the alternative when Nest classes log a lot: give it the winston instance
you built, and wrap the same instance in new WinstonLogger(instance) for the kit.
In a test
Nothing of this package: the test application binds InMemoryLogger of the kernel, and its
entries are data. To hold another adapter of the port to the same behaviour, run the contract:
import { loggerContract } from '@ddd-ts-kit/testing/contracts';
loggerContract({ describe, it, expect, beforeEach }, {
name: 'WinstonLogger',
createLogger: (setup) => { // { level, fields, scrubMessages }
const written: string[] = [];
const stream = new Writable({
write: (chunk, _encoding, done) => {
written.push(String(chunk));
done();
},
});
const logger = createWinstonLogger({ ...setup, transports: [new winston.transports.Stream({ stream })] });
return { logger, lines: () => written.map((line) => lineOf(JSON.parse(line))) }; // { level, message, fields }
},
});