Last updated: June 9, 2025

Mastering Winston for Production Logging in Node.js

Building robust Node.js applications requires a logging strategy that helps you track application behavior, troubleshoot issues, and maintain visibility across your entire system.

Winston has established itself as one of the most trusted logging libraries in the Node.js ecosystem by offering the flexibility and extensibility needed for production applications.

In this guide, we’ll explore Winston’s core capabilities, demonstrate how to configure it for common logging use cases, and show you how to build a system that handles all your production needs.

Getting started with Winston

Winston is available as an npm package and is installable with a simple command:

sh
1
npm install winston

Once installed, you can import Winston and create a logger instance as follows:

JavaScript
13457
import winston from "winston";
const logger = winston.createLogger({
transports: [new winston.transports.Console()],
});
logger.info("an info message");

This configuration will print JSON-formatted logs to the console consisting of a severity level and the log message:

json
1
{ "level": "info", "message": "Hello world!" }

For a more production-ready logger, you’ll need to add a timestamp:

JavaScript
134568
const { combine, timestamp, json } = winston.format;
const logger = winston.createLogger({
format: combine(timestamp(), json()),
transports: [new winston.transports.Console()],
});
logger.info("Hello world!");

This enhanced logger will produce output similar to the following, now including an ISO-8601 formatted timestamp:

json
12345
{
"level": "info",
"message": "Hello world!",
"timestamp": "2025-05-27T14:51:07.920Z"
}

Working with Winston log levels

Winston provides three predefined sets of log levels: npm, syslog, and cli. It defaults to the npm log levels, which are listed below in increasing order of severity:

JavaScript
1234567
logger.silly("a silly message");
logger.debug("a debug message");
logger.verbose("a verbose message");
logger.http("an http message");
logger.info("an info message");
logger.warn("a warn message");
logger.error("an error message");

To switch to syslog levels, you can configure your logger like this:

JavaScript
123
const logger = winston.createLogger({
levels: winston.config.syslog.levels,
});

This provides the following logging methods, also in increasing order of severity:

JavaScript
12345678
logger.debug("a debug message");
logger.info("an info message");
logger.notice("a notice message");
logger.warning("a warning message");
logger.error("a error message");
logger.crit("a crit message");
logger.alert("a alert message");
logger.emerg("a emerg message");

For modern production logging, we recommend aligning with the level categories defined in OpenTelemetry. Winston’s flexibility with custom log levels makes this straightforward:

JavaScript
12345678910
const logger = winston.createLogger({
levels: {
fatal: 0,
error: 1,
warn: 2,
info: 3,
debug: 4,
trace: 5,
},
});

Here, the smallest number indicates the most severe level, while the largest number indicates the least severe. Note that this numeric assignment for Winston’s internal priority doesn’t directly correspond to OpenTelemetry’s severityNumber designation, but we’ll address this when discussing OpenTelemetry integration.

With this, you’ll have the following logging methods available to you:

JavaScript
123456
logger.trace("a trace message");
logger.debug("a debug message");
logger.info("an info message");
logger.warn("a warning message");
logger.error("an error message");
logger.fatal("a fatal message");

Which outputs:

json
1234
{"level":"info","message":"an info message","timestamp":"2025-05-22T15:58:35.884Z"}
{"level":"warn","message":"a warning message","timestamp":"2025-05-22T15:58:35.888Z"}
{"level":"error","message":"an error message","timestamp":"2025-05-22T15:58:35.888Z"}
{"level":"fatal","message":"a fatal message","timestamp":"2025-05-22T15:58:35.891Z"}

The default log level is info so less severe levels are suppressed accordingly. To change this, you can set the level option:

JavaScript
123
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
});

With this setup, you can adjust log verbosity when starting your application:

sh
1
LOG_LEVEL=error node index.js

Capturing contextual metadata in logs

Structured logging demands contextual data to make sense of logs for troubleshooting, and Winston provides several powerful options for adding contextual attributes.

