Skip to content

Commit f1ffce6

Browse files
keeganirbykaranA-aws
authored andcommitted
feat(cwl): Initialize TailLogGroup command with Wizard (aws#5722)
CWL is planning on supporting a LiveTail experience in AWSToolkit for VSCode Registers `aws.cwl.tailLogGroup` as a recognized command in AWS Toolkit. In this PR, running this command will simply take the user through a Wizard to collect the configuration for the tailing session, and logs it. In follow up PRs, I will take this Wizard input and use it to call CWL APIs and output streamed LogEvents to a TextDocument. This command will be able to be executed in the command pallet, or by pressing a "play button" icon when viewing LogGroups in the CloudWatch Logs explorer menu.
1 parent 6c9a107 commit f1ffce6

File tree

7 files changed

+418
-0
lines changed

7 files changed

+418
-0
lines changed

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import { DeployedResourceNode } from '../appBuilder/explorer/nodes/deployedNode'
2323
import { isTreeNode } from '../../shared/treeview/resourceTreeDataProvider'
2424
import { getLogger } from '../../shared/logger/logger'
2525
import { ToolkitError } from '../../shared'
26+
import { clearDocument, closeSession, tailLogGroup } from './commands/tailLogGroup'
27+
import { LiveTailDocumentProvider } from './document/liveTailDocumentProvider'
28+
import { LiveTailSessionRegistry } from './registry/liveTailSessionRegistry'
29+
import { LiveTailCodeLensProvider } from './document/liveTailCodeLensProvider'
2630

2731
export async function activate(context: vscode.ExtensionContext, configuration: Settings): Promise<void> {
2832
const registry = LogDataRegistry.instance
@@ -94,7 +98,15 @@ export async function activate(context: vscode.ExtensionContext, configuration:
9498
Commands.register('aws.cwl.changeFilterPattern', async () => changeLogSearchParams(registry, 'filterPattern')),
9599

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

103+
Commands.register('aws.cwl.tailLogGroup', async (node: LogGroupNode | CloudWatchLogsNode) => {
104+
const logGroupInfo =
105+
node instanceof LogGroupNode
106+
? { regionName: node.regionCode, groupName: node.logGroup.logGroupName! }
107+
: undefined
108+
await tailLogGroup(logGroupInfo)
109+
})
98110
Commands.register('aws.appBuilder.searchLogs', async (node: DeployedResourceNode) => {
99111
try {
100112
const logGroupInfo = isTreeNode(node)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as nls from 'vscode-nls'
7+
import { DefaultCloudWatchLogsClient } from '../../../shared/clients/cloudWatchLogsClient'
8+
import { createBackButton, createExitButton, createHelpButton } from '../../../shared/ui/buttons'
9+
import { createInputBox } from '../../../shared/ui/inputPrompter'
10+
import { DataQuickPickItem } from '../../../shared/ui/pickerPrompter'
11+
import { Wizard } from '../../../shared/wizards/wizard'
12+
import { CloudWatchLogsGroupInfo } from '../registry/logDataRegistry'
13+
import { RegionSubmenu, RegionSubmenuResponse } from '../../../shared/ui/common/regionSubmenu'
14+
import { CancellationError } from '../../../shared/utilities/timeoutUtils'
15+
import { LogStreamFilterResponse, LogStreamFilterSubmenu } from '../liveTailLogStreamSubmenu'
16+
import { getLogger, ToolkitError } from '../../../shared'
17+
import { cwlFilterPatternHelpUrl } from '../../../shared/constants'
18+
19+
const localize = nls.loadMessageBundle()
20+
21+
export interface TailLogGroupWizardResponse {
22+
regionLogGroupSubmenuResponse: RegionSubmenuResponse<string>
23+
logStreamFilter: LogStreamFilterResponse
24+
filterPattern: string
25+
}
26+
27+
export async function tailLogGroup(logData?: { regionName: string; groupName: string }): Promise<void> {
28+
const wizard = new TailLogGroupWizard(logData)
29+
const wizardResponse = await wizard.run()
30+
if (!wizardResponse) {
31+
throw new CancellationError('user')
32+
}
33+
34+
//TODO: Remove Log. For testing while we aren't yet consuming the wizardResponse.
35+
getLogger().info(JSON.stringify(wizardResponse))
36+
}
37+
38+
export class TailLogGroupWizard extends Wizard<TailLogGroupWizardResponse> {
39+
public constructor(logGroupInfo?: CloudWatchLogsGroupInfo) {
40+
super({
41+
initState: {
42+
regionLogGroupSubmenuResponse: logGroupInfo
43+
? {
44+
data: logGroupInfo.groupName,
45+
region: logGroupInfo.regionName,
46+
}
47+
: undefined,
48+
},
49+
})
50+
this.form.regionLogGroupSubmenuResponse.bindPrompter(createRegionLogGroupSubmenu)
51+
this.form.logStreamFilter.bindPrompter((state) => {
52+
if (!state.regionLogGroupSubmenuResponse?.data) {
53+
throw new ToolkitError('LogGroupName is null')
54+
}
55+
return new LogStreamFilterSubmenu(
56+
state.regionLogGroupSubmenuResponse.data,
57+
state.regionLogGroupSubmenuResponse.region
58+
)
59+
})
60+
this.form.filterPattern.bindPrompter((state) => createFilterPatternPrompter())
61+
}
62+
}
63+
64+
export function createRegionLogGroupSubmenu(): RegionSubmenu<string> {
65+
return new RegionSubmenu(
66+
getLogGroupQuickPickOptions,
67+
{
68+
title: localize('AWS.cwl.tailLogGroup.logGroupPromptTitle', 'Select Log Group to tail'),
69+
buttons: [createExitButton()],
70+
},
71+
{ title: localize('AWS.cwl.tailLogGroup.regionPromptTitle', 'Select Region for Log Group') },
72+
'LogGroups'
73+
)
74+
}
75+
76+
async function getLogGroupQuickPickOptions(regionCode: string): Promise<DataQuickPickItem<string>[]> {
77+
const client = new DefaultCloudWatchLogsClient(regionCode)
78+
const logGroups = client.describeLogGroups()
79+
80+
const logGroupsOptions: DataQuickPickItem<string>[] = []
81+
82+
for await (const logGroupObject of logGroups) {
83+
if (!logGroupObject.arn || !logGroupObject.logGroupName) {
84+
throw new ToolkitError('LogGroupObject name or arn undefined')
85+
}
86+
87+
logGroupsOptions.push({
88+
label: logGroupObject.logGroupName,
89+
data: formatLogGroupArn(logGroupObject.arn),
90+
})
91+
}
92+
93+
return logGroupsOptions
94+
}
95+
96+
function formatLogGroupArn(logGroupArn: string): string {
97+
return logGroupArn.endsWith(':*') ? logGroupArn.substring(0, logGroupArn.length - 2) : logGroupArn
98+
}
99+
100+
export function createFilterPatternPrompter() {
101+
const helpUri = cwlFilterPatternHelpUrl
102+
return createInputBox({
103+
title: 'Provide log event filter pattern',
104+
placeholder: 'filter pattern (case sensitive; empty matches all)',
105+
prompt: 'Optional pattern to use to filter the results to include only log events that match the pattern.',
106+
buttons: [createHelpButton(helpUri), createBackButton(), createExitButton()],
107+
})
108+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import { Prompter, PromptResult } from '../../shared/ui/prompter'
6+
import { DefaultCloudWatchLogsClient } from '../../shared/clients/cloudWatchLogsClient'
7+
import { createCommonButtons } from '../../shared/ui/buttons'
8+
import { createInputBox, InputBoxPrompter } from '../../shared/ui/inputPrompter'
9+
import { createQuickPick, DataQuickPickItem, QuickPickPrompter } from '../../shared/ui/pickerPrompter'
10+
import { pageableToCollection } from '../../shared/utilities/collectionUtils'
11+
import { CloudWatchLogs } from 'aws-sdk'
12+
import { isValidResponse, StepEstimator } from '../../shared/wizards/wizard'
13+
import { isNonNullable } from '../../shared/utilities/tsUtils'
14+
import {
15+
startLiveTailHelpUrl,
16+
startLiveTailLogStreamNamesHelpUrl,
17+
startLiveTailLogStreamPrefixHelpUrl,
18+
} from '../../shared/constants'
19+
20+
export type LogStreamFilterType = 'menu' | 'prefix' | 'specific' | 'all'
21+
22+
export interface LogStreamFilterResponse {
23+
readonly filter?: string
24+
readonly type: LogStreamFilterType
25+
}
26+
27+
export class LogStreamFilterSubmenu extends Prompter<LogStreamFilterResponse> {
28+
private logStreamPrefixRegEx = /^[^:*]*$/
29+
private currentState: LogStreamFilterType = 'menu'
30+
private steps?: [current: number, total: number]
31+
private region: string
32+
private logGroupArn: string
33+
public defaultPrompter: QuickPickPrompter<LogStreamFilterType> = this.createMenuPrompter()
34+
35+
public constructor(logGroupArn: string, region: string) {
36+
super()
37+
this.region = region
38+
this.logGroupArn = logGroupArn
39+
}
40+
41+
public createMenuPrompter() {
42+
const helpUri = startLiveTailHelpUrl
43+
const prompter = createQuickPick(this.menuOptions, {
44+
title: 'Select LogStream filter type',
45+
buttons: createCommonButtons(helpUri),
46+
})
47+
return prompter
48+
}
49+
50+
private get menuOptions(): DataQuickPickItem<LogStreamFilterType>[] {
51+
const options: DataQuickPickItem<LogStreamFilterType>[] = []
52+
options.push({
53+
label: 'All',
54+
detail: 'Include log events from all LogStreams in the selected LogGroup',
55+
data: 'all',
56+
})
57+
options.push({
58+
label: 'Specific',
59+
detail: 'Include log events from only a specific LogStream',
60+
data: 'specific',
61+
})
62+
options.push({
63+
label: 'Prefix',
64+
detail: 'Include log events from LogStreams that begin with a provided prefix',
65+
data: 'prefix',
66+
})
67+
return options
68+
}
69+
70+
public createLogStreamPrefixBox(): InputBoxPrompter {
71+
const helpUri = startLiveTailLogStreamPrefixHelpUrl
72+
return createInputBox({
73+
title: 'Enter LogStream prefix',
74+
placeholder: 'logStream prefix (case sensitive; empty matches all)',
75+
prompt: 'Only log events in the LogStreams that have names that start with the prefix that you specify here are included in the Live Tail session',
76+
validateInput: (input) => this.validateLogStreamPrefix(input),
77+
buttons: createCommonButtons(helpUri),
78+
})
79+
}
80+
81+
public validateLogStreamPrefix(prefix: string) {
82+
if (prefix.length > 512) {
83+
return 'LogStream prefix cannot be longer than 512 characters'
84+
}
85+
86+
if (!this.logStreamPrefixRegEx.test(prefix)) {
87+
return 'LogStream prefix must match pattern: [^:*]*'
88+
}
89+
}
90+
91+
public createLogStreamSelector(): QuickPickPrompter<string> {
92+
const helpUri = startLiveTailLogStreamNamesHelpUrl
93+
const client = new DefaultCloudWatchLogsClient(this.region)
94+
const request: CloudWatchLogs.DescribeLogStreamsRequest = {
95+
logGroupIdentifier: this.logGroupArn,
96+
orderBy: 'LastEventTime',
97+
descending: true,
98+
}
99+
const requester = (request: CloudWatchLogs.DescribeLogStreamsRequest) => client.describeLogStreams(request)
100+
const collection = pageableToCollection(requester, request, 'nextToken', 'logStreams')
101+
102+
const items = collection
103+
.filter(isNonNullable)
104+
.map((streams) => streams!.map((stream) => ({ data: stream.logStreamName!, label: stream.logStreamName! })))
105+
106+
return createQuickPick(items, {
107+
title: 'Select LogStream',
108+
buttons: createCommonButtons(helpUri),
109+
})
110+
}
111+
112+
private switchState(newState: LogStreamFilterType) {
113+
this.currentState = newState
114+
}
115+
116+
protected async promptUser(): Promise<PromptResult<LogStreamFilterResponse>> {
117+
while (true) {
118+
switch (this.currentState) {
119+
case 'menu': {
120+
const prompter = this.createMenuPrompter()
121+
this.steps && prompter.setSteps(this.steps[0], this.steps[1])
122+
123+
const resp = await prompter.prompt()
124+
if (resp === 'prefix') {
125+
this.switchState('prefix')
126+
} else if (resp === 'specific') {
127+
this.switchState('specific')
128+
} else if (resp === 'all') {
129+
return { filter: undefined, type: resp }
130+
} else {
131+
return undefined
132+
}
133+
134+
break
135+
}
136+
case 'prefix': {
137+
const resp = await this.createLogStreamPrefixBox().prompt()
138+
if (isValidResponse(resp)) {
139+
return { filter: resp, type: 'prefix' }
140+
}
141+
this.switchState('menu')
142+
break
143+
}
144+
case 'specific': {
145+
const resp = await this.createLogStreamSelector().prompt()
146+
if (isValidResponse(resp)) {
147+
return { filter: resp, type: 'specific' }
148+
}
149+
this.switchState('menu')
150+
break
151+
}
152+
}
153+
}
154+
}
155+
156+
public setSteps(current: number, total: number): void {
157+
this.steps = [current, total]
158+
}
159+
160+
// Unused
161+
public get recentItem(): any {
162+
return
163+
}
164+
public set recentItem(response: any) {}
165+
public setStepEstimator(estimator: StepEstimator<LogStreamFilterResponse>): void {}
166+
}

packages/core/src/shared/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ export const AWS_SCHEME = 'aws' // eslint-disable-line @typescript-eslint/naming
150150
export const ec2LogsScheme = 'aws-ec2'
151151
export const amazonQDiffScheme = 'amazon-q-diff'
152152

153+
export const startLiveTailHelpUrl =
154+
'https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_StartLiveTail.html'
155+
export const startLiveTailLogStreamPrefixHelpUrl = `${startLiveTailHelpUrl}#CWL-StartLiveTail-request-logStreamNamePrefixes`
156+
export const startLiveTailLogStreamNamesHelpUrl = `${startLiveTailHelpUrl}#CWL-StartLiveTail-request-logStreamNames`
157+
158+
export const cwlFilterPatternHelpUrl =
159+
'https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html'
160+
153161
export const lambdaPackageTypeImage = 'Image'
154162

155163
// URLs for App Runner
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { TailLogGroupWizard } from '../../../../awsService/cloudWatchLogs/commands/tailLogGroup'
7+
import { createWizardTester } from '../../../shared/wizards/wizardTestUtils'
8+
9+
describe('TailLogGroupWizard', async function () {
10+
it('prompts regionLogGroup submenu first if context not provided', async function () {
11+
const wizard = new TailLogGroupWizard()
12+
const tester = await createWizardTester(wizard)
13+
tester.regionLogGroupSubmenuResponse.assertShowFirst()
14+
tester.logStreamFilter.assertShowSecond()
15+
tester.filterPattern.assertShowThird()
16+
})
17+
18+
it('skips regionLogGroup submenu if context provided', async function () {
19+
const wizard = new TailLogGroupWizard({
20+
groupName: 'test-groupName',
21+
regionName: 'test-regionName',
22+
})
23+
const tester = await createWizardTester(wizard)
24+
tester.regionLogGroupSubmenuResponse.assertDoesNotShow()
25+
tester.logStreamFilter.assertShowFirst()
26+
tester.filterPattern.assertShowSecond()
27+
})
28+
})

0 commit comments

Comments
 (0)