Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

telemetry(lsp): Integrate language server/manifest resolver telemetry #6385

Merged
merged 45 commits into from
Feb 6, 2025
Merged
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
75a4d43
define metric within VSC telemetry
Hweinstock Jan 16, 2025
191e0ea
implement telemetry for fetching stage
Hweinstock Jan 16, 2025
0b4cec4
lift telemetry logic to top level
Hweinstock Jan 16, 2025
3727ea9
add telemetry for remaining stages
Hweinstock Jan 16, 2025
5d5a7d8
seperate verification and downloading
Hweinstock Jan 17, 2025
a7b446d
add telemetry for validation step
Hweinstock Jan 17, 2025
3304e99
rename metrics and types
Hweinstock Jan 17, 2025
45b0236
reduce duplication across stage implementations
Hweinstock Jan 17, 2025
c2596cf
undo unnecessary change
Hweinstock Jan 17, 2025
4f2b662
Merge branch 'feature/amazonqLSP' into telemetry/setup
Hweinstock Jan 21, 2025
6ef6633
switch to fallback instead of cache when remote option fails
Hweinstock Jan 21, 2025
3b3b39b
fix telemetry metadata field
Hweinstock Jan 23, 2025
abdb5fd
port metrics down from commons until release
Hweinstock Jan 23, 2025
0aadddf
implement logic for manifest telemetry
Hweinstock Jan 23, 2025
f03bf8d
Merge branch 'feature/amazonqLSP' into telemetry/setup
Hweinstock Jan 24, 2025
7d05c68
telemetry: implement logic for server resolution
Hweinstock Jan 24, 2025
c7b97e2
refactor: move tryFunctions to core and include tests
Hweinstock Jan 24, 2025
5284763
telemtry: implement telemetry for amazonQLSP
Hweinstock Jan 24, 2025
7dfefe6
test: implement tests for manifestResolver
Hweinstock Jan 24, 2025
bad6697
Merge branch 'feature/amazonqLSP' into telemetry/setup
Hweinstock Jan 24, 2025
4b1e90a
refactor: simplify lspResolver implementation
Hweinstock Jan 24, 2025
45901e7
test: add tests for lsp resolver (WIP)
Hweinstock Jan 24, 2025
2ee4792
test: add tests for lspResolver
Hweinstock Jan 27, 2025
3a6bad2
test: remove duplicate data in tests
Hweinstock Jan 27, 2025
9e708fd
refactor: move telemetry work to more general location
Hweinstock Jan 27, 2025
0b4da9d
fix: change outdated reference
Hweinstock Jan 27, 2025
c37ef4a
merge: bring in HttpResourceFetcher changes
Hweinstock Jan 27, 2025
9cee3e7
refactor: rename stage -> setupStage
Hweinstock Jan 27, 2025
05f3a2b
fix: add workaround for windows tests
Hweinstock Jan 27, 2025
80fa5d6
test: disable lspResolver test on nonmac, add techdebt test
Hweinstock Jan 27, 2025
8ebcc97
jscpd: ignore verbose telemetry checks
Hweinstock Jan 27, 2025
3cf254a
refactor: avoid duplicate import line
Hweinstock Jan 27, 2025
2c60608
merge: handle tsUtils conflicts
Hweinstock Jan 28, 2025
9b9443b
deps: bump telemetry version and remove local changes
Hweinstock Jan 28, 2025
077e402
refactor: cleanup minor details
Hweinstock Jan 28, 2025
0ef9f0d
merge: resolve conflicts
Hweinstock Jan 29, 2025
b0a37f3
refactor: fix spelling mistake
Hweinstock Feb 3, 2025
42ed745
fix: remove accidental space
Hweinstock Feb 3, 2025
ea4e35a
Merge branch 'feature/amazonqLSP' into telemetry/setup
Hweinstock Feb 3, 2025
d286070
test: add telemetry to e2e tests
Hweinstock Feb 4, 2025
3888ac5
test: adjust telemetry to mirror CI reality
Hweinstock Feb 4, 2025
4819d49
test: adjust results to mirror ci
Hweinstock Feb 4, 2025
3aa2670
test: delete duplicate coverage test case
Hweinstock Feb 5, 2025
e55dd7f
test: avoid bleedthrough of globalstate
Hweinstock Feb 5, 2025
8bc7ccc
test: ensure consistent global storage state to ensure consistent beh…
Hweinstock Feb 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -40,7 +40,7 @@
"mergeReports": "ts-node ./scripts/mergeReports.ts"
},
"devDependencies": {
"@aws-toolkits/telemetry": "^1.0.295",
"@aws-toolkits/telemetry": "^1.0.296",
"@playwright/browser-chromium": "^1.43.1",
"@stylistic/eslint-plugin": "^2.11.0",
"@types/he": "^1.2.3",
8 changes: 5 additions & 3 deletions packages/amazonq/src/lsp/activation.ts
Original file line number Diff line number Diff line change
@@ -6,12 +6,14 @@
import vscode from 'vscode'
import { startLanguageServer } from './client'
import { AmazonQLSPResolver } from './lspInstaller'
import { Commands, ToolkitError } from 'aws-core-vscode/shared'
import { Commands, lspSetupStage, ToolkitError } from 'aws-core-vscode/shared'

export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
try {
const installResult = await new AmazonQLSPResolver().resolve()
await startLanguageServer(ctx, installResult.resourcePaths)
await lspSetupStage('all', async () => {
const installResult = await new AmazonQLSPResolver().resolve()
await lspSetupStage('launch', async () => await startLanguageServer(ctx, installResult.resourcePaths))
})
ctx.subscriptions.push(
Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => {
await vscode.commands.executeCommand('editor.action.inlineSuggest.trigger')
107 changes: 107 additions & 0 deletions packages/amazonq/test/e2e/lsp/lspInstaller.test.ts
Original file line number Diff line number Diff line change
@@ -8,12 +8,17 @@ import sinon from 'sinon'
import { AmazonQLSPResolver, supportedLspServerVersions } from '../../../src/lsp/lspInstaller'
import {
fs,
globals,
LanguageServerResolver,
makeTemporaryToolkitFolder,
ManifestResolver,
manifestStorageKey,
request,
} from 'aws-core-vscode/shared'
import * as semver from 'semver'
import { assertTelemetry } from 'aws-core-vscode/test'
import { LspController } from 'aws-core-vscode/amazonq'
import { LanguageServerSetup } from 'aws-core-vscode/telemetry'

function createVersion(version: string) {
return {
@@ -40,12 +45,22 @@ describe('AmazonQLSPInstaller', () => {
let resolver: AmazonQLSPResolver
let sandbox: sinon.SinonSandbox
let tempDir: string
// If globalState contains an ETag that is up to date with remote, we won't fetch it resulting in inconsistent behavior.
// Therefore, we clear it temporarily for these tests to ensure consistent behavior.
let manifestStorage: { [key: string]: any }

before(async () => {
manifestStorage = globals.globalState.get(manifestStorageKey) || {}
})

beforeEach(async () => {
sandbox = sinon.createSandbox()
resolver = new AmazonQLSPResolver()
tempDir = await makeTemporaryToolkitFolder()
sandbox.stub(LanguageServerResolver.prototype, 'defaultDownloadFolder').returns(tempDir)
// Called on extension activation and can contaminate telemetry.
sandbox.stub(LspController.prototype, 'trySetupLsp')
await globals.globalState.update(manifestStorageKey, {})
})

afterEach(async () => {
@@ -56,6 +71,10 @@ describe('AmazonQLSPInstaller', () => {
})
})

after(async () => {
await globals.globalState.update(manifestStorageKey, manifestStorage)
})

describe('resolve()', () => {
it('uses AWS_LANGUAGE_SERVER_OVERRIDE', async () => {
const overridePath = '/custom/path/to/lsp'
@@ -117,6 +136,94 @@ describe('AmazonQLSPInstaller', () => {
assert.ok(fallback.assetDirectory.startsWith(tempDir))
assert.deepStrictEqual(fallback.location, 'fallback')
assert.ok(semver.satisfies(fallback.version, supportedLspServerVersions))

/* First Try Telemetry
getManifest: remote succeeds
getServer: cache fails then remote succeeds.
validate: succeeds.
*/
const firstTryTelemetry: Partial<LanguageServerSetup>[] = [
{
id: 'AmazonQ',
manifestLocation: 'remote',
languageServerSetupStage: 'getManifest',
result: 'Succeeded',
},
{
id: 'AmazonQ',
languageServerLocation: 'cache',
languageServerSetupStage: 'getServer',
result: 'Failed',
},
{
id: 'AmazonQ',
languageServerLocation: 'remote',
languageServerSetupStage: 'validate',
result: 'Succeeded',
},
{
id: 'AmazonQ',
languageServerLocation: 'remote',
languageServerSetupStage: 'getServer',
result: 'Succeeded',
},
]

/* Second Try Telemetry
getManifest: remote fails, then cache succeeds.
getServer: cache succeeds
validate: doesn't run since its cached.
*/
const secondTryTelemetry: Partial<LanguageServerSetup>[] = [
{
id: 'AmazonQ',
manifestLocation: 'remote',
languageServerSetupStage: 'getManifest',
result: 'Failed',
},
{
id: 'AmazonQ',
manifestLocation: 'cache',
languageServerSetupStage: 'getManifest',
result: 'Succeeded',
},
{
id: 'AmazonQ',
languageServerLocation: 'cache',
languageServerSetupStage: 'getServer',
result: 'Succeeded',
},
]

/* Third Try Telemetry
getManifest: (stubbed to fail, no telemetry)
getServer: remote and cache fail
validate: no validation since not remote.
*/
const thirdTryTelemetry: Partial<LanguageServerSetup>[] = [
{
id: 'AmazonQ',
languageServerLocation: 'cache',
languageServerSetupStage: 'getServer',
result: 'Failed',
},
{
id: 'AmazonQ',
languageServerLocation: 'remote',
languageServerSetupStage: 'getServer',
result: 'Failed',
},
{
id: 'AmazonQ',
languageServerLocation: 'fallback',
languageServerSetupStage: 'getServer',
result: 'Succeeded',
},
]

const expectedTelemetry = firstTryTelemetry.concat(secondTryTelemetry, thirdTryTelemetry)

assertTelemetry('languageServer_setup', expectedTelemetry)
})
})
})
2 changes: 1 addition & 1 deletion packages/core/package.nls.json
Original file line number Diff line number Diff line change
@@ -20,7 +20,7 @@
"AWS.configuration.description.suppressPrompts": "Prompts which ask for confirmation. Checking an item suppresses the prompt.",
"AWS.configuration.enableCodeLenses": "Enable SAM hints in source code and template.yaml files",
"AWS.configuration.description.resources.enabledResources": "AWS resources to display in the 'Resources' portion of the explorer.",
"AWS.configuration.description.featureDevelopment.allowRunningCodeAndTests": "Allow /dev to run code and test commands",
"AWS.configuration.description.featureDevelopment.allowRunningCodeAndTests": "Allow /dev to run code and test commands",
"AWS.configuration.description.experiments": "Try experimental features and give feedback. Note that experimental features may be removed at any time.\n * `jsonResourceModification` - Enables basic create, update, and delete support for cloud resources via the JSON Resources explorer component.",
"AWS.stepFunctions.asl.format.enable.desc": "Enables the default formatter used with Amazon States Language files",
"AWS.stepFunctions.asl.maxItemsComputed.desc": "The maximum number of outline symbols and folding regions computed (limited for performance reasons).",
13 changes: 10 additions & 3 deletions packages/core/src/amazonq/lsp/lspController.ts
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ import { isCloud9 } from '../../shared/extensionUtilities'
import globals, { isWeb } from '../../shared/extensionGlobals'
import { isAmazonInternalOs } from '../../shared/vscode/env'
import { WorkspaceLSPResolver } from './workspaceInstaller'
import { lspSetupStage } from '../../shared'

export interface Chunk {
readonly filePath: string
@@ -160,9 +161,7 @@ export class LspController {
}
setImmediate(async () => {
try {
const installResult = await new WorkspaceLSPResolver().resolve()
await activateLsp(context, installResult.resourcePaths)
getLogger().info('LspController: LSP activated')
await this.setupLsp(context)
void LspController.instance.buildIndex(buildIndexConfig)
// log the LSP server CPU and Memory usage per 30 minutes.
globals.clock.setInterval(
@@ -183,4 +182,12 @@ export class LspController {
}
})
}

private async setupLsp(context: vscode.ExtensionContext) {
await lspSetupStage('all', async () => {
const installResult = await new WorkspaceLSPResolver().resolve()
await lspSetupStage('launch', async () => activateLsp(context, installResult.resourcePaths))
getLogger().info('LspController: LSP activated')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessarily an issue for this PR, but I think we should probably scope these messages to regular codewhisperer language server vs workspace context one

Copy link
Contributor Author

@Hweinstock Hweinstock Feb 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think we could make use of the logging header here that references which LSP it is. Can do as a follow up.

})
}
}
1 change: 1 addition & 0 deletions packages/core/src/shared/index.ts
Original file line number Diff line number Diff line change
@@ -65,6 +65,7 @@ export { TabTypeDataMap } from '../amazonq/webview/ui/tabs/constants'
export * from './lsp/manifestResolver'
export * from './lsp/lspResolver'
export * from './lsp/types'
export * from './lsp/utils/setupStage'
export * from './lsp/utils/cleanup'
export { default as request } from './request'
export * from './lsp/utils/platform'
156 changes: 99 additions & 57 deletions packages/core/src/shared/lsp/lspResolver.ts
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ import AdmZip from 'adm-zip'
import { TargetContent, logger, LspResult, LspVersion, Manifest } from './types'
import { getApplicationSupportFolder } from '../vscode/env'
import { createHash } from '../crypto'
import { lspSetupStage, StageResolver, tryStageResolvers } from './utils/setupStage'
import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher'

export class LanguageServerResolver {
@@ -30,63 +31,90 @@ export class LanguageServerResolver {
* @throws ToolkitError if no compatible version can be found
*/
async resolve() {
const result: LspResult = {
location: 'unknown',
version: '',
assetDirectory: '',
}

const latestVersion = this.latestCompatibleLspVersion()
const targetContents = this.getLSPTargetContents(latestVersion)
const cacheDirectory = this.getDownloadDirectory(latestVersion.serverVersion)

if (await this.hasValidLocalCache(cacheDirectory, targetContents)) {
result.location = 'cache'
result.version = latestVersion.serverVersion
result.assetDirectory = cacheDirectory
return result
} else {
// Delete the cached directory since it's invalid
if (await fs.existsDir(cacheDirectory)) {
await fs.delete(cacheDirectory, {
recursive: true,
})
}
}

if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion)) {
result.location = 'remote'
result.version = latestVersion.serverVersion
result.assetDirectory = cacheDirectory
return result
} else {
// clean up any leftover content that may have been downloaded
if (await fs.existsDir(cacheDirectory)) {
await fs.delete(cacheDirectory, {
recursive: true,
})
const serverResolvers: StageResolver<LspResult>[] = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not neccessarily a problem but just wanted to call it out for anyone reading -- this is where we will slightly deviate from eclipse/visual studios implementations. We can just do some things a bit simpler

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what ways does this deviate from their implementations? Would it be easier if I modified the implementation to closer mirror those implementations?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just the structure of having resolvers. It's not a big deal. I just copied Visual Studios implementation and basically ported it to typescript. AFAIK Eclipse ported Visual Studio's implementation for this code to java. This is just a slight refactor of their work

{
resolve: async () => await this.getLocalServer(cacheDirectory, latestVersion, targetContents),
telemetryMetadata: { id: this.lsName, languageServerLocation: 'cache' },
},
{
resolve: async () => await this.fetchRemoteServer(cacheDirectory, latestVersion, targetContents),
telemetryMetadata: { id: this.lsName, languageServerLocation: 'remote' },
},
{
resolve: async () => await this.getFallbackServer(latestVersion),
telemetryMetadata: { id: this.lsName, languageServerLocation: 'fallback' },
},
]

return await tryStageResolvers('getServer', serverResolvers, getServerVersion)

function getServerVersion(result: LspResult) {
return {
languageServerVersion: result.version,
}
}
}

logger.info(
`Unable to download language server version ${latestVersion.serverVersion}. Attempting to fetch from fallback location`
)

private async getFallbackServer(latestVersion: LspVersion): Promise<LspResult> {
const fallbackDirectory = await this.getFallbackDir(latestVersion.serverVersion)
if (!fallbackDirectory) {
throw new ToolkitError('Unable to find a compatible version of the Language Server')
throw new ToolkitError('Unable to find a compatible version of the Language Server', {
code: 'IncompatibleVersion',
})
}

const version = path.basename(fallbackDirectory)
logger.info(
`Unable to install ${this.lsName} language server v${latestVersion.serverVersion}. Launching a previous version from ${fallbackDirectory}`
)

result.location = 'fallback'
result.version = version
result.assetDirectory = fallbackDirectory
return {
location: 'fallback',
version: version,
assetDirectory: fallbackDirectory,
}
}

private async fetchRemoteServer(
cacheDirectory: string,
latestVersion: LspVersion,
targetContents: TargetContent[]
): Promise<LspResult> {
if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion)) {
return {
location: 'remote',
version: latestVersion.serverVersion,
assetDirectory: cacheDirectory,
}
} else {
throw new ToolkitError('Failed to download server from remote', { code: 'RemoteDownloadFailed' })
}
}

return result
private async getLocalServer(
cacheDirectory: string,
latestVersion: LspVersion,
targetContents: TargetContent[]
): Promise<LspResult> {
if (await this.hasValidLocalCache(cacheDirectory, targetContents)) {
return {
location: 'cache',
version: latestVersion.serverVersion,
assetDirectory: cacheDirectory,
}
} else {
// Delete the cached directory since it's invalid
if (await fs.existsDir(cacheDirectory)) {
await fs.delete(cacheDirectory, {
recursive: true,
})
}
throw new ToolkitError('Failed to retrieve server from cache', { code: 'InvalidCache' })
}
}

/**
@@ -164,25 +192,37 @@ export class LanguageServerResolver {
await fs.mkdir(downloadDirectory)
}

const downloadTasks = contents.map(async (content) => {
const res = await new HttpResourceFetcher(content.url, { showUrl: true }).get()
if (!res || !res.ok || !res.body) {
return false
const fetchTasks = contents.map(async (content) => {
return {
res: await new HttpResourceFetcher(content.url, { showUrl: true }).get(),
hash: content.hashes[0],
filename: content.filename,
}
})
const fetchResults = await Promise.all(fetchTasks)
const verifyTasks = fetchResults
.filter((fetchResult) => fetchResult.res && fetchResult.res.ok && fetchResult.res.body)
.flatMap(async (fetchResult) => {
const arrBuffer = await fetchResult.res!.arrayBuffer()
const data = Buffer.from(arrBuffer)

const hash = createHash('sha384', data)
if (hash === fetchResult.hash) {
return [{ filename: fetchResult.filename, data }]
}
return []
})
if (verifyTasks.length !== contents.length) {
return false
}

const arrBuffer = await res.arrayBuffer()
const data = Buffer.from(arrBuffer)
const filesToDownload = await lspSetupStage('validate', async () => (await Promise.all(verifyTasks)).flat())

const hash = createHash('sha384', data)
if (hash === content.hashes[0]) {
await fs.writeFile(`${downloadDirectory}/${content.filename}`, data)
return true
}
return false
})
const downloadResults = await Promise.all(downloadTasks)
const downloadResult = downloadResults.every(Boolean)
return downloadResult && this.extractZipFilesFromRemote(downloadDirectory)
for (const file of filesToDownload) {
await fs.writeFile(`${downloadDirectory}/${file.filename}`, file.data)
}

return this.extractZipFilesFromRemote(downloadDirectory)
}

private async extractZipFilesFromRemote(downloadDirectory: string) {
@@ -333,7 +373,9 @@ export class LanguageServerResolver {
private getCompatibleLspTarget(version: LspVersion) {
// TODO make this web friendly
// TODO make this fully support windows
const platform = process.platform

// Workaround: Manifest platform field is `windows`, whereas node returns win32
const platform = process.platform === 'win32' ? 'windows' : process.platform
const arch = process.arch
return version.targets.find((x) => x.arch === arch && x.platform === platform)
}
27 changes: 21 additions & 6 deletions packages/core/src/shared/lsp/manifestResolver.ts
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import { ToolkitError } from '../errors'
import { Timeout } from '../utilities/timeoutUtils'
import globals from '../extensionGlobals'
import { Manifest } from './types'
import { StageResolver, tryStageResolvers } from './utils/setupStage'
import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher'

const logger = getLogger('lsp')
@@ -19,7 +20,7 @@ interface StorageManifest {

type ManifestStorage = Record<string, StorageManifest>

const manifestStorageKey = 'aws.toolkit.lsp.manifest'
export const manifestStorageKey = 'aws.toolkit.lsp.manifest'
const manifestTimeoutMs = 15000

export class ManifestResolver {
@@ -32,10 +33,23 @@ export class ManifestResolver {
* Fetches the latest manifest, falling back to local cache on failure
*/
async resolve(): Promise<Manifest> {
try {
return await this.fetchRemoteManifest()
} catch (error) {
return await this.getLocalManifest()
const resolvers: StageResolver<Manifest>[] = [
{
resolve: async () => await this.fetchRemoteManifest(),
telemetryMetadata: { id: this.lsName, manifestLocation: 'remote' },
},
{
resolve: async () => await this.getLocalManifest(),
telemetryMetadata: { id: this.lsName, manifestLocation: 'cache' },
},
]

return await tryStageResolvers('getManifest', resolvers, extractMetadata)

function extractMetadata(r: Manifest) {
return {
manifestSchemaVersion: r.manifestSchemaVersion,
}
}
}

@@ -52,7 +66,7 @@ export class ManifestResolver {
const manifest = this.parseManifest(resp.content)
await this.saveManifest(resp.eTag, resp.content)
this.checkDeprecation(manifest)

manifest.location = 'remote'
return manifest
}

@@ -67,6 +81,7 @@ export class ManifestResolver {

const manifest = this.parseManifest(manifestData.content)
this.checkDeprecation(manifest)
manifest.location = 'cache'
return manifest
}

6 changes: 3 additions & 3 deletions packages/core/src/shared/lsp/types.ts
Original file line number Diff line number Diff line change
@@ -4,13 +4,12 @@
*/

import { getLogger } from '../logger/logger'
import { LanguageServerLocation, ManifestLocation } from '../telemetry'

export const logger = getLogger('lsp')

type Location = 'remote' | 'cache' | 'override' | 'fallback' | 'unknown'

export interface LspResult {
location: Location
location: LanguageServerLocation
version: string
assetDirectory: string
}
@@ -53,6 +52,7 @@ export interface Manifest {
artifactDescription: string
isManifestDeprecated: boolean
versions: LspVersion[]
location?: ManifestLocation
}

export interface VersionRange {
66 changes: 66 additions & 0 deletions packages/core/src/shared/lsp/utils/setupStage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { LanguageServerSetup, LanguageServerSetupStage, telemetry } from '../../telemetry'
import { tryFunctions } from '../../utilities/tsUtils'

/**
* Runs the designated stage within a telemetry span and optionally uses the getMetadata extractor to record metadata from the result of the stage.
* @param stageName name of stage for telemetry.
* @param runStage stage to be run.
* @param getMetadata metadata extractor to be applied to result.
* @returns result of stage
*/
export async function lspSetupStage<Result>(
stageName: LanguageServerSetupStage,
runStage: () => Promise<Result>,
getMetadata?: MetadataExtractor<Result>
) {
return await telemetry.languageServer_setup.run(async (span) => {
span.record({ languageServerSetupStage: stageName })
const result = await runStage()
if (getMetadata) {
span.record(getMetadata(result))
}
return result
})
}
/**
* Tries to resolve the result of a stage using the resolvers provided in order. The first one to succceed
* has its result returned, but all intermediate will emit telemetry.
* @param stageName name of stage to resolve.
* @param resolvers stage resolvers to try IN ORDER
* @param getMetadata function to be applied to result to extract necessary metadata for telemetry.
* @returns result of the first succesful resolver.
*/
export async function tryStageResolvers<Result>(
stageName: LanguageServerSetupStage,
resolvers: StageResolver<Result>[],
getMetadata: MetadataExtractor<Result>
) {
const fs = resolvers.map((resolver) => async () => {
return await lspSetupStage(
stageName,
async () => {
telemetry.record(resolver.telemetryMetadata)
const result = await resolver.resolve()
return result
},
getMetadata
)
})

return await tryFunctions(fs)
}

/**
* A method that returns the result of a stage along with the default telemetry metadata to attach to the stage metric.
*/
export interface StageResolver<Result> {
resolve: () => Promise<Result>
telemetryMetadata: Partial<LanguageServerSetup>
}

type MetadataExtractor<R> = (r: R) => Partial<LanguageServerSetup>
17 changes: 17 additions & 0 deletions packages/core/src/shared/utilities/tsUtils.ts
Original file line number Diff line number Diff line change
@@ -94,6 +94,23 @@ export function createFactoryFunction<T extends new (...args: any[]) => any>(cto
return (...args) => new ctor(...args)
}

/**
* Try functions in the order presented and return the first returned result. If none return, throw the final error.
* @param functions non-empty list of functions to try.
* @returns
*/
export async function tryFunctions<Result>(functions: (() => Promise<Result>)[]): Promise<Result> {
let currentError: Error = new Error('No functions provided')
for (const func of functions) {
try {
return await func()
} catch (e) {
currentError = e as Error
}
}
throw currentError
}

/**
* Split a list into two sublists based on the result of a predicate.
* @param lst list to split
101 changes: 101 additions & 0 deletions packages/core/src/test/shared/lsp/manifestResolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'assert'
import sinon from 'sinon'
import { Manifest, ManifestResolver } from '../../../shared'
import { assertTelemetry } from '../../testUtil'
import { ManifestLocation } from '../../../shared/telemetry'

const manifestSchemaVersion = '1.0.0'
const serverName = 'myLS'

/**
* Helper function generating valid manifest results for tests.
* @param location
* @returns
*/
function manifestResult(location: ManifestLocation): Manifest {
return {
location,
manifestSchemaVersion,
artifactId: 'artifact-id',
artifactDescription: 'artifact-description',
isManifestDeprecated: false,
versions: [],
}
}

describe('manifestResolver', function () {
let remoteStub: sinon.SinonStub
let localStub: sinon.SinonStub

before(function () {
remoteStub = sinon.stub(ManifestResolver.prototype, 'fetchRemoteManifest' as any)
localStub = sinon.stub(ManifestResolver.prototype, 'getLocalManifest' as any)
})

after(function () {
sinon.restore()
})

it('attempts to fetch from remote first', async function () {
remoteStub.resolves(manifestResult('remote'))

const r = await new ManifestResolver('remote-manifest.com', serverName).resolve()
assert.strictEqual(r.location, 'remote')
assertTelemetry('languageServer_setup', {
manifestLocation: 'remote',
manifestSchemaVersion,
languageServerSetupStage: 'getManifest',
id: serverName,
result: 'Succeeded',
})
})

it('uses local cache when remote fails', async function () {
remoteStub.rejects(new Error('failed to fetch'))
localStub.resolves(manifestResult('cache'))

const r = await new ManifestResolver('remote-manifest.com', serverName).resolve()
assert.strictEqual(r.location, 'cache')
assertTelemetry('languageServer_setup', [
{
manifestLocation: 'remote',
languageServerSetupStage: 'getManifest',
id: serverName,
result: 'Failed',
},
{
manifestLocation: 'cache',
manifestSchemaVersion,
languageServerSetupStage: 'getManifest',
id: serverName,
result: 'Succeeded',
},
])
})

it('fails if both local and remote fail', async function () {
remoteStub.rejects(new Error('failed to fetch'))
localStub.rejects(new Error('failed to fetch'))

await assert.rejects(new ManifestResolver('remote-manifest.com', serverName).resolve(), /failed to fetch/)
assertTelemetry('languageServer_setup', [
{
manifestLocation: 'remote',
languageServerSetupStage: 'getManifest',
id: serverName,
result: 'Failed',
},
{
manifestLocation: 'cache',
languageServerSetupStage: 'getManifest',
id: serverName,
result: 'Failed',
},
])
})
})
25 changes: 23 additions & 2 deletions packages/core/src/test/shared/utilities/tsUtils.test.ts
Original file line number Diff line number Diff line change
@@ -2,9 +2,30 @@
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { partition } from '../../../shared/utilities/tsUtils'
import assert from 'assert'
import { tryFunctions } from '../../../shared/utilities/tsUtils'
import { partition } from '../../../shared/utilities/tsUtils'

describe('tryFunctions', function () {
it('should return the result of the first function that returns', async function () {
const f1 = () => Promise.reject('f1')
const f2 = () => Promise.resolve('f2')
const f3 = () => Promise.reject('f3')

assert.strictEqual(await tryFunctions([f1, f2, f3]), 'f2')
})

it('if all reject, then should throw final error', async function () {
const f1 = () => Promise.reject('f1')
const f2 = () => Promise.reject('f2')
const f3 = () => Promise.reject('f3')

await assert.rejects(
async () => await tryFunctions([f1, f2, f3]),
(e) => e === 'f3'
)
})
})

describe('partition', function () {
it('should split the list according to predicate', function () {