At the most basic level, you can apply global metadata to all logs produced by a specific logger instance using the defaultMeta option. This is useful for process-wide context like process ID, hostname, or application version:

JavaScript
1245678910
import os from "node:os";
import packageJson from "./package.json" with { type: "json" };
const logger = winston.createLogger({
defaultMeta: {
pid: process.pid,
hostname: os.hostname(),
version: packageJson.version,
},
});

This ensures that all logs produced by the logger contain the specified fields in the output:

json
12345678
{
"hostname": "Falcon",
"level": "info",
"message": "an info message",
"pid": 422509,
"timestamp": "2025-05-22T16:40:46.058Z",
"version": "1.0.0"
}

Beyond this, you can capture event-specific contextual metadata by passing an object as the second argument to a logging method. This object’s properties are merged into the log entry:

JavaScript
12345
logger.info("User login successful", {
userId: "u_14b7e21d",
loginMethod: "password",
sessionId: "sess_9f72bc3a",
});

For scenarios where you need to add context for a specific scope or request, child loggers provide an elegant solution.

A common use case is tagging each log generated as part of an HTTP request with a requestId to group logs by request. You can call logger.child() in your middleware and attach any request-wide metadata like this:

JavaScript
123456
app.use((req, res, next) => {
req.log = logger.child({
requestId: req.headers["x-request-id"] || uuidv4(),
});
next();
});

Then, in your route handlers, use req.log as follows:

JavaScript
12345
app.get("/", (req, res) => {
req.log.info("Fetching user data", {
userId: req.query.userId,
});
});

You’ll see that requestId is included in all logs:

json
1234567
{
"level": "info",
"message": "Fetching user data",
"requestId": "0de163e7-39b7-4f55-9b43-7d05d0df650e",
"userId": "usr-1234",
"timestamp": "2025-05-22T17:04:23.354Z"
}

This layered approach to contextual metadata provides rich, queryable logs without cluttering your logging statements.

Logging errors in Winston

Winston requires that you specify the errors formatter for properly logging error objects. Without it, errors will not be captured in your logs.

JavaScript
1345
const { errors, combine, timestamp, json } = winston.format;
const logger = winston.createLogger({
format: combine(errors({ stack: true }), timestamp(), json()),
});

With the logger correctly configured, you can log an Error object directly:

JavaScript
1
logger.error(err);

This will result in a log entry that includes both the error’s message and stack trace:

json
123456
{
"level": "error",
"message": "something happened",
"stack": "[...]",
"timestamp": "2025-05-22T17:37:54.267Z"
}

Using Winston transports to route logs

Winston’s transport system provides great flexibility in determining where your logs are routed.

The library includes several built-in transports that cover most common logging scenarios, from simple console output to file storage and HTTP endpoints.

For instance, the File transport allows you to persist logs to the filesystem:

JavaScript
1234567
const logger = winston.createLogger({
transports: [
new winston.transports.File({
filename: "app.log",
}),
],
});

The file will be created automatically if it doesn’t exist, and new log entries are appended to the end. The content written to the file will adhere to the format defined at the logger level.

For long-running applications, you can use the winston-daily-rotate-file transport handles log rotation automatically. You’ll need to install it separately:

sh
1
npm install winston-daily-rotate-file

Then, integrate it into your logger configuration:

JavaScript
13456789
import "winston-daily-rotate-file";
const logger = winston.createLogger({
transports: [
new winston.transports.DailyRotateFile({
filename: "app.log",
}),
],
});

You’ll start to see your logs in a file suffixed with the current date such as:

1
app.log.2025-05-23

The file name suffix and rotation parameters can all be customized through the available options.

Winston also provides the flexibility to log to multiple transports at once, such as logging to the console and a file simultaneously:

JavaScript
12345678
const logger = winston.createLogger({
transports: [
new winston.transports.Console(),
new winston.transports.DailyRotateFile({
filename: "combined.log",
}),
],
});

