Skip to content

Commit a6755f0

Browse files
kaizenccgithub-actionsiliapolo
authored
feat(cli): send telemetry events to local file (#631)
Introduces two telemetry events: invoke and synth: `Invoke`: all cli commands send an invoke event. this event gets sent at the conclusion of the command and includes the entire duration of the command. `Synth`: some commands synthesize a cloud assembly (`cdk synth`, `cdk deploy`, `cdk watch`, `cdk list`, `cdk diff`). these events also send a synth event that includes the duration of the atomic synthesis function that all commands share. > This PR sends telemetry events to a local file. It does not send to an external endpoint yet. Example: `cdk deploy` with no credentials (synth event succeeds, invoke event fails) -- ```bash > cdk deploy MyStack --unstable=telemetry --telemetry-file=my/local/file ``` ```json [ { "event": { "command": { "path": [ "deploy", "$STACK1" ], "parameters": { "telemetry-file": "<redacted>", "unstable": "<redacted>", "verbose": 3, "lookups": true, "ignore-errors": false, "json": false, "debug": false, "staging": true, "notices": true, "no-color": false, "ci": false, "all": false, "build-exclude": "<redacted>", "import-existing-resources": false, "force": false, "parameters": "<redacted>", "previous-parameters": true, "logs": true, "concurrency": 1, "asset-prebuild": true, "ignore-no-stacks": false }, "config": { "bags": true, "fileNames": true } }, "state": "SUCCEEDED", "eventType": "SYNTH" }, "identifiers": { "installationId": "7cb67dad-250e-46fa-a28f-ea365b604b8d", "sessionId": "5f1cf087-44dc-4ba5-8f2b-520a7fbe32b4", "telemetryVersion": "1.0", "cdkCliVersion": "0.0.0", "region": "us-east-1", "eventId": "5f1cf087-44dc-4ba5-8f2b-520a7fbe32b4:1", "timestamp": "2025-07-11T13:16:49.201Z" }, "environment": { "ci": false, "os": { "platform": "darwin", "release": "node" }, "nodeVersion": "v20.19.1" }, "project": {}, "duration": { "total": 4350 } }, { "event": { "command": { "path": [ "deploy", "$STACK1" ], "parameters": { "telemetry-file": "<redacted>", "unstable": "<redacted>", "verbose": 3, "lookups": true, "ignore-errors": false, "json": false, "debug": false, "staging": true, "notices": true, "no-color": false, "ci": false, "all": false, "build-exclude": "<redacted>", "import-existing-resources": false, "force": false, "parameters": "<redacted>", "previous-parameters": true, "logs": true, "concurrency": 1, "asset-prebuild": true, "ignore-no-stacks": false }, "config": { "bags": true, "fileNames": true } }, "state": "FAILED", "eventType": "INVOKE" }, "identifiers": { "installationId": "7cb67dad-250e-46fa-a28f-ea365b604b8d", "sessionId": "5f1cf087-44dc-4ba5-8f2b-520a7fbe32b4", "telemetryVersion": "1.0", "cdkCliVersion": "0.0.0", "region": "us-east-1", "eventId": "5f1cf087-44dc-4ba5-8f2b-520a7fbe32b4:2", "timestamp": "2025-07-11T13:16:49.385Z" }, "environment": { "ci": false, "os": { "platform": "darwin", "release": "node" }, "nodeVersion": "v20.19.1" }, "project": {}, "duration": { "total": 4609 }, "error": { "name": "ExpiredToken" } } ] ``` ---- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Signed-off-by: github-actions <github-actions@github.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Eli Polonsky <epolon@amazon.com>
1 parent 8b5b952 commit a6755f0

30 files changed

+1791
-93
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import * as path from 'path';
2+
import * as fs from 'fs-extra';
3+
import { integTest, withDefaultFixture } from '../../../lib';
4+
5+
jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime
6+
7+
integTest(
8+
'cdk synth with telemetry and validation error leads to invoke failure',
9+
withDefaultFixture(async (fixture) => {
10+
const telemetryFile = path.join(fixture.integTestDir, `telemetry-${Date.now()}.json`);
11+
const output = await fixture.cdk(['synth', '--unstable=telemetry', `--telemetry-file=${telemetryFile}`], {
12+
allowErrExit: true,
13+
modEnv: {
14+
INTEG_STACK_SET: 'stage-with-errors',
15+
},
16+
});
17+
18+
expect(output).toContain('This is an error');
19+
20+
const json = fs.readJSONSync(telemetryFile);
21+
expect(json).toEqual([
22+
expect.objectContaining({
23+
event: expect.objectContaining({
24+
command: expect.objectContaining({
25+
path: ['synth'],
26+
parameters: {
27+
verbose: 1,
28+
unstable: '<redacted>',
29+
['telemetry-file']: '<redacted>',
30+
lookups: true,
31+
['ignore-errors']: false,
32+
json: false,
33+
debug: false,
34+
staging: true,
35+
notices: true,
36+
['no-color']: false,
37+
ci: expect.anything(), // changes based on where this is called
38+
validation: true,
39+
quiet: false,
40+
},
41+
config: {
42+
context: {},
43+
},
44+
}),
45+
state: 'SUCCEEDED',
46+
eventType: 'SYNTH',
47+
}),
48+
identifiers: expect.objectContaining({
49+
installationId: expect.anything(),
50+
sessionId: expect.anything(),
51+
telemetryVersion: '1.0',
52+
cdkCliVersion: expect.anything(),
53+
cdkLibraryVersion: fixture.library.requestedVersion(),
54+
region: expect.anything(),
55+
eventId: expect.stringContaining(':1'),
56+
timestamp: expect.anything(),
57+
}),
58+
environment: {
59+
ci: expect.anything(),
60+
os: {
61+
platform: expect.anything(),
62+
release: expect.anything(),
63+
},
64+
nodeVersion: expect.anything(),
65+
},
66+
project: {},
67+
duration: {
68+
total: expect.anything(),
69+
},
70+
}),
71+
expect.objectContaining({
72+
event: expect.objectContaining({
73+
command: expect.objectContaining({
74+
path: ['synth'],
75+
parameters: {
76+
verbose: 1,
77+
unstable: '<redacted>',
78+
['telemetry-file']: '<redacted>',
79+
lookups: true,
80+
['ignore-errors']: false,
81+
json: false,
82+
debug: false,
83+
staging: true,
84+
notices: true,
85+
['no-color']: false,
86+
ci: expect.anything(), // changes based on where this is called
87+
validation: true,
88+
quiet: false,
89+
},
90+
config: {
91+
context: {},
92+
},
93+
}),
94+
state: 'FAILED',
95+
eventType: 'INVOKE',
96+
}),
97+
identifiers: expect.objectContaining({
98+
installationId: expect.anything(),
99+
sessionId: expect.anything(),
100+
telemetryVersion: '1.0',
101+
cdkCliVersion: expect.anything(),
102+
cdkLibraryVersion: fixture.library.requestedVersion(),
103+
region: expect.anything(),
104+
eventId: expect.stringContaining(':2'),
105+
timestamp: expect.anything(),
106+
}),
107+
environment: {
108+
ci: expect.anything(),
109+
os: {
110+
platform: expect.anything(),
111+
release: expect.anything(),
112+
},
113+
nodeVersion: expect.anything(),
114+
},
115+
project: {},
116+
duration: {
117+
total: expect.anything(),
118+
},
119+
error: {
120+
name: 'AssemblyError',
121+
},
122+
}),
123+
]);
124+
fs.unlinkSync(telemetryFile);
125+
}),
126+
);
127+
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import * as path from 'path';
2+
import * as fs from 'fs-extra';
3+
import { integTest, withDefaultFixture } from '../../../lib';
4+
5+
jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime
6+
7+
integTest(
8+
'cdk synth with telemetry data',
9+
withDefaultFixture(async (fixture) => {
10+
const telemetryFile = path.join(fixture.integTestDir, `telemetry-${Date.now()}.json`);
11+
await fixture.cdk(['synth', fixture.fullStackName('test-1'), '--unstable=telemetry', `--telemetry-file=${telemetryFile}`]);
12+
const json = fs.readJSONSync(telemetryFile);
13+
expect(json).toEqual([
14+
expect.objectContaining({
15+
event: expect.objectContaining({
16+
command: expect.objectContaining({
17+
path: ['synth', '$STACKS_1'],
18+
parameters: {
19+
verbose: 1,
20+
unstable: '<redacted>',
21+
['telemetry-file']: '<redacted>',
22+
lookups: true,
23+
['ignore-errors']: false,
24+
json: false,
25+
debug: false,
26+
staging: true,
27+
notices: true,
28+
['no-color']: false,
29+
ci: expect.anything(), // changes based on where this is called
30+
validation: true,
31+
quiet: false,
32+
},
33+
config: {
34+
context: {},
35+
},
36+
}),
37+
state: 'SUCCEEDED',
38+
eventType: 'SYNTH',
39+
}),
40+
// some of these can change; but we assert that some value is recorded
41+
identifiers: expect.objectContaining({
42+
installationId: expect.anything(),
43+
sessionId: expect.anything(),
44+
telemetryVersion: '1.0',
45+
cdkCliVersion: expect.anything(),
46+
cdkLibraryVersion: fixture.library.requestedVersion(),
47+
region: expect.anything(),
48+
eventId: expect.stringContaining(':1'),
49+
timestamp: expect.anything(),
50+
}),
51+
environment: {
52+
ci: expect.anything(),
53+
os: {
54+
platform: expect.anything(),
55+
release: expect.anything(),
56+
},
57+
nodeVersion: expect.anything(),
58+
},
59+
project: {},
60+
duration: {
61+
total: expect.anything(),
62+
},
63+
}),
64+
expect.objectContaining({
65+
event: expect.objectContaining({
66+
command: expect.objectContaining({
67+
path: ['synth', '$STACKS_1'],
68+
parameters: {
69+
verbose: 1,
70+
unstable: '<redacted>',
71+
['telemetry-file']: '<redacted>',
72+
lookups: true,
73+
['ignore-errors']: false,
74+
json: false,
75+
debug: false,
76+
staging: true,
77+
notices: true,
78+
['no-color']: false,
79+
ci: expect.anything(), // changes based on where this is called
80+
validation: true,
81+
quiet: false,
82+
},
83+
config: {
84+
context: {},
85+
},
86+
}),
87+
state: 'SUCCEEDED',
88+
eventType: 'INVOKE',
89+
}),
90+
identifiers: expect.objectContaining({
91+
installationId: expect.anything(),
92+
sessionId: expect.anything(),
93+
telemetryVersion: '1.0',
94+
cdkCliVersion: expect.anything(),
95+
cdkLibraryVersion: fixture.library.requestedVersion(),
96+
region: expect.anything(),
97+
eventId: expect.stringContaining(':2'),
98+
timestamp: expect.anything(),
99+
}),
100+
environment: {
101+
ci: expect.anything(),
102+
os: {
103+
platform: expect.anything(),
104+
release: expect.anything(),
105+
},
106+
nodeVersion: expect.anything(),
107+
},
108+
project: {},
109+
duration: {
110+
total: expect.anything(),
111+
},
112+
}),
113+
]);
114+
fs.unlinkSync(telemetryFile);
115+
}),
116+
);
117+

packages/aws-cdk/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,20 @@ cdk gc --unstable=gc
12541254
The command will fail if `--unstable=gc` is not passed in, which acknowledges that the user
12551255
is aware of the caveats in place for the feature.
12561256

1257+
### `telemetry-file`
1258+
1259+
Send your telemetry data to a local file (note that `--telemetry-file` is unstable, and must
1260+
be passed in conjunction with `--unstable=telemetry`).
1261+
1262+
```bash
1263+
cdk list --telemetry-file=my/file/path --unstable=telemetry
1264+
```
1265+
1266+
The supplied path must be a non existing file. If the file exists, it will fail to log telemetry
1267+
data but the command itself will continue uninterrupted.
1268+
1269+
> Note: The file will be written to regardless of your opt-out status.
1270+
12571271
## Notices
12581272

12591273
CDK Notices are important messages regarding security vulnerabilities, regressions, and usage of unsupported

packages/aws-cdk/lib/cli/cli-config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export async function makeConfig(): Promise<CliConfig> {
4242
'no-color': { type: 'boolean', desc: 'Removes colors and other style from console output', default: false },
4343
'ci': { type: 'boolean', desc: 'Force CI detection. If CI=true then logs will be sent to stdout instead of stderr', default: YARGS_HELPERS.isCI() },
4444
'unstable': { type: 'array', desc: 'Opt in to unstable features. The flag indicates that the scope and API of a feature might still change. Otherwise the feature is generally production ready and fully supported. Can be specified multiple times.', default: [] },
45+
'telemetry-file': { type: 'string', desc: 'Send telemetry data to a local file.', default: undefined },
4546
},
4647
commands: {
4748
'list': {

packages/aws-cdk/lib/cli/cli-type-registry.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@
122122
"type": "array",
123123
"desc": "Opt in to unstable features. The flag indicates that the scope and API of a feature might still change. Otherwise the feature is generally production ready and fully supported. Can be specified multiple times.",
124124
"default": []
125+
},
126+
"telemetry-file": {
127+
"type": "string",
128+
"desc": "Send telemetry data to a local file."
125129
}
126130
},
127131
"commands": {

packages/aws-cdk/lib/cli/cli.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { getMigrateScanType } from '../commands/migrate';
3131
import { execProgram, CloudExecutable } from '../cxapp';
3232
import type { StackSelector, Synthesizer } from '../cxapp';
3333
import { ProxyAgentProvider } from './proxy-agent';
34+
import { cdkCliErrorName } from './telemetry/error';
35+
import type { ErrorDetails } from './telemetry/schema';
3436
import { isDeveloperBuildVersion, versionWithBuild, versionNumber } from './version';
3537

3638
if (!process.stdout.isTTY) {
@@ -97,6 +99,12 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
9799
caBundlePath: configuration.settings.get(['caBundlePath']),
98100
});
99101

102+
try {
103+
await ioHost.startTelemetry(argv, configuration.context);
104+
} catch (e: any) {
105+
await ioHost.asIoHelper().defaults.trace(`Telemetry instantiation failed: ${e.message}`);
106+
}
107+
100108
const shouldDisplayNotices = configuration.settings.get(['notices']);
101109
// Notices either go to stderr, or nowhere
102110
ioHost.noticesDestination = shouldDisplayNotices ? 'stderr' : 'drop';
@@ -125,6 +133,12 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
125133
pluginHost: GLOBAL_PLUGIN_HOST,
126134
}, configuration.settings.get(['profile']));
127135

