This commit is contained in:
Nathnael
2026-08-12 07:53:05 +00:00
parent d4767e0d06
commit 03e498d6e8
11 changed files with 391 additions and 26 deletions

View File

@@ -7,6 +7,8 @@ import {
Logger,
} from "@nestjs/common";
import { logCtx } from "../logging/request-context";
interface ErrorResponseBody {
success: false;
statusCode: number;
@@ -120,6 +122,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
path: request.url,
};
// Land the failure on the request's canonical log line too — the middleware
// only sees a status code, not what threw. No-op outside a request.
logCtx(
{
name: exception instanceof Error ? exception.name : "Error",
message,
status,
stack: status >= 500 ? (exception as Error)?.stack : undefined,
},
{ path: "error", mode: "set" },
);
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} -> ${status}`,

View File

@@ -6,6 +6,10 @@ export * from "./decorators/public.decorator";
// Filters
export * from "./filters/http-exception.filter";
// Logging
export * from "./logging/request-context";
export * from "./logging/request-log.middleware";
// Interceptors
export * from "./interceptors/response-transform.interceptor";

View File

@@ -0,0 +1,109 @@
import { AsyncLocalStorage } from "node:async_hooks";
/**
* Per-request bag of data points that end up on the canonical request log line
* (see request-log.middleware.ts). Anything written here is flattened into one
* JSON object at the end of the request, so keep it to values that are safe to
* ship to a log aggregator: ids, states, counts, outcomes — never secrets,
* tokens, or raw uploads.
*/
export type RequestLogContext = Record<string, unknown>;
const storage = new AsyncLocalStorage<RequestLogContext>();
export const getLogContext = (): RequestLogContext | undefined =>
storage.getStore();
export const runWithLogContext = <T>(
seed: RequestLogContext,
fn: () => T,
): T => storage.run(seed, fn);
export interface LogCtxOptions {
/**
* Dot path to write under, e.g. "booking.transition". Namespacing is just a
* path with one segment ("payment"). Root object when omitted.
*/
path?: string;
/**
* How the value lands:
* - `merge` (default) — shallow-merge objects into the target
* - `set` — overwrite the target
* - `push` — append to an array at the target
* - `count` — add the value (default 1) to a numeric counter at the target
*/
mode?: "merge" | "set" | "push" | "count";
}
/** Escape hatch when the four modes don't fit: mutate the context yourself. */
export type LogCtxMutator = (ctx: RequestLogContext, value: unknown) => void;
// ponytail: fixed cap so a loop calling logCtx in `push` mode can't grow an
// unbounded array in memory / a megabyte log line. Per-path caps if one ever
// legitimately needs more.
const MAX_PUSHED = 100;
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
/**
* Append a data point to the current request's canonical log line.
*
* No-op outside an HTTP request (bootstrap, cron, queue consumers) — callers
* never need to guard.
*
* logCtx({ bookingId, status }); // root merge
* logCtx(intentId, { path: "payment.intentId", mode: "set" });
* logCtx({ from, to }, { path: "booking.transition" }); // namespaced
* logCtx({ wagonId }, { path: "wagons", mode: "push" }); // array
* logCtx(1, { path: "smsSent", mode: "count" }); // counter
* logCtx(err, (ctx, e) => { ctx.lastError = String(e); }); // custom
*/
export function logCtx(
value: unknown,
opts: LogCtxOptions | LogCtxMutator = {},
): void {
const ctx = storage.getStore();
if (!ctx) return;
if (typeof opts === "function") {
opts(ctx, value);
return;
}
const { path, mode = "merge" } = opts;
if (!path) {
if (isPlainObject(value)) Object.assign(ctx, value);
return;
}
const keys = path.split(".");
const leaf = keys.pop() as string;
let node: Record<string, unknown> = ctx;
for (const key of keys) {
if (!isPlainObject(node[key])) node[key] = {};
node = node[key] as Record<string, unknown>;
}
switch (mode) {
case "set":
node[leaf] = value;
break;
case "push": {
const arr = Array.isArray(node[leaf]) ? (node[leaf] as unknown[]) : [];
if (arr.length < MAX_PUSHED) arr.push(value);
node[leaf] = arr;
break;
}
case "count":
node[leaf] =
((node[leaf] as number) ?? 0) + (typeof value === "number" ? value : 1);
break;
default:
node[leaf] =
isPlainObject(node[leaf]) && isPlainObject(value)
? Object.assign(node[leaf] as Record<string, unknown>, value)
: value;
}
}

View 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();
});
}
}

View File

@@ -55,7 +55,8 @@ export interface ETradeCompanyInfo {
RenewedFrom: string;
RenewedTo: string;
BusinessLicensingGroupMain: string | null;
SubGroups: Array<{ Code: number; Description: string }> | null;
/** eTrade returns null entries in this array as well as a null array. */
SubGroups: Array<{ Code: number; Description: string | null } | null> | null;
}>;
}