diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index a8e57931..c79cdc74 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING** Replace the logger utilities with a configurable `Logger` class that defaults to trace logging and supports level filtering, per-instance prefixes, and method decorators. `log` is retained as a deprecated alias for `info`. + ## [1.0.0] ### Added diff --git a/packages/snap-networks-utils/README.md b/packages/snap-networks-utils/README.md index 4f615c52..3865a602 100644 --- a/packages/snap-networks-utils/README.md +++ b/packages/snap-networks-utils/README.md @@ -20,13 +20,7 @@ yarn workspace @metamask/tron-wallet-snap add @metamask/snap-networks-utils@work ### Logger -```typescript -import { logger, createPrefixedLogger } from '@metamask/snap-networks-utils'; -// or: import { logger } from '@metamask/snap-networks-utils/logger'; - -const snapLogger = createPrefixedLogger(logger, '[tron-wallet-snap]'); -snapLogger.info('account synced'); -``` +See the [logger README](./src/logger/README.md). ### Core AssetsController reads diff --git a/packages/snap-networks-utils/package.json b/packages/snap-networks-utils/package.json index bf55f781..9455899b 100644 --- a/packages/snap-networks-utils/package.json +++ b/packages/snap-networks-utils/package.json @@ -34,12 +34,12 @@ }, "./logger": { "import": { - "types": "./dist/logger.d.mts", - "default": "./dist/logger.mjs" + "types": "./dist/logger/index.d.mts", + "default": "./dist/logger/index.mjs" }, "require": { - "types": "./dist/logger.d.cts", - "default": "./dist/logger.cjs" + "types": "./dist/logger/index.d.cts", + "default": "./dist/logger/index.cjs" } }, "./package.json": "./package.json" diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index 42176311..ee84b9d6 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -1,5 +1,3 @@ -export type { Logger } from './logger'; -export { createPrefixedLogger, logger, noOpLogger } from './logger'; export { ASSETS_PROVIDER_NAME, AssetsProvider, diff --git a/packages/snap-networks-utils/src/logger.test.ts b/packages/snap-networks-utils/src/logger.test.ts deleted file mode 100644 index 8b24491b..00000000 --- a/packages/snap-networks-utils/src/logger.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { logger, createPrefixedLogger, noOpLogger } from './logger'; -import type { Logger } from './logger'; - -describe('logger', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it.each([ - ['log', 'log'], - ['info', 'info'], - ['warn', 'warn'], - ['error', 'error'], - ['debug', 'debug'], - ] as const)('forwards %s calls to console.%s', (method, consoleMethod) => { - const spy = jest.spyOn(console, consoleMethod).mockImplementation(); - - logger[method]('hello', 42); - - expect(spy).toHaveBeenCalledWith('hello', 42); - }); - - it('prefixes messages with createPrefixedLogger', () => { - const infoSpy = jest.spyOn(console, 'info').mockImplementation(); - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); - const debugSpy = jest.spyOn(console, 'debug').mockImplementation(); - const logSpy = jest.spyOn(console, 'log').mockImplementation(); - const prefixed = createPrefixedLogger(logger, '[snap-networks-utils]'); - - prefixed.log('a'); - prefixed.info('b'); - prefixed.warn('c'); - prefixed.error('d'); - prefixed.debug('e'); - - expect(logSpy).toHaveBeenCalledWith('[snap-networks-utils]', 'a'); - expect(infoSpy).toHaveBeenCalledWith('[snap-networks-utils]', 'b'); - expect(warnSpy).toHaveBeenCalledWith('[snap-networks-utils]', 'c'); - expect(errorSpy).toHaveBeenCalledWith('[snap-networks-utils]', 'd'); - expect(debugSpy).toHaveBeenCalledWith('[snap-networks-utils]', 'e'); - }); - - it('provides a no-op logger for tests', () => { - const noop: Logger = noOpLogger; - - expect(() => { - noop.log('silent'); - noop.info('silent'); - noop.warn('silent'); - noop.error('silent'); - noop.debug('silent'); - }).not.toThrow(); - }); -}); diff --git a/packages/snap-networks-utils/src/logger.ts b/packages/snap-networks-utils/src/logger.ts deleted file mode 100644 index 2eca10cd..00000000 --- a/packages/snap-networks-utils/src/logger.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Minimal console logger shared by network snaps. - * - * This is a scaffold example for `@metamask/snap-networks-utils`. Later MONO-2 - * tickets will expand shared utilities; snaps can start importing from here. - */ - -export type Logger = { - log: (...args: unknown[]) => void; - info: (...args: unknown[]) => void; - warn: (...args: unknown[]) => void; - error: (...args: unknown[]) => void; - debug: (...args: unknown[]) => void; -}; - -/** - * Default logger that forwards to `console`. - */ -export const logger: Logger = { - log: (...args: unknown[]) => { - console.log(...args); - }, - info: (...args: unknown[]) => { - console.info(...args); - }, - warn: (...args: unknown[]) => { - console.warn(...args); - }, - error: (...args: unknown[]) => { - console.error(...args); - }, - debug: (...args: unknown[]) => { - console.debug(...args); - }, -}; - -/** - * Logger that discards all messages. Useful in unit tests. - */ -export const noOpLogger: Logger = { - log: () => undefined, - info: () => undefined, - warn: () => undefined, - error: () => undefined, - debug: () => undefined, -}; - -/** - * Returns a logger that prefixes every message. - * - * @param baseLogger - Logger to wrap. - * @param prefix - Prefix prepended to each call. - * @returns Prefixed logger. - */ -export function createPrefixedLogger( - baseLogger: Logger, - prefix: string, -): Logger { - return { - log: (...args: unknown[]) => baseLogger.log(prefix, ...args), - info: (...args: unknown[]) => baseLogger.info(prefix, ...args), - warn: (...args: unknown[]) => baseLogger.warn(prefix, ...args), - error: (...args: unknown[]) => baseLogger.error(prefix, ...args), - debug: (...args: unknown[]) => baseLogger.debug(prefix, ...args), - }; -} diff --git a/packages/snap-networks-utils/src/logger/Logger.test.ts b/packages/snap-networks-utils/src/logger/Logger.test.ts new file mode 100644 index 00000000..35d4f4ef --- /dev/null +++ b/packages/snap-networks-utils/src/logger/Logger.test.ts @@ -0,0 +1,168 @@ +import { Logger, LogLevel } from './Logger'; + +const setupTest = () => { + jest.restoreAllMocks(); + + return { + loggerMethods: [ + { method: 'log', consoleMethod: 'info', filteredAt: LogLevel.WARN }, + { method: 'info', consoleMethod: 'info', filteredAt: LogLevel.WARN }, + { method: 'warn', consoleMethod: 'warn', filteredAt: LogLevel.ERROR }, + { + method: 'error', + consoleMethod: 'error', + filteredAt: LogLevel.SILENT, + }, + { method: 'debug', consoleMethod: 'debug', filteredAt: LogLevel.INFO }, + { method: 'trace', consoleMethod: 'trace', filteredAt: LogLevel.DEBUG }, + ] as const, + mockConsole: { + debug: jest.spyOn(console, 'debug').mockImplementation(), + error: jest.spyOn(console, 'error').mockImplementation(), + info: jest.spyOn(console, 'info').mockImplementation(), + trace: jest.spyOn(console, 'trace').mockImplementation(), + warn: jest.spyOn(console, 'warn').mockImplementation(), + }, + }; +}; + +describe('Logger', () => { + it('forwards calls to the matching console method', () => { + const { loggerMethods, mockConsole } = setupTest(); + + const logger = new Logger({ + enabled: true, + }); + + for (const { method, consoleMethod } of loggerMethods) { + logger[method]('hello', 42); + + expect(mockConsole[consoleMethod]).toHaveBeenCalledWith('hello', 42); + } + }); + + it('prefixes messages with a derived logger', () => { + const { mockConsole } = setupTest(); + + const logger = new Logger({ + enabled: true, + }); + + const prefixed = logger.withPrefix('[snap-networks-utils]'); + + prefixed.info('a'); + prefixed.warn('b'); + prefixed.error('c'); + prefixed.debug('d'); + prefixed.trace('e'); + + expect(mockConsole.info).toHaveBeenCalledWith('[snap-networks-utils]', 'a'); + expect(mockConsole.warn).toHaveBeenCalledWith('[snap-networks-utils]', 'b'); + expect(mockConsole.error).toHaveBeenCalledWith( + '[snap-networks-utils]', + 'c', + ); + expect(mockConsole.debug).toHaveBeenCalledWith( + '[snap-networks-utils]', + 'd', + ); + expect(mockConsole.trace).toHaveBeenCalledWith( + '[snap-networks-utils]', + 'e', + ); + }); + + it('combines prefixes from derived loggers', () => { + const { mockConsole } = setupTest(); + + const logger = new Logger({ + enabled: true, + }); + + const parentLogger = logger.withPrefix('[parent]'); + const childLogger = parentLogger.withPrefix('[child]'); + + childLogger.info('message'); + + expect(mockConsole.info).toHaveBeenCalledWith( + '[parent] [child]', + 'message', + ); + }); + + it('defaults to the trace level', () => { + const { mockConsole } = setupTest(); + + const logger = new Logger({ enabled: true }); + + logger.trace('trace'); + + expect(mockConsole.trace).toHaveBeenCalledWith('trace'); + }); + + it('runs decorators through the configured output', () => { + const decorator = jest.fn((next: (...args: unknown[]) => void) => { + next('decoded error'); + }); + const { mockConsole } = setupTest(); + + const baseLogger = new Logger({ + enabled: true, + decorators: { error: decorator }, + }); + + const logger = baseLogger.withPrefix('[Solana]'); + + logger.error('original error'); + + expect(decorator).toHaveBeenCalledWith( + expect.any(Function), + 'original error', + ); + expect(mockConsole.error).toHaveBeenCalledWith('[Solana]', 'decoded error'); + }); + + it('does not run decorators when logging is disabled', () => { + const decorator = jest.fn(); + const { mockConsole } = setupTest(); + + const logger = new Logger({ + enabled: false, + decorators: { error: decorator }, + }); + + logger.error('silent'); + + expect(decorator).not.toHaveBeenCalled(); + expect(mockConsole.error).not.toHaveBeenCalled(); + }); + + it('does not log calls when disabled', () => { + const { loggerMethods, mockConsole } = setupTest(); + + const logger = new Logger({ + enabled: false, + }); + + for (const { method, consoleMethod } of loggerMethods) { + logger[method]('silent'); + + expect(mockConsole[consoleMethod]).not.toHaveBeenCalled(); + } + }); + + it('filters messages above the configured level', () => { + const { loggerMethods, mockConsole } = setupTest(); + + for (const { method, consoleMethod, filteredAt } of loggerMethods) { + const logger = new Logger({ + enabled: true, + level: filteredAt, + }); + + logger[method]('filtered'); + + expect(mockConsole[consoleMethod]).not.toHaveBeenCalled(); + } + }); +}); diff --git a/packages/snap-networks-utils/src/logger/Logger.ts b/packages/snap-networks-utils/src/logger/Logger.ts new file mode 100644 index 00000000..3bce4da5 --- /dev/null +++ b/packages/snap-networks-utils/src/logger/Logger.ts @@ -0,0 +1,153 @@ +/** + * The severity levels supported by {@link Logger}. + */ +export const LogLevel = { + SILENT: 'silent', + ERROR: 'error', + WARN: 'warn', + INFO: 'info', + DEBUG: 'debug', + TRACE: 'trace', +} as const; + +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; + +const logLevelPriority = { + [LogLevel.SILENT]: 0, + [LogLevel.ERROR]: 1, + [LogLevel.WARN]: 2, + [LogLevel.INFO]: 3, + [LogLevel.DEBUG]: 4, + [LogLevel.TRACE]: 5, +}; + +export type LoggerMethod = Exclude; + +export type LogMethod = (...args: unknown[]) => void; + +/** + * A function that can add behavior around a logger method. + * + * Call `next` to forward a message through the logger's configured output. + */ +export type LogMethodDecorator = (next: LogMethod, ...args: unknown[]) => void; + +export type LoggerDecorators = Partial< + Record +>; + +/** + * Configuration for a {@link Logger}. + */ +export type LoggerOptions = { + /** Whether this logger should forward messages to the console. */ + enabled: boolean; + /** The most verbose severity level that should be logged. */ + level?: LogLevel; + /** An optional prefix prepended to every message. */ + prefix?: string; + /** Optional behavior to apply to individual log methods. */ + decorators?: LoggerDecorators; +}; + +/** + * A console logger for network snaps. + * + * Consumers are responsible for resolving environment-specific configuration + * before creating an instance. For example, a Snap can set `enabled` to false + * when its injected `ENVIRONMENT` value is `production`. + */ +export class Logger { + readonly #enabled: boolean; + + readonly #level: LogLevel; + + readonly #prefix?: string; + + readonly #decorators?: LoggerDecorators; + + constructor({ + enabled, + level = LogLevel.TRACE, + prefix, + decorators, + }: LoggerOptions) { + this.#enabled = enabled; + this.#level = level; + this.#prefix = prefix; + this.#decorators = decorators; + } + + /** + * Returns a logger with an additional prefix. + * + * The returned logger has the same enabled state and log level as this one. + * + * @param prefix - The prefix to add to each message. + * @returns A derived logger. + */ + withPrefix(prefix: string): Logger { + return new Logger({ + enabled: this.#enabled, + level: this.#level, + prefix: this.#prefix ? `${this.#prefix} ${prefix}` : prefix, + decorators: this.#decorators, + }); + } + + error(...args: unknown[]): void { + this.#write(LogLevel.ERROR, console.error, args); + } + + warn(...args: unknown[]): void { + this.#write(LogLevel.WARN, console.warn, args); + } + + /** + * Logs an informational message. + * + * @deprecated Use {@link Logger.info} instead. + */ + log(...args: unknown[]): void { + this.info(...args); + } + + info(...args: unknown[]): void { + this.#write(LogLevel.INFO, console.info, args); + } + + debug(...args: unknown[]): void { + this.#write(LogLevel.DEBUG, console.debug, args); + } + + trace(...args: unknown[]): void { + this.#write(LogLevel.TRACE, console.trace, args); + } + + #shouldLog(level: LoggerMethod): boolean { + return ( + this.#enabled && logLevelPriority[level] <= logLevelPriority[this.#level] + ); + } + + #write( + level: LoggerMethod, + writeToConsole: (...args: unknown[]) => void, + args: unknown[], + ): void { + if (!this.#shouldLog(level)) return; + + const next: LogMethod = this.#prefix + ? (...nextArgs) => writeToConsole(this.#prefix, ...nextArgs) + : writeToConsole; + + const decorator = this.#decorators?.[level]; + + if (decorator) { + decorator(next, ...args); + return; + } + + next(...args); + } +} diff --git a/packages/snap-networks-utils/src/logger/README.md b/packages/snap-networks-utils/src/logger/README.md new file mode 100644 index 00000000..23ebb2ce --- /dev/null +++ b/packages/snap-networks-utils/src/logger/README.md @@ -0,0 +1,93 @@ +# Logger + +`Logger` is a configurable console logger for MetaMask network snaps. + +## Usage + +```typescript +import { Logger } from '@metamask/snap-networks-utils/logger'; + +const logger = new Logger({ enabled: true }); +``` + +## Configuration + +### `enabled` (required) + +`enabled` controls whether the logger writes messages to the console. Set it to +`false` when logging should be disabled, such as in production. + +```typescript +const logger = new Logger({ + enabled: process.env.ENVIRONMENT !== 'production', +}); +``` + +A disabled `Logger` is the no-op logger; no separate `noOpLogger` export is +needed: + +```typescript +const logger = new Logger({ + enabled: false, +}); +``` + +### `level` + +`level` selects the most verbose severity that is written. It defaults to +`LogLevel.TRACE`, so an enabled logger writes every supported level by default. +Set a lower level to reduce output. + +```typescript +const logger = new Logger({ + enabled: true, + level: LogLevel.INFO, +}); +``` + +`log` is a deprecated compatibility alias for `info`; prefer `info` in new code. + +### `prefix` + +Set a prefix when constructing a logger, or use `withPrefix` later during setup +to create a logger for a Snap or component. A derived logger retains the parent +logger's configuration and adds its prefix to the existing prefix. + +```typescript +import { Logger } from '@metamask/snap-networks-utils/logger'; + +const logger = new Logger({ + enabled: true, + prefix: '[tron-wallet-snap]', +}); + +logger.info('Account synced'); +``` + +Call `withPrefix` as many times as needed. Assign each derived logger before +using it: + +```typescript +const rootLogger = new Logger({ enabled: true }); +const snapLogger = rootLogger.withPrefix('[tron-wallet-snap]'); +const accountsLogger = snapLogger.withPrefix('[accounts]'); + +accountsLogger.debug('Refreshing account balances'); +``` + +### `decorators` + +Use a decorator to add Snap-specific behavior to one logging method. `next` +retains the logger's configured level, prefix, and console output. + +```typescript +const logger = new Logger({ + enabled: true, + decorators: { + error: (next, error) => { + const details = getSolanaErrorDetails(error); + next(details ? ...details : error); + }, + }, +}); +``` diff --git a/packages/snap-networks-utils/src/logger/index.ts b/packages/snap-networks-utils/src/logger/index.ts new file mode 100644 index 00000000..93847b79 --- /dev/null +++ b/packages/snap-networks-utils/src/logger/index.ts @@ -0,0 +1,8 @@ +export { Logger, LogLevel } from './Logger'; +export type { + LoggerOptions, + LoggerMethod, + LogMethod, + LogMethodDecorator, + LoggerDecorators, +} from './Logger';