const LEVELS = { debug: 20, info: 30, warn: 40, error: 50, fatal: 60 } as const; type Level = keyof typeof LEVELS; const COLORS: Record = { debug: '\x1b[34m', info: '\x1b[32m', warn: '\x1b[33m', error: '\x1b[31m', fatal: '\x1b[41m\x1b[37m', }; const KEY_COLOR = '\x1b[35m'; const MSG_COLOR = '\x1b[36m'; const RESET = '\x1b[39m'; const FULL_RESET = '\x1b[0m'; const threshold = LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info; function formatErr(err: unknown): string { if (err instanceof Error) { return `{\n "type": "${err.constructor.name}",\n "message": "${err.message}",\n "stack":\n ${err.stack}\n }`; } return JSON.stringify(err); } function formatData(data: Record): string { let out = ''; for (const [k, v] of Object.entries(data)) { if (k === 'err') { out += `\n ${KEY_COLOR}err${RESET}: ${formatErr(v)}`; } else { out += `\n ${KEY_COLOR}${k}${RESET}: ${JSON.stringify(v)}`; } } return out; } function ts(): string { const d = new Date(); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`; } function log( level: Level, dataOrMsg: Record | string, msg?: string, ): void { if (LEVELS[level] < threshold) return; const tag = `${COLORS[level]}${level.toUpperCase()}${level === 'fatal' ? FULL_RESET : RESET}`; const stream = LEVELS[level] >= LEVELS.warn ? process.stderr : process.stdout; if (typeof dataOrMsg === 'string') { stream.write( `[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`, ); } else { stream.write( `[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${msg}${RESET}${formatData(dataOrMsg)}\n`, ); } } export const logger = { debug: (dataOrMsg: Record | string, msg?: string) => log('debug', dataOrMsg, msg), info: (dataOrMsg: Record | string, msg?: string) => log('info', dataOrMsg, msg), warn: (dataOrMsg: Record | string, msg?: string) => log('warn', dataOrMsg, msg), error: (dataOrMsg: Record | string, msg?: string) => log('error', dataOrMsg, msg), fatal: (dataOrMsg: Record | string, msg?: string) => log('fatal', dataOrMsg, msg), }; // Route uncaught errors through logger so they get timestamps in stderr process.on('uncaughtException', (err) => { logger.fatal({ err }, 'Uncaught exception'); process.exit(1); }); process.on('unhandledRejection', (reason) => { logger.error({ err: reason }, 'Unhandled rejection'); });