Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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);
}),
);
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
};
}

Expand Down
8 changes: 8 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/payloads/hotswap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
27 changes: 26 additions & 1 deletion packages/aws-cdk/lib/cli/io-host/cli-io-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ 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';
import type { IoHelper, ActivityPrinterProps, IActivityPrinter } from '../../../lib/api-private';
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';
Expand Down Expand Up @@ -625,6 +626,12 @@ function eventFromMessage(msg: IoMessage<unknown>): 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<EventResult>): TelemetryEvent {
Expand All @@ -636,3 +643,21 @@ function eventFromMessage(msg: IoMessage<unknown>): 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,
},
};
}
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/cli/telemetry/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
67 changes: 67 additions & 0 deletions packages/aws-cdk/test/cli/io-host/cli-io-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,73 @@ describe('CliIoHost', () => {
},
}));
});

test('emit telemetry on HOTSWAP event with successful hotswap', async () => {
const message: IoMessage<unknown> = {
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<unknown> = {
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', () => {
Expand Down
Loading