203 lines
5.4 KiB
TypeScript
203 lines
5.4 KiB
TypeScript
/**
|
|
* 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<LogLevel, (s: string) => 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<void> {
|
|
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<void> = 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<void> {
|
|
await this.queue;
|
|
}
|
|
|
|
private async doWrite(line: string): Promise<void> {
|
|
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<void> {
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
}
|