diff --git a/README.md b/README.md index ac119ad..c4591d8 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Whether you need a single barrel refreshed or an entire tree brought into alignm 1. Select one of the Barrel Roll commands: - `Barrel Roll: Barrel Directory` (updates only the selected folder) - `Barrel Roll: Barrel Directory (Recursive)` (updates the selected folder and all subfolders) + 1. The extension will: - Scan all `.ts`/`.tsx` files in the folder (excluding `index.ts` and declaration files) - Recursively process each subfolder and generate its `index.ts` diff --git a/package.json b/package.json index 049b545..9872e03 100644 --- a/package.json +++ b/package.json @@ -83,9 +83,7 @@ } ] }, - "dependencies": { - "pino": "^10.0.0" - }, + "dependencies": {}, "description": "A Visual Studio Code extension to automatically export types, functions, constants, and classes through barrel files", "devDependencies": { "depcheck": "^1.4.7", diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 7f09ed1..0eeaf11 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -12,6 +12,7 @@ const { globSync } = require('glob'); const patterns = [ 'dist/core/barrel/*.test.js', 'dist/core/parser/*.test.js', + 'dist/logging/*.test.js', 'dist/utils/*.test.js', ]; diff --git a/src/extension.ts b/src/extension.ts index c37dbef..c50c4ac 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; import { BarrelFileGenerator } from './core/barrel/barrel-file.generator.js'; -import { PinoLogger } from './logging/pino.logger.js'; +import { OutputChannelLogger } from './logging/output-channel.logger.js'; import { BarrelGenerationMode, type IBarrelGenerationOptions } from './types/index.js'; type CommandDescriptor = { @@ -18,7 +18,7 @@ export function activate(context: vscode.ExtensionContext) { const outputChannel = vscode.window.createOutputChannel('Barrel Roll'); context.subscriptions.push(outputChannel); - PinoLogger.configureOutputChannel(outputChannel); + OutputChannelLogger.configureOutputChannel(outputChannel); outputChannel.appendLine('Barrel Roll: logging initialized'); const generator = new BarrelFileGenerator(); diff --git a/src/logging/index.ts b/src/logging/index.ts index c1795f5..c024465 100644 --- a/src/logging/index.ts +++ b/src/logging/index.ts @@ -15,4 +15,9 @@ * */ -export { PinoLogger } from './pino.logger.js'; +export { + type LoggerOptions, + type LogLevel, + type LogMetadata, + OutputChannelLogger, +} from './output-channel.logger.js'; diff --git a/src/logging/output-channel.logger.test.ts b/src/logging/output-channel.logger.test.ts new file mode 100644 index 0000000..b37dce7 --- /dev/null +++ b/src/logging/output-channel.logger.test.ts @@ -0,0 +1,252 @@ +/* + * Copyright 2025 Robert Lindley + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { OutputChannel } from 'vscode'; + +import { LogLevel, OutputChannelLogger } from './output-channel.logger.js'; + +describe('OutputChannelLogger', () => { + let outputLines: string[]; + let consoleOutput: { level: string; message: string }[]; + let originalConsole: { + log: typeof console.log; + debug: typeof console.debug; + warn: typeof console.warn; + error: typeof console.error; + }; + + beforeEach(() => { + outputLines = []; + consoleOutput = []; + + // Save original console methods + originalConsole = { + log: console.log, + debug: console.debug, + warn: console.warn, + error: console.error, + }; + + // Mock console methods + console.log = mock.fn((...args: unknown[]) => { + consoleOutput.push({ level: 'info', message: String(args[0]) }); + }); + console.debug = mock.fn((...args: unknown[]) => { + consoleOutput.push({ level: 'debug', message: String(args[0]) }); + }); + console.warn = mock.fn((...args: unknown[]) => { + consoleOutput.push({ level: 'warn', message: String(args[0]) }); + }); + console.error = mock.fn((...args: unknown[]) => { + consoleOutput.push({ level: 'error', message: String(args[0]) }); + }); + + OutputChannelLogger.configureOutputChannel({ + appendLine(line: string) { + outputLines.push(line); + }, + } as OutputChannel); + }); + + afterEach(() => { + OutputChannelLogger.configureOutputChannel(undefined); + // Restore console methods + console.log = originalConsole.log; + console.debug = originalConsole.debug; + console.warn = originalConsole.warn; + console.error = originalConsole.error; + }); + + it('should report logger as available when output channel is configured', () => { + const logger = new OutputChannelLogger(); + assert.strictEqual(logger.isLoggerAvailable(), true); + }); + + it('should report logger as available when console is enabled even without output channel', () => { + OutputChannelLogger.configureOutputChannel(undefined); + const logger = new OutputChannelLogger({ console: true }); + assert.strictEqual(logger.isLoggerAvailable(), true); + }); + + it('should report logger as unavailable when no output channel and console disabled', () => { + OutputChannelLogger.configureOutputChannel(undefined); + const logger = new OutputChannelLogger({ console: false }); + assert.strictEqual(logger.isLoggerAvailable(), false); + }); + + it('should log info messages with metadata', () => { + const logger = new OutputChannelLogger({ console: false }); + + logger.info('initialized', { service: 'barrel' }); + + assert.strictEqual(outputLines.length, 1); + assert.ok(outputLines[0].includes('[INFO] initialized {"service":"barrel"}')); + }); + + it('should log debug messages with timestamp', () => { + const logger = new OutputChannelLogger({ level: LogLevel.Debug, console: false }); + + logger.debug('diagnostic'); + + assert.strictEqual(outputLines.length, 1); + assert.ok(outputLines[0].includes('[DEBUG] diagnostic')); + assert.ok(/^\d{4}-\d{2}-\d{2}T/.test(outputLines[0])); + }); + + it('should not log debug messages when level is info', () => { + const logger = new OutputChannelLogger({ level: LogLevel.Info, console: false }); + + logger.debug('diagnostic'); + + assert.strictEqual(outputLines.length, 0); + }); + + it('should log warnings with metadata', () => { + const logger = new OutputChannelLogger({ console: false }); + + logger.warn('threshold exceeded', { attempt: 3 }); + + assert.strictEqual(outputLines.length, 1); + assert.ok(outputLines[0].includes('[WARN] threshold exceeded {"attempt":3}')); + }); + + it('should normalize errors before logging', () => { + const logger = new OutputChannelLogger({ console: false }); + const error = new Error('boom'); + + logger.error('operation failed', { error }); + + assert.strictEqual(outputLines.length, 1); + assert.ok(outputLines[0].includes('[ERROR] operation failed {"error":"boom"}')); + }); + + it('should prefix fatal messages for action failures', () => { + const logger = new OutputChannelLogger({ console: false }); + + logger.fatal('deploy'); + + assert.strictEqual(outputLines.length, 1); + assert.ok(outputLines[0].includes('[FATAL] Action failed: deploy')); + }); + + it('should skip output channel writes when none is configured', () => { + OutputChannelLogger.configureOutputChannel(undefined); + const logger = new OutputChannelLogger({ console: false }); + + logger.info('quiet'); + + assert.strictEqual(outputLines.length, 0); + }); + + it('should stringify circular metadata safely', () => { + const logger = new OutputChannelLogger({ console: false }); + const circular: Record = {}; + circular.self = circular; + + logger.info('circular', circular); + + assert.strictEqual(outputLines.length, 1); + assert.ok(outputLines[0].includes('[INFO] circular [object Object]')); + }); + + it('should create child logger with inherited bindings', () => { + const logger = new OutputChannelLogger({ console: false }); + const childLogger = logger.child({ requestId: '123' }); + + childLogger.info('child message'); + + assert.strictEqual(outputLines.length, 1); + assert.ok(outputLines[0].includes('{"requestId":"123"}')); + }); + + it('should merge child bindings with log metadata', () => { + const logger = new OutputChannelLogger({ console: false }); + const childLogger = logger.child({ requestId: '123' }); + + childLogger.info('message', { extra: 'data' }); + + assert.strictEqual(outputLines.length, 1); + assert.ok(outputLines[0].includes('"requestId":"123"')); + assert.ok(outputLines[0].includes('"extra":"data"')); + }); + + it('should log to console when enabled', () => { + const logger = new OutputChannelLogger({ console: true }); + + logger.info('console message'); + + assert.strictEqual(consoleOutput.length, 1); + assert.strictEqual(consoleOutput[0].level, 'info'); + }); + + it('should not log to console when disabled', () => { + const logger = new OutputChannelLogger({ console: false }); + + logger.info('quiet message'); + + assert.strictEqual(consoleOutput.length, 0); + }); + + it('should execute grouped operations and log start/complete', async () => { + const logger = new OutputChannelLogger({ console: false }); + + const result = await logger.group('build', async () => 42); + + assert.strictEqual(result, 42); + assert.strictEqual(outputLines.length, 2); + assert.ok(outputLines[0].includes('Starting group: build')); + assert.ok(outputLines[1].includes('Completed group: build')); + }); + + it('should log errors from grouped operations', async () => { + const logger = new OutputChannelLogger({ console: false }); + const failure = new Error('group failure'); + + await assert.rejects( + logger.group('failures', async () => { + throw failure; + }), + (error) => error === failure, + ); + + assert.strictEqual(outputLines.length, 2); + assert.ok(outputLines[0].includes('Starting group: failures')); + assert.ok(outputLines[1].includes('[ERROR] Failed in group: failures')); + }); + + it('should use error level for fatal console output', () => { + const logger = new OutputChannelLogger({ console: true }); + + logger.fatal('critical'); + + assert.strictEqual(consoleOutput.length, 1); + assert.strictEqual(consoleOutput[0].level, 'error'); + }); + + it('should omit metadata from output when empty', () => { + const logger = new OutputChannelLogger({ console: false }); + + logger.info('no metadata'); + + assert.strictEqual(outputLines.length, 1); + assert.ok(!outputLines[0].includes('{}')); + assert.ok(outputLines[0].endsWith('no metadata')); + }); +}); diff --git a/src/logging/output-channel.logger.ts b/src/logging/output-channel.logger.ts new file mode 100644 index 0000000..1c9cf0c --- /dev/null +++ b/src/logging/output-channel.logger.ts @@ -0,0 +1,232 @@ +/* + * Copyright 2025 Robert Lindley + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import type { OutputChannel } from 'vscode'; + +import { isObject, isString } from '../utils/guards.js'; + +export type LogMetadata = Record; + +export enum LogLevel { + Debug = 'debug', + Info = 'info', + Warn = 'warn', + Error = 'error', + Fatal = 'fatal', +} + +/** + * Configuration options for the OutputChannelLogger. + */ +export interface LoggerOptions { + /** Minimum log level to emit. Defaults to LogLevel.Info. */ + level?: LogLevel; + /** Whether to also log to the console. Defaults to true. */ + console?: boolean; +} + +/** + * A logger abstraction over VS Code's OutputChannel API. + * Provides structured logging with metadata support and optional console output. + */ +export class OutputChannelLogger { + private static sharedOutputChannel?: OutputChannel; + private readonly options: Required; + private bindings: LogMetadata = {}; + + private static readonly LOG_LEVELS: Record = { + [LogLevel.Debug]: 0, + [LogLevel.Info]: 1, + [LogLevel.Warn]: 2, + [LogLevel.Error]: 3, + [LogLevel.Fatal]: 4, + }; + + constructor(options?: LoggerOptions) { + this.options = { + level: options?.level ?? LogLevel.Info, + console: options?.console ?? true, + }; + } + + /** + * Configure a shared VS Code output channel used by all logger instances. + * @param channel - Output channel to use for log messages. + */ + static configureOutputChannel(channel: OutputChannel | undefined): void { + OutputChannelLogger.sharedOutputChannel = channel; + } + + /** + * Check if the logger has an output channel configured. + * @returns True if an output channel is available. + */ + public isLoggerAvailable(): boolean { + return OutputChannelLogger.sharedOutputChannel !== undefined || this.options.console; + } + + /** + * Logs an informational message. + * @param message - The message to log. + * @param metadata - Optional metadata to include with the log. + */ + info(message: string, metadata?: LogMetadata): void { + this.log(LogLevel.Info, message, metadata); + } + + /** + * Logs a debug message. + * @param message - The message to log. + * @param metadata - Optional metadata to include with the log. + */ + debug(message: string, metadata?: LogMetadata): void { + this.log(LogLevel.Debug, message, metadata); + } + + /** + * Logs a warning message. + * @param message - The message to log. + * @param metadata - Optional metadata to include with the log. + */ + warn(message: string, metadata?: LogMetadata): void { + this.log(LogLevel.Warn, message, metadata); + } + + /** + * Logs an error message. + * @param message - The message to log. + * @param metadata - Optional metadata to include with the log. + */ + error(message: string, metadata?: LogMetadata): void { + this.log(LogLevel.Error, message, metadata); + } + + /** + * Logs a fatal error message (used for action failures). + * @param message - The failure message. + * @param metadata - Optional metadata to include with the failure. + */ + fatal(message: string, metadata?: LogMetadata): void { + this.log(LogLevel.Fatal, `Action failed: ${message}`, metadata); + } + + /** + * Creates a child logger with additional context bindings. + * @param bindings - Additional metadata to include with all logs from the child logger. + * @returns A new logger instance with the bindings applied. + */ + child(bindings: LogMetadata): OutputChannelLogger { + const childLogger = new OutputChannelLogger(this.options); + childLogger.bindings = { ...this.bindings, ...bindings }; + return childLogger; + } + + /** + * Executes a grouped operation with child logger context. + * @param name - The name of the group. + * @param fn - The function to execute within the group. + * @returns A promise that resolves when the group operation completes. + */ + async group(name: string, fn: () => Promise): Promise { + const childLogger = this.child({ group: name }); + childLogger.log(LogLevel.Info, `Starting group: ${name}`); + + try { + const result = await fn(); + childLogger.log(LogLevel.Info, `Completed group: ${name}`); + return result; + } catch (error) { + childLogger.log(LogLevel.Error, `Failed in group: ${name}`, { + error: this.normalizeError(error), + }); + throw error; + } + } + + private log(level: LogLevel, message: string, metadata?: LogMetadata): void { + if (!this.shouldLog(level)) return; + + const mergedMetadata = { ...this.bindings, ...metadata }; + const formattedLine = this.formatLine(level, message, mergedMetadata); + + this.writeToOutputChannel(formattedLine); + this.writeToConsole(level, formattedLine); + } + + private shouldLog(level: LogLevel): boolean { + return ( + OutputChannelLogger.LOG_LEVELS[level] >= OutputChannelLogger.LOG_LEVELS[this.options.level] + ); + } + + private writeToOutputChannel(line: string): void { + OutputChannelLogger.sharedOutputChannel?.appendLine(line); + } + + private writeToConsole(level: LogLevel, line: string): void { + if (!this.options.console) return; + + const consoleMethods: Record void> = { + [LogLevel.Debug]: console.debug, + [LogLevel.Info]: console.log, + [LogLevel.Warn]: console.warn, + [LogLevel.Error]: console.error, + [LogLevel.Fatal]: console.error, + }; + + consoleMethods[level](line); + } + + private formatLine(level: LogLevel, message: string, metadata?: LogMetadata): string { + const timestamp = new Date().toISOString(); + const formattedMetadata = this.formatMetadata(metadata); + const levelTag = `[${level.toUpperCase()}]`; + + return formattedMetadata + ? `${timestamp} ${levelTag} ${message} ${formattedMetadata}` + : `${timestamp} ${levelTag} ${message}`; + } + + private formatMetadata(metadata?: LogMetadata): string | undefined { + if (!metadata || Object.keys(metadata).length === 0) return; + + const normalized = Object.entries(metadata).reduce>( + (accumulator, [key, value]) => { + accumulator[key] = value instanceof Error ? value.message : value; + return accumulator; + }, + {}, + ); + + return this.safeStringify(normalized); + } + + private normalizeError(error: unknown): string { + if (error instanceof Error) return error.stack || error.message; + if (isObject(error)) return this.safeStringify(error); + return String(error); + } + + private safeStringify(value: unknown): string { + if (isString(value)) return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } +} diff --git a/src/logging/pino.logger.test.ts b/src/logging/pino.logger.test.ts deleted file mode 100644 index 9672f9d..0000000 --- a/src/logging/pino.logger.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import assert from 'node:assert/strict'; -import { afterEach, beforeEach, describe, it, mock } from 'node:test'; -import type { Logger, LoggerOptions } from 'pino'; -import type { OutputChannel } from 'vscode'; -import type { PinoLoggerConstructor } from '../test/testTypes.js'; - -describe('PinoLogger', () => { - type CallStore = { - info: Array<[unknown, string]>; - debug: Array<[unknown, string]>; - warn: Array<[unknown, string]>; - error: Array<[unknown, string]>; - fatal: Array<[unknown, string]>; - }; - - const createCallStore = (): CallStore => ({ - info: [], - debug: [], - warn: [], - error: [], - fatal: [], - }); - - const makeLogger = (calls: CallStore, childArgs: unknown[], childLogger?: Logger): Logger => { - return { - info(metadata: unknown, message: string) { - calls.info.push([metadata, message]); - }, - debug(metadata: unknown, message: string) { - calls.debug.push([metadata, message]); - }, - warn(metadata: unknown, message: string) { - calls.warn.push([metadata, message]); - }, - error(metadata: unknown, message: string) { - calls.error.push([metadata, message]); - }, - fatal(metadata: unknown, message: string) { - calls.fatal.push([metadata, message]); - }, - child(metadata: unknown) { - childArgs.push(metadata); - return childLogger ?? makeLogger(createCallStore(), childArgs); - }, - } as unknown as Logger; - }; - - const mockIsoTime = () => '2025-01-01T00:00:00.000Z'; - const restoreLogLevel = (value: string | undefined): void => { - if (value === undefined) { - delete process.env.LOG_LEVEL; - return; - } - process.env.LOG_LEVEL = value; - }; - - let mockPinoLogger: Logger; - let mockChildLogger: Logger; - let rootCalls: CallStore; - let childCalls: CallStore; - let childMetadataArgs: unknown[]; - let outputLines: string[]; - let lastOptions: LoggerOptions | undefined; - let shouldThrowOnCreate = false; - let consoleWarnings: unknown[][]; - let PinoLogger: PinoLoggerConstructor; - let originalWarn: typeof console.warn; - let previousLogLevel: string | undefined; - - mock.module('pino', { - defaultExport: Object.assign( - (options?: LoggerOptions) => { - lastOptions = options; - if (shouldThrowOnCreate) { - throw new Error('pino init failure'); - } - return mockPinoLogger; - }, - { - stdTimeFunctions: { - isoTime: mockIsoTime, - }, - }, - ), - }); - - beforeEach(async () => { - previousLogLevel = process.env.LOG_LEVEL; - - rootCalls = createCallStore(); - childCalls = createCallStore(); - childMetadataArgs = []; - outputLines = []; - lastOptions = undefined; - shouldThrowOnCreate = false; - consoleWarnings = []; - - // Capture and override console.warn for test inspection - originalWarn = console.warn.bind(console); - console.warn = (...args: unknown[]) => { - consoleWarnings.push(args); - }; - - mockChildLogger = makeLogger(childCalls, childMetadataArgs); - mockPinoLogger = makeLogger(rootCalls, childMetadataArgs, mockChildLogger); - - ({ PinoLogger } = (await import('./pino.logger.js')) as unknown as { - PinoLogger: PinoLoggerConstructor; - }); - - PinoLogger.configureOutputChannel({ - appendLine(line: string) { - outputLines.push(line); - }, - } as OutputChannel); - }); - - afterEach(() => { - PinoLogger.configureOutputChannel(undefined); - console.warn = originalWarn; - // Restore LOG_LEVEL if tests changed it - restoreLogLevel(previousLogLevel); - }); - - it('should use default configuration when LOG_LEVEL env is set', () => { - const previousLogLevel = process.env.LOG_LEVEL; - process.env.LOG_LEVEL = 'warn'; - try { - const logger = new PinoLogger(); - - assert.ok(lastOptions); - assert.strictEqual(lastOptions?.level, 'warn'); - assert.strictEqual(logger.isLoggerAvailable(), true); - } finally { - restoreLogLevel(previousLogLevel); - } - }); - - it('should set timestamp and formatter defaults when no options are provided', () => { - const logger = new PinoLogger(); - assert.ok(logger); - - assert.strictEqual(lastOptions?.transport, undefined); - assert.strictEqual(lastOptions?.timestamp, mockIsoTime); - assert.strictEqual(typeof lastOptions?.formatters?.level, 'function'); - }); - - it('should pass provided options through to pino', () => { - const options: LoggerOptions = { level: 'debug' }; - const logger = new PinoLogger(options); - - assert.strictEqual(lastOptions, options); - logger.info('custom message'); - assert.strictEqual(rootCalls.info.length, 1); - }); - - it('should log info messages with metadata', () => { - const logger = new PinoLogger(); - - logger.info('initialized', { service: 'barrel' }); - - assert.deepStrictEqual(rootCalls.info, [[{ service: 'barrel' }, 'initialized']]); - assert.deepStrictEqual(outputLines, ['[INFO] initialized {"service":"barrel"}']); - }); - - it('should omit metadata from debug output when none is provided', () => { - const logger = new PinoLogger(); - - logger.debug('diagnostic'); - - assert.deepStrictEqual(rootCalls.debug, [[{}, 'diagnostic']]); - assert.deepStrictEqual(outputLines, ['[DEBUG] diagnostic']); - }); - - it('should log warnings with metadata', () => { - const logger = new PinoLogger(); - - logger.warn('threshold exceeded', { attempt: 3 }); - - assert.deepStrictEqual(rootCalls.warn, [[{ attempt: 3 }, 'threshold exceeded']]); - assert.deepStrictEqual(outputLines, ['[WARN] threshold exceeded {"attempt":3}']); - }); - - it('should normalize errors before logging', () => { - const logger = new PinoLogger(); - const error = new Error('boom'); - - logger.error('operation failed', { error }); - - assert.deepStrictEqual(rootCalls.error, [[{ error }, 'operation failed']]); - assert.deepStrictEqual(outputLines, ['[ERROR] operation failed {"error":"boom"}']); - }); - - it('should leave string errors unchanged during normalization', () => { - const logger = new PinoLogger(); - const normalizeError = ( - logger as unknown as { normalizeError(error: unknown): string } - ).normalizeError.bind(logger); - - assert.strictEqual(normalizeError('fail'), 'fail'); - }); - - it('should prefix fatal messages for action failures', () => { - const logger = new PinoLogger(); - - logger.fatal('deploy'); - - assert.deepStrictEqual(rootCalls.fatal, [[{}, 'Action failed: deploy']]); - assert.deepStrictEqual(outputLines, ['[FATAL] Action failed: deploy']); - }); - - it('should skip output channel writes when none is configured', () => { - const logger = new PinoLogger(); - PinoLogger.configureOutputChannel(undefined); - - logger.info('quiet'); - - assert.deepStrictEqual(outputLines, []); - }); - - it('should stringify circular metadata safely', () => { - const logger = new PinoLogger(); - const circular: Record = {}; - circular.self = circular; - - logger.info('circular', circular); - - assert.deepStrictEqual(rootCalls.info, [[circular, 'circular']]); - assert.deepStrictEqual(outputLines, ['[INFO] circular [object Object]']); - }); - - it('should use a child logger when grouping operations and restore afterward', async () => { - const logger = new PinoLogger(); - - const result = await ( - logger as unknown as { group(name: string, fn: () => Promise): Promise } - ).group('build', async () => 42); - - assert.strictEqual(result, 42); - assert.deepStrictEqual(childMetadataArgs, [{ group: 'build' }]); - assert.deepStrictEqual(childCalls.info, [ - [{}, 'Starting group: build'], - [{}, 'Completed group: build'], - ]); - assert.deepStrictEqual(outputLines, [ - '[GROUP] Starting group: build', - '[GROUP] Completed group: build', - ]); - - logger.debug('post-group'); - assert.deepStrictEqual(childCalls.debug, []); - assert.deepStrictEqual(rootCalls.debug.at(-1), [{}, 'post-group']); - }); - - it('should propagate errors from grouped operations with normalized metadata', async () => { - const logger = new PinoLogger(); - const failure = { code: 'EFAIL' }; - - await assert.rejects( - ( - logger as unknown as { group(name: string, fn: () => Promise): Promise } - ).group('failures', async () => { - throw failure; - }), - (error) => error === failure, - ); - - const expectedMetadata = JSON.stringify({ error: JSON.stringify(failure) }); - assert.deepStrictEqual(childCalls.error, [[{ error: failure }, 'Failed in group: failures']]); - assert.strictEqual(outputLines.at(-1), `[ERROR] Failed in group: failures ${expectedMetadata}`); - }); - - it('should fall back to a no-op logger when pino initialization fails', () => { - shouldThrowOnCreate = true; - - const logger = new PinoLogger(); - - assert.strictEqual(logger.isLoggerAvailable(), false); - assert.strictEqual(consoleWarnings.length, 1); - - logger.info('fallback'); - - assert.deepStrictEqual(rootCalls.info, []); - assert.deepStrictEqual(outputLines, ['[INFO] fallback']); - }); -}); diff --git a/src/logging/pino.logger.ts b/src/logging/pino.logger.ts deleted file mode 100644 index 035ccc3..0000000 --- a/src/logging/pino.logger.ts +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright 2025 Robert Lindley - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -import pino from 'pino'; -import type { OutputChannel } from 'vscode'; - -import { isObject, isString } from '../utils/guards.js'; - -type LogMetadata = Record; -type PinoLevel = 'info' | 'debug' | 'warn' | 'error' | 'fatal'; - -/** - * Minimal structured logger used by the extension. Falls back to a no-op implementation if pino - * fails to initialize in the host environment. - */ -export class PinoLogger { - private logger: pino.Logger; - private readonly isAvailable: boolean = true; - - private static sharedOutputChannel?: OutputChannel; - - constructor(options?: pino.LoggerOptions) { - try { - this.logger = pino(this.resolveOptions(options)); - } catch (error) { - this.isAvailable = false; - console.warn('Pino logger initialization failed:', error); - this.logger = createFallbackLogger(); - } - } - - /** - * Configure a shared VS Code output channel used by all logger instances. - * @param channel - Output channel to use for human-readable log messages. - */ - static configureOutputChannel(channel: OutputChannel | undefined): void { - PinoLogger.sharedOutputChannel = channel; - } - - /** - * Check if Pino logger is available and functioning. - * @returns True if the logger is available, false otherwise. - */ - public isLoggerAvailable(): boolean { - return this.isAvailable; - } - - /** - * Logs an informational message using Pino. - * @param message - The message to log. - * @param metadata - Optional metadata to include with the log. - */ - info(message: string, metadata?: LogMetadata): void { - this.logWithLevel('info', message, metadata); - } - - /** - * Logs a debug message using Pino. - * @param message - The message to log. - */ - debug(message: string): void { - this.logWithLevel('debug', message); - } - - /** - * Logs a warning message using Pino. - * @param message - The message to log. - * @param metadata - Optional metadata to include with the log. - */ - warn(message: string, metadata?: LogMetadata): void { - this.logWithLevel('warn', message, metadata); - } - - /** - * Logs an error message using Pino. - * @param message - The message to log. - * @param metadata - Optional metadata to include with the log. - */ - error(message: string, metadata?: LogMetadata): void { - this.logWithLevel('error', message, metadata); - } - - /** - * Logs a fatal error message using Pino (used for action failures). - * @param message - The failure message. - * @param metadata - Optional metadata to include with the failure. - */ - fatal(message: string, metadata?: LogMetadata): void { - this.logWithLevel('fatal', `Action failed: ${message}`, metadata); - } - - /** - * Executes a grouped operation with Pino child logger context. - * @param name - The name of the group. - * @param fn - The function to execute within the group. - * @returns A promise that resolves when the group operation completes. - */ - async group(name: string, fn: () => Promise): Promise { - const childLogger = this.logger.child({ group: name }); - const originalLogger = this.logger; - - this.logger = childLogger; - childLogger.info(`Starting group: ${name}`); - this.appendToOutputChannel('GROUP', `Starting group: ${name}`); - - try { - const result = await fn(); - childLogger.info(`Completed group: ${name}`); - this.appendToOutputChannel('GROUP', `Completed group: ${name}`); - return result; - } catch (error) { - childLogger.error({ error }, `Failed in group: ${name}`); - this.appendToOutputChannel('ERROR', `Failed in group: ${name}`, { - error: this.normalizeError(error), - }); - throw error; - } finally { - this.logger = originalLogger; - } - } - - private appendToOutputChannel(level: string, message: string, metadata?: LogMetadata): void { - const channel = PinoLogger.sharedOutputChannel; - if (!channel) return; - channel.appendLine(this.formatOutputLine(level, message, metadata)); - } - - private formatMetadata(metadata?: LogMetadata): string | undefined { - if (!metadata || Object.keys(metadata).length === 0) return; - - const normalized = Object.entries(metadata).reduce>( - (accumulator, [key, value]) => { - accumulator[key] = value instanceof Error ? value.message : value; - return accumulator; - }, - {}, - ); - - return this.safeStringify(normalized); - } - - private normalizeError(error: unknown): string { - if (error instanceof Error) return error.stack || error.message; - if (isObject(error)) return this.safeStringify(error); - return String(error); - } - - private safeStringify(value: unknown): string { - if (isString(value)) return value; - try { - return JSON.stringify(value); - } catch { - return String(value); - } - } - - private logWithLevel(level: PinoLevel, message: string, metadata?: LogMetadata): void { - const payload = metadata ?? {}; - this.logger[level](payload, message); - this.appendToOutputChannel(level.toUpperCase(), message, metadata); - } - - private formatOutputLine(level: string, message: string, metadata?: LogMetadata): string { - const formattedMetadata = this.formatMetadata(metadata); - return formattedMetadata - ? `[${level}] ${message} ${formattedMetadata}` - : `[${level}] ${message}`; - } - - private resolveOptions(options?: pino.LoggerOptions): pino.LoggerOptions { - if (options) return options; - return { - level: process.env.LOG_LEVEL || 'info', - // Avoid transports (pino-pretty, thread-stream) that don't work when bundled - formatters: { - level: (label) => ({ level: label }), - }, - timestamp: pino.stdTimeFunctions.isoTime, - }; - } -} - -function createFallbackLogger(): pino.Logger { - const noop = (..._args: unknown[]) => { - /* no-op */ - }; - - const fallback: Record = {}; - fallback.info = noop; - fallback.debug = noop; - fallback.warn = noop; - fallback.error = noop; - fallback.fatal = noop; - fallback.child = () => fallback; - - return fallback as unknown as pino.Logger; -} diff --git a/src/test/testTypes.ts b/src/test/testTypes.ts index 10cf0b5..477e5af 100644 --- a/src/test/testTypes.ts +++ b/src/test/testTypes.ts @@ -27,8 +27,8 @@ export type TestWorkspaceApi = { fs: { stat(uri: FakeUri): Promise<{ type: numbe export type ActivateFn = (context: ExtensionContext) => Promise | void; export type DeactivateFn = () => void; -// Minimal runtime shape for the PinoLogger class used in tests -export interface PinoLoggerInstance { +// Minimal runtime shape for the OutputChannelLogger class used in tests +export interface LoggerInstance { isLoggerAvailable(): boolean; info(message: string, metadata?: Record): void; debug(message: string, metadata?: Record): void; @@ -36,9 +36,15 @@ export interface PinoLoggerInstance { error(message: string, metadata?: Record): void; fatal(message: string, metadata?: Record): void; group?(name: string, fn: () => Promise): Promise; + child?(bindings: Record): LoggerInstance; } -export interface PinoLoggerConstructor { - new (...args: unknown[]): PinoLoggerInstance; +export interface LoggerConstructor { + new (...args: unknown[]): LoggerInstance; configureOutputChannel(channel?: { appendLine(value: string): void }): void; } + +/** @deprecated Use LoggerInstance instead */ +export type PinoLoggerInstance = LoggerInstance; +/** @deprecated Use LoggerConstructor instead */ +export type PinoLoggerConstructor = LoggerConstructor; diff --git a/webpack.config.cjs b/webpack.config.cjs index 95e3ee8..5b046af 100644 --- a/webpack.config.cjs +++ b/webpack.config.cjs @@ -3,7 +3,6 @@ 'use strict'; const path = require('node:path'); -const webpack = require('webpack'); /**@type {import('webpack').Configuration}*/ const config = { @@ -17,10 +16,6 @@ const config = { devtoolModuleFilenameTemplate: '../[resource-path]', }, devtool: 'source-map', - plugins: [ - // Ignore optional transports and thread-stream used by pino to avoid bundling them - new webpack.IgnorePlugin({ resourceRegExp: /pino-pretty|thread-stream/ }), - ], externals: { vscode: 'commonjs vscode', },