Skip to content

Commit

Permalink
Merge #6193 CloudWatch Logs LiveTail
Browse files Browse the repository at this point in the history
  • Loading branch information
justinmk3 authored Dec 11, 2024
2 parents e63079d + 97f1dbe commit f57f4f5
Show file tree
Hide file tree
Showing 19 changed files with 2,409 additions and 3 deletions.
912 changes: 912 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@
"dependencies": {
"@amzn/amazon-q-developer-streaming-client": "file:../../src.gen/@amzn/amazon-q-developer-streaming-client",
"@amzn/codewhisperer-streaming": "file:../../src.gen/@amzn/codewhisperer-streaming",
"@aws-sdk/client-cloudwatch-logs": "^3.666.0",
"@aws-sdk/client-cloudformation": "^3.667.0",
"@aws-sdk/client-cognito-identity": "^3.637.0",
"@aws-sdk/client-lambda": "^3.637.0",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
"AWS.command.downloadSchemaItemCode": "Download Code Bindings",
"AWS.command.viewLogs": "View Logs",
"AWS.command.cloudWatchLogs.searchLogGroup": "Search Log Group",
"AWS.command.cloudWatchLogs.tailLogGroup": "Tail Log Group",
"AWS.command.sam.newTemplate": "Create new SAM Template",
"AWS.command.cloudFormation.newTemplate": "Create new CloudFormation Template",
"AWS.command.quickStart": "View Quick Start",
Expand Down Expand Up @@ -253,7 +254,7 @@
"AWS.appcomposer.explorerTitle": "Infrastructure Composer",
"AWS.cdk.explorerTitle": "CDK",
"AWS.codecatalyst.explorerTitle": "CodeCatalyst",
"AWS.cwl.limit.desc": "Maximum amount of log entries pulled per request from CloudWatch Logs (max 10000)",
"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)",
"AWS.samcli.deploy.bucket.recentlyUsed": "Buckets recently used for SAM deployments",
"AWS.submenu.amazonqEditorContextSubmenu.title": "Amazon Q",
"AWS.submenu.auth.title": "Authentication",
Expand Down
42 changes: 40 additions & 2 deletions packages/core/src/awsService/cloudWatchLogs/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import * as vscode from 'vscode'
import { CLOUDWATCH_LOGS_SCHEME } from '../../shared/constants'
import { cloudwatchLogsLiveTailScheme, CLOUDWATCH_LOGS_SCHEME } from '../../shared/constants'
import { Settings } from '../../shared/settings'
import { addLogEvents } from './commands/addLogEvents'
import { copyLogResource } from './commands/copyLogResource'
Expand All @@ -19,16 +19,22 @@ import { searchLogGroup } from './commands/searchLogGroup'
import { changeLogSearchParams } from './changeLogSearch'
import { CloudWatchLogsNode } from './explorer/cloudWatchLogsNode'
import { loadAndOpenInitialLogStreamFile, LogStreamCodeLensProvider } from './document/logStreamsCodeLensProvider'
import { clearDocument, closeSession, tailLogGroup } from './commands/tailLogGroup'
import { LiveTailDocumentProvider } from './document/liveTailDocumentProvider'
import { LiveTailSessionRegistry } from './registry/liveTailSessionRegistry'
import { DeployedResourceNode } from '../appBuilder/explorer/nodes/deployedNode'
import { isTreeNode } from '../../shared/treeview/resourceTreeDataProvider'
import { getLogger } from '../../shared/logger/logger'
import { ToolkitError } from '../../shared'
import { LiveTailCodeLensProvider } from './document/liveTailCodeLensProvider'

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

const documentProvider = new LogDataDocumentProvider(registry)

const liveTailDocumentProvider = new LiveTailDocumentProvider()
const liveTailCodeLensProvider = new LiveTailCodeLensProvider(liveTailRegistry)
context.subscriptions.push(
vscode.languages.registerCodeLensProvider(
{
Expand All @@ -43,6 +49,20 @@ export async function activate(context: vscode.ExtensionContext, configuration:
vscode.workspace.registerTextDocumentContentProvider(CLOUDWATCH_LOGS_SCHEME, documentProvider)
)

context.subscriptions.push(
vscode.languages.registerCodeLensProvider(
{
language: 'log',
scheme: cloudwatchLogsLiveTailScheme,
},
liveTailCodeLensProvider
)
)

context.subscriptions.push(
vscode.workspace.registerTextDocumentContentProvider(cloudwatchLogsLiveTailScheme, liveTailDocumentProvider)
)

context.subscriptions.push(
vscode.workspace.onDidCloseTextDocument((doc) => {
if (doc.isClosed && doc.uri.scheme === CLOUDWATCH_LOGS_SCHEME) {
Expand Down Expand Up @@ -95,6 +115,23 @@ export async function activate(context: vscode.ExtensionContext, configuration:

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

Commands.register('aws.cwl.tailLogGroup', async (node: LogGroupNode | CloudWatchLogsNode) => {
const logGroupInfo =
node instanceof LogGroupNode
? { regionName: node.regionCode, groupName: node.logGroup.logGroupName! }
: undefined
const source = node ? (logGroupInfo ? 'ExplorerLogGroupNode' : 'ExplorerServiceNode') : 'Command'
await tailLogGroup(liveTailRegistry, source, liveTailCodeLensProvider, logGroupInfo)
}),

Commands.register('aws.cwl.stopTailingLogGroup', async (document: vscode.TextDocument, source: string) => {
closeSession(document.uri, liveTailRegistry, source, liveTailCodeLensProvider)
}),

Commands.register('aws.cwl.clearDocument', async (document: vscode.TextDocument) => {
await clearDocument(document)
}),

Commands.register('aws.appBuilder.searchLogs', async (node: DeployedResourceNode) => {
try {
const logGroupInfo = isTreeNode(node)
Expand All @@ -112,6 +149,7 @@ export async function activate(context: vscode.ExtensionContext, configuration:
})
)
}

function getFunctionLogGroupName(configuration: any) {
const logGroupPrefix = '/aws/lambda/'
return configuration.logGroupName || logGroupPrefix + configuration.FunctionName
Expand Down
283 changes: 283 additions & 0 deletions packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import * as vscode from 'vscode'
import { telemetry } from '../../../shared/telemetry/telemetry'
import { TailLogGroupWizard } from '../wizard/tailLogGroupWizard'
import { CancellationError } from '../../../shared/utilities/timeoutUtils'
import { LiveTailSession, LiveTailSessionConfiguration } from '../registry/liveTailSession'
import { LiveTailSessionRegistry } from '../registry/liveTailSessionRegistry'
import {
LiveTailSessionLogEvent,
LiveTailSessionUpdate,
StartLiveTailResponseStream,
} from '@aws-sdk/client-cloudwatch-logs'
import { getLogger, globals, ToolkitError } from '../../../shared'
import { uriToKey } from '../cloudWatchLogsUtils'
import { LiveTailCodeLensProvider } from '../document/liveTailCodeLensProvider'

export async function tailLogGroup(
registry: LiveTailSessionRegistry,
source: string,
codeLensProvider: LiveTailCodeLensProvider,
logData?: { regionName: string; groupName: string }
): Promise<void> {
await telemetry.cloudwatchlogs_startLiveTail.run(async (span) => {
const wizard = new TailLogGroupWizard(logData)
const wizardResponse = await wizard.run()
if (!wizardResponse) {
throw new CancellationError('user')
}
if (wizardResponse.logStreamFilter.type === 'menu' || wizardResponse.logStreamFilter.type === undefined) {
// logstream filter wizard uses type to determine which submenu to show. 'menu' is set when no type is selected
// and to show the 'menu' of selecting a type. This should not be reachable due to the picker logic, but validating in case.
throw new ToolkitError(`Invalid Log Stream filter type: ${wizardResponse.logStreamFilter.type}`)
}
const awsCredentials = await globals.awsContext.getCredentials()
if (awsCredentials === undefined) {
throw new ToolkitError('Failed to start LiveTail session: credentials are undefined.')
}
const liveTailSessionConfig: LiveTailSessionConfiguration = {
logGroupArn: wizardResponse.regionLogGroupSubmenuResponse.data,
logStreamFilter: wizardResponse.logStreamFilter,
logEventFilterPattern: wizardResponse.filterPattern,
region: wizardResponse.regionLogGroupSubmenuResponse.region,
awsCredentials: awsCredentials,
}
const session = new LiveTailSession(liveTailSessionConfig)
if (registry.has(uriToKey(session.uri))) {
await vscode.window.showTextDocument(session.uri, { preview: false })
void vscode.window.showInformationMessage(`Switching editor to an existing session that matches request.`)
span.record({
result: 'Succeeded',
sessionAlreadyStarted: true,
source: source,
})
return
}
const document = await prepareDocument(session)

const disposables: vscode.Disposable[] = []
disposables.push(hideShowStatusBarItemsOnActiveEditor(session, document))
disposables.push(closeSessionWhenAllEditorsClosed(session, registry, document, codeLensProvider))

try {
const stream = await session.startLiveTailSession()
registry.set(uriToKey(session.uri), session)
codeLensProvider.refresh()
getLogger().info(`LiveTail session started: ${uriToKey(session.uri)}`)
span.record({
source: source,
result: 'Succeeded',
sessionAlreadyStarted: false,
hasTextFilter: Boolean(wizardResponse.filterPattern),
filterType: wizardResponse.logStreamFilter.type,
})
await handleSessionStream(stream, document, session)
} finally {
disposables.forEach((disposable) => disposable.dispose())
}
})
}

export function closeSession(
sessionUri: vscode.Uri,
registry: LiveTailSessionRegistry,
source: string,
codeLensProvider: LiveTailCodeLensProvider
) {
telemetry.cloudwatchlogs_stopLiveTail.run((span) => {
const session = registry.get(uriToKey(sessionUri))
if (session === undefined) {
throw new ToolkitError(`No LiveTail session found for URI: ${uriToKey(sessionUri)}`)
}
session.stopLiveTailSession()
registry.delete(uriToKey(sessionUri))
void vscode.window.showInformationMessage(`Stopped LiveTail session: ${uriToKey(sessionUri)}`)
codeLensProvider.refresh()
span.record({
result: 'Succeeded',
source: source,
duration: session.getLiveTailSessionDuration(),
})
})
}

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<vscode.TextDocument> {
const textDocument = await vscode.workspace.openTextDocument(session.uri)
await clearDocument(textDocument)
await vscode.window.showTextDocument(textDocument, { preview: false })
await vscode.languages.setTextDocumentLanguage(textDocument, 'log')
session.showStatusBarItem(true)
return textDocument
}

async function handleSessionStream(
stream: AsyncIterable<StartLiveTailResponseStream>,
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<string>((logEvent) =>
formatLogEvent(logEvent)
)
if (formattedLogEvents.length !== 0) {
// Determine should scroll before adding new lines to doc because adding large
// amount of new lines can push bottom of file out of view before scrolling.
const editorsToScroll = getTextEditorsToScroll(document)
await updateTextDocumentWithNewLogEvents(formattedLogEvents, document, session.maxLines)
editorsToScroll.forEach(scrollTextEditorToBottom)
}
session.eventRate = eventRate(event.sessionUpdate)
session.isSampled = isSampled(event.sessionUpdate)
}
}
} catch (e) {
if (session.isAborted) {
// Expected case. User action cancelled stream (CodeLens, Close Editor, etc.).
// AbortSignal interrupts the LiveTail stream, causing error to be thrown here.
// Can assume that stopLiveTailSession() has already been called - AbortSignal is only
// exposed through that method.
getLogger().info(`LiveTail session stopped: ${uriToKey(session.uri)}`)
} else {
// Unexpected exception.
session.stopLiveTailSession()
throw ToolkitError.chain(
e,
`Unexpected on-stream exception while tailing session: ${session.uri.toString()}`
)
}
}
}

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
}

// Auto scroll visible LiveTail session editors if the end-of-file is in view.
// This allows for newly added log events to stay in view.
function getTextEditorsToScroll(document: vscode.TextDocument): vscode.TextEditor[] {
return vscode.window.visibleTextEditors.filter((editor) => {
if (editor.document !== document) {
return false
}
return editor.visibleRanges[0].contains(new vscode.Position(document.lineCount - 1, 0))
})
}

function scrollTextEditorToBottom(editor: vscode.TextEditor) {
const position = new vscode.Position(Math.max(editor.document.lineCount - 2, 0), 0)
editor.revealRange(new vscode.Range(position, position), vscode.TextEditorRevealType.Default)
}

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)
)
if (document.lineCount + formattedLogEvents.length > maxLines) {
trimOldestLines(formattedLogEvents.length, maxLines, document, edit)
}
await vscode.workspace.applyEdit(edit)
}

function trimOldestLines(
numNewLines: number,
maxLines: number,
document: vscode.TextDocument,
edit: vscode.WorkspaceEdit
) {
const numLinesToTrim = document.lineCount + numNewLines - maxLines
const startPosition = new vscode.Position(0, 0)
const endPosition = new vscode.Position(numLinesToTrim, 0)
const range = new vscode.Range(startPosition, endPosition)
edit.delete(document.uri, range)
}

function isSampled(event: LiveTailSessionUpdate): boolean {
return event.sessionMetadata === undefined || event.sessionMetadata.sampled === undefined
? false
: event.sessionMetadata.sampled
}

function eventRate(event: LiveTailSessionUpdate): number {
return event.sessionResults === undefined ? 0 : event.sessionResults.length
}

function hideShowStatusBarItemsOnActiveEditor(
session: LiveTailSession,
document: vscode.TextDocument
): vscode.Disposable {
return vscode.window.onDidChangeActiveTextEditor((editor) => {
session.showStatusBarItem(editor?.document === document)
})
}

/**
* 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 closeSessionWhenAllEditorsClosed(
session: LiveTailSession,
registry: LiveTailSessionRegistry,
document: vscode.TextDocument,
codeLensProvider: LiveTailCodeLensProvider
): vscode.Disposable {
return vscode.window.tabGroups.onDidChangeTabs((tabEvent) => {
const isOpen = isLiveTailSessionOpenInAnyTab(session)
if (!isOpen) {
closeSession(session.uri, registry, 'ClosedEditors', codeLensProvider)
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
}
Loading

0 comments on commit f57f4f5

Please sign in to comment.