Guides
Guide

Winston Logger: Formats, Transports, and Request IDs

By Sazabi Team

Last verified on Nov 8, 2026.

Winston is a Node.js logger built around two ideas: formats that transform each log record, and transports that deliver it. You call logger.info('order placed', { order_id: 4812 }), the record passes through your format pipeline, and every transport writes it. This guide shows a production pipeline that emits JSON, handles errors and redaction in the right order, adds request IDs with AsyncLocalStorage, and connects to OpenTelemetry.

Install and Configure

npm install winston
const winston = require('winston')
const { combine, timestamp, errors, splat, json } = winston.format

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  defaultMeta: { service: 'checkout' },
  format: combine(errors({ stack: true }), splat(), timestamp(), json()),
  transports: [new winston.transports.Console()],
})

logger.info('order placed', { order_id: 4812 })
{
  "level": "info",
  "message": "order placed",
  "order_id": 4812,
  "service": "checkout",
  "timestamp": "2026-11-08T06:54:37.942Z"
}

createLogger defaults to the info level, the json format, and no transports at all, so a logger with an empty transports list writes nothing. The Winston docs warn the shared default logger has the same property, and that leaving it without transports may cause high memory use. Always define your own logger with at least one transport.

Levels

Winston's default levels are the npm set, numbered from most to least important:

LevelNumber
error0
warn1
info2
http3
verbose4
debug5
silly6

A logger set to info writes levels 0 to 2 and drops the rest. The http level is handy for access lines you want hidden at info. Each level is also a method (logger.warn(...)), and you can set a different level per transport. See log levels for guidance on usage.

Formats: Order Matters

A format is a function that receives the record and returns it, possibly changed. format.combine runs formats in the order you list them. Order is the most common source of Winston bugs, and one of them took a test to find.

Consider a redaction format:

const redact = winston.format((info) => {
  for (const key of ['password', 'token', 'authorization']) {
    if (key in info) info[key] = '[Redacted]'
  }
  return info
})

Put it before splat() and it appears to work in a quick test, but the secret still reaches the output when metadata is passed as an object:

// Broken: splat() runs after redact and restores the original metadata
combine(errors({ stack: true }), redact(), timestamp(), splat(), json())

In testing, logger.info('order placed', { password: 'hunter2' }) logged "password":"hunter2" with that ordering, and "password":"[Redacted]" once splat() moved ahead of redact():

// Works: interpolate first, then redact, then timestamp and serialize
combine(errors({ stack: true }), splat(), redact(), timestamp(), json())

Treat json() as the last step, and run any security transform as late as possible, after splat(). Redaction by key name also misses secrets under keys you did not list, so avoid logging whole request or user objects.

Errors and Stack Traces

The errors format from logform controls whether an Error keeps its stack. Without errors({ stack: true }) ahead of json(), passing an error produces little more than a message. With it, the stack is a field:

logger.error(new Error('db down'))
{ "level": "error", "message": "db down", "service": "checkout", "stack": "Error: db down\n    at ..." }

Passing an error as the second argument, as in logger.error('payment failed', new Error('card declined')), merges the messages into "payment failed card declined" in testing. Keep your message constant and add structured fields next to it if you want the text stable for search.

For crashes, Winston can log uncaught exceptions and unhandled rejections itself:

const logger = winston.createLogger({
  transports: [new winston.transports.Console()],
  exceptionHandlers: [new winston.transports.Console()],
  rejectionHandlers: [new winston.transports.Console()],
})

By default Winston exits after logging an uncaught exception. Set exitOnError: false only if you have a reason to keep a process running after one, which is rarely a good idea.

Transports

The Console transport writes to stdout and stderr. Every transport accepts its own level and format, so you can send everything to the console as JSON and keep a second file for errors:

transports: [
  new winston.transports.Console({ level: 'info' }),
  new winston.transports.File({ filename: 'error.log', level: 'error' }),
]

In containers, skip the file and write JSON to stdout. For development, add a readable console transport only when NODE_ENV is not production, using format.combine(format.colorize(), format.simple()).

Before exiting on purpose, call logger.end() and wait for the finish event. Each Winston logger is a stream, and the docs describe finish as the signal that all records reached every transport. Also attach logger.on('error', ...) so a failing transport does not become an unhandled exception.

Add a Request ID with AsyncLocalStorage

