diff --git a/packages/amazonq/src/extension.ts b/packages/amazonq/src/extension.ts index 1e26724ff61..53d7cd88037 100644 --- a/packages/amazonq/src/extension.ts +++ b/packages/amazonq/src/extension.ts @@ -45,7 +45,6 @@ import { registerCommands } from './commands' import { focusAmazonQPanel } from 'aws-core-vscode/codewhispererChat' import { activate as activateAmazonqLsp } from './lsp/activation' import { hasGlibcPatch } from './lsp/client' -import { RotatingLogChannel } from './lsp/rotatingLogChannel' export const amazonQContextPrefix = 'amazonq' @@ -104,12 +103,7 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is globals.manifestPaths.endpoints = context.asAbsolutePath(join('resources', 'endpoints.json')) globals.regionProvider = RegionProvider.fromEndpointsProvider(makeEndpointsProvider()) - // Create rotating log channel for all Amazon Q logs - const qLogChannel = new RotatingLogChannel( - 'Amazon Q Logs', - context, - vscode.window.createOutputChannel('Amazon Q Logs', { log: true }) - ) + const qLogChannel = vscode.window.createOutputChannel('Amazon Q Logs', { log: true }) await activateLogger(context, amazonQContextPrefix, qLogChannel) globals.logOutputChannel = qLogChannel globals.loginManager = new LoginManager(globals.awsContext, new CredentialsStore()) @@ -118,8 +112,6 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is getLogger().error('fs.init: invalid env vars found: %O', homeDirLogs) } - getLogger().info('Rotating logger has been setup') - await activateTelemetry(context, globals.awsContext, Settings.instance, 'Amazon Q For VS Code') await initializeAuth(globals.loginManager) diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index f869bbe0da3..38ed7c95c94 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -81,7 +81,14 @@ import { SecurityIssueTreeViewProvider, CodeWhispererConstants, } from 'aws-core-vscode/codewhisperer' -import { amazonQDiffScheme, AmazonQPromptSettings, messages, openUrl, isTextEditor } from 'aws-core-vscode/shared' +import { + amazonQDiffScheme, + AmazonQPromptSettings, + messages, + openUrl, + isTextEditor, + globals, +} from 'aws-core-vscode/shared' import { DefaultAmazonQAppInitContext, messageDispatcher, @@ -429,6 +436,38 @@ export function registerMessageListeners( case listMcpServersRequestType.method: case mcpServerClickRequestType.method: case tabBarActionRequestType.method: + // handling for show_logs button + if (message.params.action === 'show_logs') { + languageClient.info('[VSCode Client] Received show_logs action, showing disclaimer') + + // Show warning message without buttons - just informational + void vscode.window.showWarningMessage( + 'Log files may contain sensitive information such as account IDs, resource names, and other data. Be careful when sharing these logs.' + ) + + // Get the log directory path + const logPath = globals.context.logUri?.fsPath + const result = { ...message.params, success: false } + + if (logPath) { + // Open the log directory in the OS file explorer directly + languageClient.info('[VSCode Client] Opening logs directory') + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(logPath)) + result.success = true + } else { + // Fallback: show error if log path is not available + void vscode.window.showErrorMessage('Log location not available.') + languageClient.error('[VSCode Client] Log location not available') + } + + void webview?.postMessage({ + command: message.command, + params: result, + }) + + break + } + // eslint-disable-next-line no-fallthrough case listAvailableModelsRequestType.method: await resolveChatResponse(message.command, message.params, languageClient, webview) break diff --git a/packages/amazonq/src/lsp/client.ts b/packages/amazonq/src/lsp/client.ts index 2d8fdb182fc..70bb746b456 100644 --- a/packages/amazonq/src/lsp/client.ts +++ b/packages/amazonq/src/lsp/client.ts @@ -8,7 +8,6 @@ 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, @@ -95,23 +94,6 @@ 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,6 +173,7 @@ export async function startLanguageServer( window: { notifications: true, showSaveFileDialog: true, + showLogs: true, }, textDocument: { inlineCompletionWithReferences: { @@ -209,9 +192,15 @@ export async function startLanguageServer( }, }, /** - * Using our RotatingLogger for all logs + * 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. */ - outputChannel: lspLogChannel, + ...(traceServerEnabled + ? {} + : { + outputChannel: globals.logOutputChannel, + }), } const client = new LanguageClient( diff --git a/packages/amazonq/src/lsp/rotatingLogChannel.ts b/packages/amazonq/src/lsp/rotatingLogChannel.ts deleted file mode 100644 index b8e3df276f9..00000000000 --- a/packages/amazonq/src/lsp/rotatingLogChannel.ts +++ /dev/null @@ -1,246 +0,0 @@ -/*! - * 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 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 - private static currentLogPath: string | undefined - - private static generateNewLogPath(logDir: string): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '-').replace('Z', '') - return path.join(logDir, `amazonq-lsp-${timestamp}.log`) - } - - 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 { - // If we already have a path, reuse it - if (RotatingLogChannel.currentLogPath) { - return RotatingLogChannel.currentLogPath - } - - const logDir = this.extensionContext.storageUri?.fsPath - if (!logDir) { - throw new Error('No storage URI available') - } - - // Generate initial path - RotatingLogChannel.currentLogPath = RotatingLogChannel.generateNewLogPath(logDir) - return RotatingLogChannel.currentLogPath - } - - private async rotateLog(): Promise { - try { - // Close current stream - if (this.fileStream) { - this.fileStream.end() - } - - const logDir = this.extensionContext.storageUri?.fsPath - if (!logDir) { - throw new Error('No storage URI available') - } - - // Generate new path directly - RotatingLogChannel.currentLogPath = RotatingLogChannel.generateNewLogPath(logDir) - - // Create new log file with new path - this.fileStream = fs.createWriteStream(RotatingLogChannel.currentLogPath, { flags: 'a' }) - this.currentFileSize = 0 - - // Clean up old files - await this.cleanupOldLogs() - - this.logger.info(`Created new log file: ${RotatingLogChannel.currentLogPath}`) - } 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.originalChannel.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 deleted file mode 100644 index 87c4c109603..00000000000 --- a/packages/amazonq/src/test/rotatingLogChannel.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -/*! - * 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') - }) - - it('delegates log level to the original channel', () => { - // Set up a mock output channel with a specific log level - const mockChannel = { - ...mockOutputChannel, - logLevel: vscode.LogLevel.Trace, - } - - // Create a new log channel with the mock - const testLogChannel = new RotatingLogChannel('test-delegate', mockExtensionContext, mockChannel) - - // Verify that the log level is delegated correctly - assert.strictEqual( - testLogChannel.logLevel, - vscode.LogLevel.Trace, - 'Should delegate log level to original channel' - ) - - // Change the mock's log level - mockChannel.logLevel = vscode.LogLevel.Debug - - // Verify that the change is reflected - assert.strictEqual( - testLogChannel.logLevel, - vscode.LogLevel.Debug, - 'Should reflect changes to original channel log level' - ) - }) -})