Structured Logging — Winston, Morgan, Correlation IDs, and AsyncLocalStorage

Structured logging transforms your application’s runtime behaviour from an opaque black box into an observable, queryable stream of events. Instead of console.log("user logged in") scattered through the codebase, structured logging produces machine-readable JSON events with consistent fields — timestamp, level, request ID, user ID, duration, error details — that can be indexed, aggregated, and alerted on by a log management system. Winston is the most widely used Node.js logging library; combined with Morgan for HTTP request logging and a correlation ID middleware for distributed tracing, it provides production-grade observability.

Log Level Strategy

Level Use For Production Volume
error Unhandled errors, failed operations that affect users Every error, always logged
warn Degraded behaviour, deprecated usage, retried operations Always logged
info Key business events: user login, payment, order created Always logged
http HTTP request/response (via Morgan) All requests in production
debug Detailed diagnostic: query params, function inputs/outputs Off in production — too verbose
verbose Step-by-step execution traces Off in production
Note: Structured logs use JSON format with consistent field names. Every log entry should include at minimum: timestamp, level, message, and service. Request-scoped logs should add requestId, userId, method, path, and duration. Error logs should add error.message, error.stack, and error.code. Log aggregation tools (Datadog, CloudWatch, ELK) can then filter, group, and alert on any of these fields.
Tip: Use a correlation ID (also called request ID or trace ID) to link all log entries from a single HTTP request. Generate a UUID at request entry (or read it from an X-Request-ID header), store it in AsyncLocalStorage, and automatically include it in every log entry made during that request’s lifetime. When debugging a specific user’s error, filtering logs by their correlation ID shows the complete request context — all database queries, cache misses, and function calls — in chronological order.
Warning: Never log sensitive data — passwords, JWT tokens, credit card numbers, PII like social security numbers, or full request bodies that may contain these. Create a sanitise function that redacts known sensitive keys before logging. Even internal logs can become a security liability if they are stored in a log aggregation service that multiple engineers can access. Apply the principle of minimum necessary information to every log entry.

Complete Winston Logging Setup

// src/config/logger.js — production-grade Winston logger
const winston = require('winston');
const { AsyncLocalStorage } = require('async_hooks');

// ── Async context for correlation IDs ─────────────────────────────────────
const asyncLocalStorage = new AsyncLocalStorage();

function getRequestContext() {
    return asyncLocalStorage.getStore() || {};
}

// ── Custom log format ──────────────────────────────────────────────────────
const logFormat = winston.format.combine(
    winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
    winston.format.errors({ stack: true }),  // include stack traces
    winston.format.splat(),
    winston.format.printf(({ timestamp, level, message, stack, ...meta }) => {
        const ctx     = getRequestContext();
        const context = Object.keys(ctx).length > 0 ? ctx : {};
        const entry   = {
            timestamp,
            level,
            message,
            service: process.env.SERVICE_NAME || 'taskmanager-api',
            env:     process.env.NODE_ENV || 'development',
            ...context,   // requestId, userId, method, path from AsyncLocalStorage
            ...meta,      // any additional metadata passed to the log call
        };
        if (stack) entry.error = { stack };
        return JSON.stringify(entry);
    }),
);

// ── Console format for development ────────────────────────────────────────
const devFormat = winston.format.combine(
    winston.format.colorize(),
    winston.format.timestamp({ format: 'HH:mm:ss' }),
    winston.format.printf(({ timestamp, level, message, ...meta }) => {
        const ctx   = getRequestContext();
        const reqId = ctx.requestId ? ` [${ctx.requestId.slice(0,8)}]` : '';
        const extra = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
        return `${timestamp}${reqId} ${level}: ${message}${extra}`;
    }),
);

// ── Create logger ─────────────────────────────────────────────────────────
const logger = winston.createLogger({
    level:      process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
    transports: [
        new winston.transports.Console({
            format: process.env.NODE_ENV === 'production' ? logFormat : devFormat,
        }),
        // File transport in production (or use log aggregation service)
        ...(process.env.NODE_ENV === 'production' ? [
            new winston.transports.File({
                filename: 'logs/error.log',
                level:    'error',
                format:   logFormat,
                maxsize:  10 * 1024 * 1024,   // 10MB per file
                maxFiles: 5,
                tailable:  true,
            }),
        ] : []),
    ],
});

// ── Correlation ID middleware ─────────────────────────────────────────────
const { v4: uuidv4 } = require('uuid');

function correlationMiddleware(req, res, next) {
    const requestId = req.headers['x-request-id'] || uuidv4();
    res.setHeader('X-Request-ID', requestId);

    // Store context in AsyncLocalStorage — automatically available in all async code
    asyncLocalStorage.run({
        requestId,
        userId: null,   // populated after auth middleware
    }, () => {
        next();
    });
}

// Set userId after auth middleware
function setLogUser(req) {
    const store = asyncLocalStorage.getStore();
    if (store && req.user?.sub) {
        store.userId = req.user.sub;
    }
}

module.exports = { logger, correlationMiddleware, setLogUser, asyncLocalStorage };

// ── HTTP request logging with Morgan ─────────────────────────────────────
// src/config/morgan.js
const morgan = require('morgan');
const { logger } = require('./logger');

const stream = {
    write: (message) => {
        logger.http(message.trim());
    },
};

const morganFormat = ':method :url :status :res[content-length] - :response-time ms';

// Skip health check logs (too noisy)
const skip = (req) => req.url === '/api/v1/health';

module.exports = morgan(morganFormat, { stream, skip });

// ── App integration ───────────────────────────────────────────────────────
// src/app.js
const app = require('express')();
const { correlationMiddleware, setLogUser, logger } = require('./config/logger');
const morganMiddleware = require('./config/morgan');

