Nodejs

Node.js Logging Best Practices with Winston

A practical guide to Node.js logging with Winston covering structured logging, log levels, transports, log rotation, request logging, and production configuration.

Node.js Logging Best Practices with Winston

console.log works for debugging. It does not work for production. When your application runs on a server, you need structured logs that can be searched, filtered, and analyzed. You need log levels to distinguish critical errors from routine information. You need log rotation so files do not fill up the disk. You need consistent formatting so monitoring tools can parse your logs.

Winston is the most widely used logging library for Node.js. It provides structured logging, multiple output destinations (transports), log levels, formatting, and child loggers. This guide covers setting up Winston for production Node.js applications.

Prerequisites

  • Node.js installed (v16+)
  • An Express.js application

Installation

npm install winston

For additional features:

npm install winston-daily-rotate-file  # Log rotation

Basic Setup

// logger.js
var winston = require("winston");

var logger = winston.createLogger({
  level: process.env.LOG_LEVEL || "info",
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  defaultMeta: { service: "myapp" },
  transports: [
    new winston.transports.Console({
      format: process.env.NODE_ENV === "production"
        ? winston.format.json()
        : winston.format.combine(
            winston.format.colorize(),
            winston.format.simple()
          )
    })
  ]
});

module.exports = logger;
// Usage
var logger = require("./logger");

logger.info("Server started", { port: 3000 });
logger.warn("Slow query detected", { duration: 2500, query: "SELECT * FROM users" });
logger.error("Database connection failed", { host: "db.example.com", error: err.message });

Output in production (JSON):

{"level":"info","message":"Server started","port":3000,"service":"myapp","timestamp":"2026-02-13T10:30:00.000Z"}

Output in development (colored, human-readable):

info: Server started {"port":3000,"service":"myapp","timestamp":"2026-02-13T10:30:00.000Z"}

Log Levels

Winston uses npm-style log levels by default, from most to least severe:

Level Value When to Use
error 0 Application errors that need attention
warn 1 Potential problems, degraded performance
info 2 Normal application events
http 3 HTTP request/response logging
verbose 4 More detailed informational messages
debug 5 Debugging information
silly 6 Everything, including noisy output

Setting the logger level to info means it logs error, warn, and info messages, but not http, verbose, debug, or silly.

logger.error("Database connection failed", { error: err.message });
logger.warn("Memory usage high", { heapUsed: 450, heapTotal: 512 });
logger.info("User registered", { userId: 123, email: "[email protected]" });
logger.debug("Cache miss", { key: "user:123" });

Choosing the Right Level

  • Production: Set to info. Logs errors, warnings, and important events.
  • Staging: Set to debug. Includes debugging output for troubleshooting.
  • Development: Set to debug or silly. Maximum verbosity.

Change the level at runtime without restarting:

// API endpoint to change log level (admin only)
app.put("/admin/log-level", authorize("admin"), function(req, res) {
  var level = req.body.level;
  var validLevels = ["error", "warn", "info", "http", "verbose", "debug", "silly"];

  if (validLevels.indexOf(level) === -1) {
    return res.status(400).json({ error: "Invalid log level" });
  }

  logger.level = level;
  logger.info("Log level changed", { newLevel: level, changedBy: req.user.id });
  res.json({ level: level });
});

Transports

Transports are output destinations for log messages. Winston can send logs to multiple places simultaneously.

Console Transport

new winston.transports.Console({
  level: "debug",
  format: winston.format.combine(
    winston.format.colorize(),
    winston.format.printf(function(info) {
      return info.timestamp + " [" + info.level + "]: " + info.message +
        (Object.keys(info).length > 4 ? " " + JSON.stringify(info, null, 2) : "");
    })
  )
})

File Transport

new winston.transports.File({
  filename: "logs/error.log",
  level: "error",
  maxsize: 5242880,  // 5MB
  maxFiles: 5
})

new winston.transports.File({
  filename: "logs/combined.log",
  maxsize: 5242880,
  maxFiles: 10
})

Daily Rotate File Transport

var DailyRotateFile = require("winston-daily-rotate-file");

