Skip to main content

Command Palette

Search for a command to run...

Node.js and Express: Structured Logging with SEQ

Updated
5 min read
Node.js and Express: Structured Logging with SEQ
R

Somebody who likes to code

Logging is a critical aspect of modern applications that directly impacts debugging capabilities, monitoring effectiveness, and operational visibility. While many developers start with simple console log statements during development, production applications require a more sophisticated approach to capture, structure, and analyze log data.

Traditional unstructured logging produces human-readable messages that are difficult for machines to parse and analyze. Consider this typical log entry:

[29/Aug/2025:22:50:02 +0000] "POST /api/todos HTTP/1.1" 201 155

While readable, this format makes it challenging to:

  • Query logs programmatically.

  • Create dashboards and alerts.

  • Perform statistical analysis.

  • Correlate events across distributed systems.

Structured logging addresses these limitations by organizing log data into key-value pairs or JSON objects, enabling powerful querying and analysis capabilities. This article demonstrates how to implement structured logging in Node.js applications using Winston and Seq. The starting code is available here.

Seq

Seq is a centralized logging server built specifically for structured log data. It provides powerful search capabilities, real-time monitoring, and intuitive visualization of log events. Update the docker-compose.yml file as follows to run Seq locally:

services:
  postgres:
    container_name: postgres-server
    image: postgres
    ports:
      - '5432:5432'
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
  seq:
    container_name: seq-server
    image: datalust/seq:latest
    restart: unless-stopped
    ports:
      - '5341:5341'
      - '8080:80'
    environment:
      ACCEPT_EULA: Y

This configuration exposes Seq on two ports:

  • Port 8080: Web interface for viewing and searching logs

  • Port 5341: HTTP ingestion endpoint for receiving log events

Run the following command to start the Docker Compose file:

npm run docker:up

Navigate to http://localhost:8080 in our browser. The Seq interface provides:

  • Real-time log streaming: View logs as they arrive.

  • Advanced search capabilities: Query logs using Seq's powerful expression language.

  • Dashboard creation: Build custom views for monitoring specific metrics.

  • Alert configuration: Set up notifications for critical events.

Structured Logging with Winston

Winston is the most popular logging library for Node.js, providing flexible configuration options and multiple transport mechanisms. We will configure Winston to send structured logs to Seq. First, install the required dependencies:

npm install winston @datalust/winston-seq

Create the src/config/logger.js file with the following content:

import winston from 'winston';
import { SeqTransport } from '@datalust/winston-seq';

const logger = winston.createLogger({
  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: {
    application: 'todo-api',
  },
  transports: [
    new SeqTransport({
      serverUrl: process.env.SEQ_URL,
      apiKey: process.env.SEQ_API_KEY,
      handleExceptions: true,
      handleRejections: true,
      onError: e => {
        console.error('Seq failed to send log:', e.message);
      },
    }),
  ],
});

export default logger;

Let's walk through the Winston logger configuration step by step:

  • level: Sets the minimum log level to info. This means error, warn, and info logs will be processed.

  • format: This is used to transform, structure, or style our log messages before they are sent to a transport. Formats are defined using winston.format and can be chained using winston.format.combine.

    • format.timestamp: Adds a timestamp field.

    • format.errors({ stack: true }): Includes stack traces from errors.

    • format.json: Outputs logs as JSON objects.

  • defaultMeta: Adds default fields to every log entry automatically.

  • transports: A transport is essentially a storage/output mechanism for our logs.
    It defines where the logs go once Winston has formatted them.

    • SeqTransport: The SeqTransport is a custom Winston transport used to send logs directly to Seq.

      • serverUrl: Seq ingestion endpoint.

      • apiKey: Optional authentication key.

      • handleExceptions: Logs uncaught exceptions.

      • handleRejections: Logs unhandled promise rejections.

      • onError: Graceful fallback when Seq is unavailable.

Custom Log Messages

To write custom log messages, import the logger and start using it. Update the src/features/todos/addTodo.js file with the following content:

