/** * Production-grade logger for peardock server. * * - Levels: error | warn | info | debug (env LOG_LEVEL) * - Console: pretty colored (default TTY) or JSON lines (LOG_FORMAT=json / non-TTY) * - Optional file logging with size rotation (ENABLE_FILE_LOGGING=1) * - Redacts secrets in meta (password, seed, token, …) */ import fs from 'fs' import path from 'path' import { inspect } from 'util' const LEVELS = Object.freeze({ error: 0, warn: 1, info: 2, debug: 3, }) const LEVEL_LABEL = { 0: 'error', 1: 'warn', 2: 'info', 3: 'debug', } const LEVEL_COLOR = { 0: '\x1b[31m', // red 1: '\x1b[33m', // yellow 2: '\x1b[36m', // cyan 3: '\x1b[90m', // gray } const DIM = '\x1b[2m' const BOLD = '\x1b[1m' const RESET = '\x1b[0m' const GREEN = '\x1b[32m' const MAGENTA = '\x1b[35m' const REDACT_KEYS = /^(password|passwd|secret|token|seed|authorization|auth|api[_-]?key|private[_-]?key|server_seed|invite)$/i /** * @param {string} [raw] * @returns {number} */ export function parseLogLevel(raw) { const v = String(raw || process.env.LOG_LEVEL || '') .trim() .toLowerCase() if (v === 'error' || v === '0') return LEVELS.error if (v === 'warn' || v === 'warning' || v === '1') return LEVELS.warn if (v === 'info' || v === '2') return LEVELS.info if (v === 'debug' || v === 'trace' || v === '3') return LEVELS.debug // Default: info in production, debug otherwise if (process.env.NODE_ENV === 'production') return LEVELS.info return LEVELS.debug } /** * @param {unknown} value * @param {number} [depth] */ function redact(value, depth = 0) { if (depth > 4) return '[Depth]' if (value == null) return value if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1)) if (typeof value === 'object') { if (value instanceof Error) { return { name: value.name, message: value.message, code: value.code, stack: process.env.LOG_STACK === '1' ? value.stack : undefined, } } const out = {} for (const [k, v] of Object.entries(value)) { if (REDACT_KEYS.test(k)) { out[k] = '[Redacted]' } else if (typeof v === 'string' && v.length > 2000) { out[k] = `${v.slice(0, 2000)}…(+${v.length - 2000})` } else { out[k] = redact(v, depth + 1) } } return out } if (typeof value === 'string' && value.length > 4000) { return `${value.slice(0, 4000)}…(+${value.length - 4000})` } return value } /** * @param {unknown} meta */ function formatMetaPretty(meta) { if (meta == null) return '' const keys = Object.keys(meta) if (!keys.length) return '' try { return ( ' ' + inspect(meta, { colors: true, depth: 4, compact: true, breakLength: 100, sorted: true, }) ) } catch { return ` ${JSON.stringify(meta)}` } } class Logger { /** * @param {{ * level?: number, * name?: string, * enableFileLogging?: boolean, * logDir?: string, * maxFileSize?: number, * maxFiles?: number, * format?: 'pretty'|'json', * color?: boolean, * }} [options] */ constructor(options = {}) { this.level = options.level ?? parseLogLevel() this.name = options.name || 'peardock' this.enableFileLogging = options.enableFileLogging !== undefined ? Boolean(options.enableFileLogging) : process.env.ENABLE_FILE_LOGGING === 'true' || process.env.ENABLE_FILE_LOGGING === '1' this.logDir = options.logDir || process.env.LOG_DIR || './logs' this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024 this.maxFiles = options.maxFiles || 5 const fmtEnv = String(process.env.LOG_FORMAT || options.format || '').toLowerCase() const isTty = Boolean(process.stdout?.isTTY) this.format = fmtEnv === 'json' || fmtEnv === 'pretty' ? fmtEnv : isTty ? 'pretty' : process.env.NODE_ENV === 'production' ? 'json' : 'pretty' this.color = options.color !== undefined ? Boolean(options.color) : this.format === 'pretty' && isTty && process.env.NO_COLOR !== '1' if (this.enableFileLogging) { this.ensureLogDirectory() this.currentLogFile = this.getLogFileName() } } /** * Child logger with bound name / base meta. * @param {string} name * @param {Record} [baseMeta] */ child(name, baseMeta = {}) { const child = new Logger({ level: this.level, name: this.name === 'peardock' ? name : `${this.name}:${name}`, enableFileLogging: this.enableFileLogging, logDir: this.logDir, maxFileSize: this.maxFileSize, maxFiles: this.maxFiles, format: this.format, color: this.color, }) child._baseMeta = { ...(this._baseMeta || {}), ...baseMeta } return child } ensureLogDirectory() { if (!fs.existsSync(this.logDir)) { fs.mkdirSync(this.logDir, { recursive: true, mode: 0o750 }) } } getLogFileName() { const date = new Date().toISOString().split('T')[0] return path.join(this.logDir, `peardock-${date}.log`) } rotateLogFile() { if (!this.enableFileLogging || !this.currentLogFile) return try { if (!fs.existsSync(this.currentLogFile)) return const stats = fs.statSync(this.currentLogFile) if (stats.size <= this.maxFileSize) return const archiveName = this.currentLogFile.replace( /\.log$/, `-${Date.now()}.log` ) fs.renameSync(this.currentLogFile, archiveName) this.cleanupOldLogs() this.currentLogFile = this.getLogFileName() } catch { // ignore } } cleanupOldLogs() { try { const files = fs .readdirSync(this.logDir) .filter((f) => f.startsWith('peardock-') && f.endsWith('.log')) .map((f) => ({ name: f, path: path.join(this.logDir, f), time: fs.statSync(path.join(this.logDir, f)).mtime.getTime(), })) .sort((a, b) => b.time - a.time) if (files.length > this.maxFiles) { for (const file of files.slice(this.maxFiles)) { try { fs.unlinkSync(file.path) } catch { // ignore } } } } catch { // ignore } } /** * @param {number} level * @param {string} message * @param {Record} [meta] */ formatPretty(level, message, meta = {}) { const ts = new Date().toISOString() const label = LEVEL_LABEL[level] || 'info' const c = this.color const lvl = c ? `${LEVEL_COLOR[level] || ''}${BOLD}${label.toUpperCase().padEnd(5)}${RESET}` : label.toUpperCase().padEnd(5) const time = c ? `${DIM}${ts}${RESET}` : ts const name = c ? `${MAGENTA}${this.name}${RESET}` : this.name const msg = c && level === LEVELS.info ? `${GREEN}${message}${RESET}` : message const metaStr = formatMetaPretty(meta) return `${time} ${lvl} [${name}] ${msg}${metaStr}` } /** * @param {number} level * @param {string} message * @param {Record} [meta] */ formatJson(level, message, meta = {}) { return JSON.stringify({ ts: new Date().toISOString(), level: LEVEL_LABEL[level] || 'info', name: this.name, msg: message, pid: process.pid, ...meta, }) } writeToFile(line) { if (!this.enableFileLogging) return try { this.rotateLogFile() // Always store JSON lines in files for grepping fs.appendFileSync(this.currentLogFile, line + '\n', { mode: 0o640 }) } catch (err) { console.error('Failed to write log file:', err.message) } } /** * @param {number} level * @param {string} message * @param {Record|Error} [metaOrErr] */ log(level, message, metaOrErr = {}) { if (level > this.level) return let meta = {} if (metaOrErr instanceof Error) { meta = { err: metaOrErr } } else if (metaOrErr && typeof metaOrErr === 'object') { meta = metaOrErr } const merged = redact({ ...(this._baseMeta || {}), ...meta }) const consoleLine = this.format === 'json' ? this.formatJson(level, message, merged) : this.formatPretty(level, message, merged) switch (level) { case LEVELS.error: console.error(consoleLine) break case LEVELS.warn: console.warn(consoleLine) break default: console.log(consoleLine) } // File always JSON for production tooling if (this.enableFileLogging) { this.writeToFile(this.formatJson(level, message, merged)) } } error(message, meta) { this.log(LEVELS.error, message, meta) } warn(message, meta) { this.log(LEVELS.warn, message, meta) } info(message, meta) { this.log(LEVELS.info, message, meta) } debug(message, meta) { this.log(LEVELS.debug, message, meta) } /** * Multi-line banner (startup). Always shown at info+. * @param {string[]} lines */ banner(lines) { if (LEVELS.info > this.level) return const width = Math.min( 72, Math.max(40, ...lines.map((l) => String(l).length)) + 4 ) const bar = '─'.repeat(width) const c = this.color const paint = (s) => (c ? `${BOLD}${GREEN}${s}${RESET}` : s) console.log('') console.log(paint(`┌${bar}┐`)) for (const line of lines) { const pad = ' '.repeat(Math.max(0, width - String(line).length - 1)) console.log(paint(`│ ${line}${pad}│`)) } console.log(paint(`└${bar}┘`)) console.log('') } } const logger = new Logger() export { Logger, LEVELS } export default logger