Appearance
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.logis a non-standard Express property — new team members need to learn the convention- Middleware must be mounted before any handler that calls
res.log