import db from '../../config/database.js';
import { v7 as uuidv7 } from 'uuid';
import * as yup from 'yup';
import logger from '../../config/logger.js';
export const addTodoSchema = yup.object({
  title: yup.string().required(),
});

export const addTodo = async (req, res) => {
  const todo = {
    id: uuidv7(),
    title: req.body.title,
    completed: false,
    created_at: new Date(),
  };
  logger.info('Adding a new todo {id}', { id: todo.id, title: todo.title });
  await db('todos').insert(todo);
  res.status(201).json(todo);
};

HTTP Request Logging

Implementing request logging can be a good reason to write middleware in Express. Fortunately, someone else has already done this:

npm install express-winston

The express-winston package provides the expressWinston.logger(options) function to create a middleware to log our HTTP requests. This middleware should be placed before any routes.

app.use(
  expressWinston.logger({
    winstonInstance: logger,
    msg: 'HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms',
  })
);

The winstonInstance is set to reuse the instance we already exported in src/config/logger.js. Our logs will show up as structured JSON objects in Seq:

{
  "@t": "2025-09-02T02:37:58.4610000Z",
  "@mt": "HTTP POST /api/todos 201 67ms",
  "@m": "HTTP POST /api/todos 201 67ms",
  "@i": "9e6cf66c",
  "meta": {
    "req": {
      "url": "/api/todos",
      "headers": {
        "user-agent": "vscode-restclient",
        "content-type": "application/json",
        "accept-encoding": "gzip, deflate",
        "content-length": "60",
        "host": "localhost:5000",
        "connection": "close"
      },
      "method": "POST",
      "httpVersion": "1.1",
      "originalUrl": "/api/todos",
      "query": {

      }
    },
    "res": {
      "statusCode": 201
    },
    "responseTime": 67
  },
  "application": "todo-api",
  "timestamp": "2025-09-01 21:37:58"
}

Error Logging

Just like HTTP request logging, express-winston provides the expressWinston.errorLogger(options) function to create a middleware for logging errors. This middleware must be placed after all routes and before any custom error handlers.

app.use(
  expressWinston.errorLogger({
    winstonInstance: logger,
    msg: '{{err.message}} {{res.statusCode}} {{req.method}}',
  })
);

Extra: Morgan

Morgan is a console HTTP request logger middleware primarily used in development environments.

  • Logs details about each incoming HTTP request (method, URL, status code, response time, etc.).

  • Provides predefined logging formats (like dev, tiny, combined, etc.).

  • Can be customized to log only what we need.

  • The logs can be written to a stream, using process.stdout by default.

Use Morgan for quick setup and standardized HTTP logging

Run the following command to install Morgan:

npm install morgan

The complete src/server.js file, including Winston and Morgan, will be:

import express from 'express';
import dotenv from 'dotenv';
import todosRoutes from './features/todos/routes.js';
import { errorHandler, NotFoundError } from './middlewares/errorHandler.js';
import morgan from 'morgan';
import expressWinston from 'express-winston';
import logger from './config/logger.js';

process.on('uncaughtException', err => {
  console.error(err.name, err.message);
  process.exit(1);
});
dotenv.config();
const PORT = process.env.PORT || 3000;
const app = express();
app.use(express.json());
app.use(morgan('dev'));

app.use(
  expressWinston.logger({
    winstonInstance: logger,
    msg: 'HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms',
  })
);
app.use('/api/todos', todosRoutes);
app.all('/*splat', (req, res, next) => {
  const pathSegments = req.params.splat;
  const fullPath = pathSegments.join('/');
  next(new NotFoundError(`The requested URL /${fullPath} does not exist`));
});
app.use(
  expressWinston.errorLogger({
    winstonInstance: logger,
    msg: '{{err.message}} {{res.statusCode}} {{req.method}}',
  })
);
app.use(errorHandler);
const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

process.on('unhandledRejection', err => {
  console.error(err.name, err.message);
  server.close(() => {
    process.exit(1);
  });
});

You can find all the code here. Thanks, and happy coding.

More from this blog

raulnq

171 posts

Somebody who likes to code