import { randomUUID } from "node:crypto"; import { Injectable, NestMiddleware } from "@nestjs/common"; import { RequestLogContext, logCtx, runWithLogContext, } from "./request-context"; /** * Structural request/response shapes — express types are not a dependency of * this package (they arrive under @nestjs/platform-express in the apps). */ interface LoggedRequest { method: string; url: string; originalUrl?: string; baseUrl?: string; route?: { path?: string }; headers: Record; ip?: string; query?: Record; user?: Record | null; } interface LoggedResponse { statusCode: number; writableEnded?: boolean; setHeader(name: string, value: string): void; on(event: string, listener: () => void): void; } const header = (req: LoggedRequest, name: string): string | undefined => { const value = req.headers[name]; return Array.isArray(value) ? value[0] : value; }; const userId = (req: LoggedRequest): string | undefined => { const user = req.user; if (!user) return undefined; const id = user.id ?? user.sub ?? user.userId; return typeof id === "string" || typeof id === "number" ? String(id) : undefined; }; /** * Opens an AsyncLocalStorage context for the request (so anything downstream * can `logCtx(...)` into it) and emits ONE canonical JSON line per request when * the response ends — request metadata plus every data point collected during * the flow, on the same Nest logger transport as the rest of the app. * * Base fields win over collected context on key collisions, so a stray * `logCtx({ status })` can never corrupt the fields dashboards filter on. * * Apply FIRST in `configure()`: middleware registered before this one runs * outside the context and its `logCtx` calls are silently dropped. */ @Injectable() export class RequestLogMiddleware implements NestMiddleware { use(req: LoggedRequest, res: LoggedResponse, next: () => void): void { const start = Date.now(); const requestId = header(req, "x-request-id") ?? randomUUID(); res.setHeader("x-request-id", requestId); // Held by reference, NOT read back through the ALS at emit time: response // "finish"/"close" listeners are plain EventEmitter callbacks, which Node // does not bind to the async context they were registered in — getStore() // there returns whatever context happened to call res.end(). const ctx: RequestLogContext = {}; runWithLogContext(ctx, () => { logCtx({ requestId }); let emitted = false; // "finish" covers normal responses; "close" catches client aborts, where // "finish" never fires and the request would vanish from the logs. const emit = () => { if (emitted) return; emitted = true; const url = req.originalUrl ?? req.url; const status = res.statusCode; const durationMs = Date.now() - start; const line = { ...ctx, // Fields the Nest console prefix used to carry. They are IN the JSON // now because this line is written raw (see below) — a log shipper // needs level and time as parsable fields, not as console decoration. time: new Date().toISOString(), level: status >= 500 ? "error" : status >= 400 ? "warn" : "info", logger: "request", type: "http_request", requestId, method: req.method, route: req.route?.path ? `${req.baseUrl ?? ""}${req.route.path}` : undefined, url, status, durationMs, userId: userId(req), ip: req.ip, userAgent: header(req, "user-agent"), query: req.query && Object.keys(req.query).length ? req.query : undefined, aborted: res.writableEnded === false ? true : undefined, }; // A circular value pushed into the context must not throw inside a // response listener — that would take the process down, not the log. let json: string; try { json = JSON.stringify(line); } catch { json = JSON.stringify({ time: line.time, level: line.level, logger: "request", type: "http_request", requestId, method: req.method, url, status, durationMs, contextSerializationFailed: true, }); } // Written raw, NOT through Nest's Logger: the console logger wraps every // message in "[Nest] pid - date LEVEL [ctx] …", which makes the line // un-parsable as JSON. Same destination the Nest logger writes to // (stdout, stderr for errors) — only the decoration is dropped. (status >= 500 ? process.stderr : process.stdout).write(`${json}\n`); }; res.on("finish", emit); res.on("close", emit); next(); }); } }