diff --git a/.changeset/shy-beans-deny.md b/.changeset/shy-beans-deny.md new file mode 100644 index 000000000..22b521722 --- /dev/null +++ b/.changeset/shy-beans-deny.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/cli': patch +--- + +Add --log and --verbosity settings to CLI diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 2f7e454be..94192960c 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -7,7 +7,8 @@ on: workflow_dispatch: env: - DEBUG: 'hyperlane:*' + LOG_LEVEL: DEBUG + LOG_FORMAT: PRETTY jobs: # copied from test.yml @@ -91,7 +92,7 @@ jobs: key: ${{ github.sha }} - name: Metadata Health Check - run: LOG_PRETTY=true yarn workspace @hyperlane-xyz/sdk run test:metadata + run: yarn workspace @hyperlane-xyz/sdk run test:metadata - name: Post to discord webhook if metadata check fails if: failure() diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 276119083..c6e720885 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,8 @@ concurrency: cancel-in-progress: ${{ github.ref_name != 'main' }} env: - DEBUG: 'hyperlane:*' + LOG_LEVEL: DEBUG + LOG_FORMAT: PRETTY CARGO_TERM_COLOR: always RUST_BACKTRACE: full diff --git a/README.md b/README.md index de21d97f4..d1767c3c3 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,16 @@ This monorepo uses [Yarn Workspaces](https://yarnpkg.com/features/workspaces). I If you are using [VSCode](https://code.visualstudio.com/), you can launch the [multi-root workspace](https://code.visualstudio.com/docs/editor/multi-root-workspaces) with `code mono.code-workspace`, install the recommended workspace extensions, and use the editor settings. +### Logging + +The typescript tooling uses [Pino](https://github.com/pinojs/pino) based logging, which outputs structured JSON logs by default. +The verbosity level and style can be configured with environment variables: + +```sh +LOG_LEVEL=DEBUG|INFO|WARN|ERROR|OFF +LOG_FORMAT=PRETTY|JSON +``` + ### Rust See [`rust/README.md`](rust/README.md) diff --git a/typescript/cli/README.md b/typescript/cli/README.md index 168c2dba6..20fe12cda 100644 --- a/typescript/cli/README.md +++ b/typescript/cli/README.md @@ -55,3 +55,8 @@ Run warp route deployments: `hyperlane deploy warp` View SDK contract addresses: `hyperlane chains addresses` Send test message: `hyperlane send message` + +## Logging + +The logging format can be toggled between human-readable vs JSON-structured logs using the `LOG_FORMAT` environment variable or the `--log ` flag. +The logging verbosity can be configured using the `LOG_LEVEL` environment variable or the `--verbosity ` flag. diff --git a/typescript/cli/ci-test.sh b/typescript/cli/ci-test.sh index 6ff32e574..b13044ed8 100755 --- a/typescript/cli/ci-test.sh +++ b/typescript/cli/ci-test.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +export LOG_LEVEL=DEBUG + # set script location as repo root cd "$(dirname "$0")/../.." @@ -87,8 +89,6 @@ set -e echo "{}" > /tmp/empty-artifacts.json -export LOG_LEVEL=DEBUG - DEPLOYER=$(cast rpc eth_accounts | jq -r '.[0]') BEFORE=$(cast balance $DEPLOYER --rpc-url http://127.0.0.1:${CHAIN1_PORT}) diff --git a/typescript/cli/cli.ts b/typescript/cli/cli.ts index 4df7f5347..dfdfbae1f 100644 --- a/typescript/cli/cli.ts +++ b/typescript/cli/cli.ts @@ -2,13 +2,19 @@ import chalk from 'chalk'; import yargs from 'yargs'; +import type { LogFormat, LogLevel } from '@hyperlane-xyz/utils'; + import './env.js'; import { chainsCommand } from './src/commands/chains.js'; import { configCommand } from './src/commands/config.js'; import { deployCommand } from './src/commands/deploy.js'; +import { + logFormatCommandOption, + logLevelCommandOption, +} from './src/commands/options.js'; import { sendCommand } from './src/commands/send.js'; import { statusCommand } from './src/commands/status.js'; -import { errorRed } from './src/logger.js'; +import { configureLogger, errorRed } from './src/logger.js'; import { checkVersion } from './src/utils/version-check.js'; import { VERSION } from './src/version.js'; @@ -22,6 +28,12 @@ await checkVersion(); try { await yargs(process.argv.slice(2)) .scriptName('hyperlane') + .option('log', logFormatCommandOption) + .option('verbosity', logLevelCommandOption) + .global(['log', 'verbosity']) + .middleware((argv) => { + configureLogger(argv.log as LogFormat, argv.verbosity as LogLevel); + }) .command(chainsCommand) .command(configCommand) .command(deployCommand) diff --git a/typescript/cli/env.ts b/typescript/cli/env.ts index ad055d9e6..b658ce580 100644 --- a/typescript/cli/env.ts +++ b/typescript/cli/env.ts @@ -1,9 +1,6 @@ // This file isn't in the src dir so it it's imported before others // See https://github.com/trivago/prettier-plugin-sort-imports/issues/112 -// Default rootLogger into pretty mode -process.env.LOG_PRETTY ??= 'true'; - // Workaround for bug in bigint-buffer which solana-web3.js depends on // https://github.com/no2chem/bigint-buffer/issues/31#issuecomment-1752134062 const defaultWarn = console.warn; diff --git a/typescript/cli/src/commands/options.ts b/typescript/cli/src/commands/options.ts index 1edaa3a03..e12725d55 100644 --- a/typescript/cli/src/commands/options.ts +++ b/typescript/cli/src/commands/options.ts @@ -1,6 +1,20 @@ // A set of common options import { Options } from 'yargs'; +import { LogFormat, LogLevel } from '@hyperlane-xyz/utils'; + +export const logFormatCommandOption: Options = { + type: 'string', + description: 'Log output format', + choices: Object.values(LogFormat), +}; + +export const logLevelCommandOption: Options = { + type: 'string', + description: 'Log verbosity level', + choices: Object.values(LogLevel), +}; + export const keyCommandOption: Options = { type: 'string', description: diff --git a/typescript/cli/src/logger.ts b/typescript/cli/src/logger.ts index bb7649b65..4b999999d 100644 --- a/typescript/cli/src/logger.ts +++ b/typescript/cli/src/logger.ts @@ -1,9 +1,24 @@ import chalk, { ChalkInstance } from 'chalk'; import { pino } from 'pino'; -import { isLogPretty, rootLogger } from '@hyperlane-xyz/utils'; +import { + LogFormat, + LogLevel, + configureRootLogger, + getLogFormat, + rootLogger, + safelyAccessEnvVar, +} from '@hyperlane-xyz/utils'; + +let logger = rootLogger; + +export function configureLogger(logFormat: LogFormat, logLevel: LogLevel) { + logFormat = + logFormat || safelyAccessEnvVar('LOG_FORMAT', true) || LogFormat.Pretty; + logLevel = logLevel || safelyAccessEnvVar('LOG_LEVEL', true) || LogLevel.Info; + logger = configureRootLogger(logFormat, logLevel).child({ module: 'cli' }); +} -export const logger = rootLogger.child({ module: 'cli' }); export const log = (msg: string, ...args: any) => logger.info(msg, ...args); export function logColor( @@ -12,7 +27,7 @@ export function logColor( ...args: any ) { // Only use color when pretty is enabled - if (isLogPretty) { + if (getLogFormat() === LogFormat.Pretty) { logger[level](chalkInstance(...args)); } else { // @ts-ignore pino type more restrictive than pino's actual arg handling diff --git a/typescript/sdk/src/deploy/HyperlaneDeployer.ts b/typescript/sdk/src/deploy/HyperlaneDeployer.ts index f06fdb5fb..06b6bb9b2 100644 --- a/typescript/sdk/src/deploy/HyperlaneDeployer.ts +++ b/typescript/sdk/src/deploy/HyperlaneDeployer.ts @@ -119,7 +119,7 @@ export abstract class HyperlaneDeployer< ); const signerAddress = await this.multiProvider.getSignerAddress(chain); const fromString = signerUrl || signerAddress; - this.logger.debug(`Deploying to ${chain} from ${fromString}`); + this.logger.info(`Deploying to ${chain} from ${fromString}`); this.startingBlockNumbers[chain] = await this.multiProvider .getProvider(chain) .getBlockNumber(); @@ -345,7 +345,7 @@ export abstract class HyperlaneDeployer< } } - this.logger.debug( + this.logger.info( `Deploy ${contractName} on ${chain} with constructor args (${constructorArgs.join( ', ', )})`, diff --git a/typescript/utils/index.ts b/typescript/utils/index.ts index b98c1ee8c..bf928ae29 100644 --- a/typescript/utils/index.ts +++ b/typescript/utils/index.ts @@ -73,9 +73,19 @@ export { isS3CheckpointWithId, } from './src/checkpoints'; export { domainHash } from './src/domains'; -export { envVarToBoolean, safelyAccessEnvVar } from './src/env'; +export { safelyAccessEnvVar } from './src/env'; export { canonizeId, evmId } from './src/ids'; -export { isLogPretty, rootLogger } from './src/logging'; +export { + LogFormat, + LogLevel, + configureRootLogger, + createHyperlanePinoLogger, + getLogFormat, + getLogLevel, + getRootLogger, + rootLogger, + setRootLogger, +} from './src/logging'; export { mean, median, stdDev, sum } from './src/math'; export { formatMessage, messageId, parseMessage } from './src/messages'; export { diff --git a/typescript/utils/src/env.ts b/typescript/utils/src/env.ts index 840697a5b..ff24f2486 100644 --- a/typescript/utils/src/env.ts +++ b/typescript/utils/src/env.ts @@ -1,16 +1,9 @@ // Should be used instead of referencing process directly in case we don't // run in node.js -export function safelyAccessEnvVar(name: string) { +export function safelyAccessEnvVar(name: string, toLowerCase = false) { try { - return process.env[name]; + return toLowerCase ? process.env[name]?.toLowerCase() : process.env[name]; } catch (error) { return undefined; } } - -export function envVarToBoolean(value: any) { - if (typeof value === 'boolean') return value; - if (typeof value === 'string') return value.toLowerCase() === 'true'; - if (typeof value === 'number') return value !== 0; - return !!value; -} diff --git a/typescript/utils/src/logging.ts b/typescript/utils/src/logging.ts index eaacad879..c5543ddaa 100644 --- a/typescript/utils/src/logging.ts +++ b/typescript/utils/src/logging.ts @@ -1,38 +1,98 @@ -import { LevelWithSilent, pino } from 'pino'; - -import { envVarToBoolean, safelyAccessEnvVar } from './env'; - -let logLevel: LevelWithSilent = 'info'; -const envLogLevel = safelyAccessEnvVar('LOG_LEVEL')?.toLowerCase(); -if (envLogLevel && pino.levels.values[envLogLevel]) { - logLevel = envLogLevel as LevelWithSilent; -} -// For backwards compat and also to match agent level options -else if (envLogLevel === 'none' || envLogLevel === 'off') { - logLevel = 'silent'; -} - -export const isLogPretty = envVarToBoolean(safelyAccessEnvVar('LOG_PRETTY')); - -export const rootLogger = pino({ - level: logLevel, - name: 'hyperlane', - formatters: { - // Remove pino's default bindings of hostname but keep pid - bindings: (defaultBindings) => ({ pid: defaultBindings.pid }), - }, - hooks: { - logMethod(inputArgs, method, level) { - // Pino has no simple way of setting custom log shapes and they - // recommend against using pino-pretty in production so when - // pretty is enabled we circumvent pino and log directly to console - if (isLogPretty && level >= pino.levels.values[logLevel]) { - // eslint-disable-next-line no-console - console.log(...inputArgs); - // Then return null to prevent pino from logging - return null; - } - return method.apply(this, inputArgs); +import { LevelWithSilent, Logger, pino } from 'pino'; + +import { safelyAccessEnvVar } from './env'; + +// Level and format here should correspond with the agent options as much as possible +// https://docs.hyperlane.xyz/docs/operate/config-reference#logfmt + +// A custom enum definition because pino does not export an enum +// and because we use 'off' instead of 'silent' to match the agent options +export enum LogLevel { + Debug = 'debug', + Info = 'info', + Warn = 'warn', + Error = 'error', + Off = 'off', +} + +let logLevel: LevelWithSilent = + toPinoLevel(safelyAccessEnvVar('LOG_LEVEL', true)) || 'info'; + +function toPinoLevel(level?: string): LevelWithSilent | undefined { + if (level && pino.levels.values[level]) return level as LevelWithSilent; + // For backwards compat and also to match agent level options + else if (level === 'none' || level === 'off') return 'silent'; + else return undefined; +} + +export function getLogLevel() { + return logLevel; +} + +export enum LogFormat { + Pretty = 'pretty', + JSON = 'json', +} +let logFormat: LogFormat = LogFormat.JSON; +const envLogFormat = safelyAccessEnvVar('LOG_FORMAT', true) as + | LogFormat + | undefined; +if (envLogFormat && Object.values(LogFormat).includes(envLogFormat)) + logFormat = envLogFormat; + +export function getLogFormat() { + return logFormat; +} + +// Note, for brevity and convenience, the rootLogger is exported directly +export let rootLogger = createHyperlanePinoLogger(logLevel, logFormat); + +export function getRootLogger() { + return rootLogger; +} + +export function configureRootLogger( + newLogFormat: LogFormat, + newLogLevel: LogLevel, +) { + logFormat = newLogFormat; + logLevel = toPinoLevel(newLogLevel) || logLevel; + rootLogger = createHyperlanePinoLogger(logLevel, logFormat); + return rootLogger; +} + +export function setRootLogger(logger: Logger) { + rootLogger = logger; + return rootLogger; +} + +export function createHyperlanePinoLogger( + logLevel: LevelWithSilent, + logFormat: LogFormat, +) { + return pino({ + level: logLevel, + name: 'hyperlane', + formatters: { + // Remove pino's default bindings of hostname but keep pid + bindings: (defaultBindings) => ({ pid: defaultBindings.pid }), }, - }, -}); + hooks: { + logMethod(inputArgs, method, level) { + // Pino has no simple way of setting custom log shapes and they + // recommend against using pino-pretty in production so when + // pretty is enabled we circumvent pino and log directly to console + if ( + logFormat === LogFormat.Pretty && + level >= pino.levels.values[logLevel] + ) { + // eslint-disable-next-line no-console + console.log(...inputArgs); + // Then return null to prevent pino from logging + return null; + } + return method.apply(this, inputArgs); + }, + }, + }); +}