new DailyRotateFile({
  filename: "logs/app-%DATE%.log",
  datePattern: "YYYY-MM-DD",
  maxSize: "20m",
  maxFiles: "14d",       // Keep logs for 14 days
  zippedArchive: true    // Compress old logs
})

Complete Multi-Transport Setup

// logger.js
var winston = require("winston");
var DailyRotateFile = require("winston-daily-rotate-file");

var 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()
  ),
  defaultMeta: { service: process.env.SERVICE_NAME || "myapp" },
  transports: [
    // Console — always enabled
    new winston.transports.Console({
      format: process.env.NODE_ENV === "production"
        ? winston.format.json()
        : winston.format.combine(
            winston.format.colorize(),
            winston.format.printf(function(info) {
              var msg = info.timestamp + " " + info.level + ": " + info.message;
              if (info.stack) msg += "\n" + info.stack;
              return msg;
            })
          )
    }),

    // Error log file
    new DailyRotateFile({
      filename: "logs/error-%DATE%.log",
      datePattern: "YYYY-MM-DD",
      level: "error",
      maxFiles: "30d",
      zippedArchive: true
    }),

    // Combined log file
    new DailyRotateFile({
      filename: "logs/combined-%DATE%.log",
      datePattern: "YYYY-MM-DD",
      maxFiles: "14d",
      zippedArchive: true
    })
  ]
});

module.exports = logger;

Structured Logging

Why Structure Matters

Unstructured logs:

User 123 logged in from 192.168.1.1 at 2026-02-13 10:30:00
Error: Database connection failed - ECONNREFUSED
Request to /api/users took 2500ms

Structured logs (JSON):

{"level":"info","message":"user_login","userId":123,"ip":"192.168.1.1","timestamp":"2026-02-13T10:30:00Z"}
{"level":"error","message":"database_connection_failed","error":"ECONNREFUSED","host":"db.example.com","timestamp":"2026-02-13T10:30:01Z"}
{"level":"warn","message":"slow_request","method":"GET","path":"/api/users","duration":2500,"timestamp":"2026-02-13T10:30:02Z"}

Structured logs are:

  • Searchable — find all logs where userId=123
  • Filterable — show only level=error entries
  • Parseable — monitoring tools extract metrics automatically
  • Consistent — every entry has the same format

Logging Conventions

// Use event names as messages, not sentences
logger.info("user_login", { userId: user.id, ip: req.ip });          // Good
logger.info("User " + user.id + " logged in from " + req.ip);        // Bad

// Include relevant context as properties
logger.error("payment_failed", {
  orderId: order.id,
  amount: order.amount,
  error: err.message,
  provider: "stripe"
});

// Use consistent property names across the application
// userId (not user_id, uid, or id)
// duration (not time, elapsed, ms)
// error (not err, message, msg)

Request Logging Middleware

HTTP Request Logger

// middleware/requestLogger.js
var logger = require("../logger");

function requestLogger(req, res, next) {
  var start = Date.now();

  // Generate or use existing request ID
  req.requestId = req.headers["x-request-id"] ||
    Date.now().toString(36) + Math.random().toString(36).substr(2, 9);

  res.setHeader("X-Request-ID", req.requestId);

  res.on("finish", function() {
    var duration = Date.now() - start;
    var level = res.statusCode >= 500 ? "error"
      : res.statusCode >= 400 ? "warn"
      : "http";

    logger.log(level, "http_request", {
      requestId: req.requestId,
      method: req.method,
      path: req.path,
      query: Object.keys(req.query).length > 0 ? req.query : undefined,
      status: res.statusCode,
      duration: duration,
      ip: req.ip,
      userAgent: req.get("user-agent"),
      userId: req.user ? req.user.id : undefined,
      contentLength: res.get("content-length")
    });
  });

  next();
}

module.exports = requestLogger;
// app.js
app.use(require("./middleware/requestLogger"));

Logging Slow Requests

function slowRequestLogger(threshold) {
  threshold = threshold || 3000;

  return function(req, res, next) {
    var start = Date.now();

    res.on("finish", function() {
      var duration = Date.now() - start;

      if (duration > threshold) {
        logger.warn("slow_request", {
          method: req.method,
          path: req.path,
          status: res.statusCode,
          duration: duration,
          threshold: threshold,
          userId: req.user ? req.user.id : undefined
        });
      }
    });

    next();
  };
}

