Skip to content
AyoKoding

Logging

Why Logging Matters

Logging provides runtime visibility into application behavior, enabling debugging production issues, monitoring system health, tracking user actions, and meeting compliance requirements.

Core Benefits:

  • Debug production issues: Reproduce and fix bugs from logs
  • Monitor system health: Track errors, performance, resource usage
  • Audit trail: Compliance and security tracking
  • Alerting: Detect critical issues automatically
  • Performance analysis: Identify bottlenecks and slow operations

Problem: console.log lacks log levels, structured data, rotation, and integration with log aggregation systems.

Solution: Use production logging frameworks (Winston, Pino) that provide log levels, structured logging, multiple transports, and performance optimizations.

Standard Library First: console.log

Node.js provides the console object for basic logging without external dependencies.

Basic Console Logging

The console module writes text output to stdout (standard output) and stderr (standard error).

Pattern:

// Basic logging
console.log("Application started");
// => Writes to stdout
// => Timestamp not included
// => No log level
 
const user = { id: 1, name: "Alice", email: "alice@example.com" };
console.log("User created:", user);
// => Outputs: User created: { id: 1, name: 'Alice', email: 'alice@example.com' }
// => Objects formatted automatically
 
// Error logging
try {
  const result = riskyOperation();
  console.log("Operation succeeded:", result);
} catch (error) {
  console.error("Operation failed:", error);
  // => Writes to stderr (standard error)
  // => Error objects show stack trace
}
 
// Warning logging
if (diskSpace < threshold) {
  console.warn("Low disk space:", diskSpace, "MB");
  // => Writes to stderr
  // => Visual distinction in terminal (yellow)
}
 
// Debugging with console.debug
console.debug("Debug info:", { step: 1, value: 42 });
// => Only shown if log level supports debug
// => Node.js: same as console.log

Console methods:

console.log("Info message");
// => General information
// => stdout
 
console.error("Error message");
// => Error conditions
// => stderr, red in terminal
 
console.warn("Warning message");
// => Warning conditions
// => stderr, yellow in terminal
 
console.debug("Debug message");
// => Detailed debugging
// => stdout
 
console.info("Info message");
// => Alias for console.log
// => stdout
 
console.trace("Trace message");
// => Includes stack trace
// => Shows call location

Structured logging attempt:

// Manual JSON formatting
const logEntry = {
  timestamp: new Date().toISOString(),
  // => ISO 8601 timestamp
  level: "info",
  // => Manual log level
  message: "User login",
  // => Log message
  userId: 123,
  // => Structured data
  ip: "192.168.1.1",
  // => Additional context
};
 
console.log(JSON.stringify(logEntry));
// => Output: {"timestamp":"2026-02-07T10:00:00.000Z","level":"info",...}
// => Manual JSON formatting
// => Parseable by log aggregators

Limitations for production:

  • No log levels: Cannot filter by severity (debug vs error)
  • No log rotation: Logs grow indefinitely, filling disk
  • No structured logging: Text-only, hard to parse and query
  • No transports: Cannot send logs to files, databases, or services
  • No filtering: All logs always written (performance impact)
  • No context: No correlation IDs, request IDs, or trace IDs
  • No performance optimization: Synchronous writes block event loop

When standard library suffices:

  • Small scripts (≤100 lines)
  • Development debugging (not production)
  • Learning Node.js fundamentals
  • Logs go to stdout only (Docker/Kubernetes capture)

Production Framework: Winston

Winston is a mature, feature-rich logging library with multiple transports, log levels, and flexible configuration.

Installation and Basic Setup

npm install winston
# => Install Winston logging library
# => Mature, widely adopted (10M+ weekly downloads)
# => Extensive transport ecosystem

Basic configuration:

import winston from "winston";
// => Import Winston library
// => Provides logger factory and transports
 
const logger = winston.createLogger({
  // => Create logger instance
  // => Centralized configuration
  level: "info",
  // => Minimum log level (debug < info < warn < error)
  // => Logs at 'info' level and above written
  format: winston.format.combine(
    // => Combine multiple formatters
    // => Applied in order
    winston.format.timestamp(),
    // => Add timestamp to each log
    // => ISO 8601 format by default
    winston.format.errors({ stack: true }),
    // => Include stack traces for errors
    // => Preserves error properties
    winston.format.json(),
    // => Format as JSON
    // => Structured, parseable logs
  ),
  transports: [
    // => Output destinations
    // => Multiple transports supported
    new winston.transports.Console(),
    // => Write to stdout/stderr
    // => Good for Docker/K8s environments
    new winston.transports.File({ filename: "error.log", level: "error" }),
    // => Error logs to error.log
    // => Only 'error' level and above
    new winston.transports.File({ filename: "combined.log" }),
    // => All logs to combined.log
    // => Full log history
  ],
});
 
