diff --git a/package-lock.json b/package-lock.json index 3cd83bc87a0..3e88fc0ceb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "plugins/*" ], "dependencies": { + "@aws-sdk/protocol-http": "^3.370.0", "@types/node": "^22.7.5", "vscode-nls": "^5.2.0", "vscode-nls-dev": "^4.0.4" @@ -4658,6 +4659,44 @@ "node": ">= 12.0.0" } }, + "node_modules/@aws-sdk/protocol-http": { + "version": "3.370.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.370.0.tgz", + "integrity": "sha512-MfZCgSsVmir+4kJps7xT0awOPNi+swBpcVp9ZtAP7POduUVV6zVLurMNLXsppKsErggssD5E9HUgQFs5w06U4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.370.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/protocol-http/node_modules/@aws-sdk/types": { + "version": "3.370.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.370.0.tgz", + "integrity": "sha512-8PGMKklSkRKjunFhzM2y5Jm0H2TBu7YRNISdYzXLUHKSP9zlMEYagseKVdmox0zKHf1LXVNuSlUV2b6SRrieCQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/protocol-http/node_modules/@smithy/types": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.2.0.tgz", + "integrity": "sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aws-sdk/region-config-resolver": { "version": "3.614.0", "license": "Apache-2.0", diff --git a/package.json b/package.json index 70f9d0f4c35..ba74a460f13 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "webpack-merge": "^5.10.0" }, "dependencies": { + "@aws-sdk/protocol-http": "^3.370.0", "@types/node": "^22.7.5", "vscode-nls": "^5.2.0", "vscode-nls-dev": "^4.0.4" diff --git a/packages/core/src/shared/awsClientBuilderV3.ts b/packages/core/src/shared/awsClientBuilderV3.ts new file mode 100644 index 00000000000..966084e7d1a --- /dev/null +++ b/packages/core/src/shared/awsClientBuilderV3.ts @@ -0,0 +1,141 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CredentialsShim } from '../auth/deprecated/loginManager' +import { AwsContext } from './awsContext' +import { AwsCredentialIdentityProvider, RetryStrategyV2 } from '@smithy/types' +import { getUserAgent } from './telemetry/util' +import { DevSettings } from './settings' +import { + DeserializeHandler, + DeserializeHandlerOptions, + DeserializeMiddleware, + HandlerExecutionContext, + Provider, + RetryStrategy, + UserAgent, +} from '@aws-sdk/types' +import { HttpResponse } from '@aws-sdk/protocol-http' +import { ConfiguredRetryStrategy } from '@smithy/util-retry' +import { telemetry } from './telemetry' +import { getRequestId, getTelemetryReason, getTelemetryReasonDesc, getTelemetryResult } from './errors' +import { extensionVersion } from '.' +import { getLogger } from './logger' +import { omitIfPresent } from './utilities/tsUtils' + +export type AwsClientConstructor = new (o: AwsClientOptions) => C + +interface AwsClient { + middlewareStack: any // Ideally this would extends MiddlewareStack, but this causes issues on client construction. +} + +interface AwsConfigOptions { + credentials: AwsCredentialIdentityProvider + region: string | Provider + customUserAgent: UserAgent + requestHandler: any + apiVersion: string + endpoint: string + retryStrategy: RetryStrategy | RetryStrategyV2 +} +export type AwsClientOptions = AwsConfigOptions + +export class AWSClientBuilderV3 { + public constructor(private readonly context: AwsContext) {} + + private getShim(): CredentialsShim { + const shim = this.context.credentialsShim + if (!shim) { + throw new Error('Toolkit is not logged-in.') + } + return shim + } + + public async createAwsService( + type: AwsClientConstructor, + options?: Partial, + region?: string, + userAgent: boolean = true, + settings?: DevSettings + ): Promise { + const shim = this.getShim() + const opt = (options ?? {}) as AwsClientOptions + + if (!opt.region && region) { + opt.region = region + } + + if (!opt.customUserAgent && userAgent) { + opt.customUserAgent = [[getUserAgent({ includePlatform: true, includeClientId: true }), extensionVersion]] + } + + if (!opt.retryStrategy) { + // Simple exponential backoff strategy as default. + opt.retryStrategy = new ConfiguredRetryStrategy(5, (attempt: number) => 1000 * 2 ** attempt) + } + // TODO: add tests for refresh logic. + opt.credentials = async () => { + const creds = await shim.get() + if (creds.expiration && creds.expiration.getTime() < Date.now()) { + return shim.refresh() + } + return creds + } + + const service = new type(opt) + // TODO: add middleware for logging, telemetry, endpoints. + service.middlewareStack.add(telemetryMiddleware, { step: 'deserialize' } as DeserializeHandlerOptions) + return service + } +} + +export function getServiceId(context: { clientName?: string; commandName?: string }): string { + return context.clientName?.toLowerCase().replace(/client$/, '') ?? 'unknown-service' +} + +/** + * Record request IDs to the current context, potentially overriding the field if + * multiple API calls are made in the same context. We only do failures as successes are generally uninteresting and noisy. + */ +export function recordErrorTelemetry(err: Error, serviceName?: string) { + telemetry.record({ + requestId: getRequestId(err), + requestServiceType: serviceName, + reasonDesc: getTelemetryReasonDesc(err), + reason: getTelemetryReason(err), + result: getTelemetryResult(err), + }) +} + +function logAndThrow(e: any, serviceId: string, errorMessageAppend: string): never { + if (e instanceof Error) { + recordErrorTelemetry(e, serviceId) + const err = { ...e } + delete err['stack'] + getLogger().error('API Response %s: %O', errorMessageAppend, err) + } + throw e +} +/** + * Telemetry logic to be added to all created clients. Adds logging and emitting metric on errors. + */ + +const telemetryMiddleware: DeserializeMiddleware = + (next: DeserializeHandler, context: HandlerExecutionContext) => async (args: any) => { + if (!HttpResponse.isInstance(args.request)) { + return next(args) + } + const serviceId = getServiceId(context as object) + const { hostname, path } = args.request + const logTail = `(${hostname} ${path})` + const result = await next(args).catch((e: any) => logAndThrow(e, serviceId, logTail)) + if (HttpResponse.isInstance(result.response)) { + // TODO: omit credentials / sensitive info from the logs / telemetry. + const output = omitIfPresent(result.output, []) + getLogger().debug('API Response %s: %O', logTail, output) + } + + return result + } diff --git a/packages/core/src/shared/utilities/tsUtils.ts b/packages/core/src/shared/utilities/tsUtils.ts index e4fbf5a2b3f..b6f5139d57c 100644 --- a/packages/core/src/shared/utilities/tsUtils.ts +++ b/packages/core/src/shared/utilities/tsUtils.ts @@ -151,3 +151,13 @@ export type FactoryFunction any> = ( /** Can be used to isolate all number fields of a record `T` */ export type NumericKeys = { [P in keyof T]-?: T[P] extends number | undefined ? P : never }[keyof T] + +export function omitIfPresent>(obj: T, keys: string[]): T { + const objCopy = { ...obj } + for (const key of keys) { + if (key in objCopy) { + ;(objCopy as any)[key] = '[omitted]' + } + } + return objCopy +} diff --git a/packages/core/src/test/shared/awsClientBuilderV3.test.ts b/packages/core/src/test/shared/awsClientBuilderV3.test.ts new file mode 100644 index 00000000000..d0184be1ded --- /dev/null +++ b/packages/core/src/test/shared/awsClientBuilderV3.test.ts @@ -0,0 +1,79 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { version } from 'vscode' +import { getClientId } from '../../shared/telemetry/util' +import { FakeMemento } from '../fakeExtensionContext' +import { FakeAwsContext } from '../utilities/fakeAwsContext' +import { GlobalState } from '../../shared/globalState' +import { AWSClientBuilderV3, getServiceId, recordErrorTelemetry } from '../../shared/awsClientBuilderV3' +import { Client } from '@aws-sdk/smithy-client' +import { extensionVersion } from '../../shared' +import { assertTelemetry } from '../testUtil' +import { telemetry } from '../../shared/telemetry' + +describe('AwsClientBuilderV3', function () { + let builder: AWSClientBuilderV3 + + beforeEach(async function () { + builder = new AWSClientBuilderV3(new FakeAwsContext()) + }) + + describe('createAndConfigureSdkClient', function () { + it('includes Toolkit user-agent if no options are specified', async function () { + const service = await builder.createAwsService(Client) + const clientId = getClientId(new GlobalState(new FakeMemento())) + + assert.ok(service.config.customUserAgent) + assert.strictEqual( + service.config.customUserAgent![0][0].replace('---Insiders', ''), + `AWS-Toolkit-For-VSCode/testPluginVersion Visual-Studio-Code/${version} ClientId/${clientId}` + ) + assert.strictEqual(service.config.customUserAgent![0][1], extensionVersion) + }) + + it('adds region to client', async function () { + const service = await builder.createAwsService(Client, { region: 'us-west-2' }) + + assert.ok(service.config.region) + assert.strictEqual(service.config.region, 'us-west-2') + }) + + it('adds Client-Id to user agent', async function () { + const service = await builder.createAwsService(Client) + const clientId = getClientId(new GlobalState(new FakeMemento())) + const regex = new RegExp(`ClientId/${clientId}`) + assert.ok(service.config.customUserAgent![0][0].match(regex)) + }) + + it('does not override custom user-agent if specified in options', async function () { + const service = await builder.createAwsService(Client, { + customUserAgent: [['CUSTOM USER AGENT']], + }) + + assert.strictEqual(service.config.customUserAgent[0][0], 'CUSTOM USER AGENT') + }) + }) +}) + +describe('getServiceId', function () { + it('returns the service ID', function () { + assert.strictEqual(getServiceId({ clientName: 'ec2' }), 'ec2') + assert.strictEqual(getServiceId({ clientName: 'ec2client' }), 'ec2') + assert.strictEqual(getServiceId({ clientName: 's3client' }), 's3') + }) +}) + +describe('recordErrorTelemetry', function () { + it('includes requestServiceType in span', function () { + const e = new Error('test error') + // Using vscode_executeCommand as general span to test functionality. This metric is unrelated to what is done here. + telemetry.vscode_executeCommand.run((span) => { + recordErrorTelemetry(e, 'aws-service') + }) + assertTelemetry('vscode_executeCommand', { requestServiceType: 'aws-service' }) + }) +})