@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
456 lines (399 loc) • 16.3 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
import pino, {type Logger as PinoLogger, type TransportTargetOptions, type LoggerOptions, type StreamEntry} 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 {type SoloLogger} from './solo-logger.js';
import {SoloError} from '../errors/solo-error.js';
import {MessageLevel} from './message-level.js';
type ChalkColor = typeof chalk.red;
/**
* 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
*/
export class SoloPinoLogger implements SoloLogger {
private readonly pinoLogger: PinoLogger;
private traceId?: string;
private readonly logBindings: Record<string, unknown> = {};
private messageGroupMap: Map<string, string[]> = new Map();
private readonly MINOR_LINE_SEPARATOR: string =
'-------------------------------------------------------------------------------';
private static readonly MAX_BOX_WIDTH: number = 120;
private static readonly MIN_BOX_WIDTH: number = 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
*/
public constructor(
logLevel?: string,
private developmentMode?: boolean,
) {
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: string = 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: TransportTargetOptions = {
target: 'pino/file',
level: logLevel,
options: {destination: PathEx.join(logsDirectory, 'solo.ndjson')},
};
const prettyTarget: TransportTargetOptions = {
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: LoggerOptions = {
level: logLevel,
// Always include traceId and active log bindings when set via mixin
mixin: (): Record<string, unknown> => ({
...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: ReturnType<typeof pino.destination> = pino.destination({
dest: PathEx.join(logsDirectory, 'solo.ndjson'),
sync: true,
});
const prettyStream: ReturnType<typeof pinoPretty> = 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},
] as StreamEntry[]),
);
} else {
this.pinoLogger = pino(baseOptions, pino.transport({targets: [ndjsonTarget, prettyTarget]}));
}
}
public setDevMode(developmentMode: boolean): void {
this.debug(`dev mode logging: ${developmentMode}`);
this.developmentMode = developmentMode;
}
public isDevMode(): boolean {
return this.developmentMode ?? false;
}
public nextTraceId(): void {
this.traceId = uuidv4();
}
public setLogBinding(key: string, value: unknown): void {
if (value === undefined || value === null || value === '') {
delete this.logBindings[key];
return;
}
this.logBindings[key] = value;
}
public addLogBindings(bindings: Record<string, unknown>): void {
for (const [key, value] of Object.entries(bindings)) {
this.setLogBinding(key, value);
}
}
public clearLogBindings(...keys: string[]): void {
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];
}
}
public prepMeta(meta: Record<string, unknown> = {}): Record<string, unknown> {
if (this.traceId) {
(meta as Record<string, unknown>)['traceId'] = this.traceId;
}
return meta;
}
public showUser(message: unknown, ...arguments_: unknown[]): void {
const formatted: string = util.format(String(message), ...arguments_.map(String));
console.log(formatted);
// Mirror existing behavior: also persist to logs at info level
this.info(formatted);
}
private stripAnsi(text: string): string {
// eslint-disable-next-line no-control-regex
return text.replaceAll(/\u001B\[[0-9;]*m/g, '');
}
public padWithBorder(
message: string,
chalkColor: (...text: unknown[]) => string = chalk.red,
length: number = 83,
): string {
const border: string = chalkColor('│');
const messageLines: string[] = [];
for (const line of message.split('\n')) {
const repeats: number = Math.max(0, length - this.stripAnsi(line).length - 4);
messageLines.push(`${border} ${line}${' '.repeat(repeats)} ${border}`);
}
return messageLines.join('\n');
}
private buildCauseChain(error: Error): Error[] {
const chain: Error[] = [error];
let cause: unknown = error.cause;
let depth: number = 0;
while (cause instanceof Error && depth < 10) {
chain.push(cause);
cause = cause.cause;
depth += 1;
}
return chain;
}
private getFormattedCode(error: Error): string {
const formattedCode: string | undefined = error instanceof SoloError ? error.getFormattedCode() : undefined;
return formattedCode ? `[${formattedCode}] ` : '';
}
private buildContentLines(error: Error, causeChain: Error[]): string[] {
const lines: string[] = [];
if (this.developmentMode) {
let indent: string = ' ';
let prefix: string = '';
for (const entry of causeChain) {
const messageText: string = this.getFormattedCode(entry) + entry.message;
lines.push(chalk.red(indent + prefix + messageText));
if (entry.stack) {
const formatted: string = entry.stack
.split('\n')
.filter((line: string): boolean => !line.includes('node:internal'))
.join('\n')
.trim();
lines.push(...(indent + formatted).split('\n').map((line: string): string => chalk.gray(line)), '');
}
indent += ' ';
prefix += 'Caused by: ';
}
} else {
const errorMessage: string = this.getFormattedCode(error) + error.message;
lines.push(...errorMessage.split('\n').map((line: string): string => chalk.red(line)));
}
if (error instanceof SoloError) {
const documentUrl: string | undefined = error.getDocumentUrl();
if (!this.developmentMode) {
const troubleshootingSteps: ReadonlyArray<string> | undefined = 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;
}
private wrapLine(line: string, maxWidth: number): string[] {
const plainText: string = this.stripAnsi(line);
if (plainText.length <= maxWidth) {
return [line];
}
// eslint-disable-next-line no-control-regex
const ansiPrefix: string = line.match(/^(?:\[[0-9;]*m)+/)?.[0] ?? '';
const ansiSuffix: string = ansiPrefix ? '[0m' : '';
const indent: string = plainText.match(/^(\s*)/)?.[1] ?? '';
const result: string[] = [];
let remaining: string = 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: number = remaining.slice(indent.length).lastIndexOf(' ', maxWidth - 1 - indent.length);
const spaceAt: number = relativeSpaceAt === -1 ? -1 : indent.length + relativeSpaceAt;
const breakAt: number = spaceAt > 0 ? spaceAt : maxWidth;
result.push(ansiPrefix + remaining.slice(0, breakAt) + ansiSuffix);
const afterBreak: string = remaining.slice(spaceAt > 0 ? breakAt + 1 : breakAt);
remaining = indent + afterBreak;
}
if (remaining) {
result.push(ansiPrefix + remaining + ansiSuffix);
}
return result.length > 0 ? result : [line];
}
private renderErrorBox(lines: string[]): void {
const maxInteriorWidth: number = SoloPinoLogger.MAX_BOX_WIDTH - 4;
const wrappedLines: string[] = lines.flatMap((line: string): string[] => this.wrapLine(line, maxInteriorWidth));
const maxContentWidth: number = Math.max(...wrappedLines.map((l): number => this.stripAnsi(l).length));
const boxWidth: number = Math.min(
SoloPinoLogger.MAX_BOX_WIDTH,
Math.max(SoloPinoLogger.MIN_BOX_WIDTH, maxContentWidth + 4),
);
const interiorWidth: number = 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)}╯`));
}
public showUserError(error: unknown): void {
const normalizedError: Error = error instanceof Error ? error : new Error(String(error));
const causeChain: Error[] = this.buildCauseChain(normalizedError);
const lines: string[] = this.buildContentLines(normalizedError, causeChain);
this.renderErrorBox(lines);
this.toPino('error', error, []);
}
public error(message: unknown, ...arguments_: unknown[]): void {
this.toPino('error', message, arguments_);
}
public warn(message: unknown, ...arguments_: unknown[]): void {
this.toPino('warn', message, arguments_);
}
public info(message: unknown, ...arguments_: unknown[]): void {
this.toPino('info', message, arguments_);
}
public debug(message: unknown, ...arguments_: unknown[]): void {
this.toPino('debug', message, arguments_);
}
public showList(title: string, items: string[] = []): boolean {
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;
}
public showJSON(title: string, object: object): void {
this.showUser(chalk.green(`\n *** ${title} ***`));
this.showUser(chalk.green(this.MINOR_LINE_SEPARATOR));
console.log(JSON.stringify(object, undefined, 2));
}
public getMessageGroup(key: string): string[] {
if (!this.messageGroupMap.has(key)) {
throw new SoloError(`Message group with key "${key}" does not exist.`);
}
return this.messageGroupMap.get(key);
}
public addMessageGroup(key: string, title: string): void {
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}".`);
}
public addMessageGroupMessage(key: string, message: string): void {
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}`);
}
public showMessageGroup(key: string, messageLevel: MessageLevel = MessageLevel.INFO): void {
if (!this.messageGroupMap.has(key)) {
this.warn(`Message group with key "${key}" does not exist.`);
return;
}
let titleColor: ChalkColor;
let textColor: ChalkColor;
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: string[] = this.messageGroupMap.get(key)!;
this.showUser(titleColor(`\n *** ${messages[0]} ***`));
this.showUser(titleColor(this.MINOR_LINE_SEPARATOR));
for (let index: number = 1; index < messages.length; index++) {
this.showUser(textColor(` - ${messages[index]}`));
}
this.showUser(titleColor(this.MINOR_LINE_SEPARATOR));
this.debug(`Displayed message group "${key}".`);
}
public getMessageGroupKeys(): string[] {
return [...this.messageGroupMap.keys()];
}
public showAllMessageGroups(): void {
const keys: string[] = this.getMessageGroupKeys();
if (keys.length === 0) {
this.debug('No message groups available.');
return;
}
for (const key of keys) {
this.showMessageGroup(key);
}
}
public flush(callback: (error?: Error) => void): void {
this.info('Flushing logs and exiting...');
this.pinoLogger.flush(callback);
}
private toPino(level: 'info' | 'warn' | 'error' | 'debug', message: unknown, arguments_: unknown[]): void {
// Build base object (traceId via mixin already present, but include explicitly for clarity in unit tests)
let object: Record<string, unknown> = {};
const meta: Record<string, unknown> = this.prepMeta({});
// Prefer structured errors/objects when provided
if (message instanceof Error) {
object = {...object, ...meta, err: message};
this.pinoLogger[level](object, (message as Error).message ?? 'Error');
return;
}
if (message && typeof message === 'object') {
object = {...object, ...meta, ...(message as Record<string, unknown>)};
const message_: string | undefined =
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: string = util.format(String(message), ...(arguments_ as unknown[]));
this.pinoLogger[level](meta, formatted);
}
}