feat(freight-api): enrich canonical request log line

Emit the request line as raw JSON on stdout (level/time/logger as fields)
instead of through Nest's console logger, whose prefix made it unparsable.

Collect data points via logCtx at the flow chokepoints: BaseRepository
writes (status changes, creates, deletes), invoice transitions, payment
intent lifecycle + outbound payment-service calls, booking/contract entry
state, review-note reasons, signatures and OTP verify outcomes.
This commit is contained in:
Nathnael
2026-08-12 09:31:31 +00:00
parent 879f0b890d
commit bd9f7f354a
11 changed files with 295 additions and 14 deletions

View File

@@ -59,7 +59,6 @@ const userId = (req: LoggedRequest): string | undefined => {
@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();
@@ -90,6 +89,12 @@ export class RequestLogMiddleware implements NestMiddleware {
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,
@@ -113,6 +118,9 @@ export class RequestLogMiddleware implements NestMiddleware {
json = JSON.stringify(line);
} catch {
json = JSON.stringify({
time: line.time,
level: line.level,
logger: "request",
type: "http_request",
requestId,
method: req.method,
@@ -123,9 +131,11 @@ export class RequestLogMiddleware implements NestMiddleware {
});
}
if (status >= 500) this.canonical.error(json);
else if (status >= 400) this.canonical.warn(json);
else this.canonical.log(json);
// 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);

View File

@@ -7,9 +7,16 @@ import {
Repository,
} from "typeorm";
import { logCtx } from "../logging/request-context";
export abstract class BaseRepository<T extends ObjectLiteral> {
protected constructor(protected readonly repository: Repository<T>) {}
/** Table name, for the write trail on the canonical request log line. */
private get table(): string {
return this.repository.metadata.tableName;
}
/** Find a single entity by its primary key. */
async findById(
id: string,
@@ -34,22 +41,43 @@ export abstract class BaseRepository<T extends ObjectLiteral> {
/** Create and persist a new entity. */
async create(data: DeepPartial<T>): Promise<T> {
const entity = this.repository.create(data);
return this.repository.save(entity);
const saved = await this.repository.save(entity);
logCtx(1, { path: `db.created.${this.table}`, mode: "count" });
return saved;
}
/** Patch an entity in place and return the reloaded row. */
/**
* Patch an entity in place and return the reloaded row.
*
* Every domain status machine in this app (booking, contract, wagon,
* warehouse, transfer request…) lands here, so this is the one place that can
* record "what state did this request actually move" without every service
* remembering to log it.
*/
async update(id: string, data: DeepPartial<T>): Promise<T | null> {
await this.repository.update(id, data as never);
logCtx(1, { path: `db.updated.${this.table}`, mode: "count" });
if (data && typeof data === "object" && "status" in data) {
logCtx(
{ entity: this.table, id, status: (data as { status: unknown }).status },
{ path: "statusChanges", mode: "push" },
);
}
return this.findById(id);
}
/** Soft-delete an entity by primary key (sets deleted_at). */
async softDelete(id: string): Promise<void> {
await this.repository.softDelete(id);
logCtx({ entity: this.table, id }, { path: "deleted", mode: "push" });
}
/** Permanently delete an entity. Avoid in domain code; prefer softDelete. */
async hardDelete(id: string): Promise<void> {
await this.repository.delete(id);
logCtx(
{ entity: this.table, id, hard: true },
{ path: "deleted", mode: "push" },
);
}
}