mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
144 lines
4.9 KiB
TypeScript
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();
|
|
});
|
|
}
|
|
}
|