import consola, { type ConsolaInstance } from "consola" import fs from "node:fs" import path from "node:path" import util from "node:util" import { PATHS } from "./paths" import { registerProcessCleanup } from "./process-cleanup" import { requestContext } from "./request-context" import { state } from "./state" const LOG_RETENTION_DAYS = 7 const LOG_RETENTION_MS = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000 const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000 const LOG_DIR = path.join(PATHS.APP_DIR, "logs") const FLUSH_INTERVAL_MS = 1000 const MAX_BUFFER_SIZE = 100 const logStreams = new Map() const logBuffers = new Map>() let runtimeInitialized = false let flushInterval: ReturnType | undefined let cleanupInterval: ReturnType | undefined const ensureLogDirectory = () => { if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }) } } const cleanupOldLogs = () => { if (!fs.existsSync(LOG_DIR)) { return } const now = Date.now() for (const entry of fs.readdirSync(LOG_DIR)) { const filePath = path.join(LOG_DIR, entry) let stats: fs.Stats try { stats = fs.statSync(filePath) } catch { continue } if (!stats.isFile()) { continue } if (now - stats.mtimeMs > LOG_RETENTION_MS) { try { fs.rmSync(filePath) } catch { continue } } } } const formatArgs = (args: Array) => args .map((arg) => typeof arg === "string" ? arg : ( util.inspect(arg, { depth: null, colors: false }) ), ) .join(" ") const sanitizeName = (name: string) => { const normalized = name .toLowerCase() .replaceAll(/[^a-z0-9]+/g, "-") .replaceAll(/^-+|-+$/g, "") return normalized === "" ? "handler" : normalized } const maybeUnref = (timer: ReturnType) => { timer.unref() } const flushBuffer = (filePath: string) => { const buffer = logBuffers.get(filePath) if (!buffer || buffer.length === 0) { return } const stream = getLogStream(filePath) const content = buffer.join("\n") + "\n" stream.write(content, (error) => { if (error) { console.warn("Failed to write handler log", error) } }) logBuffers.set(filePath, []) } const flushAllBuffers = () => { for (const filePath of logBuffers.keys()) { flushBuffer(filePath) } } const cleanup = () => { if (flushInterval) { clearInterval(flushInterval) flushInterval = undefined } if (cleanupInterval) { clearInterval(cleanupInterval) cleanupInterval = undefined } flushAllBuffers() for (const stream of logStreams.values()) { stream.end() } logStreams.clear() logBuffers.clear() } const initializeLoggerRuntime = () => { if (runtimeInitialized) { return } runtimeInitialized = true ensureLogDirectory() cleanupOldLogs() flushInterval = setInterval(flushAllBuffers, FLUSH_INTERVAL_MS) maybeUnref(flushInterval) cleanupInterval = setInterval(cleanupOldLogs, CLEANUP_INTERVAL_MS) maybeUnref(cleanupInterval) registerProcessCleanup(cleanup) } const getLogStream = (filePath: string): fs.WriteStream => { initializeLoggerRuntime() let stream = logStreams.get(filePath) if (!stream || stream.destroyed) { stream = fs.createWriteStream(filePath, { flags: "a" }) logStreams.set(filePath, stream) stream.on("error", (error: unknown) => { console.warn("Log stream error", error) logStreams.delete(filePath) }) } return stream } const appendLine = (filePath: string, line: string) => { let buffer = logBuffers.get(filePath) if (!buffer) { buffer = [] logBuffers.set(filePath, buffer) } buffer.push(line) if (buffer.length >= MAX_BUFFER_SIZE) { flushBuffer(filePath) } } type DebugLogger = Pick export const debugLazy = ( logger: DebugLogger, factory: () => [unknown, ...Array], ): void => { if (!state.verbose) { return } logger.debug(...factory()) } export const debugJson = ( logger: DebugLogger, label: string, value: unknown, ): void => { debugLazy(logger, () => [label, JSON.stringify(value)]) } export const debugJsonTail = ( logger: DebugLogger, label: string, { value, tailLength = 400 }: { value: unknown; tailLength?: number }, ): void => { debugLazy(logger, () => [label, JSON.stringify(value).slice(-tailLength)]) } export const createHandlerLogger = (name: string): ConsolaInstance => { const sanitizedName = sanitizeName(name) const instance = consola.withTag(name) if (state.verbose) { instance.level = 5 } instance.setReporters([]) instance.addReporter({ log(logObj) { initializeLoggerRuntime() const context = requestContext.getStore() const traceId = context?.traceId const date = logObj.date const dateKey = date.toLocaleDateString("sv-SE") const timestamp = date.toLocaleString("sv-SE", { hour12: false }) const filePath = path.join(LOG_DIR, `${sanitizedName}-${dateKey}.log`) const message = formatArgs(logObj.args as Array) const traceIdStr = traceId ? ` [${traceId}]` : "" const line = `[${timestamp}] [${logObj.type}] [${logObj.tag || name}]${traceIdStr}${ message ? ` ${message}` : "" }` appendLine(filePath, line) }, }) return instance }