Request Logging with Morgan and Audit Trails

Logging is not an afterthought โ€” it is infrastructure. Without good request logs, debugging a production issue means flying blind. Without an audit trail, a security incident is impossible to investigate. Morgan is the standard HTTP request logger for Express, providing instant visibility into every request your API receives. Winston is the general-purpose structured logger for application-level events โ€” errors, warnings, authentication events, and business-critical operations. Together they give you complete observability: Morgan for the HTTP layer, Winston for the application layer. This lesson builds a complete, production-ready logging system for the MEAN Stack API.

Morgan Log Tokens

Token Value
:method HTTP method โ€” GET, POST, etc.
:url Request URL including query string
:status HTTP response status code
:res[content-length] Response body size in bytes
:response-time Response time in milliseconds
:remote-addr Client IP address
:date[clf] Timestamp in Common Log Format
:referrer Referer header value
:user-agent User-Agent header value
:http-version HTTP protocol version
:req[header] Any request header value
:res[header] Any response header value

Morgan Built-in Formats

Format Output Example Best For
dev GET /api/tasks 200 45 ms (coloured) Local development โ€” quick visual scan
combined Apache Common Log Format โ€” IP, date, method, URL, status, bytes, referrer, user-agent Production โ€” standard log parsing tools
common Combined without referrer and user-agent Minimal production logging
short Method, URL, status, bytes, response time Compact logs
tiny Minimal: method, URL, status, bytes, time High-volume APIs

Winston Log Levels

Level Priority Use For
error 0 (highest) Unhandled exceptions, critical failures
warn 1 Recoverable issues, deprecated usage, near-limit warnings
info 2 Application lifecycle events โ€” server start, connection, user login
http 3 HTTP request/response (Morgan integration)
verbose 4 Detailed flow โ€” useful for debugging specific features
debug 5 Detailed debugging information
silly 6 (lowest) Extremely detailed โ€” rarely used
Note: Structured logging โ€” outputting logs as JSON objects rather than free-form strings โ€” makes logs machine-parseable. Log management tools (Datadog, CloudWatch, ELK Stack, Papertrail) ingest structured JSON and let you filter by any field: level == "error", userId == "64a1f...", responseTime > 1000. Free-form string logs require expensive and fragile regex parsing. Always use structured JSON logging in production.
Tip: Add a unique requestId to every incoming request โ€” a UUID generated in a middleware at the top of the chain. Log this ID with every log statement in that request’s lifecycle. When a user reports an error, they provide the request ID (from the error response), and you can filter all logs for that single request to see exactly what happened, in order, across all middleware and database calls.
Warning: Never log sensitive data: passwords, credit card numbers, full JWT tokens, social security numbers, or personal health information. Audit what your application logs in every environment. Morgan logs the full URL including query strings โ€” if your API uses query params for sensitive data (bad practice, but it happens), those values appear in logs. Use Morgan’s skip option or a custom token sanitiser to redact sensitive query parameters before they are written to log files.

Complete Logging Setup

// npm install morgan winston

const morgan  = require('morgan');
const winston = require('winston');
const { combine, timestamp, json, colorize, printf } = winston.format;

// โ”€โ”€ Winston logger โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const logger = winston.createLogger({
    level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
    format: combine(
        timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
        json()   // structured JSON output
    ),
    transports: [
        // Console โ€” always enabled
        new winston.transports.Console({
            format: process.env.NODE_ENV === 'production'
                ? combine(timestamp(), json())
                : combine(
                    colorize(),
                    printf(({ level, message, timestamp, ...meta }) => {
                        const metaStr = Object.keys(meta).length ? ' ' + JSON.stringify(meta) : '';
                        return `${timestamp} [${level}]: ${message}${metaStr}`;
                    })
                  ),
        }),
        // Error log file โ€” production
        ...(process.env.NODE_ENV === 'production' ? [
            new winston.transports.File({ filename: 'logs/error.log',   level: 'error' }),
            new winston.transports.File({ filename: 'logs/combined.log' }),
        ] : []),
    ],
    exitOnError: false,
});

module.exports = logger;

