diff --git a/packages/core/src/awsService/cloudWatchLogs/activation.ts b/packages/core/src/awsService/cloudWatchLogs/activation.ts index 03760b158e7..065cb9a7958 100644 --- a/packages/core/src/awsService/cloudWatchLogs/activation.ts +++ b/packages/core/src/awsService/cloudWatchLogs/activation.ts @@ -4,7 +4,7 @@ */ import * as vscode from 'vscode' -import { CLOUDWATCH_LOGS_SCHEME } from '../../shared/constants' +import { CLOUDWATCH_LOGS_LIVETAIL_SCHEME, CLOUDWATCH_LOGS_SCHEME } from '../../shared/constants' import { Settings } from '../../shared/settings' import { addLogEvents } from './commands/addLogEvents' import { copyLogResource } from './commands/copyLogResource' @@ -20,11 +20,15 @@ import { changeLogSearchParams } from './changeLogSearch' import { CloudWatchLogsNode } from './explorer/cloudWatchLogsNode' import { loadAndOpenInitialLogStreamFile, LogStreamCodeLensProvider } from './document/logStreamsCodeLensProvider' import { tailLogGroup } from './commands/tailLogGroup' +import { LiveTailDocumentProvider } from './document/liveTailDocumentProvider' +import { LiveTailSessionRegistry } from './registry/liveTailSessionRegistry' export async function activate(context: vscode.ExtensionContext, configuration: Settings): Promise { const registry = LogDataRegistry.instance + const liveTailRegistry = LiveTailSessionRegistry.instance const documentProvider = new LogDataDocumentProvider(registry) + const liveTailDocumentProvider = new LiveTailDocumentProvider() context.subscriptions.push( vscode.languages.registerCodeLensProvider( @@ -40,6 +44,10 @@ export async function activate(context: vscode.ExtensionContext, configuration: vscode.workspace.registerTextDocumentContentProvider(CLOUDWATCH_LOGS_SCHEME, documentProvider) ) + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider(CLOUDWATCH_LOGS_LIVETAIL_SCHEME, liveTailDocumentProvider) + ) + context.subscriptions.push( vscode.workspace.onDidCloseTextDocument((doc) => { if (doc.isClosed && doc.uri.scheme === CLOUDWATCH_LOGS_SCHEME) { @@ -97,7 +105,7 @@ export async function activate(context: vscode.ExtensionContext, configuration: node instanceof LogGroupNode ? { regionName: node.regionCode, groupName: node.logGroup.logGroupName! } : undefined - await tailLogGroup(logGroupInfo) + await tailLogGroup(liveTailRegistry, logGroupInfo) }) ) } diff --git a/packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts b/packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts index 129d22760ee..0480bd38877 100644 --- a/packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts +++ b/packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts @@ -3,17 +3,154 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as vscode from 'vscode' import { TailLogGroupWizard } from '../wizard/tailLogGroupWizard' -import { getLogger } from '../../../shared' import { CancellationError } from '../../../shared/utilities/timeoutUtils' +import { LiveTailSession, LiveTailSessionConfiguration } from '../registry/liveTailSession' +import { LiveTailSessionRegistry } from '../registry/liveTailSessionRegistry' +import { LiveTailSessionLogEvent, StartLiveTailResponseStream } from '@aws-sdk/client-cloudwatch-logs' +import { ToolkitError } from '../../../shared' -export async function tailLogGroup(logData?: { regionName: string; groupName: string }): Promise { +export async function tailLogGroup( + registry: LiveTailSessionRegistry, + logData?: { regionName: string; groupName: string } +): Promise { const wizard = new TailLogGroupWizard(logData) const wizardResponse = await wizard.run() if (!wizardResponse) { throw new CancellationError('user') } - //TODO: Remove Log. For testing while we aren't yet consuming the wizardResponse. - getLogger().info(JSON.stringify(wizardResponse)) + const liveTailSessionConfig: LiveTailSessionConfiguration = { + logGroupName: wizardResponse.regionLogGroupSubmenuResponse.data, + logStreamFilter: wizardResponse.logStreamFilter, + logEventFilterPattern: wizardResponse.filterPattern, + region: wizardResponse.regionLogGroupSubmenuResponse.region, + } + const session = new LiveTailSession(liveTailSessionConfig) + if (registry.has(session.uri)) { + await prepareDocument(session) + return + } + registry.set(session.uri, session) + + const document = await prepareDocument(session) + registerTabChangeCallback(session, registry, document) + const stream = await session.startLiveTailSession() + + await handleSessionStream(stream, document, session) +} + +export function closeSession(sessionUri: vscode.Uri, registry: LiveTailSessionRegistry) { + const session = registry.get(sessionUri) + if (session === undefined) { + throw new ToolkitError(`No LiveTail session found for URI: ${sessionUri.toString()}`) + } + session.stopLiveTailSession() + registry.delete(sessionUri) +} + +export async function clearDocument(textDocument: vscode.TextDocument) { + const edit = new vscode.WorkspaceEdit() + const startPosition = new vscode.Position(0, 0) + const endPosition = new vscode.Position(textDocument.lineCount, 0) + edit.delete(textDocument.uri, new vscode.Range(startPosition, endPosition)) + await vscode.workspace.applyEdit(edit) +} + +async function prepareDocument(session: LiveTailSession): Promise { + const textDocument = await vscode.workspace.openTextDocument(session.uri) + await clearDocument(textDocument) + await vscode.window.showTextDocument(textDocument, { preview: false }) + await vscode.languages.setTextDocumentLanguage(textDocument, 'log') + return textDocument +} + +async function handleSessionStream( + stream: AsyncIterable, + document: vscode.TextDocument, + session: LiveTailSession +) { + try { + for await (const event of stream) { + if (event.sessionUpdate !== undefined && event.sessionUpdate.sessionResults !== undefined) { + const formattedLogEvents = event.sessionUpdate.sessionResults.map((logEvent) => + formatLogEvent(logEvent) + ) + if (formattedLogEvents.length !== 0) { + await updateTextDocumentWithNewLogEvents(formattedLogEvents, document, session.maxLines) + } + } + } + } catch (err) { + throw new ToolkitError('Caught on-stream exception') + } +} + +function formatLogEvent(logEvent: LiveTailSessionLogEvent): string { + if (!logEvent.timestamp || !logEvent.message) { + return '' + } + const timestamp = new Date(logEvent.timestamp).toLocaleTimeString('en', { + timeStyle: 'medium', + hour12: false, + timeZone: 'UTC', + }) + let line = timestamp.concat('\t', logEvent.message) + if (!line.endsWith('\n')) { + line = line.concat('\n') + } + return line +} + +async function updateTextDocumentWithNewLogEvents( + formattedLogEvents: string[], + document: vscode.TextDocument, + maxLines: number +) { + const edit = new vscode.WorkspaceEdit() + formattedLogEvents.forEach((formattedLogEvent) => + edit.insert(document.uri, new vscode.Position(document.lineCount, 0), formattedLogEvent) + ) + await vscode.workspace.applyEdit(edit) +} + +/** + * The LiveTail session should be automatically closed if the user does not have the session's + * document in any Tab in their editor. + * + * `onDidCloseTextDocument` doesn't work for our case because the tailLogGroup command will keep the stream + * writing to the doc even when all its tabs/editors are closed, seemingly keeping the doc 'open'. + * Also there is no guarantee that this event fires when an editor tab is closed + * + * `onDidChangeVisibleTextEditors` returns editors that the user can see its contents. An editor that is open, but hidden + * from view, will not be returned. Meaning a Tab that is created (shown in top bar), but not open, will not be returned. Even if + * the tab isn't visible, we want to continue writing to the doc, and keep the session alive. + */ +function registerTabChangeCallback( + session: LiveTailSession, + registry: LiveTailSessionRegistry, + document: vscode.TextDocument +) { + vscode.window.tabGroups.onDidChangeTabs((tabEvent) => { + const isOpen = isLiveTailSessionOpenInAnyTab(session) + if (!isOpen) { + closeSession(session.uri, registry) + void clearDocument(document) + } + }) +} + +function isLiveTailSessionOpenInAnyTab(liveTailSession: LiveTailSession) { + let isOpen = false + vscode.window.tabGroups.all.forEach(async (tabGroup) => { + tabGroup.tabs.forEach((tab) => { + if (tab.input instanceof vscode.TabInputText) { + if (liveTailSession.uri.toString() === tab.input.uri.toString()) { + isOpen = true + } + } + }) + }) + return isOpen } diff --git a/packages/core/src/awsService/cloudWatchLogs/document/liveTailDocumentProvider.ts b/packages/core/src/awsService/cloudWatchLogs/document/liveTailDocumentProvider.ts new file mode 100644 index 00000000000..fe909579ae3 --- /dev/null +++ b/packages/core/src/awsService/cloudWatchLogs/document/liveTailDocumentProvider.ts @@ -0,0 +1,13 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' + +export class LiveTailDocumentProvider implements vscode.TextDocumentContentProvider { + provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): vscode.ProviderResult { + //Content will be written to the document via handling a LiveTail response stream in the TailLogGroup command. + return '' + } +} diff --git a/packages/core/src/awsService/cloudWatchLogs/registry/liveTailSession.ts b/packages/core/src/awsService/cloudWatchLogs/registry/liveTailSession.ts index ff2b044616d..e277f766054 100644 --- a/packages/core/src/awsService/cloudWatchLogs/registry/liveTailSession.ts +++ b/packages/core/src/awsService/cloudWatchLogs/registry/liveTailSession.ts @@ -3,7 +3,11 @@ * SPDX-License-Identifier: Apache-2.0 */ import * as vscode from 'vscode' -import { CloudWatchLogsClient, StartLiveTailCommand, StartLiveTailCommandOutput } from '@aws-sdk/client-cloudwatch-logs' +import { + CloudWatchLogsClient, + StartLiveTailCommand, + StartLiveTailResponseStream, +} from '@aws-sdk/client-cloudwatch-logs' import { LogStreamFilterResponse } from '../wizard/liveTailLogStreamSubmenu' import { CloudWatchLogsSettings } from '../cloudWatchLogsUtils' import { Settings, ToolkitError } from '../../../shared' @@ -58,12 +62,16 @@ export class LiveTailSession { return this._logGroupName } - public startLiveTailSession(): Promise { + public async startLiveTailSession(): Promise> { const command = this.buildStartLiveTailCommand() try { - return this.liveTailClient.cwlClient.send(command, { + const commandOutput = await this.liveTailClient.cwlClient.send(command, { abortSignal: this.liveTailClient.abortController.signal, }) + if (!commandOutput.responseStream) { + throw new ToolkitError('LiveTail session response stream is undefined.') + } + return commandOutput.responseStream } catch (e) { throw new ToolkitError('Encountered error while trying to start LiveTail session.') } diff --git a/packages/core/src/test/awsService/cloudWatchLogs/commands/tailLogGroup.test.ts b/packages/core/src/test/awsService/cloudWatchLogs/commands/tailLogGroup.test.ts new file mode 100644 index 00000000000..c5061ffde1f --- /dev/null +++ b/packages/core/src/test/awsService/cloudWatchLogs/commands/tailLogGroup.test.ts @@ -0,0 +1,158 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as sinon from 'sinon' +import * as vscode from 'vscode' + +import assert from 'assert' +import { clearDocument, closeSession, tailLogGroup } from '../../../../awsService/cloudWatchLogs/commands/tailLogGroup' +import { StartLiveTailResponseStream } from '@aws-sdk/client-cloudwatch-logs' +import { LiveTailSessionRegistry } from '../../../../awsService/cloudWatchLogs/registry/liveTailSessionRegistry' +import { LiveTailSession } from '../../../../awsService/cloudWatchLogs/registry/liveTailSession' +import { asyncGenerator } from '../../../../shared/utilities/collectionUtils' +import { + TailLogGroupWizard, + TailLogGroupWizardResponse, +} from '../../../../awsService/cloudWatchLogs/wizard/tailLogGroupWizard' +import { getTestWindow } from '../../../shared/vscode/window' + +describe('TailLogGroup', function () { + const testLogGroup = 'test-log-group' + const testRegion = 'test-region' + const testMessage = 'test-message' + + let sandbox: sinon.SinonSandbox + let registry: LiveTailSessionRegistry + let startLiveTailSessionSpy: sinon.SinonSpy + let stopLiveTailSessionSpy: sinon.SinonSpy + let wizardSpy: sinon.SinonSpy + + beforeEach(function () { + sandbox = sinon.createSandbox() + registry = new LiveTailSessionRegistry() + }) + + afterEach(function () { + sandbox.restore() + }) + + it('starts LiveTailSession and writes to document. Closes tab and asserts session gets closed.', async function () { + wizardSpy = sandbox.stub(TailLogGroupWizard.prototype, 'run').callsFake(async function () { + return getTestWizardResponse() + }) + startLiveTailSessionSpy = sandbox + .stub(LiveTailSession.prototype, 'startLiveTailSession') + .callsFake(async function () { + return getTestResponseStream() + }) + stopLiveTailSessionSpy = sandbox + .stub(LiveTailSession.prototype, 'stopLiveTailSession') + .callsFake(async function () { + return + }) + await tailLogGroup(registry, { + groupName: testLogGroup, + regionName: testRegion, + }) + assert.strictEqual(wizardSpy.calledOnce, true) + assert.strictEqual(startLiveTailSessionSpy.calledOnce, true) + assert.strictEqual(registry.size, 1) + + //registry is asserted to have only one entry, so this is assumed to be the session that was + //started in this test. + let sessionUri: vscode.Uri | undefined + registry.forEach((session) => (sessionUri = session.uri)) + if (sessionUri === undefined) { + throw Error + } + const document = getTestWindow().activeTextEditor?.document + assert.strictEqual(sessionUri.toString(), document?.uri.toString()) + assert.strictEqual(document?.getText().trim(), `12:00:00\t${testMessage}`) + + //Test that closing all tabs the session's document is open in will cause the session to close + const window = getTestWindow() + let tabs: vscode.Tab[] = [] + window.tabGroups.all.forEach((tabGroup) => { + tabs = tabs.concat(getLiveTailSessionTabsFromTabGroup(tabGroup, sessionUri!)) + }) + await Promise.all(tabs.map((tab) => window.tabGroups.close(tab))) + assert.strictEqual(registry.size, 0) + assert.strictEqual(stopLiveTailSessionSpy.calledOnce, true) + }) + + it('closeSession removes session from registry and calls underlying stopLiveTailSession function.', function () { + stopLiveTailSessionSpy = sandbox + .stub(LiveTailSession.prototype, 'stopLiveTailSession') + .callsFake(async function () { + return + }) + const session = new LiveTailSession({ + logGroupName: testLogGroup, + region: testRegion, + }) + registry.set(session.uri, session) + + closeSession(session.uri, registry) + assert.strictEqual(0, registry.size) + assert.strictEqual(true, stopLiveTailSessionSpy.calledOnce) + }) + + it('clearDocument clears all text from document', async function () { + const session = new LiveTailSession({ + logGroupName: testLogGroup, + region: testRegion, + }) + const testData = 'blah blah blah' + const document = await vscode.workspace.openTextDocument(session.uri) + const edit = new vscode.WorkspaceEdit() + edit.insert(document.uri, new vscode.Position(0, 0), testData) + await vscode.workspace.applyEdit(edit) + assert.strictEqual(document.getText(), testData) + + await clearDocument(document) + assert.strictEqual(document.getText(), '') + }) + + function getLiveTailSessionTabsFromTabGroup(tabGroup: vscode.TabGroup, sessionUri: vscode.Uri): vscode.Tab[] { + return tabGroup.tabs.filter((tab) => { + if (tab.input instanceof vscode.TabInputText) { + return sessionUri!.toString() === tab.input.uri.toString() + } + }) + } + + function getTestWizardResponse(): TailLogGroupWizardResponse { + return { + regionLogGroupSubmenuResponse: { + region: testRegion, + data: testLogGroup, + }, + filterPattern: '', + logStreamFilter: { + type: 'all', + }, + } + } + + function getTestResponseStream(): AsyncIterable { + const sessionStartFrame: StartLiveTailResponseStream = { + sessionStart: { + logGroupIdentifiers: [testLogGroup], + }, + sessionUpdate: undefined, + } + const sessionUpdateFrame: StartLiveTailResponseStream = { + sessionUpdate: { + sessionResults: [ + { + message: testMessage, + timestamp: 876830400000, + }, + ], + }, + } + return asyncGenerator([sessionStartFrame, sessionUpdateFrame]) + } +})