/** * Logger sink implementations. * * - `ConsoleSink`: synchronous writes to stdout/stderr, optionally * ANSI-colorized for `console` format or compact JSON for `json` format. * - `RotatingFileSink`: asynchronous, append-only JSON lines with size-based * rotation. Writes are serialized through a promise queue so concurrent * `write()` calls cannot interleave. * * Both sinks survive transient errors (logged to stderr) — a failing log * sink must never crash the host process. */ import { dirname } from "@std/path"; import { ensureDir } from "@std/fs"; import { bold, cyan, gray, green, magenta, red, yellow, } from "@std/fmt/colors"; import { LOG_LEVEL_ORDER, type LogLevel, type LogRecord, type LogSink, } from "./types.ts"; const encoder = new TextEncoder(); const LEVEL_COLOR: Record string> = { debug: cyan, info: green, warn: yellow, error: red, fatal: (s) => bold(magenta(s)), }; // ===================================================================== // ConsoleSink // ===================================================================== export class ConsoleSink implements LogSink { constructor(private readonly format: "console" | "json") {} write(record: LogRecord): void { const line = this.format === "json" ? JSON.stringify(record) + "\n" : this.formatConsole(record) + "\n"; const useStderr = LOG_LEVEL_ORDER[record.level] >= LOG_LEVEL_ORDER.warn; const writer = useStderr ? Deno.stderr : Deno.stdout; try { writer.writeSync(encoder.encode(line)); } catch { // Never propagate sink failures to user code. } } flush(): Promise { return Promise.resolve(); } private formatConsole(record: LogRecord): string { const { time, level, logger, msg, ...rest } = record; const ts = gray(time); const lvl = LEVEL_COLOR[level](level.toUpperCase().padEnd(5)); const name = cyan(`[${logger}]`); let fields = ""; const keys = Object.keys(rest); if (keys.length > 0) { const parts: string[] = []; for (const k of keys) { parts.push(`${k}=${formatValue(rest[k])}`); } fields = " " + gray(parts.join(" ")); } return `${ts} ${lvl} ${name} ${msg}${fields}`; } } function formatValue(v: unknown): string { if (v === null) return "null"; if (v === undefined) return "undefined"; switch (typeof v) { case "string": return /\s/.test(v) ? JSON.stringify(v) : v; case "number": case "boolean": case "bigint": return String(v); case "function": return "[function]"; } if (v instanceof Error) { return `${v.name}: ${v.message}`; } try { return JSON.stringify(v); } catch { return String(v); } } // ===================================================================== // RotatingFileSink // ===================================================================== export class RotatingFileSink implements LogSink { private queue: Promise = Promise.resolve(); private readonly maxBytes: number; private readonly maxBackups: number; private dirEnsured = false; constructor( private readonly path: string, options: { maxBytes?: number; maxBackups?: number } = {}, ) { this.maxBytes = options.maxBytes ?? 10 * 1024 * 1024; this.maxBackups = options.maxBackups ?? 5; } write(record: LogRecord): void { const line = JSON.stringify(record) + "\n"; this.queue = this.queue.then(() => this.doWrite(line)).catch((err) => { const msg = err instanceof Error ? err.message : String(err); try { Deno.stderr.writeSync( encoder.encode(`[logger] RotatingFileSink write failed: ${msg}\n`), ); } catch { // give up } }); } async flush(): Promise { await this.queue; } private async doWrite(line: string): Promise { if (!this.dirEnsured) { await ensureDir(dirname(this.path)); this.dirEnsured = true; } try { const stat = await Deno.stat(this.path); if (stat.size + line.length > this.maxBytes) { await this.rotate(); } } catch (err) { if (!(err instanceof Deno.errors.NotFound)) throw err; } await Deno.writeTextFile(this.path, line, { append: true }); } private async rotate(): Promise { // Remove the oldest backup if it would be pushed past the retention limit. if (this.maxBackups > 0) { const oldest = `${this.path}.${this.maxBackups}`; try { await Deno.remove(oldest); } catch (err) { if (!(err instanceof Deno.errors.NotFound)) throw err; } } // Shift .{i} -> .{i+1} for i in [maxBackups-1 .. 1]. for (let i = this.maxBackups - 1; i >= 1; i--) { const src = `${this.path}.${i}`; const dst = `${this.path}.${i + 1}`; try { await Deno.rename(src, dst); } catch (err) { if (!(err instanceof Deno.errors.NotFound)) throw err; } } // Rotate the active log to .1 (only if there is at least one backup slot). if (this.maxBackups > 0) { try { await Deno.rename(this.path, `${this.path}.1`); } catch (err) { if (!(err instanceof Deno.errors.NotFound)) throw err; } } else { // No retention — just truncate. try { await Deno.remove(this.path); } catch (err) { if (!(err instanceof Deno.errors.NotFound)) throw err; } } } }