// โ”€โ”€ Morgan โ€” HTTP request logging โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function createMorganMiddleware() {
    // Custom token: log authenticated user ID
    morgan.token('user-id', req => req.user?.id || 'anonymous');

    // Custom token: request ID (set by requestId middleware)
    morgan.token('request-id', req => req.id || '-');

    // Custom JSON format โ€” feeds into Winston
    const format = JSON.stringify({
        requestId:    ':request-id',
        method:       ':method',
        url:          ':url',
        status:       ':status',
        responseTime: ':response-time ms',
        contentLength:':res[content-length]',
        userId:       ':user-id',
        ip:           ':remote-addr',
        userAgent:    ':user-agent',
    });

    return morgan(format, {
        stream: {
            write: (message) => {
                const log = JSON.parse(message.trim());
                const level = parseInt(log.status, 10) >= 400 ? 'warn' : 'http';
                logger.log(level, 'HTTP Request', log);
            },
        },
        skip: (req) => req.path === '/api/health',  // skip health check spam
    });
}

// โ”€โ”€ Request ID middleware โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const { v4: uuidv4 } = require('uuid');

function requestIdMiddleware(req, res, next) {
    req.id = req.headers['x-request-id'] || uuidv4();
    res.set('X-Request-ID', req.id);   // echo back to client for debugging
    next();
}

// โ”€โ”€ app.js integration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(requestIdMiddleware);            // 1. assign request ID
app.use(createMorganMiddleware());       // 2. log all HTTP requests
// ... other middleware and routes

Audit Trail Logger

// utils/audit.js โ€” structured audit logging for security-relevant events
const logger = require('./logger');

const AuditEvent = {
    USER_REGISTER:       'USER_REGISTER',
    USER_LOGIN:          'USER_LOGIN',
    USER_LOGIN_FAILED:   'USER_LOGIN_FAILED',
    USER_LOGOUT:         'USER_LOGOUT',
    PASSWORD_CHANGE:     'PASSWORD_CHANGE',
    PASSWORD_RESET_REQ:  'PASSWORD_RESET_REQUEST',
    TASK_CREATE:         'TASK_CREATE',
    TASK_DELETE:         'TASK_DELETE',
    ADMIN_ACCESS:        'ADMIN_ACCESS',
    RATE_LIMIT_HIT:      'RATE_LIMIT_HIT',
};

function audit(event, details, req) {
    logger.info('AUDIT', {
        event,
        userId:    req?.user?.id || null,
        ip:        req?.ip,
        requestId: req?.id,
        userAgent: req?.get('User-Agent'),
        timestamp: new Date().toISOString(),
        ...details,
    });
}

module.exports = { audit, AuditEvent };

// โ”€โ”€ Usage in controllers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const { audit, AuditEvent } = require('../utils/audit');

exports.login = asyncHandler(async (req, res) => {
    const { email, password } = req.body;

    try {
        const user = await AuthService.validateCredentials(email, password);

        audit(AuditEvent.USER_LOGIN, { userId: user._id, email }, req);

        const tokens = AuthService.generateTokens(user);
        setRefreshTokenCookie(res, tokens.refreshToken);
        res.json({ success: true, data: { accessToken: tokens.accessToken } });

    } catch (err) {
        audit(AuditEvent.USER_LOGIN_FAILED, { email, reason: err.message }, req);
        throw err;
    }
});

exports.deleteTask = asyncHandler(async (req, res) => {
    const task = await Task.findOneAndDelete({ _id: req.params.id, userId: req.user.id });
    if (!task) throw new NotFoundError('Task not found');

    audit(AuditEvent.TASK_DELETE, { taskId: task._id, taskTitle: task.title }, req);
    res.status(204).end();
});

How It Works

Step 1 โ€” Morgan Logs Every HTTP Request After the Response Is Sent

Morgan attaches a listener to the response’s finish event. This means the log entry is written after the response is sent to the client, and therefore includes the actual response status code, response time, and content length โ€” data that is not available before the handler runs. This makes Morgan logs complete and accurate, capturing both request and response information in a single line.

Step 2 โ€” Piping Morgan into Winston Creates a Unified Log Stream

Morgan’s stream option accepts any object with a write(message) method. By passing a custom stream that calls logger.http(), every Morgan log line flows into Winston. Winston then routes it to all configured transports โ€” console, files, external services. This means your HTTP logs and application logs appear together, in timestamp order, in the same place.

Step 3 โ€” Request IDs Enable Distributed Tracing

Assigning a UUID to each incoming request and attaching it to req.id creates a correlation ID for the entire request lifecycle. Every log statement โ€” from Morgan’s HTTP log, to the authentication middleware log, to the database query log, to the error handler โ€” includes requestId. When debugging a reported issue, filtering logs by request ID shows the complete story of a single request in chronological order.