Winston's child() method works, but the docs flag it as likely to misbehave if you also extend the Logger class. A more dependable route for request IDs is Node's AsyncLocalStorage, which creates stores that stay coherent through asynchronous operations. Run each request inside a store, and read it in a format:

const { AsyncLocalStorage } = require('node:async_hooks')
const { randomUUID } = require('node:crypto')

const als = new AsyncLocalStorage()

const addRequestId = winston.format((info) => {
  const store = als.getStore()
  if (store) info.request_id = store.requestId
  return info
})

// add addRequestId() to your combine() list, before json()

app.use((req, res, next) => {
  const requestId = req.get('x-request-id') || randomUUID()
  res.set('X-Request-ID', requestId)
  als.run({ requestId }, next)
})

Two overlapping requests in a test logged request_id values req-a and req-b correctly on both sides of an await. That request_id is the correlation ID for every line a request produces. For the Express request line itself and the choice between loggers, see the Node.js logging guide.

OpenTelemetry Correlation

@opentelemetry/instrumentation-winston adds trace_id, span_id, and trace_flags to any record logged inside a span. It can also send Winston records to the OpenTelemetry Logs SDK, but only when @opentelemetry/winston-transport is installed in the project. Register WinstonInstrumentation in your SDK setup, covered in the OpenTelemetry Node.js guide. With it in place, the trace ID on each line connects it to the request's spans. Compare with Pino, which does the same through its own instrumentation.

Common Mistakes

  • No transports. A logger without any writes nothing.
  • splat() after a redaction format. Original metadata can come back.
  • Expecting stacks without errors({ stack: true }).
  • Logging JSON.stringify(obj) as the message. The message becomes one escaped string. Pass the object as metadata.
  • Calling levels that do not exist. The npm levels have no fatal or trace, so map them to error and silly, or define your own levels.
  • Putting colorize() in the production format. Keep color for the development console transport only.

Production Tips

  • Use JSON on stdout, one record per line. See the JSON logging guide and logging best practices.
  • Keep one logger module and require it everywhere.
  • Read the level from the environment so you can raise it without a deploy.
  • Do not log secrets. Read secrets in logs for what to search for.

Sending Winston Logs to Sazabi

Sazabi, an AI-native observability and security platform, accepts logs and traces over OTLP/HTTP. Its OpenTelemetry source docs say to set OTEL_EXPORTER_OTLP_ENDPOINT to your intake URL with OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf, and to add an explicit log record processor with an OTLP log exporter if traces arrive without logs. The OpenTelemetry Node.js guide has the setup file. Winston records reach that processor through the instrumentation and transport package above. See the OpenTelemetry integration and client errors for watching the 4xx lines your access logs produce.

Frequently Asked Questions

What is the default log level in Winston?

The default level is info. With the default npm levels, error, warn, and info are logged, and http, verbose, debug, and silly are not.

Why is my Winston redaction format not working?

If format.splat() runs after your redaction format, it can copy the original metadata back onto the record. Put splat before the redaction format in format.combine.

How do I log errors with a stack trace in Winston?

Add format.errors({ stack: true }) before format.json(), then pass the error to the logger. The stack appears as a field in the JSON.

How do I add a request ID to Winston logs?

Run each request inside AsyncLocalStorage.run with the ID, and add a custom format that reads the store and sets request_id on every record.

Sources

  1. createLogger options and defaults, npm levels, defaultMeta, formats via format.combine, splat for interpolation, custom formats, per-transport level and format, exceptionHandlers, rejectionHandlers, exitOnError, and child loggers (accessed Nov 8, 2026)
  2. The errors format with stack: true, and the json format built on safe-stable-stringify (accessed Nov 8, 2026)
  3. AsyncLocalStorage creates stores that stay coherent through asynchronous operations, and run(store, callback) scopes a store to the callback (accessed Nov 8, 2026)
  4. WinstonInstrumentation adds trace_id, span_id, and trace_flags to records in a span, and sends logs to the OpenTelemetry Logs SDK when @opentelemetry/winston-transport is installed (accessed Nov 8, 2026)
  5. The intake URL goes in OTEL_EXPORTER_OTLP_ENDPOINT with the http/protobuf protocol, and when traces arrive without logs an explicit log record processor with an OTLP log exporter should be added (accessed Nov 8, 2026)