Skip to content

Request Logging Middleware ​

Express middleware that produces one structured log entry per request. Handlers accumulate context via res.log.set(key, value) and record timestamped breadcrumbs via res.log(message), implementing the unit-of-work telemetry pattern. A single line is emitted when the response finishes.

When to use ​

Use this middleware in any Express server where you want one structured, queryable log entry per HTTP request instead of scattered console.log calls. It works with any log aggregator — Cloud Logging, Datadog, ELK — and produces filterable, structured output.

The pattern ​

Handler-side usage ​

Handlers call res.log.set(key, value) to attach structured context and res.log(message) to record timestamped breadcrumbs. No wrapping or restructuring of handler code is required:

javascript
class PrototypesHandler {
  static async get(req, res, next) {
    const { user } = req;
    res.log.set("enduser.id", user.email);
    
    try {
      const ids = split(req.query.ids, ",");
      if (ids.length) {
        res.log.set("items.requested", ids.length);
      }
      const prototypes = await Prototype.forUser(
        user.email, ids,
      );
      res.log.set("items.returned", prototypes.length);
      res.json(prototypes);
    } catch (error) {
      res.log.set("error.type", error.name);
      res.log.set("error.message", error.message);
      next(new ServerError("GET failed", {
        cause: error,
      }));
    }
  }
}

Each request produces one JSON log entry with every field accumulated during handling:

json
{
  "http.request.method": "GET",
  "url.path": "/api/prototypes",
  "enduser.id": "alice@example.com",
  "items.requested": 3,
  "items.returned": 3,
  "http.response.status_code": 200,
  "http.duration_ms": 42.7,
  "message": "GET /api/prototypes 200 43ms"
}

Implementing the middleware ​

The middleware creates a Map for structured context, attaches res.log and res.log.set, and listens for the response finish event to emit the canonical line through a structured logger:

javascript
import { performance } from "node:perf_hooks";
import createLogger from "./logger.js";

const log = createLogger({ service: "server" });

function requestLogging() {
  return function requestLogging(req, res, next) {
    const start = performance.now();
    const { method, path } = req;
    
    const context = new Map();
    context.set("http.request.method", method);
    context.set("url.path", path);
    
    const breadcrumbs = [];
    
    res.log = function log(message) {
      const ms = Math.round(
        performance.now() - start,
      );
      breadcrumbs.push(`[${ms}ms] ${message}`);
    };
    
    res.log.set = function set(key, value) {
      context.set(key, value);
    };
    
    res.on("finish", function () {
      const duration = performance.now() - start;
      const { statusCode } = res;
      
      context.set(
        "http.response.status_code", statusCode,
      );
      context.set("http.duration_ms", duration);
      
      if (breadcrumbs.length) {
        context.set("logs", breadcrumbs);
      }
      
      const ctx = Object.fromEntries(context);
      const ms = duration.toFixed(0);
      log.info(
        ctx,
        `${method} ${path} ${statusCode} ${ms}ms`,
      );
    });
    next();
  };
}

Mount it early in the middleware pipeline so every downstream handler has access to res.log:

javascript
const server = express();
server.use(requestLogging());
server.use(authMiddleware());
server.use(apiMiddleware());

Trade-offs ​

Benefits:

  • One structured log line per request — easy to query in log aggregators
  • Non-invasive — handlers annotate context without wrapping their logic
  • OpenTelemetry-aligned field names (http.request.method, url.path) make logs interoperable with tracing tools
  • Breadcrumbs provide a request-internal timeline without separate log entries

Costs:

  • No output until the response finishes — long-running requests are invisible until complete
  • res.log is a non-standard Express property — new team members need to learn the convention
  • Middleware must be mounted before any handler that calls res.log