Logging with Morgan and Winston

Logging records what your application does at runtime — which requests arrive, what errors occur, how long operations take, and what the system state is when something goes wrong. Without logs, debugging production issues means guessing. With structured logs, you can trace every problem back to its root cause in minutes. This topic covers two complementary logging tools: Morgan for HTTP request logging and Winston for application-level logging.

Why Logging Matters

Without logs (production error report):
  "Users are complaining that login doesn't work"
  → You have no idea what's happening or when it started

With logs (production error report):
  [2024-03-15 14:22:11] ERROR: MongoServerError: connection timed out
    at POST /api/auth/login
    userId: undefined, ip: 203.0.113.42
    Duration: 30042ms
  → You immediately know: database connection dropped at 14:22

Two Layers of Logging

┌───────────────────────────────────────────────────────────────┐
│                  Express Logging Layers                       │
├─────────────────────┬─────────────────────────────────────────┤
│ Morgan              │ Logs every HTTP request automatically   │
│ (HTTP layer)        │ Method, URL, status, size, response time│
├─────────────────────┼─────────────────────────────────────────┤
│ Winston             │ Logs application events you choose      │
│ (App layer)         │ Errors, warnings, info, debug messages  │
└─────────────────────┴─────────────────────────────────────────┘

Morgan: HTTP Request Logging

Install and Basic Setup

npm install morgan
const morgan = require('morgan');

// 'dev' format: colored output, great for development
app.use(morgan('dev'));
// Output: GET /api/users 200 45.234 ms - 512

Morgan Format Options

┌──────────────────────────────────────────────────────────────────┐
│                    Morgan Format Options                         │
├────────────────┬─────────────────────────────────────────────────┤
│ 'dev'          │ Colored, concise — for development terminal     │
│ 'tiny'         │ Minimal — method, URL, status, size             │
│ 'short'        │ Shorter than 'combined', includes res time      │
│ 'combined'     │ Apache standard format — best for production    │
│ 'common'       │ Apache common log format                        │
└────────────────┴─────────────────────────────────────────────────┘
// Use 'combined' in production (includes IP, user-agent, referrer)
if (process.env.NODE_ENV === 'production') {
  app.use(morgan('combined'));
} else {
  app.use(morgan('dev'));
}

Write Morgan Logs to a File

const fs = require('fs');
const path = require('path');

// Create a write stream (append mode)
const accessLogStream = fs.createWriteStream(
  path.join(__dirname, 'logs', 'access.log'),
  { flags: 'a' }
);

// Write all HTTP logs to file
app.use(morgan('combined', { stream: accessLogStream }));

Custom Morgan Tokens

// Log the user ID alongside each request
morgan.token('user-id', (req) => {
  return req.user ? req.user._id.toString() : 'anonymous';
});

app.use(morgan(':method :url :status :response-time ms — user: :user-id'));
// Output: POST /api/orders 201 32.4 ms — user: 64a1b2c3d4e5f6

Winston: Application Logging

Winston handles structured logging for events your application generates: errors, business events, warnings, and debug information. Unlike Morgan (which logs HTTP only), Winston logs anything you tell it to.

Install Winston

npm install winston

Create a Logger Configuration

// utils/logger.js
const winston = require('winston');
const path = require('path');

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  transports: [
    // Write error-level logs to error.log
    new winston.transports.File({
      filename: path.join('logs', 'error.log'),
      level: 'error'
    }),
    // Write all logs to combined.log
    new winston.transports.File({
      filename: path.join('logs', 'combined.log')
    })
  ]
});

// In development, also print colorized output to terminal
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;

Winston Log Levels

┌──────────────────────────────────────────────────────────────────┐
│              Winston Log Levels (highest → lowest priority)      │
├───────────────┬──────────────────────────────────────────────────┤
│ error (0)     │ Application errors that need immediate attention │
│ warn (1)      │ Something unexpected but app keeps running       │
│ info (2)      │ Normal operational events (user logged in, etc.) │
│ http (3)      │ HTTP request details                             │
│ verbose (4)   │ Detailed operational data                        │
│ debug (5)     │ Debugging info for development                   │
│ silly (6)     │ Everything — very verbose                        │
└───────────────┴──────────────────────────────────────────────────┘

