Skip to content

Commit 7ba15ce

Browse files
committed
feature(amazonq): start rotating logging to disk with cleanup
1 parent c267700 commit 7ba15ce

File tree

3 files changed

+412
-8
lines changed

3 files changed

+412
-8
lines changed

packages/amazonq/src/lsp/client.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as nls from 'vscode-nls'
88
import { LanguageClient, LanguageClientOptions, RequestType, State } from 'vscode-languageclient'
99
import { InlineCompletionManager } from '../app/inline/completion'
1010
import { AmazonQLspAuth, encryptionKey, notificationTypes } from './auth'
11+
import { RotatingLogChannel } from './rotatingLogChannel'
1112
import {
1213
CreateFilesParams,
1314
DeleteFilesParams,
@@ -94,6 +95,23 @@ export async function startLanguageServer(
9495

9596
const clientId = 'amazonq'
9697
const traceServerEnabled = Settings.instance.isSet(`${clientId}.trace.server`)
98+
99+
// Create custom output channel that writes to disk but sends UI output to the appropriate channel
100+
const lspLogChannel = new RotatingLogChannel(
101+
traceServerEnabled ? 'Amazon Q Language Server' : 'Amazon Q Logs',
102+
extensionContext,
103+
traceServerEnabled
104+
? vscode.window.createOutputChannel('Amazon Q Language Server', { log: true })
105+
: globals.logOutputChannel
106+
)
107+
108+
// Add cleanup for our file output channel
109+
toDispose.push({
110+
dispose: () => {
111+
lspLogChannel.dispose()
112+
},
113+
})
114+
97115
let executable: string[] = []
98116
// apply the GLIBC 2.28 path to node js runtime binary
99117
if (isSageMaker()) {
@@ -191,15 +209,9 @@ export async function startLanguageServer(
191209
},
192210
},
193211
/**
194-
* When the trace server is enabled it outputs a ton of log messages so:
195-
* When trace server is enabled, logs go to a seperate "Amazon Q Language Server" output.
196-
* Otherwise, logs go to the regular "Amazon Q Logs" channel.
212+
* Using our RotatingLogger for all logs
197213
*/
198-
...(traceServerEnabled
199-
? {}
200-
: {
201-
outputChannel: globals.logOutputChannel,
202-
}),
214+
outputChannel: lspLogChannel,
203215
}
204216

205217
const client = new LanguageClient(
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as vscode from 'vscode'
7+
import * as path from 'path'
8+
import * as fs from 'fs' // eslint-disable-line no-restricted-imports
9+
import { getLogger } from 'aws-core-vscode/shared'
10+
11+
export class RotatingLogChannel implements vscode.LogOutputChannel {
12+
private fileStream: fs.WriteStream | undefined
13+
private originalChannel: vscode.LogOutputChannel
14+
private logger = getLogger('amazonqLsp')
15+
private _logLevel: vscode.LogLevel = vscode.LogLevel.Info
16+
private currentFileSize = 0
17+
// eslint-disable-next-line @typescript-eslint/naming-convention
18+
private readonly MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
19+
// eslint-disable-next-line @typescript-eslint/naming-convention
20+
private readonly MAX_LOG_FILES = 4
21+
22+
constructor(
23+
public readonly name: string,
24+
private readonly extensionContext: vscode.ExtensionContext,
25+
outputChannel: vscode.LogOutputChannel
26+
) {
27+
this.originalChannel = outputChannel
28+
this.initFileStream()
29+
}
30+
31+
private async cleanupOldLogs(): Promise<void> {
32+
try {
33+
const logDir = this.extensionContext.storageUri?.fsPath
34+
if (!logDir) {
35+
return
36+
}
37+
38+
// Get all log files
39+
const files = await fs.promises.readdir(logDir)
40+
const logFiles = files
41+
.filter((f) => f.startsWith('amazonq-lsp-') && f.endsWith('.log'))
42+
.map((f) => ({
43+
name: f,
44+
path: path.join(logDir, f),
45+
time: fs.statSync(path.join(logDir, f)).mtime.getTime(),
46+
}))
47+
.sort((a, b) => b.time - a.time) // Sort newest to oldest
48+
49+
// Remove all but the most recent MAX_LOG_FILES files
50+
for (const file of logFiles.slice(this.MAX_LOG_FILES - 1)) {
51+
try {
52+
await fs.promises.unlink(file.path)
53+
this.logger.debug(`Removed old log file: ${file.path}`)
54+
} catch (err) {
55+
this.logger.error(`Failed to remove old log file ${file.path}: ${err}`)
56+
}
57+
}
58+
} catch (err) {
59+
this.logger.error(`Failed to cleanup old logs: ${err}`)
60+
}
61+
}
62+
63+
private getLogFilePath(): string {
64+
const logDir = this.extensionContext.storageUri?.fsPath
65+
if (!logDir) {
66+
throw new Error('No storage URI available')
67+
}
68+
69+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '-').replace('Z', '')
70+
return path.join(logDir, `amazonq-lsp-${timestamp}.log`)
71+
}
72+
73+
private async rotateLog(): Promise<void> {
74+
try {
75+
// Close current stream
76+
if (this.fileStream) {
77+
this.fileStream.end()
78+
}
79+
80+
// Create new log file
81+
const newLogPath = this.getLogFilePath()
82+
this.fileStream = fs.createWriteStream(newLogPath, { flags: 'a' })
83+
this.currentFileSize = 0
84+
85+
// Clean up old files
86+
await this.cleanupOldLogs()
87+
88+
this.logger.info(`Created new log file: ${newLogPath}`)
89+
} catch (err) {
90+
this.logger.error(`Failed to rotate log file: ${err}`)
91+
}
92+
}
93+
94+
private initFileStream() {
95+
try {
96+
const logDir = this.extensionContext.storageUri
97+
if (!logDir) {
98+
this.logger.error('Failed to get storage URI for logs')
99+
return
100+
}
101+
102+
// Ensure directory exists
103+
if (!fs.existsSync(logDir.fsPath)) {
104+
fs.mkdirSync(logDir.fsPath, { recursive: true })
105+
}
106+
107+
const logPath = this.getLogFilePath()
108+
this.fileStream = fs.createWriteStream(logPath, { flags: 'a' })
109+
this.currentFileSize = 0
110+
this.logger.info(`Logging to file: ${logPath}`)
111+
} catch (err) {
112+
this.logger.error(`Failed to create log file: ${err}`)
113+
}
114+
}
115+
116+
get logLevel(): vscode.LogLevel {
117+
return this._logLevel
118+
}
119+
120+
get onDidChangeLogLevel(): vscode.Event<vscode.LogLevel> {
121+
return this.originalChannel.onDidChangeLogLevel
122+
}
123+
124+
trace(message: string, ...args: any[]): void {
125+
this.originalChannel.trace(message, ...args)
126+
this.writeToFile(`[TRACE] ${message}`)
127+
}
128+
129+
debug(message: string, ...args: any[]): void {
130+
this.originalChannel.debug(message, ...args)
131+
this.writeToFile(`[DEBUG] ${message}`)
132+
}
133+
134+
info(message: string, ...args: any[]): void {
135+
this.originalChannel.info(message, ...args)
136+
this.writeToFile(`[INFO] ${message}`)
137+
}
138+
139+
warn(message: string, ...args: any[]): void {
140+
this.originalChannel.warn(message, ...args)
141+
this.writeToFile(`[WARN] ${message}`)
142+
}
143+
144+
error(message: string | Error, ...args: any[]): void {
145+
this.originalChannel.error(message, ...args)
146+
this.writeToFile(`[ERROR] ${message instanceof Error ? message.stack || message.message : message}`)
147+
}
148+
149+
append(value: string): void {
150+
this.originalChannel.append(value)
151+
this.writeToFile(value)
152+
}
153+
154+
appendLine(value: string): void {
155+
this.originalChannel.appendLine(value)
156+
this.writeToFile(value + '\n')
157+
}
158+
159+
replace(value: string): void {
160+
this.originalChannel.replace(value)
161+
this.writeToFile(`[REPLACE] ${value}`)
162+
}
163+
164+
clear(): void {
165+
this.originalChannel.clear()
166+
}
167+
168+
show(preserveFocus?: boolean): void
169+
show(column?: vscode.ViewColumn, preserveFocus?: boolean): void
170+
show(columnOrPreserveFocus?: vscode.ViewColumn | boolean, preserveFocus?: boolean): void {
171+
if (typeof columnOrPreserveFocus === 'boolean') {
172+
this.originalChannel.show(columnOrPreserveFocus)
173+
} else {
174+
this.originalChannel.show(columnOrPreserveFocus, preserveFocus)
175+
}
176+
}
177+
178+
hide(): void {
179+
this.originalChannel.hide()
180+
}
181+
182+
dispose(): void {
183+
// First dispose the original channel
184+
this.originalChannel.dispose()
185+
186+
// Close our file stream if it exists
187+
if (this.fileStream) {
188+
this.fileStream.end()
189+
}
190+
191+
// Clean up all log files
192+
const logDir = this.extensionContext.storageUri?.fsPath
193+
if (logDir) {
194+
try {
195+
const files = fs.readdirSync(logDir)
196+
for (const file of files) {
197+
if (file.startsWith('amazonq-lsp-') && file.endsWith('.log')) {
198+
fs.unlinkSync(path.join(logDir, file))
199+
}
200+
}
201+
this.logger.info('Cleaned up all log files during disposal')
202+
} catch (err) {
203+
this.logger.error(`Failed to cleanup log files during disposal: ${err}`)
204+
}
205+
}
206+
}
207+
208+
private writeToFile(content: string): void {
209+
if (this.fileStream) {
210+
try {
211+
const timestamp = new Date().toISOString()
212+
const logLine = `${timestamp} ${content}\n`
213+
const size = Buffer.byteLength(logLine)
214+
215+
// If this write would exceed max file size, rotate first
216+
if (this.currentFileSize + size > this.MAX_FILE_SIZE) {
217+
void this.rotateLog()
218+
}
219+
220+
this.fileStream.write(logLine)
221+
this.currentFileSize += size
222+
} catch (err) {
223+
this.logger.error(`Failed to write to log file: ${err}`)
224+
void this.rotateLog()
225+
}
226+
}
227+
}
228+
}

0 commit comments

Comments
 (0)