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:

TransportDescriptionUse Case
ConsoleTerminal outputDevelopment
FileWrite to fileAll environments
HttpSend to HTTP endpointLog aggregation services
StreamWrite to any Node.js streamCustom 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

FeaturePinoWinston
Speed~5x fasterSlower
FormatJSON only (use pino-pretty for dev)Multiple built-in formats
TransportsSeparate process (pino-transport)Built-in
Child loggersFirst-class supportSupported
EcosystemGrowingLargest
Best forHigh-throughput APIsGeneral-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
FormatOutputBest For
devColorized, concise statusDevelopment
combinedFull Apache formatProduction (standard)
commonApache common (no referrer/agent)Production (minimal)
shortShorter than combinedGeneral
tinyMinimal (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

FeatureWinstonPinoMorgan
TypeGeneral loggerGeneral loggerHTTP request logger
SpeedModerateFastest (~5x Winston)N/A (middleware)
FormatsJSON, simple, customJSON (pino-pretty for dev)Predefined strings
TransportsFile, console, HTTP, etc.Separate processStream
Daily rotationPlugin (winston-daily-rotate-file)External (logrotate)N/A
Child loggersYesYes (first-class)N/A
Express integrationManualpino-httpBuilt-in middleware
CommunityLargestGrowing fastWidely used
Best forGeneral-purpose loggingHigh-performance APIsHTTP 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

  1. Winston is the most popular Node.js logger — flexible transports, formats, and the largest ecosystem
  2. Pino is the fastest — use it for high-throughput applications where logging overhead matters
  3. Morgan is HTTP-only — use it alongside Winston or Pino, not as a standalone logger
  4. Daily rotate files to prevent disk space issues — use winston-daily-rotate-file
  5. Pipe Morgan through Winston so all logs flow through one system
  6. Use JSON format in production for structured, searchable logs
  7. Use colorized/pretty format in development for readability
  8. Child loggers (Pino) are ideal for request-scoped context
  9. 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.js and its integration into app.js.