mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
fixes
This commit is contained in:
137
packages/api-common/src/logging/request-log.middleware.ts
Normal file
137
packages/api-common/src/logging/request-log.middleware.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Injectable, Logger, 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 {
|
||||
private readonly logger = new Logger("HTTP");
|
||||
private readonly canonical = new Logger("request");
|
||||
|
||||
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;
|
||||
|
||||
this.logger.log(`${req.method} ${url} ${status} ${durationMs}ms`);
|
||||
|
||||
const line = {
|
||||
...ctx,
|
||||
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({
|
||||
type: "http_request",
|
||||
requestId,
|
||||
method: req.method,
|
||||
url,
|
||||
status,
|
||||
durationMs,
|
||||
contextSerializationFailed: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (status >= 500) this.canonical.error(json);
|
||||
else if (status >= 400) this.canonical.warn(json);
|
||||
else this.canonical.log(json);
|
||||
};
|
||||
|
||||
res.on("finish", emit);
|
||||
res.on("close", emit);
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user