Node.js Logging Best Practices with Winston
A practical guide to Node.js logging with Winston covering structured logging, log levels, transports, log rotation, request logging, and production configuration.
Node.js Logging Best Practices with Winston
console.log works for debugging. It does not work for production. When your application runs on a server, you need structured logs that can be searched, filtered, and analyzed. You need log levels to distinguish critical errors from routine information. You need log rotation so files do not fill up the disk. You need consistent formatting so monitoring tools can parse your logs.
Winston is the most widely used logging library for Node.js. It provides structured logging, multiple output destinations (transports), log levels, formatting, and child loggers. This guide covers setting up Winston for production Node.js applications.
Prerequisites
- Node.js installed (v16+)
- An Express.js application
Installation
npm install winston
For additional features:
npm install winston-daily-rotate-file # Log rotation
Basic Setup
// logger.js
var winston = require("winston");
var logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
defaultMeta: { service: "myapp" },
transports: [
new winston.transports.Console({
format: process.env.NODE_ENV === "production"
? winston.format.json()
: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
})
]
});
module.exports = logger;
// Usage
var logger = require("./logger");
logger.info("Server started", { port: 3000 });
logger.warn("Slow query detected", { duration: 2500, query: "SELECT * FROM users" });
logger.error("Database connection failed", { host: "db.example.com", error: err.message });
Output in production (JSON):
{"level":"info","message":"Server started","port":3000,"service":"myapp","timestamp":"2026-02-13T10:30:00.000Z"}
Output in development (colored, human-readable):
info: Server started {"port":3000,"service":"myapp","timestamp":"2026-02-13T10:30:00.000Z"}
Log Levels
Winston uses npm-style log levels by default, from most to least severe:
| Level | Value | When to Use |
|---|---|---|
| error | 0 | Application errors that need attention |
| warn | 1 | Potential problems, degraded performance |
| info | 2 | Normal application events |
| http | 3 | HTTP request/response logging |
| verbose | 4 | More detailed informational messages |
| debug | 5 | Debugging information |
| silly | 6 | Everything, including noisy output |
Setting the logger level to info means it logs error, warn, and info messages, but not http, verbose, debug, or silly.
logger.error("Database connection failed", { error: err.message });
logger.warn("Memory usage high", { heapUsed: 450, heapTotal: 512 });
logger.info("User registered", { userId: 123, email: "[email protected]" });
logger.debug("Cache miss", { key: "user:123" });
Choosing the Right Level
- Production: Set to
info. Logs errors, warnings, and important events. - Staging: Set to
debug. Includes debugging output for troubleshooting. - Development: Set to
debugorsilly. Maximum verbosity.
Change the level at runtime without restarting:
// API endpoint to change log level (admin only)
app.put("/admin/log-level", authorize("admin"), function(req, res) {
var level = req.body.level;
var validLevels = ["error", "warn", "info", "http", "verbose", "debug", "silly"];
if (validLevels.indexOf(level) === -1) {
return res.status(400).json({ error: "Invalid log level" });
}
logger.level = level;
logger.info("Log level changed", { newLevel: level, changedBy: req.user.id });
res.json({ level: level });
});
Transports
Transports are output destinations for log messages. Winston can send logs to multiple places simultaneously.
Console Transport
new winston.transports.Console({
level: "debug",
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(function(info) {
return info.timestamp + " [" + info.level + "]: " + info.message +
(Object.keys(info).length > 4 ? " " + JSON.stringify(info, null, 2) : "");
})
)
})
File Transport
new winston.transports.File({
filename: "logs/error.log",
level: "error",
maxsize: 5242880, // 5MB
maxFiles: 5
})
new winston.transports.File({
filename: "logs/combined.log",
maxsize: 5242880,
maxFiles: 10
})
Daily Rotate File Transport
var DailyRotateFile = require("winston-daily-rotate-file");
new DailyRotateFile({
filename: "logs/app-%DATE%.log",
datePattern: "YYYY-MM-DD",
maxSize: "20m",
maxFiles: "14d", // Keep logs for 14 days
zippedArchive: true // Compress old logs
})
Complete Multi-Transport Setup
// logger.js
var winston = require("winston");
var DailyRotateFile = require("winston-daily-rotate-file");
var logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: process.env.SERVICE_NAME || "myapp" },
transports: [
// Console — always enabled
new winston.transports.Console({
format: process.env.NODE_ENV === "production"
? winston.format.json()
: winston.format.combine(
winston.format.colorize(),
winston.format.printf(function(info) {
var msg = info.timestamp + " " + info.level + ": " + info.message;
if (info.stack) msg += "\n" + info.stack;
return msg;
})
)
}),
// Error log file
new DailyRotateFile({
filename: "logs/error-%DATE%.log",
datePattern: "YYYY-MM-DD",
level: "error",
maxFiles: "30d",
zippedArchive: true
}),
// Combined log file
new DailyRotateFile({
filename: "logs/combined-%DATE%.log",
datePattern: "YYYY-MM-DD",
maxFiles: "14d",
zippedArchive: true
})
]
});
module.exports = logger;
Structured Logging
Why Structure Matters
Unstructured logs:
User 123 logged in from 192.168.1.1 at 2026-02-13 10:30:00
Error: Database connection failed - ECONNREFUSED
Request to /api/users took 2500ms
Structured logs (JSON):
{"level":"info","message":"user_login","userId":123,"ip":"192.168.1.1","timestamp":"2026-02-13T10:30:00Z"}
{"level":"error","message":"database_connection_failed","error":"ECONNREFUSED","host":"db.example.com","timestamp":"2026-02-13T10:30:01Z"}
{"level":"warn","message":"slow_request","method":"GET","path":"/api/users","duration":2500,"timestamp":"2026-02-13T10:30:02Z"}
Structured logs are:
- Searchable — find all logs where
userId=123 - Filterable — show only
level=errorentries - Parseable — monitoring tools extract metrics automatically
- Consistent — every entry has the same format
Logging Conventions
// Use event names as messages, not sentences
logger.info("user_login", { userId: user.id, ip: req.ip }); // Good
logger.info("User " + user.id + " logged in from " + req.ip); // Bad
// Include relevant context as properties
logger.error("payment_failed", {
orderId: order.id,
amount: order.amount,
error: err.message,
provider: "stripe"
});
// Use consistent property names across the application
// userId (not user_id, uid, or id)
// duration (not time, elapsed, ms)
// error (not err, message, msg)
Request Logging Middleware
HTTP Request Logger
// middleware/requestLogger.js
var logger = require("../logger");
function requestLogger(req, res, next) {
var start = Date.now();
// Generate or use existing request ID
req.requestId = req.headers["x-request-id"] ||
Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
res.setHeader("X-Request-ID", req.requestId);
res.on("finish", function() {
var duration = Date.now() - start;
var level = res.statusCode >= 500 ? "error"
: res.statusCode >= 400 ? "warn"
: "http";
logger.log(level, "http_request", {
requestId: req.requestId,
method: req.method,
path: req.path,
query: Object.keys(req.query).length > 0 ? req.query : undefined,
status: res.statusCode,
duration: duration,
ip: req.ip,
userAgent: req.get("user-agent"),
userId: req.user ? req.user.id : undefined,
contentLength: res.get("content-length")
});
});
next();
}
module.exports = requestLogger;
// app.js
app.use(require("./middleware/requestLogger"));
Logging Slow Requests
function slowRequestLogger(threshold) {
threshold = threshold || 3000;
return function(req, res, next) {
var start = Date.now();
res.on("finish", function() {
var duration = Date.now() - start;
if (duration > threshold) {
logger.warn("slow_request", {
method: req.method,
path: req.path,
status: res.statusCode,
duration: duration,
threshold: threshold,
userId: req.user ? req.user.id : undefined
});
}
});
next();
};
}
app.use(slowRequestLogger(3000));
Child Loggers
Child loggers inherit the parent configuration and add context:
// Create a child logger for a specific module
var dbLogger = logger.child({ module: "database" });
var authLogger = logger.child({ module: "auth" });
dbLogger.info("query_executed", { table: "users", duration: 45 });
// Output includes module: "database" automatically
authLogger.info("login_attempt", { email: "[email protected]" });
// Output includes module: "auth" automatically
Per-Request Logger
// middleware/requestContext.js
function requestContext(req, res, next) {
req.log = logger.child({
requestId: req.requestId,
userId: req.user ? req.user.id : undefined
});
next();
}
// Usage in routes
app.get("/api/orders", function(req, res) {
req.log.info("fetching_orders");
db.query("SELECT * FROM orders WHERE user_id = $1", [req.user.id])
.then(function(result) {
req.log.info("orders_fetched", { count: result.rows.length });
res.json(result.rows);
})
.catch(function(err) {
req.log.error("orders_fetch_failed", { error: err.message });
res.status(500).json({ error: "Failed to fetch orders" });
});
});
Every log entry from a request automatically includes requestId and userId, making it easy to trace all events for a single request.
Error Logging
Logging Errors with Stack Traces
// The errors format captures stack traces
var logger = winston.createLogger({
format: winston.format.combine(
winston.format.errors({ stack: true }),
winston.format.timestamp(),
winston.format.json()
),
transports: [new winston.transports.Console()]
});
try {
JSON.parse("invalid json");
} catch (err) {
logger.error("json_parse_failed", {
error: err.message,
stack: err.stack,
input: "invalid json"
});
}
Express Error Logger
// middleware/errorLogger.js
var logger = require("../logger");
function errorLogger(err, req, res, next) {
var logData = {
error: err.message,
stack: err.stack,
method: req.method,
path: req.path,
requestId: req.requestId,
userId: req.user ? req.user.id : undefined,
ip: req.ip
};
if (err.isOperational) {
logger.warn("operational_error", logData);
} else {
logger.error("unhandled_error", logData);
}
next(err);
}
module.exports = errorLogger;
// app.js — error logger before error handler
app.use(require("./middleware/errorLogger"));
app.use(require("./middleware/errorHandler"));
Sensitive Data Protection
Never log passwords, tokens, credit card numbers, or personal health information:
// Format that redacts sensitive fields
var redactFormat = winston.format(function(info) {
if (info.password) info.password = "[REDACTED]";
if (info.token) info.token = "[REDACTED]";
if (info.authorization) info.authorization = "[REDACTED]";
if (info.creditCard) info.creditCard = "[REDACTED]";
if (info.ssn) info.ssn = "[REDACTED]";
return info;
});
var logger = winston.createLogger({
format: winston.format.combine(
redactFormat(),
winston.format.timestamp(),
winston.format.json()
),
transports: [new winston.transports.Console()]
});
// This is safe — password gets redacted
logger.info("login_attempt", { email: "[email protected]", password: "secret123" });
// Output: {"email":"[email protected]","password":"[REDACTED]",...}
Request Body Sanitization
function sanitizeBody(body) {
if (!body) return undefined;
var sanitized = Object.assign({}, body);
var sensitiveFields = ["password", "token", "secret", "creditCard", "ssn", "authorization"];
sensitiveFields.forEach(function(field) {
if (sanitized[field]) {
sanitized[field] = "[REDACTED]";
}
});
return sanitized;
}
// In request logger
logger.http("http_request", {
method: req.method,
path: req.path,
body: sanitizeBody(req.body)
});
Production Configuration
Complete Production Logger
// logger.js
var winston = require("winston");
var DailyRotateFile = require("winston-daily-rotate-file");
var path = require("path");
var LOG_DIR = process.env.LOG_DIR || "logs";
var SERVICE = process.env.SERVICE_NAME || "myapp";
var ENV = process.env.NODE_ENV || "development";
// Redact sensitive fields
var redactFormat = winston.format(function(info) {
var sensitive = ["password", "token", "secret", "authorization", "cookie"];
sensitive.forEach(function(field) {
if (info[field]) info[field] = "[REDACTED]";
});
return info;
});
// Production format: JSON
var productionFormat = winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
winston.format.errors({ stack: true }),
redactFormat(),
winston.format.json()
);
// Development format: human-readable
var developmentFormat = winston.format.combine(
winston.format.timestamp({ format: "HH:mm:ss" }),
winston.format.errors({ stack: true }),
winston.format.colorize(),
winston.format.printf(function(info) {
var msg = info.timestamp + " " + info.level + ": " + info.message;
var meta = Object.assign({}, info);
delete meta.timestamp;
delete meta.level;
delete meta.message;
delete meta.service;
if (meta.stack) {
msg += "\n" + meta.stack;
delete meta.stack;
}
if (Object.keys(meta).length > 0) {
msg += " " + JSON.stringify(meta);
}
return msg;
})
);
var transports = [];
// Console — always enabled
transports.push(new winston.transports.Console({
format: ENV === "production" ? productionFormat : developmentFormat
}));
// File transports — production only
if (ENV === "production") {
transports.push(new DailyRotateFile({
filename: path.join(LOG_DIR, "error-%DATE%.log"),
datePattern: "YYYY-MM-DD",
level: "error",
maxFiles: "30d",
zippedArchive: true,
format: productionFormat
}));
transports.push(new DailyRotateFile({
filename: path.join(LOG_DIR, "combined-%DATE%.log"),
datePattern: "YYYY-MM-DD",
maxFiles: "14d",
zippedArchive: true,
format: productionFormat
}));
}
var logger = winston.createLogger({
level: process.env.LOG_LEVEL || (ENV === "production" ? "info" : "debug"),
defaultMeta: { service: SERVICE, env: ENV },
transports: transports,
exitOnError: false
});
module.exports = logger;
Common Issues and Troubleshooting
Logs are not appearing
The log level is too restrictive:
Fix: Check the LOG_LEVEL environment variable. If set to error, info and warn messages are suppressed. Set to debug for troubleshooting.
Log files grow without limit
File transport does not have rotation configured:
Fix: Use winston-daily-rotate-file with maxFiles and maxSize options. Set zippedArchive: true to compress old logs.
Logs contain sensitive data
Passwords or tokens are included in log entries:
Fix: Add a redaction format that strips sensitive fields before writing. Audit all logging statements for accidental sensitive data inclusion.
Logs have inconsistent formats
Different parts of the application log differently:
Fix: Use a single logger instance imported everywhere. Define the format in one place. Use child loggers to add context without changing the format.
Performance impact from excessive logging
Logging every request detail slows down the application:
Fix: Use debug level for verbose logs and set production to info. Avoid logging large objects (request bodies, query results). Sample high-frequency logs instead of logging every event.
Best Practices
- Use structured JSON logging in production. JSON logs are parseable by monitoring tools, searchable, and machine-readable. Human-readable formats are for development only.
- Log events, not sentences. Use short event names (
user_login,order_created) as the message and add context as properties. This makes filtering and searching straightforward. - Include request IDs in every log entry. A request ID traces all events for a single request across services and log entries. Generate one if the client does not send one.
- Set up log rotation. Without rotation, logs fill up the disk. Use
winston-daily-rotate-filewith retention limits. - Never log sensitive data. Add a redaction format that strips passwords, tokens, and personal information before any log entry is written.
- Use child loggers for module-specific context.
logger.child({ module: "auth" })automatically tags every log from the auth module without repeating the tag in every call. - Make the log level configurable at runtime. An API endpoint or environment variable change should adjust the log level without restarting the application.
- Log at the right level. Errors go to
error. Deprecation warnings go towarn. Business events go toinfo. Implementation details go todebug. Too manyerrorentries signal poor error handling, not good logging.