136+
try {
137+
await ioHost.telemetry?.attachRegion(sdkProvider.defaultRegion);
138+
} catch (e: any) {
139+
await ioHost.asIoHelper().defaults.trace(`Telemetry attach region failed: ${e.message}`);
140+
}
141+
128142
let outDirLock: IReadLock | undefined;
129143
const cloudExecutable = new CloudExecutable({
130144
configuration,
@@ -195,6 +209,10 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
195209
throw new ToolkitError('You must either specify a list of Stacks or the `--all` argument');
196210
}
197211

212+
if (args['telemetry-file'] && !configuration.settings.get(['unstable']).includes('telemetry')) {
213+
throw new ToolkitError('Unstable feature use: \'telemetry-file\' is unstable. It must be opted in via \'--unstable\', e.g. \'cdk deploy --unstable=telemetry --telemetry-file=my/file/path\'');
214+
}
215+
198216
args.STACKS = args.STACKS ?? (args.STACK ? [args.STACK] : []);
199217
args.ENVIRONMENTS = args.ENVIRONMENTS ?? [];
200218

@@ -648,17 +666,28 @@ function determineHotswapMode(hotswap?: boolean, hotswapFallback?: boolean, watc
648666

649667
/* c8 ignore start */ // we never call this in unit tests
650668
export function cli(args: string[] = process.argv.slice(2)) {
669+
let error: ErrorDetails | undefined;
651670
exec(args)
652671
.then(async (value) => {
653672
if (typeof value === 'number') {
654673
process.exitCode = value;
655674
}
656675
})
657-
.catch((err) => {
676+
.catch(async (err) => {
658677
// Log the stack trace if we're on a developer workstation. Otherwise this will be into a minified
659678
// file and the printed code line and stack trace are huge and useless.
660679
prettyPrintError(err, isDeveloperBuildVersion());
680+
error = {
681+
name: cdkCliErrorName(err.name),
682+
};
661683
process.exitCode = 1;
684+
})
685+
.finally(async () => {
686+
try {
687+
await CliIoHost.get()?.telemetry?.end(error);
688+
} catch (e: any) {
689+
await CliIoHost.get()?.asIoHelper().defaults.trace(`Ending Telemetry failed: ${e.message}`);
690+
}
662691
});
663692
}
664693
/* c8 ignore stop */

packages/aws-cdk/lib/cli/convert-to-user-input.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function convertYargsToUserInput(args: any): UserInput {
3434
noColor: args.noColor,
3535
ci: args.ci,
3636
unstable: args.unstable,
37+
telemetryFile: args.telemetryFile,
3738
};
3839
let commandOptions;
3940
switch (args._[0] as Command) {
@@ -325,6 +326,7 @@ export function convertConfigToUserInput(config: any): UserInput {
325326
noColor: config.noColor,
326327
ci: config.ci,
327328
unstable: config.unstable,
329+
telemetryFile: config.telemetryFile,
328330
};
329331
const listOptions = {
330332
long: config.list?.long,

0 commit comments

Comments
 (0)