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 ecosystemBasic 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 outputBasic 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 sizeConfiguration:
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:
- Start with console.log: Learn Node.js fundamentals
- Add Winston: Multiple transports and flexible configuration
- 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.