UNPKG

@hashgraph/solo

Version:

An opinionated CLI tool to deploy and manage private Hedera Networks.

410 lines 17 kB
// SPDX-License-Identifier: Apache-2.0 var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var SoloPinoLogger_1; import pino from 'pino'; import pinoPretty from 'pino-pretty'; import { mkdirSync } from 'node:fs'; import { v4 as uuidv4 } from 'uuid'; // eslint-disable-next-line unicorn/import-style import * as util from 'node:util'; import chalk from 'chalk'; import * as constants from '../constants.js'; import { inject, injectable } from 'tsyringe-neo'; import { patchInject } from '../dependency-injection/container-helper.js'; import { InjectTokens } from '../dependency-injection/inject-tokens.js'; import { PathEx } from '../../business/utils/path-ex.js'; import { SoloError } from '../errors/solo-error.js'; import { MessageLevel } from './message-level.js'; /** * Pino-based implementation of the SoloLogger interface. * * Emits two files under constants.SOLO_LOGS_DIR: * - solo.ndjson : newline-delimited JSON (authoritative) * - solo.log : pretty human-readable */ let SoloPinoLogger = class SoloPinoLogger { static { SoloPinoLogger_1 = this; } developmentMode; pinoLogger; traceId; logBindings = {}; messageGroupMap = new Map(); MINOR_LINE_SEPARATOR = '-------------------------------------------------------------------------------'; static MAX_BOX_WIDTH = 120; static MIN_BOX_WIDTH = 70; /** * @param logLevel - the log level to use (fatal|error|warn|info|debug|trace) * @param developmentMode - if true, show full stack traces in error messages */ constructor(logLevel, developmentMode) { this.developmentMode = developmentMode; logLevel = patchInject(logLevel, InjectTokens.LogLevel, this.constructor.name) ?? 'info'; this.developmentMode = patchInject(developmentMode, InjectTokens.DevelopmentMode, this.constructor.name); this.nextTraceId(); // Ensure logs directory exists const logsDirectory = constants.SOLO_LOGS_DIR; try { mkdirSync(logsDirectory, { recursive: true }); } catch { // no-op: if this fails, pino will attempt to create the files and error if impossible } // Configure dual outputs: NDJSON (machine) + pretty (human) const ndjsonTarget = { target: 'pino/file', level: logLevel, options: { destination: PathEx.join(logsDirectory, 'solo.ndjson') }, }; const prettyTarget = { target: 'pino-pretty', level: logLevel, options: { destination: PathEx.join(logsDirectory, 'solo.log'), // write formatted logs to <logsDirectory>/solo.log translateTime: 'HH:MM:ss.l', // prepend timestamp as [HH:MM:ss.ms] colorize: false, // disable pino-pretty color output (avoid ANSI codes) messageKey: 'msg', // use the 'msg' property as the main log message messageFormat: '{msg} [traceId="{traceId}"]', // format line: message + traceId suffix ignore: 'pid,hostname,traceId', // exclude these fields from printed output colorizeObjects: false, // don't colorize objects or nested values crlf: false, // use '\n' (Unix newlines) instead of '\r\n' (Windows) hideObject: false, // don't hide full object payloads after message }, }; const baseOptions = { level: logLevel, // Always include traceId and active log bindings when set via mixin mixin: () => ({ ...this.logBindings, ...(this.traceId ? { traceId: this.traceId } : {}), }), // Redact obvious secrets if they sneak into objects redact: { paths: ['*.authorization', '*.Authorization', '*.accessToken', '*.privateKey', '*.operatorKey'], remove: true, }, }; if (process.env.CI === 'true') { const ndjsonStream = pino.destination({ dest: PathEx.join(logsDirectory, 'solo.ndjson'), sync: true, }); const prettyStream = pinoPretty({ ...prettyTarget.options, destination: pino.destination({ dest: PathEx.join(logsDirectory, 'solo.log'), sync: true, }), }); this.pinoLogger = pino(baseOptions, pino.multistream([ { level: logLevel, stream: ndjsonStream }, { level: logLevel, stream: prettyStream }, ])); } else { this.pinoLogger = pino(baseOptions, pino.transport({ targets: [ndjsonTarget, prettyTarget] })); } } setDevMode(developmentMode) { this.debug(`dev mode logging: ${developmentMode}`); this.developmentMode = developmentMode; } isDevMode() { return this.developmentMode ?? false; } nextTraceId() { this.traceId = uuidv4(); } setLogBinding(key, value) { if (value === undefined || value === null || value === '') { delete this.logBindings[key]; return; } this.logBindings[key] = value; } addLogBindings(bindings) { for (const [key, value] of Object.entries(bindings)) { this.setLogBinding(key, value); } } clearLogBindings(...keys) { if (keys.length === 0) { for (const key of Object.keys(this.logBindings)) { delete this.logBindings[key]; } return; } for (const key of keys) { delete this.logBindings[key]; } } prepMeta(meta = {}) { if (this.traceId) { meta['traceId'] = this.traceId; } return meta; } showUser(message, ...arguments_) { const formatted = util.format(String(message), ...arguments_.map(String)); console.log(formatted); // Mirror existing behavior: also persist to logs at info level this.info(formatted); } stripAnsi(text) { // eslint-disable-next-line no-control-regex return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } padWithBorder(message, chalkColor = chalk.red, length = 83) { const border = chalkColor('│'); const messageLines = []; for (const line of message.split('\n')) { const repeats = Math.max(0, length - this.stripAnsi(line).length - 4); messageLines.push(`${border} ${line}${' '.repeat(repeats)} ${border}`); } return messageLines.join('\n'); } buildCauseChain(error) { const chain = [error]; let cause = error.cause; let depth = 0; while (cause instanceof Error && depth < 10) { chain.push(cause); cause = cause.cause; depth += 1; } return chain; } getFormattedCode(error) { const formattedCode = error instanceof SoloError ? error.getFormattedCode() : undefined; return formattedCode ? `[${formattedCode}] ` : ''; } buildContentLines(error, causeChain) { const lines = []; if (this.developmentMode) { let indent = ' '; let prefix = ''; for (const entry of causeChain) { const messageText = this.getFormattedCode(entry) + entry.message; lines.push(chalk.red(indent + prefix + messageText)); if (entry.stack) { const formatted = entry.stack .split('\n') .filter((line) => !line.includes('node:internal')) .join('\n') .trim(); lines.push(...(indent + formatted).split('\n').map((line) => chalk.gray(line)), ''); } indent += ' '; prefix += 'Caused by: '; } } else { const errorMessage = this.getFormattedCode(error) + error.message; lines.push(...errorMessage.split('\n').map((line) => chalk.red(line))); } if (error instanceof SoloError) { const documentUrl = error.getDocumentUrl(); if (!this.developmentMode) { const troubleshootingSteps = error.getTroubleshootingSteps(); if (troubleshootingSteps && troubleshootingSteps.length > 0) { for (const step of troubleshootingSteps) { lines.push(chalk.cyan(' →') + ' ' + step); } } } if (documentUrl) { lines.push('', chalk.cyan(`Learn more: ${documentUrl}`)); } } return lines; } wrapLine(line, maxWidth) { const plainText = this.stripAnsi(line); if (plainText.length <= maxWidth) { return [line]; } // eslint-disable-next-line no-control-regex const ansiPrefix = line.match(/^(?:\[[0-9;]*m)+/)?.[0] ?? ''; const ansiSuffix = ansiPrefix ? '' : ''; const indent = plainText.match(/^(\s*)/)?.[1] ?? ''; const result = []; let remaining = plainText; while (remaining.length > maxWidth) { // Search outside the indent so wrapping never splits within it and // continuation lines stay at the same indentation level. const relativeSpaceAt = remaining.slice(indent.length).lastIndexOf(' ', maxWidth - 1 - indent.length); const spaceAt = relativeSpaceAt === -1 ? -1 : indent.length + relativeSpaceAt; const breakAt = spaceAt > 0 ? spaceAt : maxWidth; result.push(ansiPrefix + remaining.slice(0, breakAt) + ansiSuffix); const afterBreak = remaining.slice(spaceAt > 0 ? breakAt + 1 : breakAt); remaining = indent + afterBreak; } if (remaining) { result.push(ansiPrefix + remaining + ansiSuffix); } return result.length > 0 ? result : [line]; } renderErrorBox(lines) { const maxInteriorWidth = SoloPinoLogger_1.MAX_BOX_WIDTH - 4; const wrappedLines = lines.flatMap((line) => this.wrapLine(line, maxInteriorWidth)); const maxContentWidth = Math.max(...wrappedLines.map((l) => this.stripAnsi(l).length)); const boxWidth = Math.min(SoloPinoLogger_1.MAX_BOX_WIDTH, Math.max(SoloPinoLogger_1.MIN_BOX_WIDTH, maxContentWidth + 4)); const interiorWidth = boxWidth - 4; console.log(chalk.red(`╭─ ERROR ─${'─'.repeat(interiorWidth - 7)}╮`)); for (const line of wrappedLines) { console.log(this.padWithBorder(line, chalk.red, boxWidth)); } console.log(chalk.red(`╰${'─'.repeat(interiorWidth + 2)}╯`)); } showUserError(error) { const normalizedError = error instanceof Error ? error : new Error(String(error)); const causeChain = this.buildCauseChain(normalizedError); const lines = this.buildContentLines(normalizedError, causeChain); this.renderErrorBox(lines); this.toPino('error', error, []); } error(message, ...arguments_) { this.toPino('error', message, arguments_); } warn(message, ...arguments_) { this.toPino('warn', message, arguments_); } info(message, ...arguments_) { this.toPino('info', message, arguments_); } debug(message, ...arguments_) { this.toPino('debug', message, arguments_); } showList(title, items = []) { this.showUser(chalk.green(`\n *** ${title} ***`)); this.showUser(chalk.green(this.MINOR_LINE_SEPARATOR)); if (items.length > 0) { for (const name of items) { this.showUser(chalk.cyan(` - ${name}`)); } } else { this.showUser(chalk.blue('[ None ]')); } this.showUser('\n'); return true; } showJSON(title, object) { this.showUser(chalk.green(`\n *** ${title} ***`)); this.showUser(chalk.green(this.MINOR_LINE_SEPARATOR)); console.log(JSON.stringify(object, undefined, 2)); } getMessageGroup(key) { if (!this.messageGroupMap.has(key)) { throw new SoloError(`Message group with key "${key}" does not exist.`); } return this.messageGroupMap.get(key); } addMessageGroup(key, title) { if (this.messageGroupMap.has(key)) { this.warn(`Message group with key "${key}" already exists. Skipping.`); return; } this.messageGroupMap.set(key, [`${title}:`]); this.debug(`Added message group "${title}" with key "${key}".`); } addMessageGroupMessage(key, message) { if (!this.messageGroupMap.has(key)) { throw new SoloError(`Message group with key "${key}" does not exist.`); } this.messageGroupMap.get(key).push(message); this.debug(`Added message to group "${key}": ${message}`); } showMessageGroup(key, messageLevel = MessageLevel.INFO) { if (!this.messageGroupMap.has(key)) { this.warn(`Message group with key "${key}" does not exist.`); return; } let titleColor; let textColor; switch (messageLevel) { case MessageLevel.ERROR: { titleColor = chalk.red; textColor = chalk.red; break; } case MessageLevel.WARN: { titleColor = chalk.yellow; textColor = chalk.yellow; break; } default: { titleColor = chalk.green; textColor = chalk.cyan; break; } } const messages = this.messageGroupMap.get(key); this.showUser(titleColor(`\n *** ${messages[0]} ***`)); this.showUser(titleColor(this.MINOR_LINE_SEPARATOR)); for (let index = 1; index < messages.length; index++) { this.showUser(textColor(` - ${messages[index]}`)); } this.showUser(titleColor(this.MINOR_LINE_SEPARATOR)); this.debug(`Displayed message group "${key}".`); } getMessageGroupKeys() { return [...this.messageGroupMap.keys()]; } showAllMessageGroups() { const keys = this.getMessageGroupKeys(); if (keys.length === 0) { this.debug('No message groups available.'); return; } for (const key of keys) { this.showMessageGroup(key); } } flush(callback) { this.info('Flushing logs and exiting...'); this.pinoLogger.flush(callback); } toPino(level, message, arguments_) { // Build base object (traceId via mixin already present, but include explicitly for clarity in unit tests) let object = {}; const meta = this.prepMeta({}); // Prefer structured errors/objects when provided if (message instanceof Error) { object = { ...object, ...meta, err: message }; this.pinoLogger[level](object, message.message ?? 'Error'); return; } if (message && typeof message === 'object') { object = { ...object, ...meta, ...message }; const message_ = arguments_.length > 0 ? util.format('%s', ...arguments_.map(String)) : undefined; if (message_) { this.pinoLogger[level](object, message_); } else { this.pinoLogger[level](object); } return; } const formatted = util.format(String(message), ...arguments_); this.pinoLogger[level](meta, formatted); } }; SoloPinoLogger = SoloPinoLogger_1 = __decorate([ injectable(), __param(0, inject(InjectTokens.LogLevel)), __param(1, inject(InjectTokens.DevelopmentMode)), __metadata("design:paramtypes", [String, Boolean]) ], SoloPinoLogger); export { SoloPinoLogger }; //# sourceMappingURL=solo-pino-logger.js.map