Skip to content

Commit f57f4f5

Browse files
authored
Merge #6193 CloudWatch Logs LiveTail
2 parents e63079d + 97f1dbe commit f57f4f5

19 files changed

+2409
-3
lines changed

package-lock.json

Lines changed: 912 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@
495495
"dependencies": {
496496
"@amzn/amazon-q-developer-streaming-client": "file:../../src.gen/@amzn/amazon-q-developer-streaming-client",
497497
"@amzn/codewhisperer-streaming": "file:../../src.gen/@amzn/codewhisperer-streaming",
498+
"@aws-sdk/client-cloudwatch-logs": "^3.666.0",
498499
"@aws-sdk/client-cloudformation": "^3.667.0",
499500
"@aws-sdk/client-cognito-identity": "^3.637.0",
500501
"@aws-sdk/client-lambda": "^3.637.0",

packages/core/package.nls.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@
177177
"AWS.command.downloadSchemaItemCode": "Download Code Bindings",
178178
"AWS.command.viewLogs": "View Logs",
179179
"AWS.command.cloudWatchLogs.searchLogGroup": "Search Log Group",
180+
"AWS.command.cloudWatchLogs.tailLogGroup": "Tail Log Group",
180181
"AWS.command.sam.newTemplate": "Create new SAM Template",
181182
"AWS.command.cloudFormation.newTemplate": "Create new CloudFormation Template",
182183
"AWS.command.quickStart": "View Quick Start",
@@ -253,7 +254,7 @@
253254
"AWS.appcomposer.explorerTitle": "Infrastructure Composer",
254255
"AWS.cdk.explorerTitle": "CDK",
255256
"AWS.codecatalyst.explorerTitle": "CodeCatalyst",
256-
"AWS.cwl.limit.desc": "Maximum amount of log entries pulled per request from CloudWatch Logs (max 10000)",
257+
"AWS.cwl.limit.desc": "Maximum amount of log entries pulled per request from CloudWatch Logs. For LiveTail, when the limit is reached, the oldest events will be removed to accomodate new events. (max 10000)",
257258
"AWS.samcli.deploy.bucket.recentlyUsed": "Buckets recently used for SAM deployments",
258259
"AWS.submenu.amazonqEditorContextSubmenu.title": "Amazon Q",
259260
"AWS.submenu.auth.title": "Authentication",

packages/core/src/awsService/cloudWatchLogs/activation.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import * as vscode from 'vscode'
7-
import { CLOUDWATCH_LOGS_SCHEME } from '../../shared/constants'
7+
import { cloudwatchLogsLiveTailScheme, CLOUDWATCH_LOGS_SCHEME } from '../../shared/constants'
88
import { Settings } from '../../shared/settings'
99
import { addLogEvents } from './commands/addLogEvents'
1010
import { copyLogResource } from './commands/copyLogResource'
@@ -19,16 +19,22 @@ import { searchLogGroup } from './commands/searchLogGroup'
1919
import { changeLogSearchParams } from './changeLogSearch'
2020
import { CloudWatchLogsNode } from './explorer/cloudWatchLogsNode'
2121
import { loadAndOpenInitialLogStreamFile, LogStreamCodeLensProvider } from './document/logStreamsCodeLensProvider'
22+
import { clearDocument, closeSession, tailLogGroup } from './commands/tailLogGroup'
23+
import { LiveTailDocumentProvider } from './document/liveTailDocumentProvider'
24+
import { LiveTailSessionRegistry } from './registry/liveTailSessionRegistry'
2225
import { DeployedResourceNode } from '../appBuilder/explorer/nodes/deployedNode'
2326
import { isTreeNode } from '../../shared/treeview/resourceTreeDataProvider'
2427
import { getLogger } from '../../shared/logger/logger'
2528
import { ToolkitError } from '../../shared'
29+
import { LiveTailCodeLensProvider } from './document/liveTailCodeLensProvider'
2630

2731
export async function activate(context: vscode.ExtensionContext, configuration: Settings): Promise<void> {
2832
const registry = LogDataRegistry.instance
33+
const liveTailRegistry = LiveTailSessionRegistry.instance
2934

3035
const documentProvider = new LogDataDocumentProvider(registry)
31-
36+
const liveTailDocumentProvider = new LiveTailDocumentProvider()
37+
const liveTailCodeLensProvider = new LiveTailCodeLensProvider(liveTailRegistry)
3238
context.subscriptions.push(
3339
vscode.languages.registerCodeLensProvider(
3440
{
@@ -43,6 +49,20 @@ export async function activate(context: vscode.ExtensionContext, configuration:
4349
vscode.workspace.registerTextDocumentContentProvider(CLOUDWATCH_LOGS_SCHEME, documentProvider)
4450
)
4551

52+
context.subscriptions.push(
53+
vscode.languages.registerCodeLensProvider(
54+
{
55+
language: 'log',
56+
scheme: cloudwatchLogsLiveTailScheme,
57+
},
58+
liveTailCodeLensProvider
59+
)
60+
)
61+
62+
context.subscriptions.push(
63+
vscode.workspace.registerTextDocumentContentProvider(cloudwatchLogsLiveTailScheme, liveTailDocumentProvider)
64+
)
65+
4666
context.subscriptions.push(
4767
vscode.workspace.onDidCloseTextDocument((doc) => {
4868
if (doc.isClosed && doc.uri.scheme === CLOUDWATCH_LOGS_SCHEME) {
@@ -95,6 +115,23 @@ export async function activate(context: vscode.ExtensionContext, configuration:
95115

96116
Commands.register('aws.cwl.changeTimeFilter', async () => changeLogSearchParams(registry, 'timeFilter')),
97117

118+
Commands.register('aws.cwl.tailLogGroup', async (node: LogGroupNode | CloudWatchLogsNode) => {
119+
const logGroupInfo =
120+
node instanceof LogGroupNode
121+
? { regionName: node.regionCode, groupName: node.logGroup.logGroupName! }
122+
: undefined
123+
const source = node ? (logGroupInfo ? 'ExplorerLogGroupNode' : 'ExplorerServiceNode') : 'Command'
124+
await tailLogGroup(liveTailRegistry, source, liveTailCodeLensProvider, logGroupInfo)
125+
}),
126+
127+
Commands.register('aws.cwl.stopTailingLogGroup', async (document: vscode.TextDocument, source: string) => {
128+
closeSession(document.uri, liveTailRegistry, source, liveTailCodeLensProvider)
129+
}),
130+
131+
Commands.register('aws.cwl.clearDocument', async (document: vscode.TextDocument) => {
132+
await clearDocument(document)
133+
}),
134+
98135
Commands.register('aws.appBuilder.searchLogs', async (node: DeployedResourceNode) => {
99136
try {
100137
const logGroupInfo = isTreeNode(node)
@@ -112,6 +149,7 @@ export async function activate(context: vscode.ExtensionContext, configuration:
112149
})
113150
)
114151
}
152+
115153
function getFunctionLogGroupName(configuration: any) {
116154
const logGroupPrefix = '/aws/lambda/'
117155
return configuration.logGroupName || logGroupPrefix + configuration.FunctionName
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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 { telemetry } from '../../../shared/telemetry/telemetry'
8+
import { TailLogGroupWizard } from '../wizard/tailLogGroupWizard'
9+
import { CancellationError } from '../../../shared/utilities/timeoutUtils'
10+
import { LiveTailSession, LiveTailSessionConfiguration } from '../registry/liveTailSession'
11+
import { LiveTailSessionRegistry } from '../registry/liveTailSessionRegistry'
12+
import {
13+
LiveTailSessionLogEvent,
14+
LiveTailSessionUpdate,
15+
StartLiveTailResponseStream,
16+
} from '@aws-sdk/client-cloudwatch-logs'
17+
import { getLogger, globals, ToolkitError } from '../../../shared'
18+
import { uriToKey } from '../cloudWatchLogsUtils'
19+
import { LiveTailCodeLensProvider } from '../document/liveTailCodeLensProvider'
20+
21+
export async function tailLogGroup(
22+
registry: LiveTailSessionRegistry,
23+
source: string,
24+
codeLensProvider: LiveTailCodeLensProvider,
25+
logData?: { regionName: string; groupName: string }
26+
): Promise<void> {
27+
await telemetry.cloudwatchlogs_startLiveTail.run(async (span) => {
28+
const wizard = new TailLogGroupWizard(logData)
29+
const wizardResponse = await wizard.run()
30+
if (!wizardResponse) {
31+
throw new CancellationError('user')
32+
}
33+
if (wizardResponse.logStreamFilter.type === 'menu' || wizardResponse.logStreamFilter.type === undefined) {
34+
// logstream filter wizard uses type to determine which submenu to show. 'menu' is set when no type is selected
35+
// and to show the 'menu' of selecting a type. This should not be reachable due to the picker logic, but validating in case.
36+
throw new ToolkitError(`Invalid Log Stream filter type: ${wizardResponse.logStreamFilter.type}`)
37+
}
38+
const awsCredentials = await globals.awsContext.getCredentials()
39+
if (awsCredentials === undefined) {
40+
throw new ToolkitError('Failed to start LiveTail session: credentials are undefined.')
41+
}
42+
const liveTailSessionConfig: LiveTailSessionConfiguration = {
43+
logGroupArn: wizardResponse.regionLogGroupSubmenuResponse.data,
44+
logStreamFilter: wizardResponse.logStreamFilter,
45+
logEventFilterPattern: wizardResponse.filterPattern,
46+
region: wizardResponse.regionLogGroupSubmenuResponse.region,
47+
awsCredentials: awsCredentials,
48+
}
49+
const session = new LiveTailSession(liveTailSessionConfig)
50+
if (registry.has(uriToKey(session.uri))) {
51+
await vscode.window.showTextDocument(session.uri, { preview: false })
52+
void vscode.window.showInformationMessage(`Switching editor to an existing session that matches request.`)
53+
span.record({
54+
result: 'Succeeded',
55+
sessionAlreadyStarted: true,
56+
source: source,
57+
})
58+
return
59+
}
60+
const document = await prepareDocument(session)
61+
62+
const disposables: vscode.Disposable[] = []
63+
disposables.push(hideShowStatusBarItemsOnActiveEditor(session, document))
64+
disposables.push(closeSessionWhenAllEditorsClosed(session, registry, document, codeLensProvider))
65+
66+
try {
67+
const stream = await session.startLiveTailSession()
68+
registry.set(uriToKey(session.uri), session)
69+
codeLensProvider.refresh()
70+
getLogger().info(`LiveTail session started: ${uriToKey(session.uri)}`)
71+
span.record({
72+
source: source,
73+
result: 'Succeeded',
74+
sessionAlreadyStarted: false,
75+
hasTextFilter: Boolean(wizardResponse.filterPattern),
76+
filterType: wizardResponse.logStreamFilter.type,
77+
})
78+
await handleSessionStream(stream, document, session)
79+
} finally {
80+
disposables.forEach((disposable) => disposable.dispose())
81+
}
82+
})
83+
}
84+
85+
export function closeSession(
86+
sessionUri: vscode.Uri,
87+
registry: LiveTailSessionRegistry,
88+
source: string,
89+
codeLensProvider: LiveTailCodeLensProvider
90+
) {
91+
telemetry.cloudwatchlogs_stopLiveTail.run((span) => {
92+
const session = registry.get(uriToKey(sessionUri))
93+
if (session === undefined) {
94+
throw new ToolkitError(`No LiveTail session found for URI: ${uriToKey(sessionUri)}`)
95+
}
96+
session.stopLiveTailSession()
97+
registry.delete(uriToKey(sessionUri))
98+
void vscode.window.showInformationMessage(`Stopped LiveTail session: ${uriToKey(sessionUri)}`)
99+
codeLensProvider.refresh()
100+
span.record({
101+
result: 'Succeeded',
102+
source: source,
103+
duration: session.getLiveTailSessionDuration(),
104+
})
105+
})
106+
}
107+
108+
export async function clearDocument(textDocument: vscode.TextDocument) {
109+
const edit = new vscode.WorkspaceEdit()
110+
const startPosition = new vscode.Position(0, 0)
111+
const endPosition = new vscode.Position(textDocument.lineCount, 0)
112+
edit.delete(textDocument.uri, new vscode.Range(startPosition, endPosition))
113+
await vscode.workspace.applyEdit(edit)
114+
}
115+
116+
async function prepareDocument(session: LiveTailSession): Promise<vscode.TextDocument> {
117+
const textDocument = await vscode.workspace.openTextDocument(session.uri)
118+
await clearDocument(textDocument)
119+
await vscode.window.showTextDocument(textDocument, { preview: false })
120+
await vscode.languages.setTextDocumentLanguage(textDocument, 'log')
121+
session.showStatusBarItem(true)
122+
return textDocument
123+
}
124+
125+
async function handleSessionStream(
126+
stream: AsyncIterable<StartLiveTailResponseStream>,
127+
document: vscode.TextDocument,
128+
session: LiveTailSession
129+
) {
130+
try {
131+
for await (const event of stream) {
132+
if (event.sessionUpdate !== undefined && event.sessionUpdate.sessionResults !== undefined) {
133+
const formattedLogEvents = event.sessionUpdate.sessionResults.map<string>((logEvent) =>
134+
formatLogEvent(logEvent)
135+
)
136+
if (formattedLogEvents.length !== 0) {
137+
// Determine should scroll before adding new lines to doc because adding large
138+
// amount of new lines can push bottom of file out of view before scrolling.
139+
const editorsToScroll = getTextEditorsToScroll(document)
140+
await updateTextDocumentWithNewLogEvents(formattedLogEvents, document, session.maxLines)
141+
editorsToScroll.forEach(scrollTextEditorToBottom)
142+
}
143+
session.eventRate = eventRate(event.sessionUpdate)
144+
session.isSampled = isSampled(event.sessionUpdate)
145+
}
146+
}
147+
} catch (e) {
148+
if (session.isAborted) {
149+
// Expected case. User action cancelled stream (CodeLens, Close Editor, etc.).
150+
// AbortSignal interrupts the LiveTail stream, causing error to be thrown here.
151+
// Can assume that stopLiveTailSession() has already been called - AbortSignal is only
152+
// exposed through that method.
153+
getLogger().info(`LiveTail session stopped: ${uriToKey(session.uri)}`)
154+
} else {
155+
// Unexpected exception.
156+
session.stopLiveTailSession()
157+
throw ToolkitError.chain(
158+
e,
159+
`Unexpected on-stream exception while tailing session: ${session.uri.toString()}`
160+
)
161+
}
162+
}
163+
}
164+
165+
function formatLogEvent(logEvent: LiveTailSessionLogEvent): string {
166+
if (!logEvent.timestamp || !logEvent.message) {
167+
return ''
168+
}
169+
const timestamp = new Date(logEvent.timestamp).toLocaleTimeString('en', {
170+
timeStyle: 'medium',
171+
hour12: false,
172+
timeZone: 'UTC',
173+
})
174+
let line = timestamp.concat('\t', logEvent.message)
175+
if (!line.endsWith('\n')) {
176+
line = line.concat('\n')
177+
}
178+
return line
179+
}
180+
181+
// Auto scroll visible LiveTail session editors if the end-of-file is in view.
182+
// This allows for newly added log events to stay in view.
183+
function getTextEditorsToScroll(document: vscode.TextDocument): vscode.TextEditor[] {
184+
return vscode.window.visibleTextEditors.filter((editor) => {
185+
if (editor.document !== document) {
186+
return false
187+
}
188+
return editor.visibleRanges[0].contains(new vscode.Position(document.lineCount - 1, 0))
189+
})
190+
}
191+
192+
function scrollTextEditorToBottom(editor: vscode.TextEditor) {
193+
const position = new vscode.Position(Math.max(editor.document.lineCount - 2, 0), 0)
194+
editor.revealRange(new vscode.Range(position, position), vscode.TextEditorRevealType.Default)
195+
}
196+
197+
async function updateTextDocumentWithNewLogEvents(
198+
formattedLogEvents: string[],
199+
document: vscode.TextDocument,
200+
maxLines: number
201+
) {
202+
const edit = new vscode.WorkspaceEdit()
203+
formattedLogEvents.forEach((formattedLogEvent) =>
204+
edit.insert(document.uri, new vscode.Position(document.lineCount, 0), formattedLogEvent)
205+
)
206+
if (document.lineCount + formattedLogEvents.length > maxLines) {
207+
trimOldestLines(formattedLogEvents.length, maxLines, document, edit)
208+
}
209+
await vscode.workspace.applyEdit(edit)
210+
}
211+
212+
function trimOldestLines(
213+
numNewLines: number,
214+
maxLines: number,
215+
document: vscode.TextDocument,
216+
edit: vscode.WorkspaceEdit
217+
) {
218+
const numLinesToTrim = document.lineCount + numNewLines - maxLines
219+
const startPosition = new vscode.Position(0, 0)
220+
const endPosition = new vscode.Position(numLinesToTrim, 0)
221+
const range = new vscode.Range(startPosition, endPosition)
222+
edit.delete(document.uri, range)
223+
}
224+
225+
function isSampled(event: LiveTailSessionUpdate): boolean {
226+
return event.sessionMetadata === undefined || event.sessionMetadata.sampled === undefined
227+
? false
228+
: event.sessionMetadata.sampled
229+
}
230+
231+
function eventRate(event: LiveTailSessionUpdate): number {
232+
return event.sessionResults === undefined ? 0 : event.sessionResults.length
233+
}
234+
235+
function hideShowStatusBarItemsOnActiveEditor(
236+
session: LiveTailSession,
237+
document: vscode.TextDocument
238+
): vscode.Disposable {
239+
return vscode.window.onDidChangeActiveTextEditor((editor) => {
240+
session.showStatusBarItem(editor?.document === document)
241+
})
242+
}
243+
244+
/**
245+
* The LiveTail session should be automatically closed if the user does not have the session's
246+
* document in any Tab in their editor.
247+
*
248+
* `onDidCloseTextDocument` doesn't work for our case because the tailLogGroup command will keep the stream
249+
* writing to the doc even when all its tabs/editors are closed, seemingly keeping the doc 'open'.
250+
* Also there is no guarantee that this event fires when an editor tab is closed
251+
*
252+
* `onDidChangeVisibleTextEditors` returns editors that the user can see its contents. An editor that is open, but hidden
253+
* 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
254+
* the tab isn't visible, we want to continue writing to the doc, and keep the session alive.
255+
*/
256+
function closeSessionWhenAllEditorsClosed(
257+
session: LiveTailSession,
258+
registry: LiveTailSessionRegistry,
259+
document: vscode.TextDocument,
260+
codeLensProvider: LiveTailCodeLensProvider
261+
): vscode.Disposable {
262+
return vscode.window.tabGroups.onDidChangeTabs((tabEvent) => {
263+
const isOpen = isLiveTailSessionOpenInAnyTab(session)
264+
if (!isOpen) {
265+
closeSession(session.uri, registry, 'ClosedEditors', codeLensProvider)
266+
void clearDocument(document)
267+
}
268+
})
269+
}
270+
271+
function isLiveTailSessionOpenInAnyTab(liveTailSession: LiveTailSession) {
272+
let isOpen = false
273+
vscode.window.tabGroups.all.forEach(async (tabGroup) => {
274+
tabGroup.tabs.forEach((tab) => {
275+
if (tab.input instanceof vscode.TabInputText) {
276+
if (liveTailSession.uri.toString() === tab.input.uri.toString()) {
277+
isOpen = true
278+
}
279+
}
280+
})
281+
})
282+
return isOpen
283+
}

0 commit comments

Comments
 (0)