// Usage
logger.info("Application started", { port: 3000 });
// => Log at info level
// => Output: {"level":"info","message":"Application started","port":3000,"timestamp":"..."}
 
logger.error("Database connection failed", { error: new Error("Connection timeout") });
// => Log at error level
// => Includes stack trace from error

Log Levels

Winston supports multiple severity levels for filtering and routing logs.

Standard log levels:

logger.error("Critical error", { userId: 123 });
// => Highest priority
// => System errors, exceptions
// => Always logged in production
 
logger.warn("Degraded performance", { responseTime: 5000 });
// => Warning conditions
// => Potential issues
// => Requires attention but not immediate
 
logger.info("User login", { userId: 123 });
// => General information
// => Business events
// => Normal operation
 
logger.http("GET /api/users 200 OK", { duration: 45 });
// => HTTP request logging
// => Access logs
// => Request/response tracking
 
logger.verbose("Cache hit", { key: "user:123" });
// => Verbose debugging
// => More detail than debug
// => Development/troubleshooting
 
logger.debug("Variable state", { count: 42, items: [] });
// => Debugging information
// => Development only
// => Not logged in production
 
logger.silly("Entering function", { args: [1, 2, 3] });
// => Extremely detailed
// => Rarely used
// => Development/debugging

Level hierarchy:

// Levels (highest to lowest priority)
// error > warn > info > http > verbose > debug > silly
 
// If logger.level = 'info':
logger.error("Logged"); // ✅ Logged (error >= info)
logger.warn("Logged"); // ✅ Logged (warn >= info)
logger.info("Logged"); // ✅ Logged (info == info)
logger.debug("Not logged"); // ❌ Not logged (debug < info)

Structured Logging

Winston supports structured data for queryable, filterable logs.

Pattern:

// Poor: String concatenation
logger.info("User Alice logged in from 192.168.1.1");
// => Hard to query by user or IP
// => Text parsing required
 
// Better: Structured metadata
logger.info("User login", {
  userId: 123,
  // => Queryable field
  username: "Alice",
  // => Queryable field
  ip: "192.168.1.1",
  // => Queryable field
  timestamp: new Date().toISOString(),
  // => ISO 8601 timestamp
});
// => Output: {"level":"info","message":"User login","userId":123,"username":"Alice",...}
// => Each field queryable in log aggregation systems

Contextual logging with child loggers:

// Create child logger with default metadata
const requestLogger = logger.child({
  requestId: "req-abc123",
  // => Correlation ID
  // => Tracks logs across services
  userId: 456,
  // => User context
});
 
requestLogger.info("Processing payment");
// => Output: {"level":"info","message":"Processing payment","requestId":"req-abc123","userId":456,...}
// => requestId and userId included automatically
 
requestLogger.error("Payment failed", { amount: 100, reason: "Insufficient funds" });
// => Output: {"level":"error","message":"Payment failed","requestId":"req-abc123","userId":456,"amount":100,...}
// => Context preserved across log statements

Custom Formats

Winston supports custom log formatting for different environments.

Development format (human-readable):

const devLogger = winston.createLogger({
  level: "debug",
  // => More verbose in development
  format: winston.format.combine(
    winston.format.colorize(),
    // => Add colors for readability
    // => Error = red, warn = yellow, info = green
    winston.format.simple(),
    // => Human-readable format
    // => Not JSON
  ),
  transports: [new winston.transports.Console()],
});
 
devLogger.info("Server started", { port: 3000 });
// => Output: info: Server started {"port":3000}
// => Colored, readable

Production format (JSON):

const prodLogger = winston.createLogger({
  level: "info",
  // => Less verbose in production
  format: winston.format.combine(
    winston.format.timestamp(),
    // => ISO 8601 timestamp
    winston.format.errors({ stack: true }),
    // => Error stack traces
    winston.format.json(),
    // => JSON for log aggregation
  ),
  transports: [new winston.transports.Console(), new winston.transports.File({ filename: "app.log" })],
});
 
prodLogger.info("Server started", { port: 3000 });
// => Output: {"level":"info","message":"Server started","port":3000,"timestamp":"2026-02-07T10:00:00.000Z"}
// => Structured JSON