Setting level: 'info' logs everything from error through info.
Setting level: 'debug' logs everything.

Using the Logger in Routes

const logger = require('./utils/logger');

app.post('/api/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    const user = await User.findOne({ email }).select('+password');

    if (!user || !(await user.comparePassword(password))) {
      logger.warn('Failed login attempt', {
        email,
        ip: req.ip,
        userAgent: req.headers['user-agent']
      });
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    logger.info('User logged in', {
      userId: user._id,
      email: user.email,
      ip: req.ip
    });

    const token = generateToken(user._id);
    res.json({ token });

  } catch (err) {
    logger.error('Login error', {
      error: err.message,
      stack: err.stack,
      ip: req.ip
    });
    res.status(500).json({ error: 'Login failed' });
  }
});

Integrate Winston with Express Error Handler

const logger = require('./utils/logger');

// Global error handler with Winston logging
app.use((err, req, res, next) => {
  logger.error('Unhandled error', {
    message: err.message,
    stack: err.stack,
    method: req.method,
    url: req.originalUrl,
    ip: req.ip,
    userId: req.user?._id
  });

  res.status(err.statusCode || 500).json({
    success: false,
    message: err.isOperational ? err.message : 'Something went wrong'
  });
});

Route Morgan Through Winston

For a fully unified logging system, pipe Morgan's output through Winston instead of printing directly:

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

app.use(morganMiddleware);
// Now HTTP access logs appear in combined.log alongside app logs

Log File Structure

my-express-app/
├── logs/
│   ├── access.log      ← Morgan HTTP logs
│   ├── error.log       ← Winston error-level only
│   └── combined.log    ← Winston all levels
├── app.js
└── package.json

Add the logs folder to .gitignore:

node_modules/
.env
logs/

Log Rotation

Log files grow indefinitely without rotation. Use winston-daily-rotate-file to automatically create new files daily and delete old ones:

npm install winston-daily-rotate-file
const DailyRotateFile = require('winston-daily-rotate-file');

const transport = new DailyRotateFile({
  filename: 'logs/app-%DATE%.log',
  datePattern: 'YYYY-MM-DD',
  maxFiles: '14d',   // Keep logs for 14 days, then delete
  maxSize: '20m'     // Rotate when file reaches 20MB
});

logger.add(transport);

What to Log and What Not to Log

┌─────────────────────────────────────────────────────────────────┐
│                  Logging Best Practices                         │
├─────────────────────────────┬───────────────────────────────────┤
│ LOG                         │ DO NOT LOG                        │
├─────────────────────────────┼───────────────────────────────────┤
│ Error messages and stacks   │ Passwords (never, ever)           │
│ Failed login attempts + IP  │ Credit card or payment data       │
│ User IDs (not names)        │ Session tokens or JWT values      │
│ HTTP method and URL         │ Personal health information       │
│ Response status codes       │ Full request bodies with secrets  │
│ Database error details      │ Government ID numbers             │
│ Request duration            │ Encryption keys or secrets        │
└─────────────────────────────┴───────────────────────────────────┘

Summary

Use Morgan to automatically log every HTTP request — method, URL, status code, and response time — with a single app.use(morgan('dev')) call. Use the 'combined' format in production to include IP addresses and user agents for security auditing. Use Winston for structured application logging: create a logger with file transports for errors and combined logs, and use logger.info(), logger.warn(), and logger.error() throughout your routes and error handlers. Pipe Morgan through Winston to unify both logging systems into the same output files. Add log rotation with winston-daily-rotate-file to prevent log files from growing indefinitely. Never log passwords, tokens, or sensitive personal data.

Leave a Comment

Your email address will not be published. Required fields are marked *