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.
365 lines
9.5 KiB
JavaScript
365 lines
9.5 KiB
JavaScript
/**
|
|
* 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<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, 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<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}`
|
|
}
|
|
|
|
/**
|
|
* @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()
|
|
// 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<string, unknown>|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
|