Skip to content

Commit c138cb2

Browse files
authored
feat(logging): support logger instances #5941
## Problem `getLogger('foo')` returns a global singleton, so the "current topic" only is stored until the next `getLogger` call. ## Solution Modify `getLogger('foo')` to return a wrapper which stores its topic. This allows modules to declare their own module-local shared logger: const logger = getLogger('foo')
1 parent 8966edc commit c138cb2

File tree

14 files changed

+143
-167
lines changed

14 files changed

+143
-167
lines changed

packages/core/src/codewhisperer/activation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export async function activate(context: ExtContext): Promise<void> {
114114

115115
// TODO: this is already done in packages/core/src/extensionCommon.ts, why doesn't amazonq use that?
116116
registerWebviewErrorHandler((error: unknown, webviewId: string, command: string) => {
117-
logAndShowWebviewError(localize, error, webviewId, command)
117+
return logAndShowWebviewError(localize, error, webviewId, command)
118118
})
119119

120120
/**

packages/core/src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export async function activateCommon(
8383
})
8484

8585
registerWebviewErrorHandler((error: unknown, webviewId: string, command: string) => {
86-
logAndShowWebviewError(localize, error, webviewId, command)
86+
return logAndShowWebviewError(localize, error, webviewId, command)
8787
})
8888

8989
// Setup the logger

packages/core/src/shared/logger/logger.ts

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import * as vscode from 'vscode'
77

88
export type LogTopic = 'crashReport' | 'notifications' | 'test' | 'unknown'
99

10+
class ErrorLog {
11+
constructor(
12+
public topic: string,
13+
public error: Error
14+
) {}
15+
}
16+
1017
const toolkitLoggers: {
1118
main: Logger | undefined
1219
debugConsole: Logger | undefined
@@ -30,16 +37,17 @@ export interface Logger {
3037
getLogById(logID: number, file: vscode.Uri): string | undefined
3138
/** HACK: Enables logging to vscode Debug Console. */
3239
enableDebugConsole(): void
40+
sendToLog(
41+
logLevel: 'debug' | 'verbose' | 'info' | 'warn' | 'error',
42+
message: string | Error,
43+
...meta: any[]
44+
): number
3345
}
3446

