Morgan logging: Express access logs, request IDs, and JSON

Add Morgan to Express, build structured request logs, distinguish header and total response time, integrate Winston, and verify fields in Guance.

Best practices
Illustration of logs flowing through a collection pipeline

Morgan adds HTTP access logs to an Express application. It records request and response details such as method, URL, status, timing, client address, and User-Agent. Application events still need their own logger: an access record can show that a checkout request failed, while a business log explains which operation failed.

Use a readable preset during development, then choose a stable production schema with a request ID, explicit timing units, and a collection path. This guide builds those pieces without confusing a request identifier with a distributed trace.

Add the middleware before your routes

For a new example project, install both dependencies:

npm install express morgan

Save this as app.mjs so Node.js treats the imports as ES modules:

import express from 'express';
import morgan from 'morgan';

const app = express();
app.use(morgan('combined'));
app.get('/about', (req, res) => res.send('About this service'));
app.listen(3000, '127.0.0.1');

Run node app.mjs, then request http://127.0.0.1:3000/about. A Combined-format record has this shape:

127.0.0.1 - - [14/Oct/2026:08:15:14 +0000] "GET /about HTTP/1.1" 200 18 "-" "curl/8.7.1"

The timestamp and client details vary. By default, Morgan logs when the response finishes or the connection closes; immediate: true logs at request arrival, before response status and timing are available. Middleware placement matters: a route that completes before Morgan runs will not be logged by that instance.

Pick a preset for the task

Format Included information Typical use
combined Apache-style request details, timestamp, address, Referer, and User-Agent Compatibility with existing access-log tools
common Similar request record without Referer and User-Agent A smaller traditional record
dev Concise, normally colorized status and timing Interactive development
short Address, authenticated user, request details, size, and timing Compact text inspection
tiny Method, URL, status, size, and timing Minimal local output

Change the preset with app.use(morgan('dev')). Install one access logger for the intended output; adding every example below to the same app would generate duplicate records.

Text presets include the request URL and may expose query-string data. Decide what to retain before enabling them on routes carrying sensitive information.

Assign a request ID before logging

Node.js provides randomUUID(), so this example does not need a separate UUID package:

import { randomUUID } from 'node:crypto';

app.use((req, res, next) => {
  req.id = randomUUID();
  res.setHeader('X-Request-ID', req.id);
  next();
});
morgan.token('id', (req) => req.id);
app.use(morgan(':id :method :url :status :res[content-length] - :response-time ms'));

Place this ID middleware before Morgan and the routes. Pass the ID explicitly to business logs and downstream calls if those systems are meant to share it. Merely adding a token to the access log does not propagate context.

If a trusted proxy already generates IDs, define validation and trust rules before accepting its header. A client-supplied value should not be treated as authoritative without that policy. For distributed tracing, propagate trace context through instrumentation and log the actual trace_id; a random request_id does not become a trace ID by renaming it.

Emit one JSON record per request

The following is a complete alternative app.mjs. It uses the same ID assignment, preserves numeric HTTP status and timing, and omits the query string from the recorded path:

import express from 'express';
import morgan from 'morgan';
import { randomUUID } from 'node:crypto';

const app = express();

app.use((req, res, next) => {
  req.id = randomUUID();
  res.setHeader('X-Request-ID', req.id);
  next();
});
morgan.token('id', (req) => req.id);

function numberOrNull(value) {
  if (value == null || value === '') return null;
  const number = Number(value);
  return Number.isFinite(number) ? number : null;
}

function jsonFormat(tokens, req, res) {
  return JSON.stringify({
    time: tokens.date(req, res, 'iso'),
    request_id: tokens.id(req, res),
    method: tokens.method(req, res),
    path: (req.originalUrl ?? req.url ?? '').split('?')[0],
    http_status_code: numberOrNull(tokens.status(req, res)),
    response_time_ms: numberOrNull(tokens['response-time'](req, res)),
    total_time_ms: numberOrNull(tokens['total-time'](req, res)),
    client_ip: tokens['remote-addr'](req, res),
    user_agent: req.headers['user-agent'] ?? null,
  });
}

