diff --git a/packages/amazonq/src/lsp/client.ts b/packages/amazonq/src/lsp/client.ts index 98427d17276..a4980154c27 100644 --- a/packages/amazonq/src/lsp/client.ts +++ b/packages/amazonq/src/lsp/client.ts @@ -8,6 +8,7 @@ import * as nls from 'vscode-nls' import { LanguageClient, LanguageClientOptions, RequestType, State } from 'vscode-languageclient' import { InlineCompletionManager } from '../app/inline/completion' import { AmazonQLspAuth, encryptionKey, notificationTypes } from './auth' +import { RotatingLogChannel } from './rotatingLogChannel' import { CreateFilesParams, DeleteFilesParams, @@ -94,6 +95,23 @@ export async function startLanguageServer( const clientId = 'amazonq' const traceServerEnabled = Settings.instance.isSet(`${clientId}.trace.server`) + + // Create custom output channel that writes to disk but sends UI output to the appropriate channel + const lspLogChannel = new RotatingLogChannel( + traceServerEnabled ? 'Amazon Q Language Server' : 'Amazon Q Logs', + extensionContext, + traceServerEnabled + ? vscode.window.createOutputChannel('Amazon Q Language Server', { log: true }) + : globals.logOutputChannel + ) + + // Add cleanup for our file output channel + toDispose.push({ + dispose: () => { + lspLogChannel.dispose() + }, + }) + let executable: string[] = [] // apply the GLIBC 2.28 path to node js runtime binary if (isSageMaker()) { @@ -191,15 +209,9 @@ export async function startLanguageServer( }, }, /** - * When the trace server is enabled it outputs a ton of log messages so: - * When trace server is enabled, logs go to a seperate "Amazon Q Language Server" output. - * Otherwise, logs go to the regular "Amazon Q Logs" channel. + * Using our RotatingLogger for all logs */ - ...(traceServerEnabled - ? {} - : { - outputChannel: globals.logOutputChannel, - }), + outputChannel: lspLogChannel, } const client = new LanguageClient( diff --git a/packages/amazonq/src/lsp/rotatingLogChannel.ts b/packages/amazonq/src/lsp/rotatingLogChannel.ts new file mode 100644 index 00000000000..24d6e110771 --- /dev/null +++ b/packages/amazonq/src/lsp/rotatingLogChannel.ts @@ -0,0 +1,228 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as path from 'path' +import * as fs from 'fs' // eslint-disable-line no-restricted-imports +import { getLogger } from 'aws-core-vscode/shared' + +export class RotatingLogChannel implements vscode.LogOutputChannel { + private fileStream: fs.WriteStream | undefined + private originalChannel: vscode.LogOutputChannel + private logger = getLogger('amazonqLsp') + private _logLevel: vscode.LogLevel = vscode.LogLevel.Info + private currentFileSize = 0 + // eslint-disable-next-line @typescript-eslint/naming-convention + private readonly MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB + // eslint-disable-next-line @typescript-eslint/naming-convention + private readonly MAX_LOG_FILES = 4 + + constructor( + public readonly name: string, + private readonly extensionContext: vscode.ExtensionContext, + outputChannel: vscode.LogOutputChannel + ) { + this.originalChannel = outputChannel + this.initFileStream() + } + + private async cleanupOldLogs(): Promise { + try { + const logDir = this.extensionContext.storageUri?.fsPath + if (!logDir) { + return + } + + // Get all log files + const files = await fs.promises.readdir(logDir) + const logFiles = files + .filter((f) => f.startsWith('amazonq-lsp-') && f.endsWith('.log')) + .map((f) => ({ + name: f, + path: path.join(logDir, f), + time: fs.statSync(path.join(logDir, f)).mtime.getTime(), + })) + .sort((a, b) => b.time - a.time) // Sort newest to oldest + + // Remove all but the most recent MAX_LOG_FILES files + for (const file of logFiles.slice(this.MAX_LOG_FILES - 1)) { + try { + await fs.promises.unlink(file.path) + this.logger.debug(`Removed old log file: ${file.path}`) + } catch (err) { + this.logger.error(`Failed to remove old log file ${file.path}: ${err}`) + } + } + } catch (err) { + this.logger.error(`Failed to cleanup old logs: ${err}`) + } + } + + private getLogFilePath(): string { + const logDir = this.extensionContext.storageUri?.fsPath + if (!logDir) { + throw new Error('No storage URI available') + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '-').replace('Z', '') + return path.join(logDir, `amazonq-lsp-${timestamp}.log`) + } + + private async rotateLog(): Promise { + try { + // Close current stream + if (this.fileStream) { + this.fileStream.end() + } + + // Create new log file + const newLogPath = this.getLogFilePath() + this.fileStream = fs.createWriteStream(newLogPath, { flags: 'a' }) + this.currentFileSize = 0 + + // Clean up old files + await this.cleanupOldLogs() + + this.logger.info(`Created new log file: ${newLogPath}`) + } catch (err) { + this.logger.error(`Failed to rotate log file: ${err}`) + } + } + + private initFileStream() { + try { + const logDir = this.extensionContext.storageUri + if (!logDir) { + this.logger.error('Failed to get storage URI for logs') + return + } + + // Ensure directory exists + if (!fs.existsSync(logDir.fsPath)) { + fs.mkdirSync(logDir.fsPath, { recursive: true }) + } + + const logPath = this.getLogFilePath() + this.fileStream = fs.createWriteStream(logPath, { flags: 'a' }) + this.currentFileSize = 0 + this.logger.info(`Logging to file: ${logPath}`) + } catch (err) { + this.logger.error(`Failed to create log file: ${err}`) + } + } + + get logLevel(): vscode.LogLevel { + return this._logLevel + } + + get onDidChangeLogLevel(): vscode.Event { + return this.originalChannel.onDidChangeLogLevel + } + + trace(message: string, ...args: any[]): void { + this.originalChannel.trace(message, ...args) + this.writeToFile(`[TRACE] ${message}`) + } + + debug(message: string, ...args: any[]): void { + this.originalChannel.debug(message, ...args) + this.writeToFile(`[DEBUG] ${message}`) + } + + info(message: string, ...args: any[]): void { + this.originalChannel.info(message, ...args) + this.writeToFile(`[INFO] ${message}`) + } + + warn(message: string, ...args: any[]): void { + this.originalChannel.warn(message, ...args) + this.writeToFile(`[WARN] ${message}`) + } + + error(message: string | Error, ...args: any[]): void { + this.originalChannel.error(message, ...args) + this.writeToFile(`[ERROR] ${message instanceof Error ? message.stack || message.message : message}`) + } + + append(value: string): void { + this.originalChannel.append(value) + this.writeToFile(value) + } + + appendLine(value: string): void { + this.originalChannel.appendLine(value) + this.writeToFile(value + '\n') + } + + replace(value: string): void { + this.originalChannel.replace(value) + this.writeToFile(`[REPLACE] ${value}`) + } + + clear(): void { + this.originalChannel.clear() + } + + show(preserveFocus?: boolean): void + show(column?: vscode.ViewColumn, preserveFocus?: boolean): void + show(columnOrPreserveFocus?: vscode.ViewColumn | boolean, preserveFocus?: boolean): void { + if (typeof columnOrPreserveFocus === 'boolean') { + this.originalChannel.show(columnOrPreserveFocus) + } else { + this.originalChannel.show(columnOrPreserveFocus, preserveFocus) + } + } + + hide(): void { + this.originalChannel.hide() + } + + dispose(): void { + // First dispose the original channel + this.originalChannel.dispose() + + // Close our file stream if it exists + if (this.fileStream) { + this.fileStream.end() + } + + // Clean up all log files + const logDir = this.extensionContext.storageUri?.fsPath + if (logDir) { + try { + const files = fs.readdirSync(logDir) + for (const file of files) { + if (file.startsWith('amazonq-lsp-') && file.endsWith('.log')) { + fs.unlinkSync(path.join(logDir, file)) + } + } + this.logger.info('Cleaned up all log files during disposal') + } catch (err) { + this.logger.error(`Failed to cleanup log files during disposal: ${err}`) + } + } + } + + private writeToFile(content: string): void { + if (this.fileStream) { + try { + const timestamp = new Date().toISOString() + const logLine = `${timestamp} ${content}\n` + const size = Buffer.byteLength(logLine) + + // If this write would exceed max file size, rotate first + if (this.currentFileSize + size > this.MAX_FILE_SIZE) { + void this.rotateLog() + } + + this.fileStream.write(logLine) + this.currentFileSize += size + } catch (err) { + this.logger.error(`Failed to write to log file: ${err}`) + void this.rotateLog() + } + } + } +} diff --git a/packages/amazonq/src/test/rotatingLogChannel.test.ts b/packages/amazonq/src/test/rotatingLogChannel.test.ts new file mode 100644 index 00000000000..ec963ebac9f --- /dev/null +++ b/packages/amazonq/src/test/rotatingLogChannel.test.ts @@ -0,0 +1,164 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +// eslint-disable-next-line no-restricted-imports +import * as fs from 'fs' +import * as path from 'path' +import * as assert from 'assert' +import { RotatingLogChannel } from '../lsp/rotatingLogChannel' + +describe('RotatingLogChannel', () => { + let testDir: string + let mockExtensionContext: vscode.ExtensionContext + let mockOutputChannel: vscode.LogOutputChannel + let logChannel: RotatingLogChannel + + beforeEach(() => { + // Create a temp test directory + testDir = fs.mkdtempSync('amazonq-test-logs-') + + // Mock extension context + mockExtensionContext = { + storageUri: { fsPath: testDir } as vscode.Uri, + } as vscode.ExtensionContext + + // Mock output channel + mockOutputChannel = { + name: 'Test Output Channel', + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + logLevel: vscode.LogLevel.Info, + onDidChangeLogLevel: new vscode.EventEmitter().event, + } + + // Create log channel instance + logChannel = new RotatingLogChannel('test', mockExtensionContext, mockOutputChannel) + }) + + afterEach(() => { + // Cleanup test directory + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + }) + + it('creates log file on initialization', () => { + const files = fs.readdirSync(testDir) + assert.strictEqual(files.length, 1) + assert.ok(files[0].startsWith('amazonq-lsp-')) + assert.ok(files[0].endsWith('.log')) + }) + + it('writes logs to file', async () => { + const testMessage = 'test log message' + logChannel.info(testMessage) + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const files = fs.readdirSync(testDir) + const content = fs.readFileSync(path.join(testDir, files[0]), 'utf-8') + assert.ok(content.includes(testMessage)) + }) + + it('rotates files when size limit is reached', async () => { + // Write enough data to trigger rotation + const largeMessage = 'x'.repeat(1024 * 1024) // 1MB + for (let i = 0; i < 6; i++) { + // Should create at least 2 files + logChannel.info(largeMessage) + } + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const files = fs.readdirSync(testDir) + assert.ok(files.length > 1, 'Should have created multiple log files') + assert.ok(files.length <= 4, 'Should not exceed max file limit') + }) + + it('keeps only the specified number of files', async () => { + // Write enough data to create more than MAX_LOG_FILES + const largeMessage = 'x'.repeat(1024 * 1024) // 1MB + for (let i = 0; i < 20; i++) { + // Should trigger multiple rotations + logChannel.info(largeMessage) + } + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const files = fs.readdirSync(testDir) + assert.strictEqual(files.length, 4, 'Should keep exactly 4 files') + }) + + it('cleans up all files on dispose', async () => { + // Write some logs + logChannel.info('test message') + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Verify files exist + assert.ok(fs.readdirSync(testDir).length > 0) + + // Dispose + logChannel.dispose() + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Verify files are cleaned up + const remainingFiles = fs.readdirSync(testDir).filter((f) => f.startsWith('amazonq-lsp-') && f.endsWith('.log')) + assert.strictEqual(remainingFiles.length, 0, 'Should have no log files after disposal') + }) + + it('includes timestamps in log messages', async () => { + const testMessage = 'test message' + logChannel.info(testMessage) + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const files = fs.readdirSync(testDir) + const content = fs.readFileSync(path.join(testDir, files[0]), 'utf-8') + + // ISO date format regex + const timestampRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/ + assert.ok(timestampRegex.test(content), 'Log entry should include ISO timestamp') + }) + + it('handles different log levels correctly', async () => { + const testMessage = 'test message' + logChannel.trace(testMessage) + logChannel.debug(testMessage) + logChannel.info(testMessage) + logChannel.warn(testMessage) + logChannel.error(testMessage) + + // Allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const files = fs.readdirSync(testDir) + const content = fs.readFileSync(path.join(testDir, files[0]), 'utf-8') + + assert.ok(content.includes('[TRACE]'), 'Should include TRACE level') + assert.ok(content.includes('[DEBUG]'), 'Should include DEBUG level') + assert.ok(content.includes('[INFO]'), 'Should include INFO level') + assert.ok(content.includes('[WARN]'), 'Should include WARN level') + assert.ok(content.includes('[ERROR]'), 'Should include ERROR level') + }) +})