npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 winston

Written 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 }
  },
});