Production Benefits

  • Log levels: Filter logs by severity (debug/info/warn/error)
  • Multiple transports: Console, file, HTTP, database
  • Structured logging: Queryable JSON metadata
  • Error handling: Automatic stack trace capture
  • Child loggers: Contextual logging with default metadata
  • Custom formats: Human-readable (dev) vs JSON (prod)
  • Performance: Asynchronous logging (non-blocking)

Trade-offs

  • External dependency: 2MB (Winston + dependencies)
  • Configuration complexity: Many options and transports
  • Performance overhead: Slower than console.log (features cost)

Production Framework: Pino

Pino is an extremely fast JSON logger optimized for performance with minimal overhead.

Installation and Setup

npm install pino
# => Install Pino logging library
# => Fastest Node.js logger (5-10x faster than Winston)
# => JSON-only output

Basic configuration:

import pino from "pino";
// => Import Pino logger
// => Performance-optimized
 
const logger = pino({
  // => Create logger instance
  level: "info",
  // => Minimum log level
  transport: {
    // => Transport configuration
    target: "pino-pretty",
    // => Pretty-print transport (development)
    // => Human-readable colored output
    options: {
      colorize: true,
      // => Colored output
      translateTime: "HH:MM:ss",
      // => Human-readable timestamps
      ignore: "pid,hostname",
      // => Hide pid and hostname
    },
  },
});
 
logger.info("Application started");
// => Output: [10:00:00] INFO: Application started
 
logger.info({ port: 3000 }, "Server listening");
// => Output: [10:00:00] INFO: Server listening
// =>         port: 3000
// => First argument: metadata object
// => Second argument: message

Production configuration (JSON output):

const logger = pino({
  // => Production logger (no transport)
  level: process.env.LOG_LEVEL || "info",
  // => Log level from environment variable
  // => Default: info
  formatters: {
    // => Custom formatters
    level(label) {
      // => Format log level
      return { level: label };
      // => level: 'info' instead of level: 30
    },
  },
  timestamp: pino.stdTimeFunctions.isoTime,
  // => ISO 8601 timestamps
  // => Compatible with log aggregators
});
 
logger.info({ userId: 123 }, "User login");
// => Output: {"level":"info","time":"2026-02-07T10:00:00.000Z","msg":"User login","userId":123}
// => Pure JSON (no pretty printing)

Structured Logging with Pino

Pino enforces JSON-only output for consistent structured logging.

Pattern (metadata first, message second):

// Correct: Metadata object first, message second
logger.info({ userId: 123, action: "login" }, "User authenticated");
// => Output: {"level":"info","userId":123,"action":"login","msg":"User authenticated"}
 
// Incorrect: Message first (ignored)
logger.info("User authenticated", { userId: 123 });
// => Output: {"level":"info","msg":"User authenticated"}
// => Metadata IGNORED (not in first position)

Child loggers with context:

const requestLogger = logger.child({
  requestId: "req-abc123",
  // => Request correlation ID
  userId: 456,
  // => User context
});
 
requestLogger.info({ action: "payment" }, "Processing");
// => Output: {"level":"info","requestId":"req-abc123","userId":456,"action":"payment","msg":"Processing"}
// => Context inherited from parent
 
requestLogger.error({ error: new Error("Failed"), amount: 100 }, "Payment failed");
// => Output: {"level":"error","requestId":"req-abc123","userId":456,"error":{...},"amount":100,"msg":"Payment failed"}
// => Error serialized automatically

Extreme Performance

Pino achieves 5-10x faster logging than Winston through optimizations.

Asynchronous logging (non-blocking):

import pino from "pino";
 
const logger = pino(
  pino.destination({
    // => Asynchronous file destination
    dest: "./app.log",
    // => Log file path
    sync: false,
    // => Asynchronous writes (non-blocking)
    minLength: 4096,
    // => Buffer size (4KB)
    // => Batch writes for performance
  }),
);
 
logger.info({ userId: 123 }, "User login");
// => Returns immediately (non-blocking)
// => Write buffered and batched

Serializers for expensive operations:

const logger = pino({
  serializers: {
    // => Custom serializers
    user: (user) => ({
      // => Serialize user object
      id: user.id,
      // => Only log id and name
      name: user.name,
      // => Exclude sensitive fields (email, password)
    }),
    error: pino.stdSerializers.err,
    // => Standard error serializer
    // => Includes message, stack, type
  },
});
 
