Node.js Logging: A Complete Guide to Node.js Logging
Apr 28, 2026 • 8 min read
Introduction to Node.js Logging
In node.js, there are multiple logging options like Winston, Debug, Pino, Log4js-node, Bunyan, roarr, etc. But in this blog we will deep dive into logging with Winston, a logging package designed to be simple but in the same time vast in terms of logging features like logging on console, output files, custom http servers called transports.
Understanding Winston’s Logging levels
Winston can be configured in multiple logging levels. Here are the logging levels which winston provides by default:
{
"error": 0,
"warn": 1,
"info": 2,
"http": 3,
"verbose": 4,
"debug": 5,
"silly": 6
}
Having the priority from the lower to upper, if the log level is set to silly it can log all the logs while if the log level is set to error it can only log error logs
Defining Custom Logging Levels in Winston
Winston also gives you privilage to define your custom loggings levels. Here is how you can define them
const myCustomLevels = {
levels: {
foo: 0,
bar: 1,
baz: 2,
foobar: 3,
},
};
and then your can set up these custom logging levels like this
import winston from "winston";
const customLevelLogger = winston.createLogger({
levels: myCustomLevels.levels,
});
Quick Start: Basic Setup for Winston Logger
Here is a basic setup for a winston logger for a quick start
import winston from "winston";
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
In this setup, we have used a console transport with File transport for logging into file (one file only for error logs and other for all the logs) and used only format.json() for logging in json format but we can also log with human readable format with using winston.format.combine() which allows to use multiple format with no hastle. Here is how we use that
import winston from "winston";
const { combine, colors, errors, align, timestamp, printf } from winston.format;
const logger = winston.createLogger({
levels: "info",
format: combine(errors({ stacK: true}), align(), timestamp(), colors(), printf())
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
]
})
Winston’s Formatting Functions
- colors(): adds colors to the logs based on the log level
- errors(): gives access to the error’s trace for debugging
- align(): aligns the log messages
- timestamp(): adds time stamp to the log message
- printf(): helps to print the log a human readable form
Logging Transports in Winston
A transport is the destination of your logs. Winston supports multiple transports and this is the only reason it is so popular and widely used in the nodejs ecosystem.
The most common transports are:
- Console transport for terminal output
- File transport for local log storage
- HTTP transport for sending logs to a remote service
- Custom transports for databases, queues, or observability tools like OpenTelemetry
Logging into Files
File logging is useful when you want your logs to persist after the process stops. It is especially useful for debugging, audits.
import winston from "winston";
const { File } = winston.transports;
const logDir = path.join(process.cwd(), "logs");
const fileLogger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new File({ filename: path.join(logDir, "app.log") }),
new File({ filename: path.join(logDir, "error.log"), level: "error" }),
],
});
fileLogger.info("Application started");
fileLogger.error("Something went wrong");
In this setup, all logs go into app.log, while only error logs go into error.log. Which really helps in debugging and monitoring the application.
For larger applications, you may also want log rotation so files do not grow forever.
So for file rotation you can use winston-daily-rotate-file transport which creates a new log file every day or when the file size exceeds a certain limit.
import winston from "winston";
import "winston-daily-rotate-file";
const logDir = path.join(process.cwd(), "logs");
const { combine, json, timestamp, errors } = winston.format;
const fileRotateTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, "application-%DATE%.json"),
datePattern: "YYYY-MM-DD",
maxFiles: "14d",
format: combine(json(), timestamp(), errors({ stack: true })),
zippedArchive: true,
});
const logger = winston.createLogger({
level: "info",
transports: [
fileRotateTransport
... other transports
],
});
Logging into HTTP Servers
Winston can also send logs to an HTTP endpoint. This is useful when you want centralized logging across multiple services.
import winston from "winston";
const { Http } = winston.transports;
const httpLogger = winston.createLogger({
level: "info",
transports: [
new Http({
host: "localhost",
port: 3000,
path: "/logs",
}),
... other transports
],
});
httpLogger.info("User logged in", { userId: 123 });
This sends log data to a remote HTTP server, where you can store, process, or forward it somewhere else. It works well for distributed systems because all logs can be collected in one place.
Logging Unexpected and Unhandled Errors
Not every failure happens inside a normal request flow. Sometimes an exception or rejection appears unexpectedly, and those must be logged properly.
Winston supports handlers for uncaught exceptions and unhandled promise rejections.
import winston from "winston";
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [new winston.transports.Console()],
exceptionHandlers: [
new winston.transports.File({ filename: "exceptions.log" }),
],
rejectionHandlers: [
new winston.transports.File({ filename: "rejections.log" }),
],
});
process.on("uncaughtException", (err) => {
logger.error("Uncaught Exception", {
message: err.message,
stack: err.stack,
});
});
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled Rejection", { reason });
});
In production, unhandled errors should be treated seriously. After logging them, many applications should exit and restart cleanly instead of continuing in an unstable state.
Best Practices for Logging
Logging is most useful when it is intentional. Random console output is not a logging strategy.
Use different loggers for development and production
In development, readable logs and extra detail are useful. In production, structured logs are usually better.
import winston from "winston";
const isProduction = process.env.NODE_ENV === "production";
const logger = winston.createLogger({
level: isProduction ? "info" : "debug",
format: isProduction
? winston.format.json()
: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
),
transports: [new winston.transports.Console()],
});
This keeps development convenient and production clean.
Use request IDs to trace a request
A request ID helps you follow a single request through multiple log entries. This is especially useful in APIs and microservices.
import crypto from "crypto";
const requestId = crypto.randomUUID();
logger.info("Incoming request", { requestId, path: "/users" });
You can attach the request ID in middleware so every log related to that request includes the same identifier.
Avoid using Math.random() for request IDs because it provides pseudorandom values which can be predicatable or be duplicated. Instead use crypto.randomUUID() which generates a strong and unique identifier that is more secure.
Know what to log and what not to log
Good logs are useful, but they should never leak sensitive data.
Log things like:
- request path
- status code
- timing information
- request ID
- error message
- stack trace for failures
Do not log things like:
- passwords
- tokens
- API keys
- credit card numbers
- session cookies
- private personal data
The goal is to help debugging, not expose secrets.
Sanitize logs for privacy
Sanitization means removing or masking sensitive fields before writing them to logs.
function sanitizeUser(user) {
return {
id: user.id,
name: user.name,
email: user.email?.replace(/(.{2}).+(@.+)/, "$1***$2"),
password: "[REDACTED]",
};
}
logger.info("User data", { user: sanitizeUser(user) });
You can build a helper function to remove sensitive fields from request body before logging them. That is a simple way to reduce accidental data leakage.
Example: A Practical Winston Setup
Here is a complete example that combines structured logs, environment-based formatting, and request IDs.
import winston from "winston";
import crypto from "crypto";
const isProduction = process.env.NODE_ENV === "production";
const logger = winston.createLogger({
level: isProduction ? "info" : "debug",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
),
defaultMeta: {
service: "my-app",
},
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: "combined.log" }),
new winston.transports.File({ filename: "error.log", level: "error" }),
],
});
export function logRequest(req, res, next) {
req.requestId = crypto.randomUUID();
logger.info("Incoming request", {
requestId: req.requestId,
method: req.method,
path: req.path,
});
next();
}
export default logger;
This setup gives structured logs, request tracing, file persistence, and cleaner production logging.
Conclusion
Logging is one of the most important parts of building any reliable application. Winston gives a flexible way to handle console logging, file logging, HTTP logging, custom levels, formatting, and error handling.
The real goal is not just to print messages. It is to make logs useful, structured, safe, and consistent. When that happens, debugging becomes easier, incidents become less painful, and your application becomes much easier to maintain.