There are also several third-party transports that let you ship your logs to external services, databases, or monitoring tools.

Using Winston for request and error logging

Most web applications require robust logging around HTTP requests, responses, and any errors that occur during request processing. Winston, combined with Express middleware, can effectively handle these requirements.

This example below demonstrates creating a child logger for each request, including a unique request ID, and logging key details at the start and end of the request:

JavaScript
1245678911121315161718192022232526272830313233343536373940
import { performance } from "node:perf_hooks";
import { v4 as uuidv4 } from "uuid";
app.use((req, res, next) => {
const start = performance.now();
const requestId = req.headers["x-request-id"] || uuidv4();
const { method, url, ip, headers } = req;
const userAgent = headers["user-agent"];
req.log = logger.child({
request_id: requestId,
});
req.log.info(`incoming ${method} request to ${url}`, {
method,
url,
ip,
user_agent: userAgent,
});
res.on("finish", () => {
const { statusCode } = res;
const logData = {
duration_ms: performance.now() - start,
status_code: statusCode,
};
if (statusCode >= 500) {
req.log.error("server error", logData);
} else if (statusCode >= 400) {
req.log.warn("client error", logData);
} else {
req.log.info("request completed", logData);
}
});
next();
});

The request-specific logger (req.log) is attached to the Express req object, allowing you to tie all logs generated during the lifecycle of that specific request together using the included request_id.

This middleware logs two entries per request: one when the request comes in, and another when the response finishes, detailing the duration and outcome. With this in place, you’ll see logs similar to this for each request:

json
12
{"ip":"::1","level":"info","message":"incoming GET request to /","method":"GET","request_id":"4069d19e-4f35-4146-a3c7-142906df6a74","timestamp":"2025-05-27T11:07:48.405Z","url":"/","user_agent":"curl/8.5.0"}
{"duration_ms":8.21153699999968,"level":"info","message":"request completed","request_id":"4069d19e-4f35-4146-a3c7-142906df6a74","status_code":204,"timestamp":"2025-05-27T11:07:48.411Z"}

Within your route handlers or other middleware, you can use the attached req.log to add more specific logs, which will automatically include the request_id:

JavaScript
1234
app.get("/", (req, res) => {
req.log.info("fetching some data");
res.sendStatus(204);
});

This allows you to trace the entire lifecycle of a request across multiple log entries, even if they are interleaved with logs from other concurrent requests:

json
123456
{
"level": "info",
"message": "Fetching user data",
"request_id": "4069d19e-4f35-4146-a3c7-142906df6a74",
"timestamp": "2025-05-27T11:07:48.407Z"
}

For handling errors that occur during request processing, Express uses special error-handling middleware so you can just add a logging statement there:

JavaScript
12345
app.use((err, req, res, next) => {
const errorLogger = req.log || logger; // Fallback to a default logger
errorLogger.error(err);
res.status(500).json({ error: "Internal server error" });
});

The resulting log will include the request_id and the error details:

json
1234567
{
"level": "error",
"message": "something happened",
"request_id": "9c9e910b-a7c0-4cd3-b992-7dfd34c3fa8e",
"stack": "<the stack trace>",
"timestamp": "2025-05-27T11:43:54.713Z"
}

Integrating Winston with OpenTelemetry

Winston can be integrated with OpenTelemetry to channel its logs into a broader observability framework. This connection allows your Winston logs to be correlated with traces and metrics, providing a unified view of service behavior.

Integration is primarily achieved using the OpenTelemetry SDK and the Winston instrumentation package, which automatically captures the native output and translates it to the OpenTelemetry Log Data Model.

The @opentelemetry/auto-instrumentations-node meta-package simplifies getting started by bundling common Node.js instrumentations.

To get started, install the necessary packages:

sh
123
npm install @opentelemetry/api \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/instrumentation

The quickest way to see OpenTelemetry in action is to use the register script from auto-instrumentations-node. At the time of writing, a loader hook is also required to properly patch the instrumentation, but ensure to check the docs for up-to-date information.