app.use(slowRequestLogger(3000));

Child Loggers

Child loggers inherit the parent configuration and add context:

// Create a child logger for a specific module
var dbLogger = logger.child({ module: "database" });
var authLogger = logger.child({ module: "auth" });

dbLogger.info("query_executed", { table: "users", duration: 45 });
// Output includes module: "database" automatically

authLogger.info("login_attempt", { email: "[email protected]" });
// Output includes module: "auth" automatically

Per-Request Logger

// middleware/requestContext.js
function requestContext(req, res, next) {
  req.log = logger.child({
    requestId: req.requestId,
    userId: req.user ? req.user.id : undefined
  });

  next();
}

// Usage in routes
app.get("/api/orders", function(req, res) {
  req.log.info("fetching_orders");

  db.query("SELECT * FROM orders WHERE user_id = $1", [req.user.id])
    .then(function(result) {
      req.log.info("orders_fetched", { count: result.rows.length });
      res.json(result.rows);
    })
    .catch(function(err) {
      req.log.error("orders_fetch_failed", { error: err.message });
      res.status(500).json({ error: "Failed to fetch orders" });
    });
});

Every log entry from a request automatically includes requestId and userId, making it easy to trace all events for a single request.

Error Logging

Logging Errors with Stack Traces

// The errors format captures stack traces
var logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.errors({ stack: true }),
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [new winston.transports.Console()]
});

try {
  JSON.parse("invalid json");
} catch (err) {
  logger.error("json_parse_failed", {
    error: err.message,
    stack: err.stack,
    input: "invalid json"
  });
}

Express Error Logger

// middleware/errorLogger.js
var logger = require("../logger");

function errorLogger(err, req, res, next) {
  var logData = {
    error: err.message,
    stack: err.stack,
    method: req.method,
    path: req.path,
    requestId: req.requestId,
    userId: req.user ? req.user.id : undefined,
    ip: req.ip
  };

  if (err.isOperational) {
    logger.warn("operational_error", logData);
  } else {
    logger.error("unhandled_error", logData);
  }

  next(err);
}

module.exports = errorLogger;
// app.js — error logger before error handler
app.use(require("./middleware/errorLogger"));
app.use(require("./middleware/errorHandler"));

Sensitive Data Protection

Never log passwords, tokens, credit card numbers, or personal health information:

// Format that redacts sensitive fields
var redactFormat = winston.format(function(info) {
  if (info.password) info.password = "[REDACTED]";
  if (info.token) info.token = "[REDACTED]";
  if (info.authorization) info.authorization = "[REDACTED]";
  if (info.creditCard) info.creditCard = "[REDACTED]";
  if (info.ssn) info.ssn = "[REDACTED]";
  return info;
});

