From 14dbbd7bfe3dfc6089355b7a8b8f717725209722 Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Thu, 6 Aug 2026 19:41:48 +0200 Subject: [PATCH 1/2] feat: add logger class util in @metamask/snap-networks-utils --- packages/snap-networks-utils/CHANGELOG.md | 4 + packages/snap-networks-utils/README.md | 8 +- packages/snap-networks-utils/package.json | 8 +- packages/snap-networks-utils/src/index.ts | 2 - .../snap-networks-utils/src/logger.test.ts | 55 ------ packages/snap-networks-utils/src/logger.ts | 66 ------- .../src/logger/Logger.test.ts | 168 ++++++++++++++++++ .../snap-networks-utils/src/logger/Logger.ts | 153 ++++++++++++++++ .../snap-networks-utils/src/logger/README.md | 93 ++++++++++ .../snap-networks-utils/src/logger/index.ts | 8 + 10 files changed, 431 insertions(+), 134 deletions(-) delete mode 100644 packages/snap-networks-utils/src/logger.test.ts delete mode 100644 packages/snap-networks-utils/src/logger.ts create mode 100644 packages/snap-networks-utils/src/logger/Logger.test.ts create mode 100644 packages/snap-networks-utils/src/logger/Logger.ts create mode 100644 packages/snap-networks-utils/src/logger/README.md create mode 100644 packages/snap-networks-utils/src/logger/index.ts diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index a8e57931a..c79cdc74e 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 4f615c52d..3865a6020 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 bf55f781a..9455899bc 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 421763112..ee84b9d6e 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 8b24491bb..000000000 --- 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 2eca10cdd..000000000 --- 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 000000000..35d4f4ef9 --- /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 000000000..3bce4da58 --- /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 000000000..23ebb2ce8 --- /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 000000000..93847b79f --- /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'; From 919ab13a96abfc4e77429097de2ac4cac8f5089f Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Mon, 10 Aug 2026 12:17:37 +0200 Subject: [PATCH 2/2] feat: remove enabled: true option, move exmaples to js doc comments, level is required option --- packages/snap-networks-utils/CHANGELOG.md | 2 +- packages/snap-networks-utils/README.md | 4 - packages/snap-networks-utils/package.json | 1 + .../src/logger/Logger.test.ts | 70 ++++++----- .../snap-networks-utils/src/logger/Logger.ts | 111 +++++++++++++----- .../snap-networks-utils/src/logger/README.md | 93 --------------- yarn.lock | 1 + 7 files changed, 127 insertions(+), 155 deletions(-) delete mode 100644 packages/snap-networks-utils/src/logger/README.md diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index c79cdc74e..8c3f233d3 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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`. +- **BREAKING** Replace the logger utilities with a configurable `Logger` class that requires a log level and supports level filtering, per-instance prefixes, and method decorators. ## [1.0.0] diff --git a/packages/snap-networks-utils/README.md b/packages/snap-networks-utils/README.md index 3865a6020..9f109bf7b 100644 --- a/packages/snap-networks-utils/README.md +++ b/packages/snap-networks-utils/README.md @@ -18,10 +18,6 @@ yarn workspace @metamask/tron-wallet-snap add @metamask/snap-networks-utils@work ## Usage -### Logger - -See the [logger README](./src/logger/README.md). - ### Core AssetsController reads Wire the Snap messenger endowment, then pass it to `AssetsProvider`: diff --git a/packages/snap-networks-utils/package.json b/packages/snap-networks-utils/package.json index 9455899bc..d713604dd 100644 --- a/packages/snap-networks-utils/package.json +++ b/packages/snap-networks-utils/package.json @@ -64,6 +64,7 @@ "@metamask/assets-controller": "^13.0.0", "@metamask/remote-feature-flag-controller": "^5.0.0", "@metamask/snaps-sdk": "^11.2.0", + "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.9.0" }, "devDependencies": { diff --git a/packages/snap-networks-utils/src/logger/Logger.test.ts b/packages/snap-networks-utils/src/logger/Logger.test.ts index 35d4f4ef9..4d3a3c9fe 100644 --- a/packages/snap-networks-utils/src/logger/Logger.test.ts +++ b/packages/snap-networks-utils/src/logger/Logger.test.ts @@ -1,21 +1,29 @@ import { Logger, LogLevel } from './Logger'; -const setupTest = () => { +const loggerMethodConfigurations = [ + { 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; + +type MockConsole = Record< + (typeof loggerMethodConfigurations)[number]['consoleMethod'], + jest.SpyInstance +>; + +type SetupTestResult = { + loggerMethods: typeof loggerMethodConfigurations; + mockConsole: MockConsole; +}; + +const setupTest = (): SetupTestResult => { 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, + loggerMethods: loggerMethodConfigurations, mockConsole: { debug: jest.spyOn(console, 'debug').mockImplementation(), error: jest.spyOn(console, 'error').mockImplementation(), @@ -30,9 +38,7 @@ describe('Logger', () => { it('forwards calls to the matching console method', () => { const { loggerMethods, mockConsole } = setupTest(); - const logger = new Logger({ - enabled: true, - }); + const logger = new Logger({ level: LogLevel.TRACE }); for (const { method, consoleMethod } of loggerMethods) { logger[method]('hello', 42); @@ -44,9 +50,7 @@ describe('Logger', () => { it('prefixes messages with a derived logger', () => { const { mockConsole } = setupTest(); - const logger = new Logger({ - enabled: true, - }); + const logger = new Logger({ level: LogLevel.TRACE }); const prefixed = logger.withPrefix('[snap-networks-utils]'); @@ -75,9 +79,7 @@ describe('Logger', () => { it('combines prefixes from derived loggers', () => { const { mockConsole } = setupTest(); - const logger = new Logger({ - enabled: true, - }); + const logger = new Logger({ level: LogLevel.TRACE }); const parentLogger = logger.withPrefix('[parent]'); const childLogger = parentLogger.withPrefix('[child]'); @@ -90,16 +92,25 @@ describe('Logger', () => { ); }); - it('defaults to the trace level', () => { + it('logs trace messages at the trace level', () => { const { mockConsole } = setupTest(); - const logger = new Logger({ enabled: true }); + const logger = new Logger({ level: LogLevel.TRACE }); logger.trace('trace'); expect(mockConsole.trace).toHaveBeenCalledWith('trace'); }); + it('throws when given an invalid log level', () => { + expect(() => new Logger({ level: '' as LogLevel })).toThrow( + 'Expected one of `"silent","error","warn","info","debug","trace"`, but received: ""', + ); + expect(() => new Logger({ level: 'verbose' as LogLevel })).toThrow( + 'Expected one of `"silent","error","warn","info","debug","trace"`, but received: "verbose"', + ); + }); + it('runs decorators through the configured output', () => { const decorator = jest.fn((next: (...args: unknown[]) => void) => { next('decoded error'); @@ -107,7 +118,7 @@ describe('Logger', () => { const { mockConsole } = setupTest(); const baseLogger = new Logger({ - enabled: true, + level: LogLevel.TRACE, decorators: { error: decorator }, }); @@ -122,12 +133,12 @@ describe('Logger', () => { expect(mockConsole.error).toHaveBeenCalledWith('[Solana]', 'decoded error'); }); - it('does not run decorators when logging is disabled', () => { + it('does not run decorators at the silent level', () => { const decorator = jest.fn(); const { mockConsole } = setupTest(); const logger = new Logger({ - enabled: false, + level: LogLevel.SILENT, decorators: { error: decorator }, }); @@ -137,11 +148,11 @@ describe('Logger', () => { expect(mockConsole.error).not.toHaveBeenCalled(); }); - it('does not log calls when disabled', () => { + it('does not log calls at the silent level', () => { const { loggerMethods, mockConsole } = setupTest(); const logger = new Logger({ - enabled: false, + level: LogLevel.SILENT, }); for (const { method, consoleMethod } of loggerMethods) { @@ -156,7 +167,6 @@ describe('Logger', () => { for (const { method, consoleMethod, filteredAt } of loggerMethods) { const logger = new Logger({ - enabled: true, level: filteredAt, }); diff --git a/packages/snap-networks-utils/src/logger/Logger.ts b/packages/snap-networks-utils/src/logger/Logger.ts index 3bce4da58..57230867b 100644 --- a/packages/snap-networks-utils/src/logger/Logger.ts +++ b/packages/snap-networks-utils/src/logger/Logger.ts @@ -1,3 +1,5 @@ +import { assert, enums } from '@metamask/superstruct'; + /** * The severity levels supported by {@link Logger}. */ @@ -12,6 +14,13 @@ export const LogLevel = { export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; +/** + * Defines the severity ordering used to filter log messages. + * + * Lower values are less verbose. Changing these numeric priorities changes + * which messages are emitted for each configured {@link LogLevel}, including + * in production. + */ const logLevelPriority = { [LogLevel.SILENT]: 0, [LogLevel.ERROR]: 1, @@ -21,6 +30,8 @@ const logLevelPriority = { [LogLevel.TRACE]: 5, }; +const LogLevelStruct = enums(Object.values(LogLevel)); + export type LoggerMethod = Exclude; export type LogMethod = (...args: unknown[]) => void; @@ -38,41 +49,84 @@ export type LoggerDecorators = Partial< /** * Configuration for a {@link Logger}. + * + * **Example: configure logging with `LOG_LEVEL`.** + * + * ```ts + * const logger = new Logger({ + * level: process.env.LOG_LEVEL + * }); + * ``` */ 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. */ + /** + * The most verbose severity level that should be logged. Use + * {@link LogLevel.SILENT} to disable logging. + */ + level: LogLevel; + /** + * An optional prefix prepended to every message. + * + * Use {@link Logger.withPrefix} to add prefixes after construction. + */ prefix?: string; - /** Optional behavior to apply to individual log methods. */ + /** + * Optional behavior to apply to individual log methods. + * + * Decorators receive `next`, which preserves this logger's configured level, + * prefix, and console output. + */ decorators?: LoggerDecorators; }; /** - * A console logger for network snaps. + * Logs network Snap messages to the console at configurable severity levels. + * + * Disable output with {@link LogLevel.SILENT}. + * + * **Example: create an enabled logger.** + * + * ```ts + * import { Logger } from '@metamask/snap-networks-utils/logger'; + * + * const logger = new Logger({ level: LogLevel.TRACE }); + * logger.info('Account synced'); + * ``` + * + * **Example: add a Snap or component prefix.** + * + * ```ts + * const rootLogger = new Logger({ level: LogLevel.TRACE }); + * const snapLogger = rootLogger.withPrefix('[tron-wallet-snap]'); + * const accountsLogger = snapLogger.withPrefix('[accounts]'); * - * 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`. + * accountsLogger.debug('Refreshing account balances'); + * ``` + * + * **Example: decorate a method with Snap-specific behavior.** + * + * ```ts + * const logger = new Logger({ + * level: LogLevel.TRACE, + * decorators: { + * error: (next, error) => { + * const details = getSolanaErrorDetails(error); + * next(details ?? error); + * }, + * }, + * }); + * ``` */ 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; + constructor({ level, prefix, decorators }: LoggerOptions) { + assert(level, LogLevelStruct); + this.#level = level; this.#prefix = prefix; this.#decorators = decorators; @@ -81,14 +135,15 @@ export class Logger { /** * Returns a logger with an additional prefix. * - * The returned logger has the same enabled state and log level as this one. + * The returned logger has the same log level and decorators as this one. Call + * this method as many times as needed; each derived logger appends its prefix + * to the existing prefix. * * @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, @@ -106,6 +161,7 @@ export class Logger { /** * Logs an informational message. * + * @param args - The values to write to the console. * @deprecated Use {@link Logger.info} instead. */ log(...args: unknown[]): void { @@ -124,10 +180,8 @@ export class Logger { this.#write(LogLevel.TRACE, console.trace, args); } - #shouldLog(level: LoggerMethod): boolean { - return ( - this.#enabled && logLevelPriority[level] <= logLevelPriority[this.#level] - ); + #isLevelDisabled(level: LoggerMethod): boolean { + return logLevelPriority[level] > logLevelPriority[this.#level]; } #write( @@ -135,10 +189,13 @@ export class Logger { writeToConsole: (...args: unknown[]) => void, args: unknown[], ): void { - if (!this.#shouldLog(level)) return; + if (this.#isLevelDisabled(level)) { + return; + } const next: LogMethod = this.#prefix - ? (...nextArgs) => writeToConsole(this.#prefix, ...nextArgs) + ? (...nextArgs: unknown[]): void => + writeToConsole(this.#prefix, ...nextArgs) : writeToConsole; const decorator = this.#decorators?.[level]; diff --git a/packages/snap-networks-utils/src/logger/README.md b/packages/snap-networks-utils/src/logger/README.md deleted file mode 100644 index 23ebb2ce8..000000000 --- a/packages/snap-networks-utils/src/logger/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# 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/yarn.lock b/yarn.lock index 1a044bd76..9f40d8b0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3309,6 +3309,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/snaps-sdk": "npm:^11.2.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0"