Go ahead and modify your application’s main entry script:

JavaScript
12346
import "@opentelemetry/auto-instrumentations-node/register"; // must be at the top of the file
import { register } from "node:module";
import { pathToFileURL } from "node:url";
register("@opentelemetry/instrumentation/hook.mjs", pathToFileURL("./")); // loader hoook
// rest of your code

The register script initializes a basic OpenTelemetry Node.js SDK which includes a simple NodeTracerProvider and enables common instrumentations, including those for HTTP servers and Winston (see the full list here).

With this setup, your logs will be automatically enriched with the active trace context by the Winston instrumentation:

json
12345678910111213
{
"ip": "::1",
"level": "info",
"message": "incoming GET request to /",
"method": "GET",
"request_id": "0f914569-0e06-4ab0-ba48-0dc800ebfb1b",
"span_id": "c1b93056195d6fe2",
"timestamp": "2025-05-28T09:25:37.582Z",
"trace_flags": "01",
"trace_id": "6802b761f272e706f3811ad3705c1ae3",
"url": "/",
"user_agent": "curl/8.5.0"
}

The trace_id, span_id, and trace_flags fields appear because:

  1. The auto-instrumentations-node package instruments your server to create spans for incoming requests.
  2. The Winston instrumentation then automatically injects the active trace context into your Winston log records.

While this adds valuable trace correlation, the logs themselves are not yet in the OpenTelemetry Log Data Model. If exported as-is to an OTel collector, they’d require further processing.

To gain more control and ensure logs conform to the OTLP data model, you need to programmatically configure the OpenTelemetry SDK:

JavaScript
otel.js
123456891011121315171819
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { NodeSDK } from "@opentelemetry/sdk-node";
import {
ConsoleLogRecordExporter,
SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
const sdk = new NodeSDK({
logRecordProcessors: [
new SimpleLogRecordProcessor(new ConsoleLogRecordExporter()),
],
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
process.on("SIGTERM", async () => {
await sdk.shutdown();
});

This file sets up a NodeSDK instance, and registers the auto instrumentations and ConsoleRecordExporter for inspecting and debugging your log output.

You’ll need to install the following additional packages before proceeding:

sh
1
npm install @opentelemetry/sdk-node @opentelemetry/sdk-logs

Now, replace the @opentelemetry/auto-instrumentations-node/register script and the loader hook in your application’s entry file with an import to this new setup file:

JavaScript
12
import "./otel.js";
// [...]

With this setup, you’ll see two entries per log record in the console. One is from the Winston’s own Console transport, while the other is from the ConsoleLogRecordExporter that we just configured:

JavaScript
1234567891011121314151617181920212223242526272829
{
resource: {
attributes: {
'host.name': 'Falcon',
// [...]
}
},
instrumentationScope: {
name: '@opentelemetry/winston-transport',
version: '0.12.0',
schemaUrl: undefined
},
timestamp: 1748436592499000,
traceId: '5c931fa6f30bf1f4058c7954803ffced',
spanId: 'da5fe4d5559292bf',
traceFlags: 1,
severityText: 'info',
severityNumber: 9,
body: 'request completed',
attributes: {
request_id: '665d6156-9491-4bec-bad5-df093ada20cc',
duration_ms: 6.404735000000073,
status_code: 204,
trace_id: '5c931fa6f30bf1f4058c7954803ffced',
span_id: 'da5fe4d5559292bf',
trace_flags: '01',
timestamp: '2025-05-28T12:49:52.499Z'
}
}

Compared to Winston’s own output, the OpenTelemetry instrumentation includes several changes:

  • resource: Captures service and runtime metadata (according to your resource configuration).
  • instrumentationScope: Identifies the OTel instrumentation that generated the log.
  • severityText and severityNumber: Maps Winston’s log levels to OpenTelemetry-compliant fields.
  • body: Contains the original Winston log message.
  • attributes: Contains the metadata object from your Winston log call.
  • traceId, spanId, and traceFlags: These top-level fields are populated if the log was emitted within an active trace context. The Winston instrumentation also duplicates them in the attributes as shown above.

Note that the ConsoleLogRecordExporter output is solely for debugging. The actual OTLP/JSON format has a slightly different structure (usually a batch of these records), as detailed in the OTLP specification.

Adopting semantic conventions

To maximize the value of your data, your log attributes should align with OpenTelemetry semantic conventions. This standardization ensures consistency and interoperability across different tools and services.

For instance, in your request logger, you may use the HTTP conventions to name the attributes:

JavaScript
123567891012131415
req.log = logger.child({
"http.request.id": requestId,
});
req.log.info(`incoming ${method} request to ${url}`, {
"http.request.method": method,
"url.path": url,
"client.address": ip,
"user_agent.original": userAgent,
});
const logData = {
"http.response.duration_ms": performance.now() - start,
"http.response.status_code": statusCode,
};

This produces attributes that look like this:

JavaScript
12345
attributes: {
'http.request.id': 'ef7550c1-ac47-44c6-a10b-141809bab6f4',
'http.response.duration_ms': 16.684879000000365,
'http.response.status_code': 204,
}

For logging errors, you can use the following conventions:

JavaScript
12345
req.log.error(err.message, {
"exception.message": err.message,
"exception.type": err.name,
"exception.stacktrace": err.stack,
});

For naming attributes when no conventions exist, you can follow the general naming guidelines.Centralizing your Node.js logs (and traces) in Dash0

With OpenTelemetry SDK configured for both logs and traces, the next step is to send this telemetry data to an observability platform. These platforms allow for aggregation, monitoring, filtering, and correlation of logs with traces and metrics.

Dash0 is one such solution that can ingests OpenTelemetry data using OTLP Exporters. If you haven’t already included these in your package.json file, you’ll need them:

sh
1
npm install @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-trace-otlp-http

Then import the OTLPLogExporter and OTLPTraceExporters and update your sdk instance as follows:

JavaScript
otel.js
12357891011
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
// [...]
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter(),
logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
instrumentations: [getNodeAutoInstrumentations()],
});

Here, the BatchLogRecordProcessor is used to ensure that logs are sent in configurable batches to the configured OTLP endpoint which is the recommended approach in production. Under the hood, the traceExporter also uses the BatchSpanProcessor for the same reason.

The OTLPTraceExporter and OTLPLogExporter then handle the actual forwarding of spans and logs to their respective OTLP endpoints.

After making these changes, you’ll need to configure the following environmental variables when starting your service:

sh
123
OTEL_SERVICE_NAME="<your_application_name>"
OTEL_EXPORTER_OTLP_ENDPOINT="<your_dash0_otlp_endpoint>"
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <your_dash0_secret_token>, Dash0-Dataset=<your_dash0_dataset>"

With this setup, your application will start sending both logs and traces to Dash0. You should see logs and traces appearing in the Dash0 UI, allowing you to correlate them.

Streaming logs to Dash0 and viewing trace context

For example, clicking on a log entry will provide a link to its associated trace, and viewing a trace will show all logs emitted during that trace:

Trace view in Dash0 showing associated logs

To further refine your setup, consider deploying an OpenTelemetry Collector alongside your application. You would then configure your application’s OTLP exporters to send to the Collector’s address.

The Collector, in turn, is configured to process the data and forward it to your chosen destinations. This approach makes your observability pipeline more resilient and scalable.

Final thoughts

By enhancing Winston with OpenTelemetry integration, as detailed in this guide, you leverage its mature capabilities within a modern, unified observability framework.

This powerful combination delivers the best of both worlds: sophisticated, highly configurable logging that seamlessly feeds into comprehensive, end-to-end monitoring solutions.

Thanks for reading!

Authors
Ayooluwa Isaiah
Ayooluwa Isaiah