var logger = winston.createLogger({
  format: winston.format.combine(
    redactFormat(),
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [new winston.transports.Console()]
});

// This is safe — password gets redacted
logger.info("login_attempt", { email: "[email protected]", password: "secret123" });
// Output: {"email":"[email protected]","password":"[REDACTED]",...}

Request Body Sanitization

function sanitizeBody(body) {
  if (!body) return undefined;

  var sanitized = Object.assign({}, body);
  var sensitiveFields = ["password", "token", "secret", "creditCard", "ssn", "authorization"];

  sensitiveFields.forEach(function(field) {
    if (sanitized[field]) {
      sanitized[field] = "[REDACTED]";
    }
  });

  return sanitized;
}

// In request logger
logger.http("http_request", {
  method: req.method,
  path: req.path,
  body: sanitizeBody(req.body)
});

Production Configuration

Complete Production Logger

// logger.js
var winston = require("winston");
var DailyRotateFile = require("winston-daily-rotate-file");
var path = require("path");

var LOG_DIR = process.env.LOG_DIR || "logs";
var SERVICE = process.env.SERVICE_NAME || "myapp";
var ENV = process.env.NODE_ENV || "development";

// Redact sensitive fields
var redactFormat = winston.format(function(info) {
  var sensitive = ["password", "token", "secret", "authorization", "cookie"];
  sensitive.forEach(function(field) {
    if (info[field]) info[field] = "[REDACTED]";
  });
  return info;
});

// Production format: JSON
var productionFormat = winston.format.combine(
  winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
  winston.format.errors({ stack: true }),
  redactFormat(),
  winston.format.json()
);

// Development format: human-readable
var developmentFormat = winston.format.combine(
  winston.format.timestamp({ format: "HH:mm:ss" }),
  winston.format.errors({ stack: true }),
  winston.format.colorize(),
  winston.format.printf(function(info) {
    var msg = info.timestamp + " " + info.level + ": " + info.message;
    var meta = Object.assign({}, info);
    delete meta.timestamp;
    delete meta.level;
    delete meta.message;
    delete meta.service;
    if (meta.stack) {
      msg += "\n" + meta.stack;
      delete meta.stack;
    }
    if (Object.keys(meta).length > 0) {
      msg += " " + JSON.stringify(meta);
    }
    return msg;
  })
);

var transports = [];

// Console — always enabled
transports.push(new winston.transports.Console({
  format: ENV === "production" ? productionFormat : developmentFormat
}));

// File transports — production only
if (ENV === "production") {
  transports.push(new DailyRotateFile({
    filename: path.join(LOG_DIR, "error-%DATE%.log"),
    datePattern: "YYYY-MM-DD",
    level: "error",
    maxFiles: "30d",
    zippedArchive: true,
    format: productionFormat
  }));

  transports.push(new DailyRotateFile({
    filename: path.join(LOG_DIR, "combined-%DATE%.log"),
    datePattern: "YYYY-MM-DD",
    maxFiles: "14d",
    zippedArchive: true,
    format: productionFormat
  }));
}

var logger = winston.createLogger({
  level: process.env.LOG_LEVEL || (ENV === "production" ? "info" : "debug"),
  defaultMeta: { service: SERVICE, env: ENV },
  transports: transports,
  exitOnError: false
});

module.exports = logger;

Common Issues and Troubleshooting

Logs are not appearing

The log level is too restrictive:

Fix: Check the LOG_LEVEL environment variable. If set to error, info and warn messages are suppressed. Set to debug for troubleshooting.

Log files grow without limit

File transport does not have rotation configured:

Fix: Use winston-daily-rotate-file with maxFiles and maxSize options. Set zippedArchive: true to compress old logs.

Logs contain sensitive data

Passwords or tokens are included in log entries:

Fix: Add a redaction format that strips sensitive fields before writing. Audit all logging statements for accidental sensitive data inclusion.

Logs have inconsistent formats

Different parts of the application log differently:

Fix: Use a single logger instance imported everywhere. Define the format in one place. Use child loggers to add context without changing the format.

Performance impact from excessive logging

Logging every request detail slows down the application:

Fix: Use debug level for verbose logs and set production to info. Avoid logging large objects (request bodies, query results). Sample high-frequency logs instead of logging every event.

Best Practices

  • Use structured JSON logging in production. JSON logs are parseable by monitoring tools, searchable, and machine-readable. Human-readable formats are for development only.
  • Log events, not sentences. Use short event names (user_login, order_created) as the message and add context as properties. This makes filtering and searching straightforward.
  • Include request IDs in every log entry. A request ID traces all events for a single request across services and log entries. Generate one if the client does not send one.
  • Set up log rotation. Without rotation, logs fill up the disk. Use winston-daily-rotate-file with retention limits.
  • Never log sensitive data. Add a redaction format that strips passwords, tokens, and personal information before any log entry is written.
  • Use child loggers for module-specific context. logger.child({ module: "auth" }) automatically tags every log from the auth module without repeating the tag in every call.
  • Make the log level configurable at runtime. An API endpoint or environment variable change should adjust the log level without restarting the application.
  • Log at the right level. Errors go to error. Deprecation warnings go to warn. Business events go to info. Implementation details go to debug. Too many error entries signal poor error handling, not good logging.

References

Powered by Contentful