UNPKG

metalog

Version:

Logger for Metarhia

452 lines (405 loc) 12.5 kB
'use strict'; const fs = require('node:fs'); const fsp = fs.promises; const path = require('node:path'); const util = require('node:util'); const events = require('node:events'); const readline = require('node:readline'); const metautil = require('metautil'); const concolor = require('concolor'); const DAY_MILLISECONDS = metautil.duration('1d'); const DEFAULT_WRITE_INTERVAL = metautil.duration('3s'); const DEFAULT_BUFFER_SIZE = 64 * 1024; const DEFAULT_KEEP_DAYS = 1; const STACK_AT = ' at '; const TYPE_LENGTH = 6; const LINE_SEPARATOR = ';'; const INDENT = 2; const DATE_LEN = 'YYYY-MM-DD'.length; const TIME_START = DATE_LEN + 1; const TIME_END = TIME_START + 'HH:MM:SS'.length; const LOG_TYPES = ['log', 'info', 'warn', 'debug', 'error']; const TYPE_COLOR = concolor({ log: 'b,black/white', info: 'b,white/blue', warn: 'b,black/yellow', debug: 'b,white/green', error: 'b,yellow/red', }); const TEXT_COLOR = concolor({ log: 'white', info: 'white', warn: 'b,yellow', debug: 'b,green', error: 'red', }); const DEFAULT_FLAGS = { log: false, info: false, warn: false, debug: false, error: false, }; const logTypes = (types) => { const flags = { ...DEFAULT_FLAGS }; for (const type of types) { flags[type] = true; } return flags; }; const nowDays = () => { const now = new Date(); const year = now.getUTCFullYear(); const month = now.getUTCMonth(); const day = now.getUTCDate(); const date = new Date(year, month, day, 0, 0, 0, 0); return Math.floor(date.getTime() / DAY_MILLISECONDS); }; const nameToDays = (fileName) => { const date = fileName.substring(0, DATE_LEN); const fileTime = new Date(date).getTime(); return Math.floor(fileTime / DAY_MILLISECONDS); }; const getNextReopen = () => { const now = new Date(); const curTime = now.getTime(); const nextDate = now.setUTCHours(0, 0, 0, 0); return nextDate - curTime + DAY_MILLISECONDS; }; class Console { #write; #groupIndent = 0; #counts = new Map(); #times = new Map(); #readline = readline; constructor(write) { this.#write = write; } assert(assertion, ...args) { if (!assertion) { const noArgs = args.length === 0; const message = noArgs ? 'Assertion failed' : util.format(...args); this.#write('error', this.#groupIndent, message); } } clear() { this.#readline.cursorTo(process.stdout, 0, 0); this.#readline.clearScreenDown(process.stdout); } count(label = 'default') { let cnt = this.#counts.get(label) || 0; cnt++; this.#counts.set(label, cnt); this.#write('debug', this.#groupIndent, `${label}: ${cnt}`); } countReset(label = 'default') { this.#counts.delete(label); } debug(...args) { this.#write('debug', this.#groupIndent, ...args); } dir(...args) { this.#write('debug', this.#groupIndent, ...args); } trace(...args) { const msg = util.format(...args); const err = new Error(msg); this.#write('debug', this.#groupIndent, `Trace${err.stack}`); } info(...args) { this.#write('info', this.#groupIndent, ...args); } log(...args) { this.#write('log', this.#groupIndent, ...args); } warn(...args) { this.#write('warn', this.#groupIndent, ...args); } error(...args) { this.#write('error', this.#groupIndent, ...args); } group(...args) { if (args.length !== 0) this.log(...args); this.#groupIndent += INDENT; } groupCollapsed(...args) { this.group(...args); } groupEnd() { if (this.#groupIndent === 0) return; this.#groupIndent -= INDENT; } table(tabularData) { this.#write('log', 0, JSON.stringify(tabularData)); } time(label = 'default') { this.#times.set(label, process.hrtime()); } timeEnd(label = 'default') { const startTime = this.#times.get(label); const totalTime = process.hrtime(startTime); const totalTimeMs = totalTime[0] * 1e3 + totalTime[1] / 1e6; this.timeLog(label, `${label}: ${totalTimeMs}ms`); this.#times.delete(label); } timeLog(label, ...args) { const startTime = this.#times.get(label); if (startTime === undefined) { const msg = `Warning: No such label '${label}'`; this.#write('warn', this.#groupIndent, msg); return; } this.#write('debug', this.#groupIndent, ...args); } } class Logger extends events.EventEmitter { constructor(options) { super(); const { workerId = 0, createStream = fs.createWriteStream } = options; const { writeInterval, writeBuffer, keepDays, home, json } = options; const { toFile = LOG_TYPES, toStdout = LOG_TYPES, crash } = options; this.active = false; this.path = options.path; this.workerId = `W${workerId}`; this.createStream = createStream; this.writeInterval = writeInterval || DEFAULT_WRITE_INTERVAL; this.writeBuffer = writeBuffer || DEFAULT_BUFFER_SIZE; this.keepDays = keepDays || DEFAULT_KEEP_DAYS; this.home = home; this.json = Boolean(json); this.stream = null; this.reopenTimer = null; this.flushTimer = null; this.lock = false; this.buffer = []; this.bufferLength = 0; this.file = ''; this.toFile = logTypes(toFile); this.fsEnabled = toFile.length !== 0; this.toStdout = logTypes(toStdout); this.console = new Console((...args) => this.write(...args)); if (crash === 'flush') this.#setupCrashHandling(); return this.open(); } createLogDir() { return new Promise((resolve, reject) => { fs.access(this.path, (err) => { if (!err) resolve(); fs.mkdir(this.path, (err) => { if (!err || err.code === 'EEXIST') return void resolve(); const error = new Error(`Can not create directory: ${this.path}\n`); this.emit('error', error); reject(); }); }); }); } async open() { if (this.active) return this; this.active = true; if (!this.fsEnabled) { process.nextTick(() => this.emit('open')); return this; } await this.createLogDir(); const fileName = metautil.nowDate() + '-' + this.workerId + '.log'; this.file = path.join(this.path, fileName); const nextReopen = getNextReopen(); this.reopenTimer = setTimeout(() => { this.once('close', () => { this.open(); }); this.close().catch((err) => { process.stdout.write(`${err.stack}\n`); this.emit('error', err); }); }, nextReopen); if (this.keepDays) await this.rotate(); this.stream = this.createStream(this.file, { flags: 'a' }); this.flushTimer = setInterval(() => { this.flush(); }, this.writeInterval); this.stream.on('open', () => { this.emit('open'); }); this.stream.on('error', () => { this.emit('error', new Error(`Can't open log file: ${this.file}`)); }); await events.once(this, 'open'); return this; } async close() { if (!this.active) return Promise.resolve(); if (!this.fsEnabled) { this.active = false; this.emit('close'); return Promise.resolve(); } const { stream } = this; if (!stream || stream.destroyed || stream.closed) { return Promise.resolve(); } return new Promise((resolve, reject) => { this.flush((err) => { if (err) return void reject(err); this.active = false; stream.end(() => { clearInterval(this.flushTimer); clearTimeout(this.reopenTimer); this.flushTimer = null; this.reopenTimer = null; const fileName = this.file; this.emit('close'); fs.stat(fileName, (err, stats) => { if (!err && stats.size === 0) { fsp.unlink(fileName).catch(() => {}); } resolve(); }); }); }); }); } async rotate() { if (!this.keepDays) return; const now = nowDays(); const finish = []; try { const files = await fsp.readdir(this.path); for (const fileName of files) { if (metautil.fileExt(fileName) !== 'log') continue; const fileAge = now - nameToDays(fileName); if (fileAge < this.keepDays) continue; finish.push(fsp.unlink(path.join(this.path, fileName))); } await Promise.all(finish); } catch (err) { process.stdout.write(`${err.stack}\n`); this.emit('error', err); } } format(type, indent, ...args) { const normalize = type === 'error' || type === 'debug'; const s = `${' '.repeat(indent)}${util.format(...args)}`; return normalize ? this.normalizeStack(s) : s; } formatPretty(type, indent, ...args) { const dateTime = new Date().toISOString(); const message = this.format(type, indent, ...args); const normalColor = TEXT_COLOR[type]; const markColor = TYPE_COLOR[type]; const time = normalColor(dateTime.substring(TIME_START, TIME_END)); const id = normalColor(this.workerId); const mark = markColor(' ' + type.padEnd(TYPE_LENGTH)); const msg = normalColor(message); return `${time} ${id} ${mark} ${msg}`; } formatFile(type, indent, ...args) { const dateTime = new Date().toISOString(); const message = this.format(type, indent, ...args); const msg = metautil.replace(message, '\n', LINE_SEPARATOR); return `${dateTime} [${type}] ${msg}`; } formatJson(type, indent, ...args) { const log = { timestamp: new Date().toISOString(), workerId: this.workerId, level: type, message: null, }; if (metautil.isError(args[0])) { log.err = this.expandError(args[0]); args = args.slice(1); } else if (typeof args[0] === 'object') { Object.assign(log, args[0]); if (metautil.isError(log.err)) log.err = this.expandError(log.err); if (metautil.isError(log.error)) log.error = this.expandError(log.error); args = args.slice(1); } log.message = util.format(...args); return JSON.stringify(log); } write(type, indent, ...args) { if (this.toStdout[type]) { const line = this.json ? this.formatJson(type, indent, ...args) : this.formatPretty(type, indent, ...args); process.stdout.write(line + '\n'); } if (this.toFile[type]) { const line = this.json ? this.formatJson(type, indent, ...args) : this.formatFile(type, indent, ...args); const buffer = Buffer.from(line + '\n'); this.buffer.push(buffer); this.bufferLength += buffer.length; if (this.bufferLength >= this.writeBuffer) this.flush(); } } flush(callback) { if (this.lock) { if (callback) this.once('unlocked', callback); return; } if (this.buffer.length === 0) { if (callback) callback(); return; } if (!this.active) { const err = new Error('Cannot flush log buffer: logger is not active'); this.emit('error', err); if (callback) callback(err); return; } if (!this.stream || this.stream.destroyed || this.stream.closed) { const err = new Error('Cannot flush log buffer: stream is not available'); this.emit('error', err); if (callback) callback(err); return; } this.lock = true; const buffer = Buffer.concat(this.buffer); this.buffer.length = 0; this.bufferLength = 0; this.stream.write(buffer, () => { this.lock = false; this.emit('unlocked'); if (callback) callback(); }); } normalizeStack(stack) { if (!stack) return 'no data to log'; let res = metautil.replace(stack, STACK_AT, ''); if (this.home) res = metautil.replace(res, this.home, ''); return res; } expandError(err) { return { message: err.message, stack: this.normalizeStack(err.stack), ...err, }; } #setupCrashHandling() { const exitHandler = () => { this.flush(); }; process.on('SIGTERM', exitHandler); process.on('SIGINT', exitHandler); process.on('SIGUSR1', exitHandler); process.on('SIGUSR2', exitHandler); process.on('uncaughtException', (err) => { this.write('error', 0, 'Uncaught Exception:', err); this.flush(); }); process.on('unhandledRejection', (reason) => { this.write('error', 0, 'Unhandled Rejection:', reason); this.flush(); }); process.on('exit', () => { this.flush(); }); } } const openLog = async (options) => new Logger(options); module.exports = { Logger, openLog };