logger.info({ user: { id: 123, name: "Alice", email: "alice@example.com" } }, "User created");
// => Output: {"level":"info","user":{"id":123,"name":"Alice"},"msg":"User created"}
// => Email excluded by serializer

Production Benefits

  • Extreme performance: 5-10x faster than Winston
  • JSON-only: Enforced structured logging
  • Asynchronous: Non-blocking writes
  • Low overhead: Minimal CPU and memory impact
  • Child loggers: Efficient context propagation
  • Serializers: Control what gets logged

Trade-offs

  • JSON-only: No human-readable format (without transport)
  • Less flexible: Fewer transports than Winston
  • Pino-specific: Different API than Winston

When to use Pino

  • High-throughput applications (>1000 req/sec)
  • Performance-critical systems (minimize logging overhead)
  • Microservices (JSON logs to aggregator)
  • Kubernetes/Docker (structured logs required)

Logging Framework Progression Diagram

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
%% All colors are color-blind friendly and meet WCAG AA contrast standards
 
graph TB
    A[console.log] -->|Need levels| B[Winston]
    A -->|Need performance| C[Pino]
 
    A:::standard
    B:::framework
    C:::framework
 
    classDef standard fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef framework fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
 
    subgraph Standard[" Standard Library "]
        A
    end
 
    subgraph Production[" Production Frameworks "]
        B
        C
    end
 
    style Standard fill:#F0F0F0,stroke:#0173B2,stroke-width:3px
    style Production fill:#F0F0F0,stroke:#029E73,stroke-width:3px

Production Best Practices

Environment-Specific Logging

Configure different log levels and formats for development vs production.

Pattern:

import winston from "winston";
 
const isDev = process.env.NODE_ENV === "development";
// => Check environment
 
const logger = winston.createLogger({
  level: isDev ? "debug" : "info",
  // => Verbose in dev, concise in prod
  format: winston.format.combine(
    winston.format.timestamp(),
    isDev
      ? winston.format.simple()
      : // => Human-readable in dev
        winston.format.json(),
    // => JSON in prod (log aggregation)
  ),
  transports: [
    isDev
      ? new winston.transports.Console({ format: winston.format.colorize() })
      : // => Colored console in dev
        new winston.transports.Console(),
    // => Plain console in prod (Docker captures)
  ],
});

Log Rotation

Prevent log files from growing indefinitely and filling disk.

Installation:

npm install winston-daily-rotate-file
# => Winston transport for log rotation
# => Rotates by date and size

Configuration:

import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
 
const logger = winston.createLogger({
  transports: [
    new DailyRotateFile({
      filename: "app-%DATE%.log",
      // => %DATE% replaced with date
      // => Example: app-2026-02-07.log
      datePattern: "YYYY-MM-DD",
      // => One file per day
      maxSize: "20m",
      // => Rotate when file reaches 20MB
      maxFiles: "14d",
      // => Keep logs for 14 days
      // => Older logs deleted automatically
      compress: true,
      // => Gzip old logs (save disk space)
    }),
  ],
});

Correlation IDs

Track requests across microservices with correlation IDs.

Pattern (Express middleware):

import express from "express";
import { v4 as uuidv4 } from "uuid";
import pino from "pino";
 
const logger = pino();
const app = express();
 
app.use((req, res, next) => {
  // => Middleware to add correlation ID
  const correlationId = req.headers["x-correlation-id"] || uuidv4();
  // => Use existing or generate new correlation ID
  req.correlationId = correlationId;
  // => Attach to request object
 
  req.logger = logger.child({ correlationId });
  // => Create child logger with correlation ID
  // => All logs include correlationId automatically
 
  res.setHeader("X-Correlation-ID", correlationId);
  // => Return correlation ID to client
  // => Client can reference in support requests
 
  next();
});
 
app.get("/api/users/:id", async (req, res) => {
  req.logger.info({ userId: req.params.id }, "Fetching user");
  // => Output: {"level":"info","correlationId":"abc-123","userId":"42","msg":"Fetching user"}
 
  try {
    const user = await fetchUser(req.params.id, req.logger);
    // => Pass logger to functions
    req.logger.info({ user }, "User fetched");
    res.json(user);
  } catch (error) {
    req.logger.error({ error }, "Failed to fetch user");
    // => Error includes correlationId
    res.status(500).json({ error: "Internal server error" });
  }
});

Sensitive Data Redaction

Prevent logging passwords, tokens, or PII (Personally Identifiable Information).

Pattern:

import pino from "pino";
 
