Files
edr-platform/packages/api-common/src/logging/request-log.middleware.ts

144 lines
4.9 KiB
TypeScript

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<string, string | string[] | undefined>;
ip?: string;
query?: Record<string, unknown>;
user?: Record<string, unknown> | 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();
});
}
}