app.use(morgan(jsonFormat));
app.get('/about', (req, res) => res.json({ service: 'example' }));
app.get('/failure', (req, res) => res.status(500).json({ error: 'example failure' }));
app.listen(3000, '127.0.0.1');

Test /about, /failure, and a missing route. JSON.stringify escapes strings correctly, while numberOrNull represents unavailable values explicitly. A client that disconnects before headers are sent may have no response status or response time; recording null is more useful than inventing a successful status.

Morgan's response-time measures from entry into the middleware until response headers are written. total-time extends to the response finishing on the connection; neither is the browser's end-to-end experience. Streaming responses can make the difference substantial. The timestamp token is evaluated when the record is formatted, so this schema records log-emission time rather than a separate request-start timestamp.

Morgan 1.12.1 escapes token strings for line-oriented logs even inside a format function. This example reads the raw URL and User-Agent from the request and lets JSON.stringify encode them once, avoiding literal token-escape sequences in those JSON fields. Check this behavior when upgrading Morgan.

Removing the query string reduces exposure but is not complete redaction: paths and User-Agent values can also contain sensitive data. Map routes to low-cardinality patterns separately when you need aggregate metrics. Behind a proxy, Morgan uses Express's req.ip; configure Express trust proxy for your actual proxy topology instead of trusting arbitrary forwarding headers.

Share a destination with Winston or Pino

Morgan's stream.write receives a formatted line. Forwarding Combined text to a logger works, but leaves the access fields inside a message string. Forwarding JSON as a string can double-encode it. Parse your own JSON formatter's result before handing it to a structured logger.

For Winston, install winston, then add its import and logger setup to the JSON application above. Replace the existing app.use(morgan(jsonFormat)) with this block before the routes:

import winston from 'winston';

const logger = winston.createLogger({
  format: winston.format.json(),
  transports: [new winston.transports.Console()],
});

app.use(morgan(jsonFormat, {
  stream: {
    write(line) {
      const entry = JSON.parse(line);
      const code = entry.http_status_code;
      const level = code >= 500 ? 'error' : code >= 400 ? 'warn' : 'info';
      logger.log({ ...entry, level, message: 'HTTP request completed' });
    },
  },
}));

This severity mapping is a policy example, not a rule that every 4xx is an application error. Adapt it to expected responses such as authentication failures or missing resources, and handle missing status separately if aborted requests need their own classification. With Pino, pass the parsed object as structured fields using its object-first logging API.

Keep HTTP status separate from the logger's severity. Avoid synchronous database writes or per-request remote HTTP calls inside write; destination latency and buffering behavior can dominate logging overhead.

Verify ingestion before alerting

Choose the file or container entry point in DataKit log collection. Confirm that the collector parses the application JSON rather than only its outer container envelope. Check UTC event time, numeric duration, http_status_code, severity, and request_id using known 2xx, 4xx, and 5xx requests.

In Guance, status is the log-level field, so retain the HTTP code under its own name. Find the failure by path and code before creating count or rate alerts. For application performance monitoring (APM) correlation, also emit the trace fields required by the instrumentation; see Guance's APM correlation guidance.

Frequently asked questions

Does Morgan replace Winston or Pino?

No. Morgan observes the HTTP request/response lifecycle. Winston or Pino records business events, errors, and background-job activity. They can share a transport while keeping distinct event schemas.

Is Morgan's release frequency a reason to reject it?

Evaluate the installed release, dependency security, supported Node.js version, and fit with your stack. A low release frequency alone proves neither abandonment nor safety. Check the official project rather than relying on a blanket maintenance claim.

How much overhead does it add?

Formatting, output volume, destination behavior, and load all matter. Benchmark the actual format and transport under representative traffic. Stdout can also encounter backpressure; it is not free or unlimited.

Do I need it when Nginx already logs requests?

Use both when application context helps. A proxy records its view of the request, while Morgan can include an application-assigned ID and explicitly added context. Give each source a distinct label and correlate with a propagated identifier so two layers are not counted as two independent user requests.

Further reading

Technical references checked October 14, 2026.

Get a tailored plan

Contact us

Join the community

Scan with WeChat
to join the community

Try Guance

Start online and pay only for what you use.

Get started

Choose a Guance plan

Code hosting