/** * Structured request logging middleware. Emits one record per request with a * request id, method, path, status, and duration — as pretty text (dev) or JSON * (production/log aggregation). The request id is stored on `ctx.locals` so * downstream handlers can correlate their own logs. */ import type { Context, Middleware } from "./context.ts"; export interface RequestRecord { time: string; id: string; method: string; path: string; status: number; durationMs: number; } export interface RequestLoggerOptions { /** "pretty" (default) for humans, "json" for machines. */ format?: "pretty" | "json"; /** Where each finished record goes. Default console.log. */ sink?: (line: string, record: RequestRecord) => void; /** ctx.locals key for the request id. Default "requestId". */ requestIdKey?: string; /** Clock injection for tests. Default Date.now. */ now?: () => number; } export function requestLogger(options: RequestLoggerOptions = {}): Middleware { const format = options.format ?? "pretty"; const sink = options.sink ?? ((line) => console.log(line)); const idKey = options.requestIdKey ?? "requestId"; const now = options.now ?? Date.now; return async (ctx: Context, next) => { const start = now(); const id = (ctx.locals[idKey] as string | undefined) ?? crypto.randomUUID(); ctx.locals[idKey] = id; let status = 500; try { const res = await next(); status = res.status; return res; } finally { const record: RequestRecord = { time: new Date(start).toISOString(), id, method: ctx.req.method, path: ctx.url.pathname, status, durationMs: now() - start, }; sink(format === "json" ? JSON.stringify(record) : formatPretty(record), record); } }; } function formatPretty(r: RequestRecord): string { return `${r.method} ${r.path} → ${r.status} ${r.durationMs}ms [${r.id.slice(0, 8)}]`; }