const logger = pino({
  redact: {
    // => Redaction configuration
    paths: [
      "password",
      // => Redact password field
      "token",
      // => Redact token field
      "creditCard",
      // => Redact credit card
      "user.email",
      // => Redact nested email
      "req.headers.authorization",
      // => Redact authorization header
    ],
    remove: true,
    // => Remove fields entirely (vs [Redacted])
  },
});
 
logger.info(
  {
    user: { id: 123, email: "alice@example.com" },
    password: "secret123",
  },
  "User login",
);
// => Output: {"level":"info","user":{"id":123},"msg":"User login"}
// => email and password removed

Trade-offs and When to Use Each

console.log (Standard Library)

Use when:

  • Small scripts (≤100 lines)
  • Development debugging
  • Logs captured by Docker/Kubernetes
  • No log aggregation required

Avoid when:

  • Production applications (need levels, rotation)
  • Need structured logging (queryable fields)
  • Multiple log destinations (file, database, HTTP)

Winston

Use when:

  • Need multiple transports (console, file, HTTP, database)
  • Migrating from other loggers (similar API)
  • Want flexible configuration
  • Performance not critical (<1000 req/sec)

Avoid when:

  • High-throughput systems (Pino faster)
  • Only need JSON output (Pino simpler)

Pino

Use when:

  • High-throughput applications (>1000 req/sec)
  • Microservices architecture
  • Kubernetes/Docker deployments
  • JSON logs required (log aggregation)
  • Performance critical

Avoid when:

  • Need multiple transports (Winston more flexible)
  • Want human-readable logs without additional tools
  • Team unfamiliar with Pino API

Common Pitfalls

Pitfall 1: Logging in Hot Paths

Problem: Excessive logging in performance-critical code slows application.

Solution: Use appropriate log levels and disable debug logs in production.

// Bad: Debug logging in hot path
for (let i = 0; i < 1000000; i++) {
  logger.debug({ iteration: i }, "Processing item");
  // => 1M log writes (slow!)
}
 
// Good: Conditional debug logging
if (logger.isLevelEnabled("debug")) {
  // => Check if debug enabled before expensive operations
  for (let i = 0; i < 1000000; i++) {
    logger.debug({ iteration: i }, "Processing item");
  }
}
 
// Better: Sample logging
for (let i = 0; i < 1000000; i++) {
  if (i % 10000 === 0) {
    // => Log every 10,000 iterations
    logger.info({ iteration: i, total: 1000000 }, "Processing progress");
  }
}

Pitfall 2: Logging Sensitive Data

Problem: Accidentally logging passwords, tokens, or PII.

Solution: Use serializers and redaction.

// Bad: Logging raw user object
logger.info({ user }, "User created");
// => May include email, password, SSN
 
// Good: Custom serializer
const logger = pino({
  serializers: {
    user: (user) => ({
      id: user.id,
      name: user.name,
      // => Only log safe fields
    }),
  },
});

Pitfall 3: No Log Rotation

Problem: Log files grow indefinitely, filling disk.

Solution: Configure log rotation by date and size.

import DailyRotateFile from "winston-daily-rotate-file";
 
new DailyRotateFile({
  filename: "app-%DATE%.log",
  maxSize: "20m",
  maxFiles: "14d",
  // => Automatic rotation and cleanup
});

Pitfall 4: Synchronous Logging

Problem: Blocking event loop with synchronous writes.

Solution: Use asynchronous transports.

// Pino: Async by default
const logger = pino(pino.destination({ sync: false }));
 
// Winston: Async transport
new winston.transports.File({ filename: "app.log", options: { flags: "a" } });

Summary

Logging provides runtime visibility for debugging, monitoring, and compliance. Standard library console.log lacks levels and structure, while production frameworks provide levels, structured logging, and performance optimizations.

Progression path:

  1. Start with console.log: Learn Node.js fundamentals
  2. Add Winston: Multiple transports and flexible configuration
  3. Optimize with Pino: High-throughput and performance-critical systems

Production checklist:

  • ✅ Log levels configured (info in prod, debug in dev)
  • ✅ Structured logging (JSON with queryable fields)
  • ✅ Correlation IDs (track requests across services)
  • ✅ Log rotation (prevent disk filling)
  • ✅ Sensitive data redaction (no passwords/tokens)
  • ✅ Environment-specific config (dev vs prod)
  • ✅ Asynchronous logging (non-blocking)

Choose logging framework based on project needs: Winston for flexibility, Pino for performance.

Last updated February 6, 2026

Command Palette

Search for a command to run...