import { env } from "../config/env.js"; /** * Server-side operational logging — the one place in this codebase allowed * to call `console.*` directly (see `biome.json`'s `noConsole`, which bans * bare `console.log` everywhere else: every log line here goes through a * named level instead, `logger.info(...)`/`logger.error(...)`, never an * unlabeled dump of text). Everything else (the request logger, `server.ts`'s * startup line, the error middleware) goes through this instead of touching * `console` itself, so there's exactly one place that decides the log * *shape* (structured JSON lines — one object per line, trivially * grep/parse-able by `docker logs`/Portainer or any log aggregator, unlike * free-form `console.log` text). * * A class, not a plain object literal — same convention as * `ErrorHandlerService` (`packages/error-tools`): `public` methods are the * actual API, `private` ones are internals a caller never touches directly. * `export const logger = new LoggerService()` below is the single shared * instance every caller imports — there's exactly one log stream for the * whole process, nothing to parameterize per call site, so nobody * constructs their own. */ /** Ascending severity — mirrors the standard `debug < info < warn < error` convention. */ export type LogLevel = "debug" | "info" | "warn" | "error"; /** Arbitrary structured context attached to a log line (a request id, a duration, an error's own fields, …) — merged into the emitted JSON object, never interpolated into the message string itself. */ export type LogMeta = Record; const LEVEL_SEVERITY: Record = { debug: 0, info: 1, warn: 2, error: 3, }; /** * Lines below this level are dropped rather than emitted — keeps `debug` * out of production (verbose, meant for local troubleshooting only) and * out of the test suite's own output (Mocha's reporter is noisy enough * already), while `pnpm dev:api` sees everything. A free function, not a * method — exported (unlike the rest of this module's internals) purely so * `logger.service.test.ts` can exercise the three `NODE_ENV` cases * directly, without needing a `LoggerService` instance at all. */ export function minLevelFor(nodeEnv: typeof env.NODE_ENV): LogLevel { if (nodeEnv === "production") return "info"; if (nodeEnv === "test") return "warn"; return "debug"; } /** Console method each level writes through — `error`/`warn` go to stderr (their own native behavior), `debug`/`info` to stdout, the usual split log aggregators expect. */ const CONSOLE_METHOD: Record = { debug: "debug", info: "info", warn: "warn", error: "error", }; /** Server-side operational logger — see the module doc comment for why this exists and what it's for. */ export class LoggerService { /** Computed once at construction from `env.NODE_ENV` — see {@link minLevelFor}. */ private readonly _minSeverity: number; public constructor(nodeEnv: typeof env.NODE_ENV = env.NODE_ENV) { this._minSeverity = LEVEL_SEVERITY[minLevelFor(nodeEnv)]; } public debug(message: string, meta?: LogMeta): void { this._emit("debug", message, meta); } public info(message: string, meta?: LogMeta): void { this._emit("info", message, meta); } public warn(message: string, meta?: LogMeta): void { this._emit("warn", message, meta); } public error(message: string, meta?: LogMeta): void { this._emit("error", message, meta); } private _emit(level: LogLevel, message: string, meta?: LogMeta): void { if (LEVEL_SEVERITY[level] < this._minSeverity) return; // `meta` spread first so a caller accidentally passing e.g. `{ message: // ... }` in it can never shadow the line's own core fields. const line = { ...meta, timestamp: new Date().toISOString(), level, message, }; // biome-ignore lint/suspicious/noConsole: this is the one place allowed to — see the class doc comment above. console[CONSOLE_METHOD[level]](JSON.stringify(line)); } } /** Single shared instance — this service has no per-call-site state to isolate, same reasoning as `errorHandlerService`. */ export const logger = new LoggerService();