Step 4 โ€” Audit Logs Record Security-Relevant Events

HTTP request logs tell you “a POST to /auth/login happened.” Audit logs tell you “user alice@example.com logged in successfully from IP 1.2.3.4 at 14:30 UTC.” Audit logs capture the business meaning of operations โ€” who did what, when, from where. They are essential for security incident investigations, compliance requirements (GDPR, SOC 2), and detecting anomalous behaviour like one account logging in from 50 different countries.

Step 5 โ€” Structured JSON Enables Log Query and Alerting

A log management system (Datadog, CloudWatch, Grafana Loki) ingests structured JSON logs and indexes every field. You can query event="USER_LOGIN_FAILED" AND ip="1.2.3.4" to see failed logins from a suspicious IP, or level="error" AND status=500 to find server errors, or responseTime > 2000 to find slow requests. Setting up alerts on these queries gives you automatic notification when something goes wrong in production.

Real-World Example: Log Output in Production

// Structured log output โ€” easy to parse and query

// HTTP request log (from Morgan via Winston)
{"level":"http","message":"HTTP Request","requestId":"a1b2c3d4-...","method":"POST","url":"/api/v1/auth/login","status":"200","responseTime":"143 ms","userId":"64a1f...","ip":"203.0.113.42","timestamp":"2025-07-15T14:30:22.451Z"}

// Audit event log
{"level":"info","message":"AUDIT","event":"USER_LOGIN","userId":"64a1f...","ip":"203.0.113.42","requestId":"a1b2c3d4-...","email":"alice@example.com","timestamp":"2025-07-15T14:30:22.448Z"}

// Error log
{"level":"error","message":"Task not found","requestId":"b5c6d7e8-...","path":"/api/v1/tasks/invalid-id","userId":"64a1f...","stack":"NotFoundError: Task not found\n    at ...","timestamp":"2025-07-15T14:31:05.123Z"}

// Failed login audit
{"level":"info","message":"AUDIT","event":"USER_LOGIN_FAILED","ip":"192.168.1.1","requestId":"c9d0e1f2-...","email":"admin@example.com","reason":"Invalid password","timestamp":"2025-07-15T14:32:00.000Z"}

Common Mistakes

Mistake 1 โ€” Logging passwords or tokens in error messages

โŒ Wrong โ€” password appears in logs:

logger.debug('Login attempt', { email, password });  // password in plain text in logs!
logger.error('Auth failed', { body: req.body });       // req.body contains password

✅ Correct โ€” explicitly exclude sensitive fields:

logger.debug('Login attempt', { email });              // log email only
logger.error('Auth failed', { email, ip: req.ip });    // never log passwords

Mistake 2 โ€” Using console.log() instead of structured logger in production

โŒ Wrong โ€” unstructured strings are hard to query:

console.log(`User ${userId} logged in from ${ip}`);  // free-form string โ€” unqueryable
console.error('Database error:', err);               // no context, no level, no timestamp

✅ Correct โ€” use structured logger for all application events:

logger.info('User login', { userId, ip, requestId: req.id });
logger.error('Database error', { message: err.message, stack: err.stack, requestId: req.id });

Mistake 3 โ€” Logging health check endpoints โ€” polluting logs with noise

โŒ Wrong โ€” /health logged every 30 seconds by monitoring systems:

app.use(morgan('dev'));  // logs EVERYTHING including health checks every 30s
// Logs: GET /api/health 200 1ms โ€” repeated 2880 times per day!

✅ Correct โ€” skip health checks and other high-frequency noise:

app.use(morgan('dev', {
    skip: (req) => req.path.startsWith('/api/health') || req.path.startsWith('/api/metrics'),
}));

Quick Reference

Task Code
HTTP logging (dev) app.use(morgan('dev'))
HTTP logging (prod) app.use(morgan('combined'))
Custom Morgan token morgan.token('name', req => req.user?.id)
Skip paths in Morgan morgan('dev', { skip: req => req.path === '/health' })
Winston info log logger.info('message', { key: 'value' })
Winston error log logger.error('message', { err: err.message, stack: err.stack })
Assign request ID req.id = req.headers['x-request-id'] || uuidv4()
Audit event audit(AuditEvent.USER_LOGIN, { email }, req)

🧠 Test Yourself

A support engineer needs to trace a reported error. The user provides their request ID. What information should the structured logs reveal when filtered by that request ID?