Episode 3 — NodeJS MongoDB Backend Architecture / 3.12 — Logging and Monitoring
3.12.b — Logging Libraries
Winston is the most popular Node.js logger with flexible transports and formats. Pino is the fastest with minimal overhead. Morgan handles HTTP request logging. Together, they form a complete logging stack for production Node.js applications.
< 3.12.a — Why Logging Matters | 3.12.c — Error Handling & Logging >
1. Winston — The Most Popular Logger
Winston is the most widely used Node.js logging library. It supports multiple output destinations (transports), custom formats, and log levels.
Installation
npm install winston
Basic Setup
const winston = require("winston");
const logger = winston.createLogger({
level: "info", // Minimum level to log
format: winston.format.json(), // Output format
defaultMeta: { service: "my-api" }, // Added to every log
transports: [
// Write errors to error.log
new winston.transports.File({ filename: "logs/error.log", level: "error" }),
// Write all logs to combined.log
new winston.transports.File({ filename: "logs/combined.log" }),
],
});
// In development, also log to console with colors
if (process.env.NODE_ENV !== "production") {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}));
}
module.exports = logger;
Using the Logger
const logger = require("./logger");
// Basic logging at different levels
logger.error("Database connection failed", { error: err.message });
logger.warn("Slow query detected", { queryTime: 5200, threshold: 1000 });
logger.info("User logged in", { userId: "abc123", ip: "192.168.1.1" });
logger.http("GET /api/users 200 45ms");
logger.debug("Cache miss for key", { key: "user:abc123" });
// The second argument is always a metadata object
logger.info("Order placed", {
orderId: order._id,
userId: user._id,
total: order.total,
items: order.items.length,
});
2. Winston Transports
Transports are output destinations. Winston can write to multiple destinations simultaneously:
| Transport | Description | Use Case |
|---|---|---|
Console | Terminal output | Development |
File | Write to file | All environments |
Http | Send to HTTP endpoint | Log aggregation services |
Stream | Write to any Node.js stream | Custom destinations |
File Transport
new winston.transports.File({
filename: "logs/combined.log",
level: "info", // Only log "info" and above to this file
maxsize: 5242880, // 5MB — rotate when file exceeds this size
maxFiles: 5, // Keep only 5 rotated files
tailable: true, // Always write to the same filename (latest)
});
Console Transport
new winston.transports.Console({
level: "debug",
format: winston.format.combine(
winston.format.colorize({ all: true }),
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : "";
return `${timestamp} [${level}]: ${message} ${metaStr}`;
})
),
});
// Output: 2025-06-15 14:23:45 [info]: User logged in {"userId":"abc123"}
Multiple Transports
const logger = winston.createLogger({
level: "debug",
transports: [
// Errors go to error.log
new winston.transports.File({ filename: "logs/error.log", level: "error" }),
// Everything goes to combined.log
new winston.transports.File({ filename: "logs/combined.log" }),
// Console in development only
...(process.env.NODE_ENV !== "production"
? [new winston.transports.Console({ format: winston.format.simple() })]
: []),
],
});
3. Winston Formats
Formats control how log entries are structured:
const { format } = winston;
// JSON format (recommended for production)
format.json()
// Output: {"level":"info","message":"Server started","port":3000,"timestamp":"..."}
// Simple format (human-readable)
format.simple()
// Output: info: Server started
// Colorize (development only)
format.colorize()
// Output: [32minfo[39m: Server started (green "info")
// Timestamp
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" })
// Combine multiple formats
format.combine(
format.timestamp(),
format.errors({ stack: true }), // Include error stack traces
format.json()
)
Custom Format
const customFormat = format.printf(({ timestamp, level, message, stack, ...meta }) => {
let log = `${timestamp} [${level.toUpperCase()}]: ${message}`;
if (stack) log += `\n${stack}`;
if (Object.keys(meta).length > 0) {
log += ` ${JSON.stringify(meta)}`;
}
return log;
});
const logger = winston.createLogger({
format: format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
format.errors({ stack: true }),
customFormat
),
transports: [new winston.transports.Console()],
});
// Output:
// 2025-06-15 14:23:45 [ERROR]: Database connection failed
// MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017
// at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1141:16)
4. Winston Daily Rotate File
Rotate log files daily to prevent unbounded file growth:
npm install winston-daily-rotate-file
const winston = require("winston");
require("winston-daily-rotate-file");
const dailyRotateTransport = new winston.transports.DailyRotateFile({
filename: "logs/app-%DATE%.log",
datePattern: "YYYY-MM-DD",
maxSize: "20m", // Max size per file
maxFiles: "14d", // Keep logs for 14 days
zippedArchive: true, // Compress old log files
});
// Separate transport for errors
const errorRotateTransport = new winston.transports.DailyRotateFile({
filename: "logs/error-%DATE%.log",
datePattern: "YYYY-MM-DD",
level: "error",
maxSize: "20m",
maxFiles: "30d", // Keep error logs longer
zippedArchive: true,
});
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: "my-api" },
transports: [
dailyRotateTransport,
errorRotateTransport,
],
});
if (process.env.NODE_ENV !== "production") {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}));
}
module.exports = logger;
Log file structure:
logs/
├── app-2025-06-15.log (today — active)
├── app-2025-06-14.log.gz (yesterday — compressed)
├── app-2025-06-13.log.gz (2 days ago — compressed)
├── ...
├── error-2025-06-15.log (today's errors)
├── error-2025-06-14.log.gz (yesterday's errors)
└── ...
5. Complete Winston Configuration (Production-Ready)
// config/logger.js
const winston = require("winston");
require("winston-daily-rotate-file");
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
const colors = {
error: "red",
warn: "yellow",
info: "green",
http: "magenta",
debug: "cyan",
};
winston.addColors(colors);
const level = () => {
const env = process.env.NODE_ENV || "development";
return env === "development" ? "debug" : (process.env.LOG_LEVEL || "info");
};
// Development format — colorized and readable
const devFormat = winston.format.combine(
winston.format.timestamp({ format: "HH:mm:ss" }),
winston.format.colorize({ all: true }),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length
? `\n ${JSON.stringify(meta, null, 2)}`
: "";
return `${timestamp} ${level}: ${message}${metaStr}`;
})
);
// Production format — structured JSON
const prodFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
);
const logger = winston.createLogger({
level: level(),
levels,
format: process.env.NODE_ENV === "production" ? prodFormat : prodFormat,
defaultMeta: {
service: process.env.SERVICE_NAME || "api",
environment: process.env.NODE_ENV || "development",
},
transports: [
// Daily rotating combined logs
new winston.transports.DailyRotateFile({
filename: "logs/combined-%DATE%.log",
datePattern: "YYYY-MM-DD",
maxSize: "20m",
maxFiles: "14d",
zippedArchive: true,
}),
// Daily rotating error logs
new winston.transports.DailyRotateFile({
filename: "logs/error-%DATE%.log",
datePattern: "YYYY-MM-DD",
level: "error",
maxSize: "20m",
maxFiles: "30d",
zippedArchive: true,
}),
],
});
// Console transport for non-production
if (process.env.NODE_ENV !== "production") {
logger.add(new winston.transports.Console({
format: devFormat,
}));
}
module.exports = logger;
6. Pino — The Fastest Logger
Pino is designed for maximum performance with minimal overhead. It is significantly faster than Winston because it uses asynchronous logging and avoids expensive string operations.
Installation
npm install pino
npm install pino-pretty --save-dev # Human-readable output for development
Basic Setup
const pino = require("pino");
// Production — JSON output (default)
const logger = pino({
level: process.env.LOG_LEVEL || "info",
});
// Development — readable output with pino-pretty
const logger = pino({
level: "debug",
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
});
module.exports = logger;
Using Pino
const logger = require("./logger");
logger.info("Server started on port 3000");
logger.info({ userId: "abc123", action: "login" }, "User logged in");
logger.error({ err, orderId: "12345" }, "Order processing failed");
logger.warn({ queryTime: 5200 }, "Slow database query");
// Child loggers — add persistent context
const requestLogger = logger.child({ requestId: "req-abc-123" });
requestLogger.info("Processing request");
requestLogger.info("Fetching user data");
// Both logs include requestId automatically
Pino Output
{"level":30,"time":1718457825123,"msg":"Server started on port 3000"}
{"level":30,"time":1718457825456,"msg":"User logged in","userId":"abc123","action":"login"}
{"level":50,"time":1718457825789,"msg":"Order processing failed","err":{"type":"Error","message":"..."},"orderId":"12345"}
Pino vs Winston
| Feature | Pino | Winston |
|---|---|---|
| Speed | ~5x faster | Slower |
| Format | JSON only (use pino-pretty for dev) | Multiple built-in formats |
| Transports | Separate process (pino-transport) | Built-in |
| Child loggers | First-class support | Supported |
| Ecosystem | Growing | Largest |
| Best for | High-throughput APIs | General-purpose |
Pino with Express (pino-http)
npm install pino-http
const pinoHttp = require("pino-http");
const logger = require("./logger");
app.use(pinoHttp({ logger }));
// Every request is automatically logged:
// {"level":30,"time":...,"req":{"method":"GET","url":"/api/users"},"res":{"statusCode":200},"responseTime":45}
7. Morgan — HTTP Request Logger
Morgan is Express middleware specifically for logging HTTP requests. It is not a general-purpose logger — use it alongside Winston or Pino.
Installation
npm install morgan
Predefined Formats
const morgan = require("morgan");
// dev — colorized, concise (development)
app.use(morgan("dev"));
// GET /api/users 200 45.123 ms - 1234
// combined — Apache combined log format (production)
app.use(morgan("combined"));
// ::1 - - [15/Jun/2025:14:23:45 +0000] "GET /api/users HTTP/1.1" 200 1234 "-" "Mozilla/5.0..."
// short — shorter than combined
app.use(morgan("short"));
// ::1 - GET /api/users HTTP/1.1 200 1234 - 45.123 ms
// tiny — minimal
app.use(morgan("tiny"));
// GET /api/users 200 1234 - 45.123 ms
| Format | Output | Best For |
|---|---|---|
dev | Colorized, concise status | Development |
combined | Full Apache format | Production (standard) |
common | Apache common (no referrer/agent) | Production (minimal) |
short | Shorter than combined | General |
tiny | Minimal (method, url, status, time) | Simple apps |
Custom Morgan Tokens
const morgan = require("morgan");
// Register a custom token
morgan.token("userId", (req) => req.user ? req.user._id : "anonymous");
morgan.token("requestId", (req) => req.id || "-");
morgan.token("body", (req) => {
if (req.method === "POST" || req.method === "PUT") {
const safe = { ...req.body };
delete safe.password; // Never log passwords
delete safe.token;
return JSON.stringify(safe);
}
return "-";
});
// Use custom tokens in a format string
app.use(morgan(
":requestId :method :url :status :response-time ms - :userId :body"
));
// req-abc-123 POST /api/login 200 125 ms - user456 {"email":"alice@example.com"}
Integrating Morgan with Winston
The best practice is to pipe Morgan's HTTP logs through Winston so all logs go through a single system:
const morgan = require("morgan");
const logger = require("./logger"); // Winston logger
// Create a write stream that sends to Winston
const morganStream = {
write: (message) => {
logger.http(message.trim());
},
};
// Use Morgan with the Winston stream
app.use(morgan("combined", { stream: morganStream }));
// Now HTTP request logs appear in Winston's output
// alongside all other application logs
Skip Logging for Certain Requests
// Skip health check endpoints
app.use(morgan("combined", {
stream: morganStream,
skip: (req, res) => {
return req.url === "/health" || req.url === "/ready";
},
}));
// Only log errors (status >= 400)
app.use(morgan("combined", {
stream: morganStream,
skip: (req, res) => res.statusCode < 400,
}));
8. Combining Libraries — Complete Setup
// config/logger.js — Winston setup (see section 5 above)
const logger = require("./config/logger");
// app.js — Express application
const express = require("express");
const morgan = require("morgan");
const logger = require("./config/logger");
const app = express();
// Morgan → Winston integration for HTTP logs
app.use(morgan("combined", {
stream: { write: (msg) => logger.http(msg.trim()) },
skip: (req) => req.url === "/health",
}));
// Middleware, routes, etc.
app.use(express.json());
// Use logger throughout the application
app.get("/api/users", async (req, res) => {
logger.info("Fetching users", { query: req.query });
try {
const users = await User.find(req.query);
logger.debug("Users fetched", { count: users.length });
res.json(users);
} catch (err) {
logger.error("Failed to fetch users", { error: err.message, stack: err.stack });
res.status(500).json({ error: "Internal server error" });
}
});
// Log server startup
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
logger.info("Server started", { port: PORT, env: process.env.NODE_ENV });
});
9. Comparison Table
| Feature | Winston | Pino | Morgan |
|---|---|---|---|
| Type | General logger | General logger | HTTP request logger |
| Speed | Moderate | Fastest (~5x Winston) | N/A (middleware) |
| Formats | JSON, simple, custom | JSON (pino-pretty for dev) | Predefined strings |
| Transports | File, console, HTTP, etc. | Separate process | Stream |
| Daily rotation | Plugin (winston-daily-rotate-file) | External (logrotate) | N/A |
| Child loggers | Yes | Yes (first-class) | N/A |
| Express integration | Manual | pino-http | Built-in middleware |
| Community | Largest | Growing fast | Widely used |
| Best for | General-purpose logging | High-performance APIs | HTTP access logs |
Which to Choose?
Decision tree:
├── Need HTTP request logging only?
│ └── Use Morgan (+ pipe to Winston/Pino)
├── High-throughput API (>10K req/s)?
│ └── Use Pino
├── General-purpose with rich ecosystem?
│ └── Use Winston
└── Most common combination?
└── Winston + Morgan (Morgan for HTTP, Winston for everything else)
Key Takeaways
- Winston is the most popular Node.js logger — flexible transports, formats, and the largest ecosystem
- Pino is the fastest — use it for high-throughput applications where logging overhead matters
- Morgan is HTTP-only — use it alongside Winston or Pino, not as a standalone logger
- Daily rotate files to prevent disk space issues — use
winston-daily-rotate-file - Pipe Morgan through Winston so all logs flow through one system
- Use JSON format in production for structured, searchable logs
- Use colorized/pretty format in development for readability
- Child loggers (Pino) are ideal for request-scoped context
- Skip health check and static asset logging to reduce noise
Explain-It Challenge
You are setting up logging for a production Express API that handles 5,000 requests per minute. Design the complete logging configuration: which library (or combination) would you choose and why? How would you configure transports for different environments? How would you handle log rotation? How would you ensure HTTP request logs and application logs are unified? Show the complete setup code for
logger.jsand its integration intoapp.js.