Harden server logging and production boot experience
CI / test (push) Has been cancelled

Replace ad-hoc console output with a structured logger (pretty/JSON,
levels, redaction, optional file rotation), a clean startup banner with
Docker/feature probes, graceful shutdown signals, and slow-RPC warnings.
This commit is contained in:
2026-07-10 23:59:13 -04:00
parent d87e6fe486
commit fa99bd8c6e
7 changed files with 482 additions and 143 deletions
+299 -97
View File
@@ -1,162 +1,364 @@
/**
* Structured logging utility with levels and rotation
* 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 fs from 'fs'
import path from 'path'
import { inspect } from 'util'
const LOG_LEVELS = {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
};
const LEVELS = Object.freeze({
error: 0,
warn: 1,
info: 2,
debug: 3,
})
const LOG_LEVEL_NAMES = ['ERROR', 'WARN', 'INFO', 'DEBUG'];
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 || (process.env.NODE_ENV === 'production' ? LOG_LEVELS.INFO : LOG_LEVELS.DEBUG);
this.enableFileLogging = options.enableFileLogging || false;
this.logDir = options.logDir || './logs';
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB
this.maxFiles = options.maxFiles || 5;
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();
this.ensureLogDirectory()
this.currentLogFile = this.getLogFileName()
}
}
/**
* Child logger with bound name / base meta.
* @param {string} name
* @param {Record<string, unknown>} [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 });
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`);
const date = new Date().toISOString().split('T')[0]
return path.join(this.logDir, `peardock-${date}.log`)
}
rotateLogFile() {
if (!this.enableFileLogging) return;
if (!this.enableFileLogging || !this.currentLogFile) return
try {
const stats = fs.statSync(this.currentLogFile);
if (stats.size > this.maxFileSize) {
// Rotate: move current to archive
const archiveName = this.currentLogFile.replace('.log', `-${Date.now()}.log`);
fs.renameSync(this.currentLogFile, archiveName);
// Clean up old files
this.cleanupOldLogs();
// Create new log file
this.currentLogFile = this.getLogFileName();
}
} catch (err) {
// File doesn't exist yet, that's okay
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 => ({
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()
time: fs.statSync(path.join(this.logDir, f)).mtime.getTime(),
}))
.sort((a, b) => b.time - a.time);
.sort((a, b) => b.time - a.time)
// Keep only the most recent maxFiles
if (files.length > this.maxFiles) {
files.slice(this.maxFiles).forEach(file => {
for (const file of files.slice(this.maxFiles)) {
try {
fs.unlinkSync(file.path);
} catch (err) {
console.error(`Failed to delete old log file: ${file.name}`, err);
fs.unlinkSync(file.path)
} catch {
// ignore
}
});
}
}
} catch (err) {
console.error('Failed to cleanup old logs:', err);
} catch {
// ignore
}
}
formatMessage(level, message, meta = {}) {
const timestamp = new Date().toISOString();
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
return `[${timestamp}] [${LOG_LEVEL_NAMES[level]}] ${message}${metaStr}`;
/**
* @param {number} level
* @param {string} message
* @param {Record<string, unknown>} [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}`
}
writeToFile(message) {
if (!this.enableFileLogging) return;
/**
* @param {number} level
* @param {string} message
* @param {Record<string, unknown>} [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();
fs.appendFileSync(this.currentLogFile, message + '\n');
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 to log file:', err);
console.error('Failed to write log file:', err.message)
}
}
log(level, message, meta = {}) {
if (level > this.level) return;
/**
* @param {number} level
* @param {string} message
* @param {Record<string, unknown>|Error} [metaOrErr]
*/
log(level, message, metaOrErr = {}) {
if (level > this.level) return
const formatted = this.formatMessage(level, message, meta);
// Console output
switch (level) {
case LOG_LEVELS.ERROR:
console.error(formatted);
break;
case LOG_LEVELS.WARN:
console.warn(formatted);
break;
case LOG_LEVELS.INFO:
console.log(formatted);
break;
case LOG_LEVELS.DEBUG:
console.log(formatted);
break;
let meta = {}
if (metaOrErr instanceof Error) {
meta = { err: metaOrErr }
} else if (metaOrErr && typeof metaOrErr === 'object') {
meta = metaOrErr
}
// File output
this.writeToFile(formatted);
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(LOG_LEVELS.ERROR, message, meta);
this.log(LEVELS.error, message, meta)
}
warn(message, meta) {
this.log(LOG_LEVELS.WARN, message, meta);
this.log(LEVELS.warn, message, meta)
}
info(message, meta) {
this.log(LOG_LEVELS.INFO, message, meta);
this.log(LEVELS.info, message, meta)
}
debug(message, meta) {
this.log(LOG_LEVELS.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('')
}
}
// Create singleton instance
const logger = new Logger({
enableFileLogging: process.env.ENABLE_FILE_LOGGING === 'true',
level: process.env.LOG_LEVEL === 'ERROR' ? LOG_LEVELS.ERROR :
process.env.LOG_LEVEL === 'WARN' ? LOG_LEVELS.WARN :
process.env.LOG_LEVEL === 'INFO' ? LOG_LEVELS.INFO :
LOG_LEVELS.DEBUG,
});
export default logger;
const logger = new Logger()
export { Logger, LEVELS }
export default logger