diff --git a/packages/@aws-cdk-testing/cli-integ/tests/telemetry-integ-tests/cdk-hotswap-telemetry.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/telemetry-integ-tests/cdk-hotswap-telemetry.integtest.ts new file mode 100644 index 000000000..88d91ba97 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/telemetry-integ-tests/cdk-hotswap-telemetry.integtest.ts @@ -0,0 +1,44 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { integTest, withDefaultFixture } from '../../lib'; + +jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime + +integTest( + 'cdk hotswap deploy emits HOTSWAP telemetry event', + withDefaultFixture(async (fixture) => { + const telemetryFile = path.join(fixture.integTestDir, `telemetry-hotswap-${Date.now()}.json`); + + // Initial deploy. DYNAMIC_LAMBDA_PROPERTY_VALUE is read by LambdaHotswapStack + // in app.js to set the Lambda description and env vars — changing it between + // deploys produces a hotswappable diff. + await fixture.cdkDeploy('lambda-hotswap', { + captureStderr: false, + modEnv: { DYNAMIC_LAMBDA_PROPERTY_VALUE: 'original' }, + }); + + // Hotswap deploy with telemetry + const deployOutput = await fixture.cdkDeploy('lambda-hotswap', { + options: ['--hotswap'], + telemetryFile, + verboseLevel: 3, + modEnv: { DYNAMIC_LAMBDA_PROPERTY_VALUE: 'updated' }, + }); + + // Check the trace that telemetry was executed successfully + expect(deployOutput).toContain('Telemetry Sent Successfully'); + + const json = fs.readJSONSync(telemetryFile); + expect(json).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + state: 'SUCCEEDED', + eventType: 'HOTSWAP', + }), + }), + ]), + ); + fs.unlinkSync(telemetryFile); + }), +); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-deployments.ts b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-deployments.ts index bb7d74177..fbdae6cc9 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-deployments.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-deployments.ts @@ -112,6 +112,12 @@ export async function tryHotswapDeployment( await hotswapSpan.end(result); + // This is a hotswap error that was caught during the hotswapDeployment function + // It is thrown after the hotswap span ends so it can be reported to telemetry + if (result.error) { + throw result.error; + } + if (result?.hotswapped === true) { return { type: 'did-deploy-stack', @@ -188,14 +194,20 @@ async function hotswapDeployment( } // apply the short-circuitable changes - await applyAllHotswapOperations(sdk, ioSpan, hotswappable); + let error: Error | undefined; + try { + await applyAllHotswapOperations(sdk, ioSpan, hotswappable); + } catch (e: any) { + error = e; + } return { stack, mode: hotswapMode, - hotswapped: true, + hotswapped: !error, hotswappableChanges, nonHotswappableChanges, + error, }; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/hotswap.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/hotswap.ts index daa05a47d..bad72410a 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/hotswap.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/hotswap.ts @@ -223,4 +223,12 @@ export interface HotswapResult extends Duration, HotswapDeploymentDetails { * `false` indicates that the deployment could not be hotswapped and full deployment may be attempted as fallback. */ readonly hotswapped: boolean; + + /** + * An error that occurred during the hotswap deployment, if any. + * + * When set, `hotswapped` will be `false`. The error is captured so that + * the hotswap span can be ended with full context before re-throwing. + */ + readonly error?: Error; } diff --git a/packages/@aws-cdk/toolkit-lib/test/api/_helpers/hotswap-test-setup.ts b/packages/@aws-cdk/toolkit-lib/test/api/_helpers/hotswap-test-setup.ts index f0ae683da..16385e7a4 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/_helpers/hotswap-test-setup.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/_helpers/hotswap-test-setup.ts @@ -28,7 +28,7 @@ let currentCfnStack: FakeCloudformationStack; const currentCfnStackResources: StackResourceSummary[] = []; let stackTemplates: { [stackName: string]: any }; let currentNestedCfnStackResources: { [stackName: string]: StackResourceSummary[] }; -let ioHost = new TestIoHost(); +export let ioHost = new TestIoHost(); export function setupHotswapTests(): HotswapMockSdkProvider { restoreSdkMocksToDefault(); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/deployments/hotswap-deployments.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/deployments/hotswap-deployments.test.ts index e19a5be0e..1aafffe62 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/deployments/hotswap-deployments.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/deployments/hotswap-deployments.test.ts @@ -3,6 +3,7 @@ import { UpdateFunctionCodeCommand } from '@aws-sdk/client-lambda'; import { UpdateStateMachineCommand } from '@aws-sdk/client-sfn'; import { CfnEvaluationException } from '../../../lib/api/cloudformation'; import { HotswapMode } from '../../../lib/api/hotswap'; +import type { HotswapResult } from '../../../lib/payloads/hotswap'; import { MockSdk, mockCloudFormationClient, mockLambdaClient, mockStepFunctionsClient } from '../../_helpers/mock-sdk'; import * as setup from '../_helpers/hotswap-test-setup'; @@ -685,3 +686,81 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }); }); }); + +describe('hotswap span', () => { + const lambdaTemplate = (s3Key: string) => ({ + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { S3Bucket: 'bucket', S3Key: s3Key }, + FunctionName: 'my-function', + }, + Metadata: { 'aws:asset:path': 'old-path' }, + }, + }, + }); + + beforeEach(() => { + setup.ioHost.clear(); + }); + + function spanEndMessage() { + return setup.ioHost.messages.find((m) => m.code === 'CDK_TOOLKIT_I5410'); + } + + test('successful hotswap ends span with hotswapped: true and no error', async () => { + setup.setCurrentCfnStackTemplate(lambdaTemplate('old-key')); + const artifact = setup.cdkStackArtifactOf({ template: lambdaTemplate('new-key') }); + + await hotswapMockSdkProvider.tryHotswapDeployment(HotswapMode.HOTSWAP_ONLY, artifact); + + const msg = spanEndMessage(); + expect(msg).toBeDefined(); + const result = msg!.data as HotswapResult; + expect(result.hotswapped).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.hotswappableChanges.length).toBeGreaterThan(0); + }); + + test('SDK apply error ends span with hotswapped: false and error set, then re-throws', async () => { + setup.setCurrentCfnStackTemplate(lambdaTemplate('old-key')); + const artifact = setup.cdkStackArtifactOf({ template: lambdaTemplate('new-key') }); + mockLambdaClient.on(UpdateFunctionCodeCommand).rejects(new Error('SDK boom')); + + await expect( + hotswapMockSdkProvider.tryHotswapDeployment(HotswapMode.HOTSWAP_ONLY, artifact), + ).rejects.toThrow('SDK boom'); + + const msg = spanEndMessage(); + expect(msg).toBeDefined(); + const result = msg!.data as HotswapResult; + expect(result.hotswapped).toBe(false); + expect(result.error).toBeDefined(); + expect(result.hotswappableChanges.length).toBeGreaterThan(0); + }); + + test('non-hotswappable changes in fall-back mode ends span with hotswapped: false and no error', async () => { + setup.setCurrentCfnStackTemplate({ + Resources: { + Something: { Type: 'AWS::CloudFormation::SomethingElse', Properties: { Prop: 'old' } }, + }, + }); + const artifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Something: { Type: 'AWS::CloudFormation::SomethingElse', Properties: { Prop: 'new' } }, + }, + }, + }); + + await hotswapMockSdkProvider.tryHotswapDeployment(HotswapMode.FALL_BACK, artifact); + + const msg = spanEndMessage(); + expect(msg).toBeDefined(); + const result = msg!.data as HotswapResult; + expect(result.hotswapped).toBe(false); + expect(result.error).toBeUndefined(); + expect(result.nonHotswappableChanges.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts b/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts index 47dc743bd..05f9a2a21 100644 --- a/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts +++ b/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts @@ -2,7 +2,7 @@ import type { Agent } from 'node:https'; import * as util from 'node:util'; import { RequireApproval } from '@aws-cdk/cloud-assembly-schema'; import { ToolkitError } from '@aws-cdk/toolkit-lib'; -import type { IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest, ToolkitAction } from '@aws-cdk/toolkit-lib'; +import type { HotswapResult, IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest, ToolkitAction } from '@aws-cdk/toolkit-lib'; import type { Context } from '@aws-cdk/toolkit-lib/lib/api'; import * as chalk from 'chalk'; import * as promptly from 'promptly'; @@ -10,6 +10,7 @@ import type { IoHelper, ActivityPrinterProps, IActivityPrinter } from '../../../ import { asIoHelper, IO, isMessageRelevantForLevel, CurrentActivityPrinter, HistoryActivityPrinter } from '../../../lib/api-private'; import { StackActivityProgress } from '../../commands/deploy'; import { canCollectTelemetry } from '../telemetry/collect-telemetry'; +import { cdkCliErrorName } from '../telemetry/error'; import type { EventResult } from '../telemetry/messages'; import { CLI_PRIVATE_IO } from '../telemetry/messages'; import type { TelemetryEvent } from '../telemetry/session'; @@ -625,6 +626,12 @@ function eventFromMessage(msg: IoMessage): TelemetryEvent | undefined { if (CLI_PRIVATE_IO.CDK_CLI_I3001.is(msg)) { return eventResult('DEPLOY', msg); } + // Hotswap lives in the cdk-toolkit so it cannot be a CDK_CLI error code. + // Instead we reuse the existing Hotswap span. + if (IO.CDK_TOOLKIT_I5410.is(msg)) { + // Create a telemetry-compatible result + return hotswapToEventResult(msg.data); + } return undefined; function eventResult(eventType: TelemetryEvent['eventType'], m: IoMessage): TelemetryEvent { @@ -636,3 +643,21 @@ function eventFromMessage(msg: IoMessage): TelemetryEvent | undefined { }; } } + +function hotswapToEventResult(result: HotswapResult): TelemetryEvent { + const data = result as any; + return { + eventType: 'HOTSWAP' as const, + duration: result.duration ?? 0, + ...(data.error ? { + error: { + name: cdkCliErrorName(data.error.name), + }, + } : {}), + counters: { + hotswapped: result.hotswapped ? 1 : 0, + hotswappableChanges: result.hotswappableChanges.length, + nonHotswappableChanges: result.nonHotswappableChanges.length, + }, + }; +} diff --git a/packages/aws-cdk/lib/cli/telemetry/schema.ts b/packages/aws-cdk/lib/cli/telemetry/schema.ts index 2df6973a0..be0cb0355 100644 --- a/packages/aws-cdk/lib/cli/telemetry/schema.ts +++ b/packages/aws-cdk/lib/cli/telemetry/schema.ts @@ -24,7 +24,7 @@ interface SessionEvent { readonly command: Command; } -export type EventType = 'SYNTH' | 'INVOKE' | 'DEPLOY'; +export type EventType = 'SYNTH' | 'INVOKE' | 'DEPLOY' | 'HOTSWAP'; export type State = 'ABORTED' | 'FAILED' | 'SUCCEEDED'; interface Event extends SessionEvent { readonly state: State; diff --git a/packages/aws-cdk/test/cli/io-host/cli-io-host.test.ts b/packages/aws-cdk/test/cli/io-host/cli-io-host.test.ts index e71850f31..86aaa23e8 100644 --- a/packages/aws-cdk/test/cli/io-host/cli-io-host.test.ts +++ b/packages/aws-cdk/test/cli/io-host/cli-io-host.test.ts @@ -435,6 +435,73 @@ describe('CliIoHost', () => { }, })); }); + + test('emit telemetry on HOTSWAP event with successful hotswap', async () => { + const message: IoMessage = { + time: new Date(), + level: 'info', + action: 'deploy', + code: 'CDK_TOOLKIT_I5410', + message: 'hotswap result', + data: { + duration: 456, + hotswapped: true, + hotswappableChanges: [{ a: 1 }, { b: 2 }], + nonHotswappableChanges: [{ c: 3 }], + stack: {}, + mode: 'hotswap-only', + }, + }; + + await telemetryIoHost.notify(message); + + expect(telemetryEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'HOTSWAP', + duration: 456, + counters: { + hotswapped: 1, + hotswappableChanges: 2, + nonHotswappableChanges: 1, + }, + })); + expect(telemetryEmitSpy).toHaveBeenCalledWith(expect.not.objectContaining({ + error: expect.anything(), + })); + }); + + test('emit telemetry on HOTSWAP event with error', async () => { + const message: IoMessage = { + time: new Date(), + level: 'info', + action: 'deploy', + code: 'CDK_TOOLKIT_I5410', + message: 'hotswap result', + data: { + duration: 200, + hotswapped: false, + hotswappableChanges: [{ a: 1 }], + nonHotswappableChanges: [], + stack: {}, + mode: 'hotswap-only', + error: new Error('SDK call failed'), + }, + }; + + await telemetryIoHost.notify(message); + + expect(telemetryEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'HOTSWAP', + duration: 200, + error: expect.objectContaining({ + name: 'UnknownError', + }), + counters: { + hotswapped: 0, + hotswappableChanges: 1, + nonHotswappableChanges: 0, + }, + })); + }); }); describe('requestResponse', () => {