singularity-forge/packages/daemon/src/logger.ts
2026-05-05 14:46:18 +02:00

97 lines
2.6 KiB
TypeScript

import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
import { dirname } from "node:path";
import type { LogEntry, LogLevel } from "./types.js";
const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
export interface LoggerOptions {
filePath: string;
level: LogLevel;
verbose?: boolean;
}
/**
* Structured JSON-lines file logger.
* Writes LogEntry objects one per line in append mode.
* The open write stream keeps the Node event loop alive (daemon keepalive).
*/
export class Logger {
private readonly stream: WriteStream;
private readonly level: number;
private readonly verbose: boolean;
/** The level name actually in effect — surfaced so callers (e.g. the
* startup banner) report the level the logger is using, not the level
* that was originally requested in config. */
readonly effectiveLevel: LogLevel;
constructor(opts: LoggerOptions) {
// Ensure parent directory exists
const dir = dirname(opts.filePath);
try {
mkdirSync(dir, { recursive: true });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Cannot create log directory ${dir}: ${msg}`);
}
this.stream = createWriteStream(opts.filePath, { flags: "a" });
this.level = LEVEL_ORDER[opts.level] ?? LEVEL_ORDER.info;
this.effectiveLevel = opts.level;
this.verbose = opts.verbose ?? false;
}
debug(msg: string, data?: Record<string, unknown>): void {
this.write("debug", msg, data);
}
info(msg: string, data?: Record<string, unknown>): void {
this.write("info", msg, data);
}
warn(msg: string, data?: Record<string, unknown>): void {
this.write("warn", msg, data);
}
error(msg: string, data?: Record<string, unknown>): void {
this.write("error", msg, data);
}
/** End the write stream. Resolves when the stream is fully flushed. */
close(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.stream.end(() => {
this.stream.once("close", () => resolve());
});
this.stream.once("error", reject);
});
}
private write(
level: LogLevel,
msg: string,
data?: Record<string, unknown>,
): void {
if (LEVEL_ORDER[level] < this.level) return;
const entry: LogEntry = {
ts: new Date().toISOString(),
level,
msg,
...(data !== undefined ? { data } : {}),
};
const line = JSON.stringify(entry) + "\n";
this.stream.write(line);
if (this.verbose) {
const prefix = `[${entry.ts}] ${level.toUpperCase()}`;
const suffix = data ? ` ${JSON.stringify(data)}` : "";
process.stderr.write(`${prefix}: ${msg}${suffix}\n`);
}
}
}