3547
export abstract class BaseLogger implements Logger {
3648
logFile?: vscode.Uri
3749
topic: LogTopic = 'unknown'
3850

39-
setTopic(topic: LogTopic = 'unknown') {
40-
this.topic = topic
41-
}
42-
4351
debug(message: string | Error, ...meta: any[]): number {
4452
return this.sendToLog('debug', message, ...meta)
4553
}
@@ -58,17 +66,16 @@ export abstract class BaseLogger implements Logger {
5866
log(logLevel: LogLevel, message: string | Error, ...meta: any[]): number {
5967
return this.sendToLog(logLevel, message, ...meta)
6068
}
61-
abstract setLogLevel(logLevel: LogLevel): void
62-
abstract logLevelEnabled(logLevel: LogLevel): boolean
63-
abstract getLogById(logID: number, file: vscode.Uri): string | undefined
64-
/** HACK: Enables logging to vscode Debug Console. */
65-
abstract enableDebugConsole(): void
66-
6769
abstract sendToLog(
6870
logLevel: 'debug' | 'verbose' | 'info' | 'warn' | 'error',
6971
message: string | Error,
7072
...meta: any[]
7173
): number
74+
abstract setLogLevel(logLevel: LogLevel): void
75+
abstract logLevelEnabled(logLevel: LogLevel): boolean
76+
abstract getLogById(logID: number, file: vscode.Uri): string | undefined
77+
/** HACK: Enables logging to vscode Debug Console. */
78+
abstract enableDebugConsole(): void
7279
}
7380

7481
/**
@@ -121,6 +128,20 @@ export function compareLogLevel(l1: LogLevel, l2: LogLevel): number {
121128
return logLevels.get(l1)! - logLevels.get(l2)!
122129
}
123130

131+
/* Format the message with topic header */
132+
function prependTopic(topic: string, message: string | Error): string | ErrorLog {
133+
if (typeof message === 'string') {
134+
// TODO: remove this after all calls are migrated and topic is a required param.
135+
if (topic === 'unknown') {
136+
return message
137+
}
138+
return `${topic}: ` + message
139+
} else if (message instanceof Error) {
140+
return new ErrorLog(topic, message)
141+
}
142+
return message
143+
}
144+
124145
/**
125146
* Gets the logger if it has been initialized
126147
* the logger is of `'main'` or `undefined`: Main logger; default impl: logs to log file and log output channel
@@ -131,17 +152,15 @@ export function getLogger(topic?: LogTopic): Logger {
131152
if (!logger) {
132153
return new ConsoleLogger()
133154
}
134-
;(logger as BaseLogger).setTopic?.(topic)
135-
return logger
155+
return new TopicLogger(topic ?? 'unknown', logger)
136156
}
137157

138158
export function getDebugConsoleLogger(topic?: LogTopic): Logger {
139159
const logger = toolkitLoggers['debugConsole']
140160
if (!logger) {
141161
return new ConsoleLogger()
142162
}
143-
;(logger as BaseLogger).setTopic?.(topic)
144-
return logger
163+
return new TopicLogger(topic ?? 'unknown', logger)
145164
}
146165

147166
// jscpd:ignore-start
@@ -194,6 +213,46 @@ export class ConsoleLogger extends BaseLogger {
194213
return 0
195214
}
196215
}
216+
217+
/**
218+
* Wraps a `ToolkitLogger` and defers to it for everything except `topic`.
219+
*/
220+
export class TopicLogger extends BaseLogger implements vscode.Disposable {
221+
/**
222+
* Wraps a `ToolkitLogger` and defers to it for everything except `topic`.
223+
*/
224+
public constructor(
225+
public override topic: LogTopic,
226+
public readonly logger: Logger
227+
) {
228+
super()
229+
}
230+
231+
override setLogLevel(logLevel: LogLevel): void {
232+
this.logger.setLogLevel(logLevel)
233+
}
234+
235+
override logLevelEnabled(logLevel: LogLevel): boolean {
236+
return this.logger.logLevelEnabled(logLevel)
237+
}
238+
239+
override getLogById(logID: number, file: vscode.Uri): string | undefined {
240+
return this.logger.getLogById(logID, file)
241+
}
242+
243+
override enableDebugConsole(): void {
244+
this.logger.enableDebugConsole()
245+
}
246+
247+
override sendToLog(level: LogLevel, message: string | Error, ...meta: any[]): number {
248+
if (typeof message === 'string') {
249+
message = prependTopic(this.topic, message) as string
250+
}
251+
return this.logger.sendToLog(level, message, meta)
252+
}
253+
254+
public async dispose(): Promise<void> {}
255+
}
197256
// jscpd:ignore-end
198257

199258
export function getNullLogger(type?: 'debugConsole' | 'main'): Logger {

packages/core/src/shared/logger/toolkitLogger.ts

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,12 @@ import { SharedFileTransport } from './sharedFileTransport'
1414
import { ConsoleLogTransport } from './consoleLogTransport'
1515
import { isWeb } from '../extensionGlobals'
1616

17-
class ErrorLog {
18-
constructor(
19-
public topic: string,
20-
public error: Error
21-
) {}
22-
}
23-
2417
// Need to limit how many logs are actually tracked
2518
// LRU cache would work well, currently it just dumps the least recently added log
2619
const logmapSize: number = 1000
20+
2721
export class ToolkitLogger extends BaseLogger implements vscode.Disposable {
2822
private readonly logger: winston.Logger
29-
/* topic is used for header in log messages, default is 'Unknown' */
3023
private disposed: boolean = false
3124
private idCounter: number = 0
3225
private logMap: { [logID: number]: { [filePath: string]: string } } = {}
@@ -111,20 +104,6 @@ export class ToolkitLogger extends BaseLogger implements vscode.Disposable {
111104
})
112105
}
113106

114-
/* Format the message with topic header */
115-
private addTopicToMessage(message: string | Error): string | ErrorLog {
116-
if (typeof message === 'string') {
117-
// TODO: remove this after all calls are migrated and topic is a required param.
118-
if (this.topic === 'unknown') {
119-
return message
120-
}
121-
return `${this.topic}: ` + message
122-
} else if (message instanceof Error) {
123-
return new ErrorLog(this.topic, message)
124-
}
125-
return message
126-
}
127-
128107
private mapError(level: LogLevel, err: Error): Error | string {
129108
// Use ToolkitError.trace even if we have source mapping (see below), because:
130109
// 1. it is what users will see, we want visibility into that when debugging
@@ -141,17 +120,16 @@ export class ToolkitLogger extends BaseLogger implements vscode.Disposable {
141120
}
142121

143122
override sendToLog(level: LogLevel, message: string | Error, ...meta: any[]): number {
144-
const messageWithTopic = this.addTopicToMessage(message)
145123
if (this.disposed) {
146124
throw new Error('Cannot write to disposed logger')
147125
}
148126

149127
meta = meta.map((o) => (o instanceof Error ? this.mapError(level, o) : o))
150128

151-
if (messageWithTopic instanceof ErrorLog) {
152-
this.logger.log(level, '%O', messageWithTopic, ...meta, { logID: this.idCounter })
129+
if (message instanceof Error) {
130+
this.logger.log(level, '%O', message, ...meta, { logID: this.idCounter })
153131
} else {
154-
this.logger.log(level, messageWithTopic, ...meta, { logID: this.idCounter })
132+
this.logger.log(level, message, ...meta, { logID: this.idCounter })
155133
}
156134

157135
this.logMap[this.idCounter % logmapSize] = {}

packages/core/src/shared/utilities/logAndShowUtils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export async function logAndShowError(
5252
* @param err The error that was thrown in the backend
5353
* @param webviewId Arbitrary value that identifies which webview had the error
5454
* @param command The high level command/function that was run which triggered the error
55+
*
56+
* @returns user-facing error
5557
*/
5658
export function logAndShowWebviewError(localize: nls.LocalizeFunc, err: unknown, webviewId: string, command: string) {
5759
// HACK: The following implementation is a hack, influenced by the implementation of handleError().
@@ -62,4 +64,6 @@ export function logAndShowWebviewError(localize: nls.LocalizeFunc, err: unknown,
6264
logAndShowError(localize, userFacingError, `webviewId="${webviewId}"`, 'Webview error').catch((e) => {
6365
getLogger().error('logAndShowError failed: %s', (e as Error).message)
6466
})
67+
68+
return userFacingError
6569
}

packages/core/src/test/codewhisperer/commands/basicCommands.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import { stub } from '../../utilities/stubber'
2828
import { AuthUtil } from '../../../codewhisperer/util/authUtil'
2929
import { getTestWindow } from '../../shared/vscode/window'
3030
import { ExtContext } from '../../../shared/extensions'
31-
import { getLogger } from '../../../shared/logger/logger'
3231
import {
3332
createAutoScans,
3433
createAutoSuggestions,
@@ -57,6 +56,7 @@ import * as diagnosticsProvider from '../../../codewhisperer/service/diagnostics
5756
import { SecurityIssueHoverProvider } from '../../../codewhisperer/service/securityIssueHoverProvider'
5857
import { SecurityIssueCodeActionProvider } from '../../../codewhisperer/service/securityIssueCodeActionProvider'
5958
import { randomUUID } from '../../../shared/crypto'
59+
import { assertLogsContain } from '../../globalSetup.test'
6060

6161
describe('CodeWhisperer-basicCommands', function () {
6262
let targetCommand: Command<any> & vscode.Disposable
@@ -636,7 +636,6 @@ describe('CodeWhisperer-basicCommands', function () {
636636
openTextDocumentMock.resolves(textDocumentMock)
637637

638638
sandbox.stub(vscode.workspace, 'openTextDocument').value(openTextDocumentMock)
639-
const loggerStub = sinon.stub(getLogger(), 'error')
640639

641640
sinon.stub(vscode.WorkspaceEdit.prototype, 'replace').value(replaceMock)
642641
applyEditMock.resolves(false)
@@ -652,9 +651,7 @@ describe('CodeWhisperer-basicCommands', function () {
652651
await targetCommand.execute(codeScanIssue, fileName, 'quickfix')
653652

654653
assert.ok(replaceMock.calledOnce)
655-
assert.ok(loggerStub.calledOnce)
656-
const actual = loggerStub.getCall(0).args[0]
657-
assert.strictEqual(actual, 'Apply fix command failed. Error: Failed to apply edit to the workspace.')
654+
assertLogsContain('Apply fix command failed. Error: Failed to apply edit to the workspace.', true, 'error')
658655
assertTelemetry('codewhisperer_codeScanIssueApplyFix', {
659656
detectorId: codeScanIssue.detectorId,
660657
findingId: codeScanIssue.findingId,

packages/core/src/test/globalSetup.test.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import globals from '../shared/extensionGlobals'
1414
import { CodelensRootRegistry } from '../shared/fs/codelensRootRegistry'
1515
import { CloudFormationTemplateRegistry } from '../shared/fs/templateRegistry'
1616
import { getLogger, LogLevel } from '../shared/logger'
17-
import { setLogger } from '../shared/logger/logger'
17+
import { setLogger, TopicLogger } from '../shared/logger/logger'
1818
import { FakeExtensionContext } from './fakeExtensionContext'
1919
import { TestLogger } from './testLogger'
2020
import * as testUtil from './testUtil'
@@ -136,7 +136,7 @@ export const mochaHooks = {
136136
* Verifies that the TestLogger instance is still the one set as the toolkit's logger.
137137
*/
138138
export function getTestLogger(): TestLogger {
139-
const logger = getLogger()
139+
const logger = getLogger() instanceof TopicLogger ? (getLogger() as TopicLogger).logger : getLogger()
140140
assert.strictEqual(logger, testLogger, 'The expected test logger is not the current logger')
141141
assert.ok(testLogger, 'TestLogger was expected to exist')
142142

@@ -169,18 +169,17 @@ async function writeLogsToFile(testName: string) {
169169

170170
// TODO: merge this with `toolkitLogger.test.ts:checkFile`
171171
export function assertLogsContain(text: string, exactMatch: boolean, severity: LogLevel) {
172+
const logs = getTestLogger().getLoggedEntries(severity)
172173
assert.ok(
173-
getTestLogger()
174-
.getLoggedEntries(severity)
175-
.some((e) =>
176-
e instanceof Error
177-
? exactMatch
178-
? e.message === text
179-
: e.message.includes(text)
180-
: exactMatch
181-
? e === text
182-
: e.includes(text)
183-
),
174+
logs.some((e) =>
175+
e instanceof Error
176+
? exactMatch
177+
? e.message === text
178+
: e.message.includes(text)
179+
: exactMatch
180+
? e === text
181+
: e.includes(text)
182+
),
184183
`Expected to find "${text}" in the logs as type "${severity}"`
185184
)
186185
}

packages/core/src/test/lambda/vue/remoteInvoke/invokeLambda.test.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ import { getTestWindow } from '../../../shared/vscode/window'
1616
import { LambdaFunctionNode } from '../../../../lambda/explorer/lambdaFunctionNode'
1717
import * as utils from '../../../../lambda/utils'
1818
import { HttpResourceFetcher } from '../../../../shared/resourcefetcher/httpResourceFetcher'
19-
import { getLogger } from '../../../../shared/logger'
2019
import { ExtContext } from '../../../../shared/extensions'
2120
import { FakeExtensionContext } from '../../../fakeExtensionContext'
2221
import * as samCliRemoteTestEvent from '../../../../shared/sam/cli/samCliRemoteTestEvent'
2322
import { TestEventsOperation, SamCliRemoteTestEventsParameters } from '../../../../shared/sam/cli/samCliRemoteTestEvent'
23+
import { assertLogsContain } from '../../../globalSetup.test'
2424

2525
describe('RemoteInvokeWebview', () => {
2626
let outputChannel: vscode.OutputChannel
@@ -190,20 +190,11 @@ describe('RemoteInvokeWebview', () => {
190190

191191
getTestWindow().onDidShowDialog((d) => d.selectItem(fileUri))
192192

193-
const loggerErrorStub = sinon.stub(getLogger(), 'error')
194-
195-
try {
196-
await assert.rejects(
197-
async () => await remoteInvokeWebview.promptFile(),
198-
new Error('Failed to read selected file')
199-
)
200-
assert.strictEqual(loggerErrorStub.calledOnce, true)
201-
assert.strictEqual(loggerErrorStub.firstCall.args[0], 'readFileSync: Failed to read file at path %s %O')
202-
assert.strictEqual(loggerErrorStub.firstCall.args[1], fileUri.fsPath)
203-
assert(loggerErrorStub.firstCall.args[2] instanceof Error)
204-
} finally {
205-
loggerErrorStub.restore()
206-
}
193+
await assert.rejects(
194+
async () => await remoteInvokeWebview.promptFile(),
195+
new Error('Failed to read selected file')
196+
)
197+
assertLogsContain('readFileSync: Failed to read file at path %s %O', true, 'error')
207198
})
208199
})
209200

packages/core/src/test/lambda/vue/samInvokeBackend.test.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ import path from 'path'
2222
import { addCodiconToString, fs, makeTemporaryToolkitFolder } from '../../../shared'
2323
import { LaunchConfiguration } from '../../../shared/debug/launchConfiguration'
2424
import { getTestWindow } from '../..'
25-
import { getLogger } from '../../../shared/logger'
2625
import * as extensionUtilities from '../../../shared/extensionUtilities'
2726
import * as samInvokeBackend from '../../../lambda/vue/configEditor/samInvokeBackend'
2827
import { SamDebugConfigProvider } from '../../../shared/sam/debugger/awsSamDebugger'
2928
import sinon from 'sinon'
3029
import * as nls from 'vscode-nls'
30+
import { assertLogsContain } from '../../../test/globalSetup.test'
3131

3232
const localize = nls.loadMessageBundle()
3333

@@ -438,19 +438,13 @@ describe('SamInvokeWebview', () => {
438438

439439
getTestWindow().onDidShowDialog((window) => window.selectItem(fileUri))
440440

441-
const loggerErrorStub = sinon.stub(getLogger(), 'error')
442-
443441
try {
444442
await assert.rejects(
445443
async () => await samInvokeWebview.promptFile(),
446444
new Error('Failed to read selected file')
447445
)
448-
assert.strictEqual(loggerErrorStub.calledOnce, true)
449-
assert.strictEqual(loggerErrorStub.firstCall.args[0], 'readFileSync: Failed to read file at path %s %O')
450-
assert.strictEqual(loggerErrorStub.firstCall.args[1], fileUri.fsPath)
451-
assert(loggerErrorStub.firstCall.args[2] instanceof Error)
446+
assertLogsContain('readFileSync: Failed to read file at path %s %O', true, 'error')
452447
} finally {
453-
loggerErrorStub.restore()
454448
await fs.delete(tempFolder, { recursive: true })
455449
}
456450
})

0 commit comments

Comments
 (0)