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

deps(sdkv3): start migration to v3 with new client builder #6097

Open
wants to merge 13 commits into
base: feature/sdkv3
Choose a base branch
from
39 changes: 39 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
141 changes: 141 additions & 0 deletions packages/core/src/shared/awsClientBuilderV3.ts
Original file line number Diff line number Diff line change
@@ -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<C> = new (o: AwsClientOptions) => C

interface AwsClient {
middlewareStack: any // Ideally this would extends MiddlewareStack<Input, Output>, but this causes issues on client construction.
}

interface AwsConfigOptions {
credentials: AwsCredentialIdentityProvider
region: string | Provider<string>
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<C extends AwsClient>(
type: AwsClientConstructor<C>,
options?: Partial<AwsClientOptions>,
region?: string,
userAgent: boolean = true,
settings?: DevSettings
): Promise<C> {
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<any, any> =
(next: DeserializeHandler<any, any>, 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
}
10 changes: 10 additions & 0 deletions packages/core/src/shared/utilities/tsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,13 @@ export type FactoryFunction<T extends abstract new (...args: any[]) => any> = (

/** Can be used to isolate all number fields of a record `T` */
export type NumericKeys<T> = { [P in keyof T]-?: T[P] extends number | undefined ? P : never }[keyof T]

export function omitIfPresent<T extends Record<string, unknown>>(obj: T, keys: string[]): T {
const objCopy = { ...obj }
for (const key of keys) {
if (key in objCopy) {
;(objCopy as any)[key] = '[omitted]'
}
}
return objCopy
}
79 changes: 79 additions & 0 deletions packages/core/src/test/shared/awsClientBuilderV3.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
})
})
Loading