app.use(correlationMiddleware);   // must be first
app.use(morganMiddleware);

// After auth middleware, set user ID in log context
app.use((req, res, next) => {
    if (req.user) setLogUser(req);
    next();
});

// Example structured log in a route handler
app.post('/api/v1/tasks', async (req, res) => {
    const start = Date.now();
    try {
        const task = await Task.create({ ...req.body, user: req.user.sub });
        logger.info('Task created', {
            taskId:   task._id,
            userId:   req.user.sub,
            priority: task.priority,
            duration: Date.now() - start,
        });
        res.status(201).json({ success: true, data: task });
    } catch (err) {
        logger.error('Task creation failed', {
            error:    { message: err.message, code: err.code },
            userId:   req.user?.sub,
            body:     { title: req.body.title },  // sanitised — no sensitive fields
            duration: Date.now() - start,
        });
        throw err;
    }
});

// ── Global error logger ───────────────────────────────────────────────────
app.use((err, req, res, next) => {
    logger.error('Unhandled request error', {
        error:  { message: err.message, stack: err.stack, statusCode: err.statusCode },
        method: req.method,
        path:   req.path,
        userId: req.user?.sub,
    });
    // respond...
});

// Process-level error logging
process.on('unhandledRejection', (reason) => {
    logger.error('Unhandled promise rejection', {
        error: { message: reason?.message, stack: reason?.stack },
    });
});

process.on('uncaughtException', (err) => {
    logger.error('Uncaught exception — shutting down', {
        error: { message: err.message, stack: err.stack },
    });
    process.exit(1);
});

How It Works

Step 1 — AsyncLocalStorage Propagates Context Without Passing It Explicitly

AsyncLocalStorage provides a way to store data that is automatically available to all async code within a context. When asyncLocalStorage.run({ requestId }, callback) is called in the middleware, the store is accessible via asyncLocalStorage.getStore() in every function called within that middleware chain — including Mongoose query callbacks, downstream service calls, and error handlers. This enables automatic request ID inclusion in all log entries without passing the ID through every function call.

Step 2 — JSON Format Enables Log Aggregation and Querying

JSON-formatted logs can be indexed by field in Elasticsearch, queried with CloudWatch Insights, parsed by Datadog, or processed by any log aggregation tool. A query like { "requestId": "abc-123" } retrieves every log entry from a specific request. { "level": "error", "userId": "user-456" } shows all errors encountered by a specific user. This querying capability turns logs from a stream of text into a structured audit trail.

Step 3 — Morgan Logs HTTP Requests to Winston

Morgan is an HTTP request logger middleware. By providing a custom stream that writes to logger.http(), Morgan’s output goes through Winston’s transport chain rather than directly to stdout. This means HTTP request logs are formatted consistently with application logs, include the correlation ID from AsyncLocalStorage, and are routed to the same log aggregation destination. The skip function prevents health check polling (every 30 seconds) from flooding the logs.

Step 4 — Structured Error Logging Enables Alerting

Including a consistent error object structure in all error logs ({ message, stack, code, statusCode }) allows log aggregation tools to extract error metrics automatically. Datadog can create an alert when level=error events exceed 10 per minute. Elasticsearch can aggregate by error.message to find the most common error types. CloudWatch Metric Filters can count errors and publish them to dashboards. None of this is possible with unstructured console.error messages.

Step 5 — Process-Level Handlers Catch All Errors

unhandledRejection fires when a Promise rejection is not caught. uncaughtException fires when an error is thrown outside a try-catch. Both are global last-resort handlers. The uncaught exception handler logs the error and calls process.exit(1) — the application cannot continue safely after an unexpected exception. Log aggregation alerts on uncaughtException entries trigger on-call notifications for immediate investigation.

Common Mistakes

Mistake 1 — Logging sensitive data

❌ Wrong — password and token in logs:

logger.info('Login attempt', { body: req.body });
// Logs: { "body": { "email": "user@example.com", "password": "MyPassword123!" } }

✅ Correct — only log non-sensitive fields:

logger.info('Login attempt', { email: req.body.email });
// Logs: { "email": "user@example.com" } — no password

Mistake 2 — Using console.log instead of the structured logger

❌ Wrong — unstructured, no correlation ID, no log level:

console.log('Task created:', task._id);
// Output: "Task created: 64a1..."  — no timestamp, no request ID, no level

✅ Correct — structured logger with context:

logger.info('Task created', { taskId: task._id, userId: req.user.sub });
// Output: { "timestamp": "...", "level": "info", "requestId": "...", ... }

Mistake 3 — Debug logging enabled in production

❌ Wrong — debug logs slow down the application and leak internal details:

const logger = winston.createLogger({ level: 'debug' });
// In production: logs every MongoDB query, every function call — performance hit!

✅ Correct — use environment-controlled log level:

const logger = winston.createLogger({
    level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
});

Quick Reference

Task Code
Create logger winston.createLogger({ level, transports })
Log with metadata logger.info('message', { key: value })
Log error with stack logger.error('msg', { error: { message: e.message, stack: e.stack } })
Correlation ID asyncLocalStorage.run({ requestId }, () => next())
Get context asyncLocalStorage.getStore()
Morgan to Winston morgan(format, { stream: { write: msg => logger.http(msg) } })
Skip health checks morgan(format, { skip: req => req.url === '/health' })
JSON production format winston.format.combine(timestamp(), printf(entry => JSON.stringify(entry)))
Catch unhandled rejections process.on('unhandledRejection', reason => logger.error(...))

🧠 Test Yourself

A route handler calls three service functions. All three make log entries. An error is reported by a user. How does a correlation ID help debug the issue, and what mechanism makes it available in all three service functions without passing it as a parameter?