diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/synth/cdk-synth-telemetry-with-errors.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/synth/cdk-synth-telemetry-with-errors.integtest.ts new file mode 100644 index 000000000..6b0631268 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/synth/cdk-synth-telemetry-with-errors.integtest.ts @@ -0,0 +1,127 @@ +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 synth with telemetry and validation error leads to invoke failure', + withDefaultFixture(async (fixture) => { + const telemetryFile = path.join(fixture.integTestDir, `telemetry-${Date.now()}.json`); + const output = await fixture.cdk(['synth', '--unstable=telemetry', `--telemetry-file=${telemetryFile}`], { + allowErrExit: true, + modEnv: { + INTEG_STACK_SET: 'stage-with-errors', + }, + }); + + expect(output).toContain('This is an error'); + + const json = fs.readJSONSync(telemetryFile); + expect(json).toEqual([ + expect.objectContaining({ + event: expect.objectContaining({ + command: expect.objectContaining({ + path: ['synth'], + parameters: { + verbose: 1, + unstable: '', + ['telemetry-file']: '', + lookups: true, + ['ignore-errors']: false, + json: false, + debug: false, + staging: true, + notices: true, + ['no-color']: false, + ci: expect.anything(), // changes based on where this is called + validation: true, + quiet: false, + }, + config: { + context: {}, + }, + }), + state: 'SUCCEEDED', + eventType: 'SYNTH', + }), + identifiers: expect.objectContaining({ + installationId: expect.anything(), + sessionId: expect.anything(), + telemetryVersion: '1.0', + cdkCliVersion: expect.anything(), + cdkLibraryVersion: fixture.library.requestedVersion(), + region: expect.anything(), + eventId: expect.stringContaining(':1'), + timestamp: expect.anything(), + }), + environment: { + ci: expect.anything(), + os: { + platform: expect.anything(), + release: expect.anything(), + }, + nodeVersion: expect.anything(), + }, + project: {}, + duration: { + total: expect.anything(), + }, + }), + expect.objectContaining({ + event: expect.objectContaining({ + command: expect.objectContaining({ + path: ['synth'], + parameters: { + verbose: 1, + unstable: '', + ['telemetry-file']: '', + lookups: true, + ['ignore-errors']: false, + json: false, + debug: false, + staging: true, + notices: true, + ['no-color']: false, + ci: expect.anything(), // changes based on where this is called + validation: true, + quiet: false, + }, + config: { + context: {}, + }, + }), + state: 'FAILED', + eventType: 'INVOKE', + }), + identifiers: expect.objectContaining({ + installationId: expect.anything(), + sessionId: expect.anything(), + telemetryVersion: '1.0', + cdkCliVersion: expect.anything(), + cdkLibraryVersion: fixture.library.requestedVersion(), + region: expect.anything(), + eventId: expect.stringContaining(':2'), + timestamp: expect.anything(), + }), + environment: { + ci: expect.anything(), + os: { + platform: expect.anything(), + release: expect.anything(), + }, + nodeVersion: expect.anything(), + }, + project: {}, + duration: { + total: expect.anything(), + }, + error: { + name: 'AssemblyError', + }, + }), + ]); + fs.unlinkSync(telemetryFile); + }), +); + diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/synth/cdk-synth-telemetry.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/synth/cdk-synth-telemetry.integtest.ts new file mode 100644 index 000000000..c572167aa --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/synth/cdk-synth-telemetry.integtest.ts @@ -0,0 +1,117 @@ +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 synth with telemetry data', + withDefaultFixture(async (fixture) => { + const telemetryFile = path.join(fixture.integTestDir, `telemetry-${Date.now()}.json`); + await fixture.cdk(['synth', fixture.fullStackName('test-1'), '--unstable=telemetry', `--telemetry-file=${telemetryFile}`]); + const json = fs.readJSONSync(telemetryFile); + expect(json).toEqual([ + expect.objectContaining({ + event: expect.objectContaining({ + command: expect.objectContaining({ + path: ['synth', '$STACKS_1'], + parameters: { + verbose: 1, + unstable: '', + ['telemetry-file']: '', + lookups: true, + ['ignore-errors']: false, + json: false, + debug: false, + staging: true, + notices: true, + ['no-color']: false, + ci: expect.anything(), // changes based on where this is called + validation: true, + quiet: false, + }, + config: { + context: {}, + }, + }), + state: 'SUCCEEDED', + eventType: 'SYNTH', + }), + // some of these can change; but we assert that some value is recorded + identifiers: expect.objectContaining({ + installationId: expect.anything(), + sessionId: expect.anything(), + telemetryVersion: '1.0', + cdkCliVersion: expect.anything(), + cdkLibraryVersion: fixture.library.requestedVersion(), + region: expect.anything(), + eventId: expect.stringContaining(':1'), + timestamp: expect.anything(), + }), + environment: { + ci: expect.anything(), + os: { + platform: expect.anything(), + release: expect.anything(), + }, + nodeVersion: expect.anything(), + }, + project: {}, + duration: { + total: expect.anything(), + }, + }), + expect.objectContaining({ + event: expect.objectContaining({ + command: expect.objectContaining({ + path: ['synth', '$STACKS_1'], + parameters: { + verbose: 1, + unstable: '', + ['telemetry-file']: '', + lookups: true, + ['ignore-errors']: false, + json: false, + debug: false, + staging: true, + notices: true, + ['no-color']: false, + ci: expect.anything(), // changes based on where this is called + validation: true, + quiet: false, + }, + config: { + context: {}, + }, + }), + state: 'SUCCEEDED', + eventType: 'INVOKE', + }), + identifiers: expect.objectContaining({ + installationId: expect.anything(), + sessionId: expect.anything(), + telemetryVersion: '1.0', + cdkCliVersion: expect.anything(), + cdkLibraryVersion: fixture.library.requestedVersion(), + region: expect.anything(), + eventId: expect.stringContaining(':2'), + timestamp: expect.anything(), + }), + environment: { + ci: expect.anything(), + os: { + platform: expect.anything(), + release: expect.anything(), + }, + nodeVersion: expect.anything(), + }, + project: {}, + duration: { + total: expect.anything(), + }, + }), + ]); + fs.unlinkSync(telemetryFile); + }), +); + diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index daca89cb6..d74aaf019 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -1254,6 +1254,20 @@ cdk gc --unstable=gc The command will fail if `--unstable=gc` is not passed in, which acknowledges that the user is aware of the caveats in place for the feature. +### `telemetry-file` + +Send your telemetry data to a local file (note that `--telemetry-file` is unstable, and must +be passed in conjunction with `--unstable=telemetry`). + +```bash +cdk list --telemetry-file=my/file/path --unstable=telemetry +``` + +The supplied path must be a non existing file. If the file exists, it will fail to log telemetry +data but the command itself will continue uninterrupted. + +> Note: The file will be written to regardless of your opt-out status. + ## Notices CDK Notices are important messages regarding security vulnerabilities, regressions, and usage of unsupported diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 8c5942889..acc713d29 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -42,6 +42,7 @@ export async function makeConfig(): Promise { 'no-color': { type: 'boolean', desc: 'Removes colors and other style from console output', default: false }, 'ci': { type: 'boolean', desc: 'Force CI detection. If CI=true then logs will be sent to stdout instead of stderr', default: YARGS_HELPERS.isCI() }, '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: [] }, + 'telemetry-file': { type: 'string', desc: 'Send telemetry data to a local file.', default: undefined }, }, commands: { 'list': { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 78e2476c7..fa5e73208 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -122,6 +122,10 @@ "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": [] + }, + "telemetry-file": { + "type": "string", + "desc": "Send telemetry data to a local file." } }, "commands": { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index c8121b460..d9d21e743 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -31,6 +31,8 @@ import { getMigrateScanType } from '../commands/migrate'; import { execProgram, CloudExecutable } from '../cxapp'; import type { StackSelector, Synthesizer } from '../cxapp'; import { ProxyAgentProvider } from './proxy-agent'; +import { cdkCliErrorName } from './telemetry/error'; +import type { ErrorDetails } from './telemetry/schema'; import { isDeveloperBuildVersion, versionWithBuild, versionNumber } from './version'; if (!process.stdout.isTTY) { @@ -97,6 +99,12 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise { if (typeof value === 'number') { process.exitCode = value; } }) - .catch((err) => { + .catch(async (err) => { // Log the stack trace if we're on a developer workstation. Otherwise this will be into a minified // file and the printed code line and stack trace are huge and useless. prettyPrintError(err, isDeveloperBuildVersion()); + error = { + name: cdkCliErrorName(err.name), + }; process.exitCode = 1; + }) + .finally(async () => { + try { + await CliIoHost.get()?.telemetry?.end(error); + } catch (e: any) { + await CliIoHost.get()?.asIoHelper().defaults.trace(`Ending Telemetry failed: ${e.message}`); + } }); } /* c8 ignore stop */ diff --git a/packages/aws-cdk/lib/cli/convert-to-user-input.ts b/packages/aws-cdk/lib/cli/convert-to-user-input.ts index 848944f83..af0c8832a 100644 --- a/packages/aws-cdk/lib/cli/convert-to-user-input.ts +++ b/packages/aws-cdk/lib/cli/convert-to-user-input.ts @@ -34,6 +34,7 @@ export function convertYargsToUserInput(args: any): UserInput { noColor: args.noColor, ci: args.ci, unstable: args.unstable, + telemetryFile: args.telemetryFile, }; let commandOptions; switch (args._[0] as Command) { @@ -325,6 +326,7 @@ export function convertConfigToUserInput(config: any): UserInput { noColor: config.noColor, ci: config.ci, unstable: config.unstable, + telemetryFile: config.telemetryFile, }; const listOptions = { long: config.list?.long, 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 32ec3811b..c722053ad 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 @@ -1,12 +1,19 @@ +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 { 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 { FileTelemetrySink } from '../telemetry/file-sink'; +import { CLI_PRIVATE_IO } from '../telemetry/messages'; +import type { EventType } from '../telemetry/schema'; +import { TelemetrySession } from '../telemetry/session'; +import { isCI } from '../util/ci'; export type { IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest }; @@ -94,6 +101,13 @@ export class CliIoHost implements IIoHost { return CliIoHost._instance; } + /** + * Returns the singleton instance if it exists + */ + static get(): CliIoHost | undefined { + return CliIoHost._instance; + } + /** * Singleton instance of the CliIoHost */ @@ -145,6 +159,8 @@ export class CliIoHost implements IIoHost { private corkedCounter = 0; private readonly corkedLoggingBuffer: IoMessage[] = []; + public telemetry?: TelemetrySession; + private constructor(props: CliIoHostProps = {}) { this.currentAction = props.currentAction ?? 'none'; this.isTTY = props.isTTY ?? process.stdout.isTTY ?? false; @@ -155,6 +171,36 @@ export class CliIoHost implements IIoHost { this.stackProgress = props.stackProgress ?? StackActivityProgress.BAR; } + public async startTelemetry(args: any, context: Context, _proxyAgent?: Agent) { + let sink; + const telemetryFilePath = args['telemetry-file']; + if (telemetryFilePath) { + sink = new FileTelemetrySink({ + ioHost: this, + logFilePath: telemetryFilePath, + }); + } + // TODO: uncomment this at launch + // if (canCollectTelemetry(context)) { + // sink = new EndpointTelemetrySink({ + // ioHost: this, + // agent: proxyAgent, + // endpoint: '', // TODO: add endpoint + // }); + // } + + if (sink) { + this.telemetry = new TelemetrySession({ + ioHost: this, + client: sink, + arguments: args, + context: context, + }); + } + + await this.telemetry?.begin(); + } + /** * Update the stackProgress preference. */ @@ -236,11 +282,13 @@ export class CliIoHost implements IIoHost { * The caller waits until the notification completes. */ public async notify(msg: IoMessage): Promise { + await this.maybeEmitTelemetry(msg); + if (this.isStackActivity(msg)) { if (!this.activityPrinter) { this.activityPrinter = this.makeActivityPrinter(); } - await this.activityPrinter.notify(msg); + this.activityPrinter.notify(msg); return; } @@ -258,6 +306,20 @@ export class CliIoHost implements IIoHost { stream?.write(output); } + private async maybeEmitTelemetry(msg: IoMessage) { + try { + if (this.telemetry && isTelemetryMessage(msg)) { + await this.telemetry.emit({ + eventType: getEventType(msg), + duration: msg.data.duration, + error: msg.data.error, + }); + } + } catch (e: any) { + await this.defaults.trace(`Emit Telemetry Failed ${e.message}`); + } + } + /** * Detect stack activity messages so they can be send to the printer. */ @@ -479,14 +541,6 @@ const styleMap: Record string> = { trace: chalk.gray, }; -/** - * Returns true if the current process is running in a CI environment - * @returns true if the current process is running in a CI environment - */ -export function isCI(): boolean { - return process.env.CI !== undefined && process.env.CI !== 'false' && process.env.CI !== '0'; -} - function targetStreamObject(x: TargetStream): NodeJS.WriteStream | undefined { switch (x) { case 'stderr': @@ -501,3 +555,18 @@ function targetStreamObject(x: TargetStream): NodeJS.WriteStream | undefined { function isNoticesMessage(msg: IoMessage) { return IO.CDK_TOOLKIT_I0100.is(msg) || IO.CDK_TOOLKIT_W0101.is(msg) || IO.CDK_TOOLKIT_E0101.is(msg) || IO.CDK_TOOLKIT_I0101.is(msg); } + +function isTelemetryMessage(msg: IoMessage) { + return CLI_PRIVATE_IO.CDK_CLI_I1001.is(msg) || CLI_PRIVATE_IO.CDK_CLI_I2001.is(msg); +} + +function getEventType(msg: IoMessage): EventType { + switch (msg.code) { + case CLI_PRIVATE_IO.CDK_CLI_I1001.code: + return 'SYNTH'; + case CLI_PRIVATE_IO.CDK_CLI_I2001.code: + return 'INVOKE'; + default: + throw new ToolkitError(`Unrecognized Telemetry Message Code: ${msg.code}`); + } +} diff --git a/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts b/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts index 1d93e32b9..5f2de69ab 100644 --- a/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts +++ b/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts @@ -155,6 +155,11 @@ export function parseCommandLineArguments(args: Array): any { nargs: 1, requiresArg: true, }) + .option('telemetry-file', { + default: undefined, + type: 'string', + desc: 'Send telemetry data to a local file.', + }) .command(['list [STACKS..]', 'ls [STACKS..]'], 'Lists all stacks in the app', (yargs: Argv) => yargs .option('long', { diff --git a/packages/aws-cdk/lib/cli/telemetry/error.ts b/packages/aws-cdk/lib/cli/telemetry/error.ts new file mode 100644 index 000000000..42c4dcb41 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/error.ts @@ -0,0 +1,14 @@ +import { ErrorName } from './schema'; + +export function cdkCliErrorName(name: string): ErrorName { + // We only record error names that we control. Errors coming from dependencies + // contain text that we have no control over so it is safer to not send it. + if (!isKnownErrorName(name)) { + return ErrorName.UNKNOWN_ERROR; + } + return name; +} + +function isKnownErrorName(name: string): name is ErrorName { + return Object.values(ErrorName).includes(name as ErrorName); +} diff --git a/packages/aws-cdk/lib/cli/telemetry/feature-flags.ts b/packages/aws-cdk/lib/cli/telemetry/feature-flags.ts new file mode 100644 index 000000000..ae3735538 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/feature-flags.ts @@ -0,0 +1,98 @@ +// TODO: implement this by bundling the source of truth with the CDK CLI +// We are currently hardcoding these values to facilitate a quicker release. +/** + * Enum of all valid CDK feature flag names. + * + * These flags are used to control behavior changes in the CDK. + * For more information, see: https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/cx-api/FEATURE_FLAGS.md + */ +export enum FeatureFlag { + CORE_NEW_STYLE_STACK_SYNTHESIS = '@aws-cdk/core:newStyleStackSynthesis', + CORE_STACK_RELATIVE_EXPORTS = '@aws-cdk/core:stackRelativeExports', + RDS_LOWERCASE_DB_IDENTIFIER = '@aws-cdk/aws-rds:lowercaseDbIdentifier', + APIGATEWAY_USAGE_PLAN_KEY_ORDER_INSENSITIVE_ID = '@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId', + LAMBDA_RECOGNIZE_VERSION_PROPS = '@aws-cdk/aws-lambda:recognizeVersionProps', + CLOUDFRONT_DEFAULT_SECURITY_POLICY_TLS_V1_2_2021 = '@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021', + CORE_TARGET_PARTITIONS = '@aws-cdk/core:target-partitions', + ECS_SERVICE_EXTENSIONS_ENABLE_DEFAULT_LOG_DRIVER = '@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver', + EC2_UNIQUE_IMDSV2_TEMPLATE_NAME = '@aws-cdk/aws-ec2:uniqueImdsv2TemplateName', + IAM_MINIMIZE_POLICIES = '@aws-cdk/aws-iam:minimizePolicies', + CORE_CHECK_SECRET_USAGE = '@aws-cdk/core:checkSecretUsage', + LAMBDA_RECOGNIZE_LAYER_VERSION = '@aws-cdk/aws-lambda:recognizeLayerVersion', + CORE_VALIDATE_SNAPSHOT_REMOVAL_POLICY = '@aws-cdk/core:validateSnapshotRemovalPolicy', + CODEPIPELINE_CROSS_ACCOUNT_KEY_ALIAS_STACK_SAFE_RESOURCE_NAME = '@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName', + S3_CREATE_DEFAULT_LOGGING_POLICY = '@aws-cdk/aws-s3:createDefaultLoggingPolicy', + SNS_SUBSCRIPTIONS_RESTRICT_SQS_DECRYPTION = '@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption', + ECS_ARN_FORMAT_INCLUDES_CLUSTER_NAME = '@aws-cdk/aws-ecs:arnFormatIncludesClusterName', + APIGATEWAY_DISABLE_CLOUD_WATCH_ROLE = '@aws-cdk/aws-apigateway:disableCloudWatchRole', + CORE_ENABLE_PARTITION_LITERALS = '@aws-cdk/core:enablePartitionLiterals', + ECS_DISABLE_EXPLICIT_DEPLOYMENT_CONTROLLER_FOR_CIRCUIT_BREAKER = '@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker', + EVENTS_EVENTS_TARGET_QUEUE_SAME_ACCOUNT = '@aws-cdk/aws-events:eventsTargetQueueSameAccount', + IAM_IMPORTED_ROLE_STACK_SAFE_DEFAULT_POLICY_NAME = '@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName', + S3_SERVER_ACCESS_LOGS_USE_BUCKET_POLICY = '@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy', + CUSTOMRESOURCES_INSTALL_LATEST_AWS_SDK_DEFAULT = '@aws-cdk/customresources:installLatestAwsSdkDefault', + ROUTE53_PATTERNS_USE_CERTIFICATE = '@aws-cdk/aws-route53-patterns:useCertificate', + CODEDEPLOY_REMOVE_ALARMS_FROM_DEPLOYMENT_GROUP = '@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup', + RDS_DATABASE_PROXY_UNIQUE_RESOURCE_NAME = '@aws-cdk/aws-rds:databaseProxyUniqueResourceName', + APIGATEWAY_AUTHORIZER_CHANGE_DEPLOYMENT_LOGICAL_ID = '@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId', + EC2_LAUNCH_TEMPLATE_DEFAULT_USER_DATA = '@aws-cdk/aws-ec2:launchTemplateDefaultUserData', + SECRETSMANAGER_USE_ATTACHED_SECRET_RESOURCE_POLICY_FOR_SECRET_TARGET_ATTACHMENTS = '@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments', + REDSHIFT_COLUMN_ID = '@aws-cdk/aws-redshift:columnId', + STEPFUNCTIONS_TASKS_ENABLE_EMR_SERVICE_POLICY_V2 = '@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2', + APIGATEWAY_REQUEST_VALIDATOR_UNIQUE_ID = '@aws-cdk/aws-apigateway:requestValidatorUniqueId', + EC2_RESTRICT_DEFAULT_SECURITY_GROUP = '@aws-cdk/aws-ec2:restrictDefaultSecurityGroup', + KMS_ALIAS_NAME_REF = '@aws-cdk/aws-kms:aliasNameRef', + CORE_INCLUDE_PREFIX_IN_UNIQUE_NAME_GENERATION = '@aws-cdk/core:includePrefixInUniqueNameGeneration', + AUTOSCALING_GENERATE_LAUNCH_TEMPLATE_INSTEAD_OF_LAUNCH_CONFIG = '@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig', + OPENSEARCHSERVICE_ENABLE_OPENSEARCH_MULTI_AZ_WITH_STANDBY = '@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby', + EFS_DENY_ANONYMOUS_ACCESS = '@aws-cdk/aws-efs:denyAnonymousAccess', + EFS_MOUNT_TARGET_ORDER_INSENSITIVE_LOGICAL_ID = '@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId', + LAMBDA_NODEJS_USE_LATEST_RUNTIME_VERSION = '@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion', + APPSYNC_USE_ARN_FOR_SOURCE_API_ASSOCIATION_IDENTIFIER = '@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier', + RDS_AURORA_CLUSTER_CHANGE_SCOPE_OF_INSTANCE_PARAMETER_GROUP_WITH_EACH_PARAMETERS = '@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters', + RDS_PREVENT_RENDERING_DEPRECATED_CREDENTIALS = '@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials', + CODEPIPELINE_ACTIONS_USE_NEW_DEFAULT_BRANCH_FOR_CODE_COMMIT_SOURCE = '@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource', + CLOUDWATCH_ACTIONS_CHANGE_LAMBDA_PERMISSION_LOGICAL_ID_FOR_LAMBDA_ACTION = '@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction', + CODEPIPELINE_CROSS_ACCOUNT_KEYS_DEFAULT_VALUE_TO_FALSE = '@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse', + CODEPIPELINE_DEFAULT_PIPELINE_TYPE_TO_V2 = '@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2', + KMS_REDUCE_CROSS_ACCOUNT_REGION_POLICY_SCOPE = '@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope', + EKS_NODEGROUP_NAME_ATTRIBUTE = '@aws-cdk/aws-eks:nodegroupNameAttribute', + EC2_EBS_DEFAULT_GP3_VOLUME = '@aws-cdk/aws-ec2:ebsDefaultGp3Volume', + PIPELINES_REDUCE_ASSET_ROLE_TRUST_SCOPE = '@aws-cdk/pipelines:reduceAssetRoleTrustScope', + ECS_REMOVE_DEFAULT_DEPLOYMENT_ALARM = '@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm', + CUSTOM_RESOURCES_LOG_API_RESPONSE_DATA_PROPERTY_TRUE_DEFAULT = '@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault', + S3_KEEP_NOTIFICATION_IN_IMPORTED_BUCKET = '@aws-cdk/aws-s3:keepNotificationInImportedBucket', + STEPFUNCTIONS_TASKS_USE_NEW_S3_URI_PARAMETERS_FOR_BEDROCK_INVOKE_MODEL_TASK = '@aws-cdk/aws-stepfunctions-tasks:useNewS3UriParametersForBedrockInvokeModelTask', + ECS_REDUCE_EC2_FARGATE_CLOUD_WATCH_PERMISSIONS = '@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions', + EC2_EC2_SUM_TIMEOUT_ENABLED = '@aws-cdk/aws-ec2:ec2SumTimeoutEnabled', + APPSYNC_APP_SYNC_GRAPHQL_API_SCOPE_LAMBDA_PERMISSION = '@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission', + LAMBDA_NODEJS_SDK_V3_EXCLUDE_SMITHY_PACKAGES = '@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages', + RDS_SET_CORRECT_VALUE_FOR_DATABASE_INSTANCE_READ_REPLICA_INSTANCE_RESOURCE_ID = '@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId', + CORE_CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS = '@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics', + STEPFUNCTIONS_TASKS_FIX_RUN_ECS_TASK_POLICY = '@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy', + DYNAMODB_RESOURCE_POLICY_PER_REPLICA = '@aws-cdk/aws-dynamodb:resourcePolicyPerReplica', + EC2_BASTION_HOST_USE_AMAZON_LINUX_2023_BY_DEFAULT = '@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault', + CORE_ASPECT_STABILIZATION = '@aws-cdk/core:aspectStabilization', + ROUTE53_TARGETS_USER_POOL_DOMAIN_NAME_METHOD_WITHOUT_CUSTOM_RESOURCE = '@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource', + ECS_DISABLE_ECS_IMDS_BLOCKING = '@aws-cdk/aws-ecs:disableEcsImdsBlocking', + ECS_ENABLE_IMDS_BLOCKING_DEPRECATED_FEATURE = '@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature', + ELASTICLOADBALANCINGV2_ALB_DUALSTACK_WITHOUT_PUBLIC_IPV4_SECURITY_GROUP_RULES_DEFAULT = '@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault', + IAM_OIDC_REJECT_UNAUTHORIZED_CONNECTIONS = '@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections', + CORE_ENABLE_ADDITIONAL_METADATA_COLLECTION = '@aws-cdk/core:enableAdditionalMetadataCollection', + LAMBDA_CREATE_NEW_POLICIES_WITH_ADD_TO_ROLE_POLICY = '@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy', + S3_SET_UNIQUE_REPLICATION_ROLE_NAME = '@aws-cdk/aws-s3:setUniqueReplicationRoleName', + PIPELINES_REDUCE_STAGE_ROLE_TRUST_SCOPE = '@aws-cdk/pipelines:reduceStageRoleTrustScope', + EVENTS_REQUIRE_EVENT_BUS_POLICY_SID = '@aws-cdk/aws-events:requireEventBusPolicySid', + DYNAMODB_RETAIN_TABLE_REPLICA = '@aws-cdk/aws-dynamodb:retainTableReplica', + COGNITO_LOG_USER_POOL_CLIENT_SECRET_VALUE = '@aws-cdk/cognito:logUserPoolClientSecretValue', + STEPFUNCTIONS_USE_DISTRIBUTED_MAP_RESULT_WRITER_V2 = '@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2', + PIPELINES_REDUCE_CROSS_ACCOUNT_ACTION_ROLE_TRUST_SCOPE = '@aws-cdk/pipelines:reduceCrossAccountActionRoleTrustScope', + CORE_ASPECT_PRIORITIES_MUTATING = '@aws-cdk/core:aspectPrioritiesMutating', + S3_NOTIFICATIONS_ADD_S3_TRUST_KEY_POLICY_FOR_SNS_SUBSCRIPTIONS = '@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions', + EC2_ALPHA_USE_RESOURCE_ID_FOR_VPC_V2_MIGRATION = '@aws-cdk/aws-ec2-alpha:useResourceIdForVpcV2Migration', + EC2_REQUIRE_PRIVATE_SUBNETS_FOR_EGRESS_ONLY_INTERNET_GATEWAY = '@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway', + S3_PUBLIC_ACCESS_BLOCKED_BY_DEFAULT = '@aws-cdk/aws-s3:publicAccessBlockedByDefault', + LAMBDA_USE_CDK_MANAGED_LOG_GROUP = '@aws-cdk/aws-lambda:useCdkManagedLogGroup', + KMS_APPLY_IMPORTED_ALIAS_PERMISSIONS_TO_PRINCIPAL = '@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal', + CORE_EXPLICIT_STACK_TAGS = '@aws-cdk/core:explicitStackTags', +} diff --git a/packages/aws-cdk/lib/cli/telemetry/installation-id.ts b/packages/aws-cdk/lib/cli/telemetry/installation-id.ts new file mode 100644 index 000000000..b59d860a6 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/installation-id.ts @@ -0,0 +1,47 @@ +import { randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { IoHelper } from '../../api-private'; +import { cdkCacheDir } from '../../util'; + +const INSTALLATION_ID_PATH = path.join(cdkCacheDir(), 'installation-id.json'); + +/** + * Get or create installation id + */ +export async function getOrCreateInstallationId(ioHelper: IoHelper) { + try { + // Create the cache directory if it doesn't exist + if (!fs.existsSync(path.dirname(INSTALLATION_ID_PATH))) { + fs.mkdirSync(path.dirname(INSTALLATION_ID_PATH), { recursive: true }); + } + + // Check if the installation ID file exists + if (fs.existsSync(INSTALLATION_ID_PATH)) { + const cachedId = fs.readFileSync(INSTALLATION_ID_PATH, 'utf-8').trim(); + + // Validate that the cached ID is a valid UUID + const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (UUID_REGEX.test(cachedId)) { + return cachedId; + } + // If invalid, fall through to create a new one + } + + // Create a new installation ID + const newId = randomUUID(); + try { + fs.writeFileSync(INSTALLATION_ID_PATH, newId); + } catch (e: any) { + // If we can't write the file, still return the generated ID + // but log a trace message about the failure + await ioHelper.defaults.trace(`Failed to write installation ID to ${INSTALLATION_ID_PATH}: ${e}`); + } + return newId; + } catch (e: any) { + // If anything goes wrong, generate a temporary ID for this session + // and log a trace message about the failure + await ioHelper.defaults.trace(`Error getting installation ID: ${e}`); + return randomUUID(); + } +} diff --git a/packages/aws-cdk/lib/cli/telemetry/library-version.ts b/packages/aws-cdk/lib/cli/telemetry/library-version.ts new file mode 100644 index 000000000..47c9ddee5 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/library-version.ts @@ -0,0 +1,30 @@ +import { exec } from 'child_process'; +import * as path from 'path'; +import { promisify } from 'util'; +import * as fs from 'fs-extra'; +import type { IoHelper } from '../../api-private'; + +export async function getLibraryVersion(ioHelper: IoHelper): Promise { + try { + const command = "node -e 'process.stdout.write(require.resolve(\"aws-cdk-lib\"))'"; + const { stdout } = await promisify(exec)(command); + + // stdout should be a file path but lets double check + if (!fs.existsSync(stdout)) { + await ioHelper.defaults.trace('Could not get CDK Library Version: require.resolve("aws-cdk-lib") did not return a file path'); + return; + } + + const pathToPackageJson = path.join(path.dirname(stdout), 'package.json'); + const packageJson = fs.readJSONSync(pathToPackageJson); + if (!packageJson.version) { + await ioHelper.defaults.trace('Could not get CDK Library Version: package.json does not have version field'); + return; + } + + return packageJson.version; + } catch (e: any) { + await ioHelper.defaults.trace(`Could not get CDK Library Version: ${e}`); + return; + } +} diff --git a/packages/aws-cdk/lib/cli/telemetry/messages.ts b/packages/aws-cdk/lib/cli/telemetry/messages.ts new file mode 100644 index 000000000..e51c1a1f8 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/messages.ts @@ -0,0 +1,53 @@ +import type { Duration } from '@aws-cdk/toolkit-lib'; +import type { ErrorDetails } from './schema'; +import * as make from '../../api-private'; +import type { SpanDefinition } from '../../api-private'; + +export interface EventResult extends Duration { + error?: ErrorDetails; +} + +export interface EventStart { +} + +/** + * Private message types specific to the CLI + */ +export const CLI_PRIVATE_IO = { + CDK_CLI_I1000: make.trace({ + code: 'CDK_CLI_I1000', + description: 'Cloud Execution is starting', + interface: 'EventStart', + }), + CDK_CLI_I1001: make.trace({ + code: 'CDK_CLI_I1001', + description: 'Cloud Executable Result', + interface: 'EventResult', + }), + CDK_CLI_I2000: make.trace({ + code: 'CDK_CLI_I2000', + description: 'Command has started', + interface: 'EventStart', + }), + CDK_CLI_I2001: make.trace({ + code: 'CDK_CLI_I2001', + description: 'Command has finished executing', + interface: 'EventResult', + }), +}; + +/** + * Payload type of the end message must extend Duration + */ +export const CLI_PRIVATE_SPAN = { + SYNTH_ASSEMBLY: { + name: 'Synthesis', + start: CLI_PRIVATE_IO.CDK_CLI_I1000, + end: CLI_PRIVATE_IO.CDK_CLI_I1001, + }, + COMMAND: { + name: 'Command', + start: CLI_PRIVATE_IO.CDK_CLI_I2000, + end: CLI_PRIVATE_IO.CDK_CLI_I2001, + }, +} satisfies Record>; diff --git a/packages/aws-cdk/lib/cli/telemetry/sanitation.ts b/packages/aws-cdk/lib/cli/telemetry/sanitation.ts new file mode 100644 index 000000000..ebe510ef2 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/sanitation.ts @@ -0,0 +1,76 @@ +import { FeatureFlag } from './feature-flags'; +import type { Context } from '../../api/context'; + +/** + * argv is the output of yargs + */ +export function sanitizeCommandLineArguments(argv: any): { path: string[]; parameters: { [key: string]: string } } { + // Get the configuration of the arguments + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const config = require('../cli-type-registry.json'); + const command = argv._[0]; + const path: string[] = [command]; + const parameters: { [key: string]: string } = {}; + + const globalOptions: any[] = Object.keys(config.globalOptions); + const commandOptions: any[] = Object.keys(config.commands[command]?.options ?? {}); + const commandArg: { name: string; variadic: string } | undefined = config.commands[command]?.arg; + + for (const argName of Object.keys(argv)) { + if (argName === commandArg?.name) { + if (commandArg.variadic) { + for (let i = 0; i < argv[argName].length; i++) { + path.push(`$${argName}_${i+1}`); + } + } else { + path.push(`$${argName}`); + } + } + + // Continue if the arg name is not a global option or command option + // arg name comes from yargs and could be an alias; we trust that the "normal" + // name has the same information and that is what we want to record + if (argv[argName] === undefined || (!globalOptions.includes(argName) && !commandOptions.includes(argName))) { + continue; + } + if (isNumberOrBoolean(argv[argName])) { + parameters[argName] = argv[argName]; + } else { + parameters[argName] = ''; + } + } + + return { + path, + parameters, + }; +} + +export function sanitizeContext(context: Context) { + const sanitizedContext: { [K in FeatureFlag]: boolean } = {} as { [K in FeatureFlag]: boolean }; + for (const [flag, value] of Object.entries(context.all)) { + // Skip if flag is not in the FeatureFlags enum + if (!isFeatureFlag(flag)) { + continue; + } + + // Falsy options include boolean false, string 'false' + // All other inputs evaluate to true + const sanitizedValue: boolean = isBoolean(value) ? value : (value !== 'false'); + sanitizedContext[flag] = sanitizedValue; + } + return sanitizedContext; +} + +function isBoolean(value: any): value is boolean { + return typeof value === 'boolean'; +} + +function isNumberOrBoolean(value: any): boolean { + return typeof value === 'number' || isBoolean(value); +} + +function isFeatureFlag(flag: string): flag is FeatureFlag { + return Object.values(FeatureFlag).includes(flag as FeatureFlag); +} diff --git a/packages/aws-cdk/lib/cli/telemetry/schema.ts b/packages/aws-cdk/lib/cli/telemetry/schema.ts index e1d2036d4..78ee5f55a 100644 --- a/packages/aws-cdk/lib/cli/telemetry/schema.ts +++ b/packages/aws-cdk/lib/cli/telemetry/schema.ts @@ -1,26 +1,37 @@ -interface Identifiers { +interface SessionIdentifiers { readonly cdkCliVersion: string; readonly cdkLibraryVersion?: string; readonly telemetryVersion: string; readonly sessionId: string; - readonly eventId: string; readonly installationId: string; - readonly timestamp: string; - readonly accountId?: string; readonly region?: string; } -interface Event { - readonly state: 'ABORTED' | 'FAILED' | 'SUCCEEDED'; - readonly eventType: string; - readonly command: { - readonly path: string[]; - readonly parameters: string[]; - readonly config: { [key: string]: any }; - }; +export interface Identifiers extends SessionIdentifiers { + readonly eventId: string; + readonly timestamp: string; } -interface Environment { +type ConfigEntry = { [key: string]: boolean }; + +export interface Command { + readonly path: string[]; + readonly parameters: { [key: string]: string }; + readonly config: { [key: string]: ConfigEntry }; +} + +interface SessionEvent { + readonly command: Command; +} + +export type EventType = 'SYNTH' | 'INVOKE'; +export type State = 'ABORTED' | 'FAILED' | 'SUCCEEDED'; +interface Event extends SessionEvent { + readonly state: State; + readonly eventType: EventType; +} + +export interface SessionEnvironment { readonly os: { readonly platform: string; readonly release: string; @@ -29,6 +40,9 @@ interface Environment { readonly nodeVersion: string; } +interface Environment extends SessionEnvironment { +} + interface Duration { readonly total: number; readonly components?: { [key: string]: number }; @@ -36,11 +50,19 @@ interface Duration { type Counters = { [key: string]: number }; -interface Error { - readonly name: string; - readonly message?: string; // anonymized stack message - readonly trace?: string; // anonymized stack trace - readonly logs?: string; // anonymized stack logs +export enum ErrorName { + TOOLKIT_ERROR = 'ToolkitError', + AUTHENTICATION_ERROR = 'AuthenticationError', + ASSEMBLY_ERROR = 'AssemblyError', + CONTEXT_PROVIDER_ERROR = 'ContextProviderError', + UNKNOWN_ERROR = 'UnknownError', +} + +export interface ErrorDetails { + readonly name: ErrorName; + readonly message?: string; // sanitized stack message + readonly stackTrace?: string; // sanitized stack trace + readonly logs?: string; // sanitized stack logs } interface Dependency { @@ -48,10 +70,13 @@ interface Dependency { readonly version: string; } -interface Project { +interface SessionProject { readonly dependencies?: Dependency[]; } +interface Project extends SessionProject { +} + export interface TelemetrySchema { readonly identifiers: Identifiers; readonly event: Event; @@ -59,5 +84,12 @@ export interface TelemetrySchema { readonly project: Project; readonly duration: Duration; readonly counters?: Counters; - readonly error?: Error; + readonly error?: ErrorDetails; +} + +export interface SessionSchema { + identifiers: SessionIdentifiers; + event: SessionEvent; + environment: SessionEnvironment; + project: SessionProject; } diff --git a/packages/aws-cdk/lib/cli/telemetry/session.ts b/packages/aws-cdk/lib/cli/telemetry/session.ts new file mode 100644 index 000000000..c0ac7e388 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/session.ts @@ -0,0 +1,157 @@ +import { randomUUID } from 'crypto'; +import { ToolkitError } from '@aws-cdk/toolkit-lib'; +import { getOrCreateInstallationId } from './installation-id'; +import { getLibraryVersion } from './library-version'; +import { sanitizeCommandLineArguments, sanitizeContext } from './sanitation'; +import { type EventType, type SessionSchema, type State, type ErrorDetails, ErrorName } from './schema'; +import type { ITelemetrySink } from './sink-interface'; +import type { Context } from '../../api/context'; +import type { IMessageSpan } from '../../api-private'; +import { detectCiSystem } from '../ci-systems'; +import type { CliIoHost } from '../io-host/cli-io-host'; +import type { EventResult } from '../telemetry/messages'; +import { CLI_PRIVATE_SPAN } from '../telemetry/messages'; +import { isCI } from '../util/ci'; +import { versionNumber } from '../version'; + +const ABORTED_ERROR_MESSAGE = '__CDK-Toolkit__Aborted'; + +export interface TelemetrySessionProps { + readonly ioHost: CliIoHost; + readonly client: ITelemetrySink; + readonly arguments: any; + readonly context: Context; +} + +export interface TelemetryEvent { + readonly eventType: EventType; + readonly duration: number; + readonly error?: ErrorDetails; +} + +export class TelemetrySession { + private ioHost: CliIoHost; + private client: ITelemetrySink; + private _sessionInfo?: SessionSchema; + private span?: IMessageSpan; + private count = 0; + + constructor(private readonly props: TelemetrySessionProps) { + this.ioHost = props.ioHost; + this.client = props.client; + } + + public async begin() { + // sanitize the raw cli input + const { path, parameters } = sanitizeCommandLineArguments(this.props.arguments); + this._sessionInfo = { + identifiers: { + installationId: await getOrCreateInstallationId(this.ioHost.asIoHelper()), + sessionId: randomUUID(), + telemetryVersion: '1.0', + cdkCliVersion: versionNumber(), + cdkLibraryVersion: await getLibraryVersion(this.ioHost.asIoHelper()), + }, + event: { + command: { + path, + parameters, + config: { + context: sanitizeContext(this.props.context), + }, + }, + }, + environment: { + ci: isCI() || Boolean(detectCiSystem()), + os: { + platform: process.platform, + release: process.release.name, + }, + nodeVersion: process.version, + }, + project: {}, + }; + + // If SIGINT has a listener installed, its default behavior will be removed (Node.js will no longer exit). + // This ensures that on SIGINT we process safely close the telemetry session before exiting. + process.on('SIGINT', async () => { + try { + await this.end({ + name: ErrorName.TOOLKIT_ERROR, + message: ABORTED_ERROR_MESSAGE, + }); + } catch (e: any) { + await this.ioHost.defaults.trace(`Ending Telemetry failed: ${e.message}`); + } + process.exit(1); + }); + + // Begin the session span + this.span = await this.ioHost.asIoHelper().span(CLI_PRIVATE_SPAN.COMMAND).begin({}); + } + + public async attachRegion(region: string) { + this.sessionInfo.identifiers = { + ...this.sessionInfo.identifiers, + region, + }; + } + + /** + * When the command is complete, so is the CliIoHost. Ends the span of the entire CliIoHost + * and notifies with an optional error message in the data. + */ + public async end(error?: ErrorDetails) { + await this.span?.end({ error }); + // Ideally span.end() should no-op if called twice, but that is not the case right now + this.span = undefined; + await this.client.flush(); + } + + public async emit(event: TelemetryEvent): Promise { + this.count += 1; + return this.client.emit({ + event: { + command: this.sessionInfo.event.command, + state: getState(event.error), + eventType: event.eventType, + }, + identifiers: { + ...this.sessionInfo.identifiers, + eventId: `${this.sessionInfo.identifiers.sessionId}:${this.count}`, + timestamp: new Date().toISOString(), + }, + environment: this.sessionInfo.environment, + project: this.sessionInfo.project, + duration: { + total: event.duration, + }, + ...( event.error ? { + error: { + name: event.error.name, + }, + } : {}), + }); + } + + private get sessionInfo(): SessionSchema { + if (!this._sessionInfo) { + throw new ToolkitError('Session Info not initialized. Call begin() first.'); + } + return this._sessionInfo; + } +} + +function getState(error?: ErrorDetails): State { + if (error) { + return isAbortedError(error) ? 'ABORTED' : 'FAILED'; + } + return 'SUCCEEDED'; +} + +function isAbortedError(error?: ErrorDetails) { + if (error?.name === 'ToolkitError' && error?.message?.includes(ABORTED_ERROR_MESSAGE)) { + return true; + } + return false; +} diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 128cf9d63..5400cd484 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -320,6 +320,13 @@ export interface GlobalOptions { * @default - [] */ readonly unstable?: Array; + + /** + * Send telemetry data to a local file. + * + * @default - undefined + */ + readonly telemetryFile?: string; } /** diff --git a/packages/aws-cdk/lib/cli/util/ci.ts b/packages/aws-cdk/lib/cli/util/ci.ts new file mode 100644 index 000000000..cd67e4f9a --- /dev/null +++ b/packages/aws-cdk/lib/cli/util/ci.ts @@ -0,0 +1,7 @@ +/** + * Returns true if the current process is running in a CI environment + * @returns true if the current process is running in a CI environment + */ +export function isCI(): boolean { + return process.env.CI !== undefined && process.env.CI !== 'false' && process.env.CI !== '0'; +} diff --git a/packages/aws-cdk/lib/cli/util/yargs-helpers.ts b/packages/aws-cdk/lib/cli/util/yargs-helpers.ts index fa94e215c..713547093 100644 --- a/packages/aws-cdk/lib/cli/util/yargs-helpers.ts +++ b/packages/aws-cdk/lib/cli/util/yargs-helpers.ts @@ -1,8 +1,8 @@ import { ciSystemIsStdErrSafe } from '../ci-systems'; -import { isCI } from '../io-host'; +import { isCI } from '../util/ci'; import { versionWithBuild } from '../version'; -export { isCI } from '../io-host'; +export { isCI } from '../util/ci'; /** * yargs middleware to negate an option if a negative alias is provided diff --git a/packages/aws-cdk/lib/cxapp/cloud-executable.ts b/packages/aws-cdk/lib/cxapp/cloud-executable.ts index 9eb781cd9..4d2bcc0d2 100644 --- a/packages/aws-cdk/lib/cxapp/cloud-executable.ts +++ b/packages/aws-cdk/lib/cxapp/cloud-executable.ts @@ -6,6 +6,9 @@ import type { IoHelper } from '../../lib/api-private'; import { BorrowedAssembly } from '../../lib/api-private'; import type { SdkProvider } from '../api/aws-auth'; import { GLOBAL_PLUGIN_HOST } from '../cli/singleton-plugin-host'; +import { cdkCliErrorName } from '../cli/telemetry/error'; +import { CLI_PRIVATE_SPAN } from '../cli/telemetry/messages'; +import type { ErrorDetails } from '../cli/telemetry/schema'; import type { Configuration } from '../cli/user-configuration'; import * as contextproviders from '../context-providers'; @@ -82,50 +85,60 @@ export class CloudExecutable implements ICloudAssemblySource { // but it missing. We'll then look up the context and run the executable again, and // again, until it doesn't complain anymore or we've stopped making progress). let previouslyMissingKeys: Set | undefined; - while (true) { - const assembly = await this.props.synthesizer(this.props.sdkProvider, this.props.configuration); + const synthSpan = await this.props.ioHelper.span(CLI_PRIVATE_SPAN.SYNTH_ASSEMBLY).begin({}); + let error: ErrorDetails | undefined; + try { + while (true) { + const assembly = await this.props.synthesizer(this.props.sdkProvider, this.props.configuration); + + if (assembly.manifest.missing && assembly.manifest.missing.length > 0) { + const missingKeys = missingContextKeys(assembly.manifest.missing); + + if (!this.canLookup) { + throw new ToolkitError( + 'Context lookups have been disabled. ' + + 'Make sure all necessary context is already in \'cdk.context.json\' by running \'cdk synth\' on a machine with sufficient AWS credentials and committing the result. ' + + `Missing context keys: '${Array.from(missingKeys).join(', ')}'`); + } - if (assembly.manifest.missing && assembly.manifest.missing.length > 0) { - const missingKeys = missingContextKeys(assembly.manifest.missing); + let tryLookup = true; + if (previouslyMissingKeys && setsEqual(missingKeys, previouslyMissingKeys)) { + await this.props.ioHelper.defaults.debug('Not making progress trying to resolve environmental context. Giving up.'); + tryLookup = false; + } - if (!this.canLookup) { - throw new ToolkitError( - 'Context lookups have been disabled. ' - + 'Make sure all necessary context is already in \'cdk.context.json\' by running \'cdk synth\' on a machine with sufficient AWS credentials and committing the result. ' - + `Missing context keys: '${Array.from(missingKeys).join(', ')}'`); - } + previouslyMissingKeys = missingKeys; - let tryLookup = true; - if (previouslyMissingKeys && setsEqual(missingKeys, previouslyMissingKeys)) { - await this.props.ioHelper.defaults.debug('Not making progress trying to resolve environmental context. Giving up.'); - tryLookup = false; - } + if (tryLookup) { + await this.props.ioHelper.defaults.debug('Some context information is missing. Fetching...'); - previouslyMissingKeys = missingKeys; + const updates = await contextproviders.provideContextValues( + assembly.manifest.missing, + this.props.sdkProvider, + GLOBAL_PLUGIN_HOST, + this.props.ioHelper, + ); - if (tryLookup) { - await this.props.ioHelper.defaults.debug('Some context information is missing. Fetching...'); + for (const [key, value] of Object.entries(updates)) { + this.props.configuration.context.set(key, value); + } - const updates = await contextproviders.provideContextValues( - assembly.manifest.missing, - this.props.sdkProvider, - GLOBAL_PLUGIN_HOST, - this.props.ioHelper, - ); + // Cache the new context to disk + await this.props.configuration.saveContext(); - for (const [key, value] of Object.entries(updates)) { - this.props.configuration.context.set(key, value); + // Execute again + continue; } - - // Cache the new context to disk - await this.props.configuration.saveContext(); - - // Execute again - continue; } + return new CloudAssembly(assembly, this.props.ioHelper); } - - return new CloudAssembly(assembly, this.props.ioHelper); + } catch (e: any) { + error = { + name: cdkCliErrorName(e.name), + }; + throw e; + } finally { + await synthSpan.end({ error }); } } 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 a9529dac1..dd77d2497 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 @@ -1,11 +1,23 @@ +import * as os from 'os'; +import * as path from 'path'; import { PassThrough } from 'stream'; import { RequireApproval } from '@aws-cdk/cloud-assembly-schema'; import * as chalk from 'chalk'; +import * as fs from 'fs-extra'; +import { Context } from '../../../lib/api/context'; import type { IoMessage, IoMessageLevel, IoRequest } from '../../../lib/cli/io-host'; import { CliIoHost } from '../../../lib/cli/io-host'; let passThrough: PassThrough; +// Store original process.on +const originalProcessOn = process.on; + +// Mock process.on to be a no-op function that returns process for chaining +process.on = jest.fn().mockImplementation(function() { + return process; +}) as any; + const ioHost = CliIoHost.instance({ logLevel: 'trace', }); @@ -56,6 +68,11 @@ describe('CliIoHost', () => { jest.restoreAllMocks(); }); + afterAll(() => { + // Restore original process.on + process.on = originalProcessOn; + }); + describe('stream selection', () => { test('writes to stderr by default for non-error messages in non-CI mode', async () => { ioHost.isTTY = true; @@ -262,6 +279,131 @@ describe('CliIoHost', () => { }); }); + describe('telemetry', () => { + let telemetryIoHost: CliIoHost; + let telemetryEmitSpy: jest.SpyInstance; + let telemetryDir: string; + + beforeEach(async () => { + // Create a telemetry file to satisfy requirements; we are not asserting on the file contents + telemetryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry')); + const telemetryFilePath = path.join(telemetryDir, 'telemetry-file.json'); + + // Create a new instance with telemetry enabled + telemetryIoHost = CliIoHost.instance({ + logLevel: 'trace', + }, true); + await telemetryIoHost.startTelemetry({ '_': 'init', 'telemetry-file': telemetryFilePath }, new Context()); + + expect(telemetryIoHost.telemetry).toBeDefined(); + + telemetryEmitSpy = jest.spyOn(telemetryIoHost.telemetry!, 'emit') + .mockImplementation(async () => Promise.resolve()); + }); + + afterEach(() => { + fs.rmdirSync(telemetryDir, { recursive: true }); + jest.restoreAllMocks(); + }); + + test('emit telemetry on SYNTH event', async () => { + // Create a message that should trigger telemetry using the actual message code + const message: IoMessage = { + time: new Date(), + level: 'trace', + action: 'synth', + code: 'CDK_CLI_I1001', + message: 'telemetry message', + data: { + duration: 123, + }, + }; + + // Send the notification + await telemetryIoHost.notify(message); + + // Verify that the emit method was called with the correct parameters + expect(telemetryEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'SYNTH', + duration: 123, + })); + }); + + test('emit telemetry on INVOKE event', async () => { + // Create a message that should trigger telemetry using the actual message code + const message: IoMessage = { + time: new Date(), + level: 'trace', + action: 'synth', + code: 'CDK_CLI_I2001', + message: 'telemetry message', + data: { + duration: 123, + }, + }; + + // Send the notification + await telemetryIoHost.notify(message); + + // Verify that the emit method was called with the correct parameters + expect(telemetryEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'INVOKE', + duration: 123, + })); + }); + + test('do not emit telemetry on non telemetry codes', async () => { + // Create a message that should trigger telemetry using the actual message code + const message: IoMessage = { + time: new Date(), + level: 'trace', + action: 'synth', + code: 'CDK_CLI_I2000', // only I2001, I1001 are valid + message: 'telemetry message', + data: { + duration: 123, + }, + }; + + // Send the notification + await telemetryIoHost.notify(message); + + // Verify that the emit method was not called + expect(telemetryEmitSpy).not.toHaveBeenCalled(); + }); + + test('emit telemetry with error name', async () => { + // Create a message that should trigger telemetry using the actual message code + const message: IoMessage = { + time: new Date(), + level: 'trace', + action: 'synth', + code: 'CDK_CLI_I2001', + message: 'telemetry message', + data: { + duration: 123, + error: { + name: 'MyError', + message: 'Some message', + }, + }, + }; + + // Send the notification + await telemetryIoHost.notify(message); + + // Verify that the emit method was called with the correct parameters + expect(telemetryEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'INVOKE', + duration: 123, + error: { + name: 'MyError', + message: 'Some message', + }, + })); + }); + }); + describe('requestResponse', () => { beforeEach(() => { ioHost.isTTY = true; diff --git a/packages/aws-cdk/test/cli/telemetry/endpoint-sink.test.ts b/packages/aws-cdk/test/cli/telemetry/endpoint-sink.test.ts index 6af5d4f52..1a69b1380 100644 --- a/packages/aws-cdk/test/cli/telemetry/endpoint-sink.test.ts +++ b/packages/aws-cdk/test/cli/telemetry/endpoint-sink.test.ts @@ -2,7 +2,7 @@ import * as https from 'https'; import { IoHelper } from '../../../lib/api-private'; import { CliIoHost } from '../../../lib/cli/io-host'; import { EndpointTelemetrySink } from '../../../lib/cli/telemetry/endpoint-sink'; -import type { TelemetrySchema } from '../../../lib/cli/telemetry/schema'; +import type { EventType, TelemetrySchema } from '../../../lib/cli/telemetry/schema'; // Mock the https module jest.mock('https', () => ({ @@ -10,7 +10,7 @@ jest.mock('https', () => ({ })); // Helper function to create a test event -function createTestEvent(eventType: string, properties: Record = {}): TelemetrySchema { +function createTestEvent(eventType: EventType, properties: Record = {}): TelemetrySchema { return { identifiers: { cdkCliVersion: '1.0.0', @@ -25,7 +25,7 @@ function createTestEvent(eventType: string, properties: Record = {} eventType, command: { path: ['test'], - parameters: [], + parameters: {}, config: properties, }, }, @@ -87,7 +87,7 @@ describe('EndpointTelemetrySink', () => { test('makes a POST request to the specified endpoint', async () => { // GIVEN const mockRequest = setupMockRequest(); - const testEvent = createTestEvent('test', { foo: 'bar' }); + const testEvent = createTestEvent('INVOKE', { foo: 'bar' }); const client = new EndpointTelemetrySink({ endpoint: 'https://example.com/telemetry', ioHost }); // WHEN @@ -115,7 +115,7 @@ describe('EndpointTelemetrySink', () => { test('silently catches request errors', async () => { // GIVEN const mockRequest = setupMockRequest(); - const testEvent = createTestEvent('test'); + const testEvent = createTestEvent('INVOKE'); const client = new EndpointTelemetrySink({ endpoint: 'https://example.com/telemetry', ioHost }); mockRequest.on.mockImplementation((event, callback) => { @@ -134,8 +134,8 @@ describe('EndpointTelemetrySink', () => { test('multiple events sent as one', async () => { // GIVEN const mockRequest = setupMockRequest(); - const testEvent1 = createTestEvent('test1', { foo: 'bar' }); - const testEvent2 = createTestEvent('test2', { foo: 'bazoo' }); + const testEvent1 = createTestEvent('INVOKE', { foo: 'bar' }); + const testEvent2 = createTestEvent('INVOKE', { foo: 'bazoo' }); const client = new EndpointTelemetrySink({ endpoint: 'https://example.com/telemetry', ioHost }); // WHEN @@ -165,8 +165,8 @@ describe('EndpointTelemetrySink', () => { test('successful flush clears events cache', async () => { // GIVEN setupMockRequest(); - const testEvent1 = createTestEvent('test1', { foo: 'bar' }); - const testEvent2 = createTestEvent('test2', { foo: 'bazoo' }); + const testEvent1 = createTestEvent('INVOKE', { foo: 'bar' }); + const testEvent2 = createTestEvent('INVOKE', { foo: 'bazoo' }); const client = new EndpointTelemetrySink({ endpoint: 'https://example.com/telemetry', ioHost }); // WHEN @@ -233,8 +233,8 @@ describe('EndpointTelemetrySink', () => { return mockRequest; }); - const testEvent1 = createTestEvent('test1', { foo: 'bar' }); - const testEvent2 = createTestEvent('test2', { foo: 'bazoo' }); + const testEvent1 = createTestEvent('INVOKE', { foo: 'bar' }); + const testEvent2 = createTestEvent('INVOKE', { foo: 'bazoo' }); const client = new EndpointTelemetrySink({ endpoint: 'https://example.com/telemetry', ioHost }); // WHEN @@ -317,7 +317,7 @@ describe('EndpointTelemetrySink', () => { test('handles errors gracefully and logs to trace without throwing', async () => { // GIVEN - const testEvent = createTestEvent('test'); + const testEvent = createTestEvent('INVOKE'); // Create a mock IoHelper with trace spy const traceSpy = jest.fn(); diff --git a/packages/aws-cdk/test/cli/telemetry/error.test.ts b/packages/aws-cdk/test/cli/telemetry/error.test.ts new file mode 100644 index 000000000..66ebc1496 --- /dev/null +++ b/packages/aws-cdk/test/cli/telemetry/error.test.ts @@ -0,0 +1,10 @@ +import { AuthenticationError } from '@aws-cdk/toolkit-lib'; +import { cdkCliErrorName } from '../../../lib/cli/telemetry/error'; + +test('returns known error names', () => { + expect(cdkCliErrorName(AuthenticationError.name)).toEqual(AuthenticationError.name); +}); + +test('returns UnknownError for unknown error names', () => { + expect(cdkCliErrorName('ExpiredToken')).toEqual('UnknownError'); +}); diff --git a/packages/aws-cdk/test/cli/telemetry/file-sink.test.ts b/packages/aws-cdk/test/cli/telemetry/file-sink.test.ts index f411aee71..b138c739f 100644 --- a/packages/aws-cdk/test/cli/telemetry/file-sink.test.ts +++ b/packages/aws-cdk/test/cli/telemetry/file-sink.test.ts @@ -43,11 +43,11 @@ describe('FileTelemetrySink', () => { }, event: { state: 'SUCCEEDED', - eventType: 'test', + eventType: 'INVOKE', command: { path: ['test'], - parameters: [], - config: { foo: 'bar' }, + parameters: {}, + config: { context: { foo: true } }, }, }, environment: { @@ -69,6 +69,7 @@ describe('FileTelemetrySink', () => { await client.emit(testEvent); // THEN + expect(fs.existsSync(logFilePath)).toBe(true); const fileJson = fs.readJSONSync(logFilePath, 'utf8'); expect(fileJson).toEqual([testEvent]); }); @@ -86,11 +87,11 @@ describe('FileTelemetrySink', () => { }, event: { state: 'SUCCEEDED', - eventType: 'test', + eventType: 'INVOKE', command: { path: ['test'], - parameters: [], - config: { foo: 'bar' }, + parameters: {}, + config: { context: { foo: true } }, }, }, environment: { @@ -140,11 +141,11 @@ describe('FileTelemetrySink', () => { }, event: { state: 'SUCCEEDED', - eventType: 'test', + eventType: 'INVOKE', command: { path: ['test'], - parameters: [], - config: { foo: 'bar' }, + parameters: {}, + config: { context: { foo: true } }, }, }, environment: { diff --git a/packages/aws-cdk/test/cli/telemetry/installation-id.test.ts b/packages/aws-cdk/test/cli/telemetry/installation-id.test.ts new file mode 100644 index 000000000..7d397c693 --- /dev/null +++ b/packages/aws-cdk/test/cli/telemetry/installation-id.test.ts @@ -0,0 +1,194 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// Initialize temp directory before mocking +const tempDir = path.join(os.tmpdir(), `installation-id-test-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`); + +// Mock crypto.randomUUID to return predictable values +const mockRandomUUID = jest.fn(); +jest.mock('crypto', () => ({ + randomUUID: mockRandomUUID, +})); + +// Mock the util module to use our temp directory +const mockCdkCacheDir = jest.fn(() => tempDir); +jest.mock('../../../lib/util', () => ({ + cdkCacheDir: mockCdkCacheDir, +})); + +// Now import after mocking +import type { IoHelper } from '../../../lib/api-private'; +import { getOrCreateInstallationId } from '../../../lib/cli/telemetry/installation-id'; + +describe(getOrCreateInstallationId, () => { + let mockIoHelper: IoHelper; + let traceSpy: jest.Mock; + + beforeAll(() => { + // Create the temp directory before any tests run + fs.mkdirSync(tempDir, { recursive: true }); + }); + + beforeEach(() => { + // Clean the temp directory for each test + if (fs.existsSync(tempDir)) { + const files = fs.readdirSync(tempDir); + for (const file of files) { + const filePath = path.join(tempDir, file); + if (fs.statSync(filePath).isDirectory()) { + fs.rmSync(filePath, { recursive: true, force: true }); + } else { + fs.unlinkSync(filePath); + } + } + } + + // Mock randomUUID to return predictable values + mockRandomUUID.mockReturnValue('12345678-1234-1234-1234-123456789abc'); + + // Create mock IoHelper + traceSpy = jest.fn(); + mockIoHelper = { + defaults: { + trace: traceSpy, + }, + } as any; + }); + + afterAll(() => { + // Clean up temp directory after all tests + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('creates new installation ID when file does not exist', async () => { + // WHEN + const result = await getOrCreateInstallationId(mockIoHelper); + + // THEN + expect(result).toBe('12345678-1234-1234-1234-123456789abc'); + expect(mockRandomUUID).toHaveBeenCalledTimes(1); + + // Verify the file was created + const installationIdPath = path.join(tempDir, 'installation-id.json'); + expect(fs.existsSync(installationIdPath)).toBe(true); + expect(fs.readFileSync(installationIdPath, 'utf-8')).toBe('12345678-1234-1234-1234-123456789abc'); + + // Should not have logged any trace messages + expect(traceSpy).not.toHaveBeenCalled(); + }); + + test('returns existing valid installation ID from file', async () => { + // GIVEN + const existingId = 'abcdef12-3456-7890-abcd-ef1234567890'; + const installationIdPath = path.join(tempDir, 'installation-id.json'); + fs.writeFileSync(installationIdPath, existingId); + + // WHEN + const result = await getOrCreateInstallationId(mockIoHelper); + + // THEN + expect(result).toBe(existingId); + expect(mockRandomUUID).not.toHaveBeenCalled(); + expect(traceSpy).not.toHaveBeenCalled(); + }); + + test('creates new installation ID when existing file contains invalid UUID', async () => { + // GIVEN + const installationIdPath = path.join(tempDir, 'installation-id.json'); + fs.writeFileSync(installationIdPath, 'invalid-uuid'); + + // WHEN + const result = await getOrCreateInstallationId(mockIoHelper); + + // THEN + expect(result).toBe('12345678-1234-1234-1234-123456789abc'); + expect(mockRandomUUID).toHaveBeenCalledTimes(1); + + // Verify the file was overwritten with the new ID + expect(fs.readFileSync(installationIdPath, 'utf-8')).toBe('12345678-1234-1234-1234-123456789abc'); + expect(traceSpy).not.toHaveBeenCalled(); + }); + + test('creates new installation ID when existing file is empty', async () => { + // GIVEN + const installationIdPath = path.join(tempDir, 'installation-id.json'); + fs.writeFileSync(installationIdPath, ''); + + // WHEN + const result = await getOrCreateInstallationId(mockIoHelper); + + // THEN + expect(result).toBe('12345678-1234-1234-1234-123456789abc'); + expect(mockRandomUUID).toHaveBeenCalledTimes(1); + expect(traceSpy).not.toHaveBeenCalled(); + }); + + test('creates cache directory if it does not exist', async() => { + // GIVEN + // Remove the temp directory to test directory creation + fs.rmSync(tempDir, { recursive: true, force: true }); + + // WHEN + const result = await getOrCreateInstallationId(mockIoHelper); + + // THEN + expect(result).toBe('12345678-1234-1234-1234-123456789abc'); + expect(fs.existsSync(tempDir)).toBe(true); + + const installationIdPath = path.join(tempDir, 'installation-id.json'); + expect(fs.existsSync(installationIdPath)).toBe(true); + expect(traceSpy).not.toHaveBeenCalled(); + }); + + test('handles file write error gracefully', async () => { + // GIVEN + // Make the temp directory read-only + fs.chmodSync(tempDir, 0o444); + + // WHEN + const result = await getOrCreateInstallationId(mockIoHelper); + + // THEN + expect(result).toBe('12345678-1234-1234-1234-123456789abc'); + expect(mockRandomUUID).toHaveBeenCalledTimes(1); + + // Should have logged a trace message about the write failure + expect(traceSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to write installation ID to'), + ); + + // Clean up - restore permissions so cleanup can work + fs.chmodSync(tempDir, 0o755); + }); + + test('handles general error gracefully and returns temporary ID', async () => { + // GIVEN + // Mock fs.existsSync to throw an error + const originalExistsSync = fs.existsSync; + jest.spyOn(fs, 'existsSync').mockImplementation(() => { + throw new Error('Filesystem error'); + }); + + // WHEN + const result = await getOrCreateInstallationId(mockIoHelper); + + // THEN + expect(result).toBe('12345678-1234-1234-1234-123456789abc'); + expect(mockRandomUUID).toHaveBeenCalledTimes(1); + + // Should have logged a trace message about the general error + expect(traceSpy).toHaveBeenCalledWith( + expect.stringContaining('Error getting installation ID:'), + ); + + // Restore original function + (fs.existsSync as jest.Mock).mockImplementation(originalExistsSync); + }); +}); diff --git a/packages/aws-cdk/test/cli/telemetry/io-host-sink.test.ts b/packages/aws-cdk/test/cli/telemetry/io-host-sink.test.ts index 363654cc6..5b84ce985 100644 --- a/packages/aws-cdk/test/cli/telemetry/io-host-sink.test.ts +++ b/packages/aws-cdk/test/cli/telemetry/io-host-sink.test.ts @@ -55,11 +55,11 @@ describe('IoHostTelemetrySink', () => { }, event: { state: 'SUCCEEDED', - eventType: 'test', + eventType: 'INVOKE', command: { path: ['test'], - parameters: [], - config: { foo: 'bar' }, + parameters: {}, + config: { context: { foo: true } }, }, }, environment: { @@ -110,11 +110,11 @@ describe('IoHostTelemetrySink', () => { }, event: { state: 'SUCCEEDED', - eventType: 'test', + eventType: 'INVOKE', command: { path: ['test'], - parameters: [], - config: { foo: 'bar' }, + parameters: {}, + config: { context: { foo: true } }, }, }, environment: { diff --git a/packages/aws-cdk/test/cli/telemetry/library-version.test.ts b/packages/aws-cdk/test/cli/telemetry/library-version.test.ts new file mode 100644 index 000000000..7de577459 --- /dev/null +++ b/packages/aws-cdk/test/cli/telemetry/library-version.test.ts @@ -0,0 +1,119 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs-extra'; +import type { IoHelper } from '../../../lib/api-private'; +import { getLibraryVersion } from '../../../lib/cli/telemetry/library-version'; + +// Mock child_process exec +jest.mock('child_process', () => ({ + exec: jest.fn(), +})); + +// Mock fs-extra +jest.mock('fs-extra', () => ({ + existsSync: jest.fn(), + readJSONSync: jest.fn(), +})); + +// Mock util promisify +jest.mock('util', () => ({ + promisify: jest.fn(), +})); + +const mockExec = exec as jest.MockedFunction; +const mockPromisify = promisify as jest.MockedFunction; +const mockExistsSync = fs.existsSync as jest.MockedFunction; +const mockReadJSONSync = fs.readJSONSync as jest.MockedFunction; + +describe('getLibraryVersion', () => { + let mockIoHelper: IoHelper; + let traceSpy: jest.Mock; + let mockPromisifiedExec: jest.Mock; + + beforeEach(() => { + // Create mock IoHelper + traceSpy = jest.fn(); + mockIoHelper = { + defaults: { + trace: traceSpy, + }, + } as any; + + // Create mock promisified exec function + mockPromisifiedExec = jest.fn(); + mockPromisify.mockReturnValue(mockPromisifiedExec); + + // Reset all mocks + jest.clearAllMocks(); + }); + + test('returns version when aws-cdk-lib is found and package.json is valid', async () => { + // GIVEN + const mockLibPath = '/path/to/node_modules/aws-cdk-lib/index.js'; + const mockPackageJsonPath = '/path/to/node_modules/aws-cdk-lib/package.json'; + const expectedVersion = '2.100.0'; + + mockPromisifiedExec.mockResolvedValue({ stdout: mockLibPath }); + mockExistsSync.mockReturnValue(true); + mockReadJSONSync.mockReturnValue({ version: expectedVersion }); + + // WHEN + const result = await getLibraryVersion(mockIoHelper); + + // THEN + expect(result).toBe(expectedVersion); + expect(mockPromisify).toHaveBeenCalledWith(mockExec); + expect(mockPromisifiedExec).toHaveBeenCalledWith("node -e 'process.stdout.write(require.resolve(\"aws-cdk-lib\"))'"); + expect(mockExistsSync).toHaveBeenCalledWith(mockLibPath); + expect(mockReadJSONSync).toHaveBeenCalledWith(mockPackageJsonPath); + expect(traceSpy).not.toHaveBeenCalled(); + }); + + test('returns undefined and logs trace when resolved path does not exist', async () => { + // GIVEN + const mockLibPath = '/nonexistent/path/to/aws-cdk-lib/index.js'; + mockPromisifiedExec.mockResolvedValue({ stdout: mockLibPath }); + mockExistsSync.mockReturnValue(false); + + // WHEN + const result = await getLibraryVersion(mockIoHelper); + + // THEN + expect(result).toBeUndefined(); + expect(mockExistsSync).toHaveBeenCalledWith(mockLibPath); + expect(mockReadJSONSync).not.toHaveBeenCalled(); + expect(traceSpy).toHaveBeenCalledWith( + 'Could not get CDK Library Version: require.resolve("aws-cdk-lib") did not return a file path', + ); + }); + + test('returns undefined and logs trace when exec command fails', async () => { + // GIVEN + const execError = new Error('Command failed: node -e ...'); + mockPromisifiedExec.mockRejectedValue(execError); + + // WHEN + const result = await getLibraryVersion(mockIoHelper); + + // THEN + expect(result).toBeUndefined(); + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockReadJSONSync).not.toHaveBeenCalled(); + expect(traceSpy).toHaveBeenCalledWith(`Could not get CDK Library Version: ${execError}`); + }); + + test('handles package.json without version field', async () => { + // GIVEN + const mockLibPath = '/path/to/node_modules/aws-cdk-lib/index.js'; + mockPromisifiedExec.mockResolvedValue({ stdout: mockLibPath }); + mockExistsSync.mockReturnValue(true); + mockReadJSONSync.mockReturnValue({ name: 'aws-cdk-lib' }); // No version field + + // WHEN + const result = await getLibraryVersion(mockIoHelper); + + // THEN + expect(result).toBeUndefined(); + expect(traceSpy).toHaveBeenCalledWith('Could not get CDK Library Version: package.json does not have version field'); + }); +}); diff --git a/packages/aws-cdk/test/cli/telemetry/sanitation.test.ts b/packages/aws-cdk/test/cli/telemetry/sanitation.test.ts new file mode 100644 index 000000000..060df1226 --- /dev/null +++ b/packages/aws-cdk/test/cli/telemetry/sanitation.test.ts @@ -0,0 +1,114 @@ +import { Context } from '../../../lib/api/context'; +import { Settings } from '../../../lib/api/settings'; +import { sanitizeCommandLineArguments, sanitizeContext } from '../../../lib/cli/telemetry/sanitation'; + +describe(sanitizeContext, () => { + test('boolean values are kept', () => { + const bag = { '@aws-cdk/core:newStyleStackSynthesis': true, '@aws-cdk/core:stackRelativeExports': false }; + const context = new Context({ + fileName: 'n/a', + bag: new Settings(bag, true), + }); + expect(sanitizeContext(context)).toEqual(bag); + }); + + test('string boolean values are booleanized', () => { + const bag = { '@aws-cdk/core:newStyleStackSynthesis': 'true', '@aws-cdk/core:stackRelativeExports': 'false' }; + const context = new Context({ + fileName: 'n/a', + bag: new Settings(bag, true), + }); + expect(sanitizeContext(context)).toEqual({ '@aws-cdk/core:newStyleStackSynthesis': true, '@aws-cdk/core:stackRelativeExports': false }); + }); + + test('strings values are booleanized', () => { + const bag = { '@aws-cdk/core:newStyleStackSynthesis': 'fancy-value' }; + const context = new Context({ + fileName: 'n/a', + bag: new Settings(bag, true), + }); + expect(sanitizeContext(context)).toEqual({ '@aws-cdk/core:newStyleStackSynthesis': true }); + }); + + test('list values are booleanized', () => { + const bag = { '@aws-cdk/core:newStyleStackSynthesis': [true, false] }; + const context = new Context({ + fileName: 'n/a', + bag: new Settings(bag, true), + }); + expect(sanitizeContext(context)).toEqual({ '@aws-cdk/core:newStyleStackSynthesis': true }); + }); + + test('non feature flag keys are dropped', () => { + const bag = { 'my-special-key': true, '@aws-cdk/core:newStyleStackSynthesis': true }; + const context = new Context({ + fileName: 'n/a', + bag: new Settings(bag, true), + }); + expect(sanitizeContext(context)).toEqual({ '@aws-cdk/core:newStyleStackSynthesis': true }); + }); +}); + +describe(sanitizeCommandLineArguments, () => { + test('arguments are sanitized', () => { + const argv = { + _: ['deploy'], + STACKS: ['MyStack'], + }; + expect(sanitizeCommandLineArguments(argv)).toEqual({ + path: ['deploy', '$STACKS_1'], + parameters: {}, + }); + }); + + test('multiple arguments are sanitized with a counter', () => { + const argv = { + _: ['deploy'], + STACKS: ['MyStackA', 'MyStackB'], + }; + expect(sanitizeCommandLineArguments(argv)).toEqual({ + path: ['deploy', '$STACKS_1', '$STACKS_2'], + parameters: {}, + }); + }); + + test('boolean and number options are recorded', () => { + const argv = { + _: ['deploy'], + STACKS: ['MyStack'], + all: true, + concurrency: 4, + }; + expect(sanitizeCommandLineArguments(argv)).toEqual({ + path: ['deploy', '$STACKS_1'], + parameters: { all: true, concurrency: 4 }, + }); + }); + + test('unknown and aliased options are dropped', () => { + const argv = { + _: ['deploy'], + STACKS: ['MyStack'], + all: true, + a: true, + blah: false, + }; + expect(sanitizeCommandLineArguments(argv)).toEqual({ + path: ['deploy', '$STACKS_1'], + parameters: { all: true }, + }); + }); + + test('non-boolean options are redacted', () => { + const argv = { + _: ['deploy'], + STACKS: ['MyStack'], + ['require-approval']: 'broadening', + ['build-exclude']: ['something'], + }; + expect(sanitizeCommandLineArguments(argv)).toEqual({ + path: ['deploy', '$STACKS_1'], + parameters: { 'require-approval': '', 'build-exclude': '' }, + }); + }); +}); diff --git a/packages/aws-cdk/test/cli/telemetry/session.test.ts b/packages/aws-cdk/test/cli/telemetry/session.test.ts new file mode 100644 index 000000000..3b52d1983 --- /dev/null +++ b/packages/aws-cdk/test/cli/telemetry/session.test.ts @@ -0,0 +1,216 @@ +import { Context } from '../../../lib/api/context'; +import { CliIoHost } from '../../../lib/cli/io-host'; +import { IoHostTelemetrySink } from '../../../lib/cli/telemetry/io-host-sink'; +import { ErrorName, type TelemetrySchema } from '../../../lib/cli/telemetry/schema'; +import { TelemetrySession } from '../../../lib/cli/telemetry/session'; +import { withEnv } from '../../_helpers/with-env'; + +let ioHost: CliIoHost; +let session: TelemetrySession; +let clientEmitSpy: jest.SpyInstance; +let clientFlushSpy: jest.SpyInstance; + +describe('TelemetrySession', () => { + beforeEach(async () => { + ioHost = CliIoHost.instance({ + logLevel: 'trace', + }); + + const client = new IoHostTelemetrySink({ ioHost }); + + session = new TelemetrySession({ + ioHost, + client, + arguments: { _: ['deploy'], STACKS: ['MyStack'] }, + context: new Context(), + }); + await session.begin(); + + clientEmitSpy = jest.spyOn(client, 'emit'); + clientFlushSpy = jest.spyOn(client, 'flush'); + }); + + test('can emit data to the client', async () => { + // WHEN + await session.emit({ + eventType: 'SYNTH', + duration: 1234, + }); + + // THEN + expect(clientEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + event: expect.objectContaining({ + state: 'SUCCEEDED', + eventType: 'SYNTH', + }), + duration: expect.objectContaining({ + total: 1234, + }), + })); + }); + + test('state is failed if error supplied', async () => { + // WHEN + await session.emit({ + eventType: 'SYNTH', + duration: 1234, + error: { + name: ErrorName.TOOLKIT_ERROR, + }, + }); + + // THEN + expect(clientEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + event: expect.objectContaining({ + state: 'FAILED', + }), + })); + }); + + test('state is aborted if special error supplied', async () => { + // WHEN + await session.emit({ + eventType: 'SYNTH', + duration: 1234, + error: { + name: ErrorName.TOOLKIT_ERROR, + message: '__CDK-Toolkit__Aborted', + }, + }); + + // THEN + expect(clientEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + event: expect.objectContaining({ + state: 'ABORTED', + }), + })); + }); + + test('emit messsages are counted correctly', async () => { + // WHEN + await session.emit({ + eventType: 'SYNTH', + duration: 1234, + }); + await session.emit({ + eventType: 'SYNTH', + duration: 1234, + }); + + // THEN + expect(clientEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + identifiers: expect.objectContaining({ + eventId: expect.stringContaining(':1'), + }), + })); + expect(clientEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + identifiers: expect.objectContaining({ + eventId: expect.stringContaining(':2'), + }), + })); + }); + + test('calling end more than once results in no-op', async () => { + // GIVEN + const privateSpan = (session as any).span; + const spanEndSpy = jest.spyOn(privateSpan, 'end'); + + // WHEN + await session.end(); + await session.end(); + await session.end(); + + // THEN + expect(spanEndSpy).toHaveBeenCalledTimes(1); + }); + + test('end flushes events', async () => { + // GIVEN + await session.emit({ + eventType: 'SYNTH', + duration: 1234, + }); + + // WHEN + await session.end(); + + // THEN + expect(clientFlushSpy).toHaveBeenCalledTimes(1); + }); +}); + +test('ci is recorded properly - true', async () => { + await withEnv(async () => { + // GIVEN + ioHost = CliIoHost.instance({ + logLevel: 'trace', + }); + + const client = new IoHostTelemetrySink({ ioHost }); + clientEmitSpy = jest.spyOn(client, 'emit'); + const ciSession = new TelemetrySession({ + ioHost, + client, + arguments: { _: ['deploy'], STACKS: ['MyStack'] }, + context: new Context(), + }); + await ciSession.begin(); + + // WHEN + await ciSession.emit({ + eventType: 'SYNTH', + duration: 1234, + }); + + // THEN + expect(clientEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + environment: expect.objectContaining({ + ci: true, + }), + })); + }, { + CI: 'true', + + // Our tests can run in these environments and we check for them too + CODEBUILD_BUILD_ID: undefined, + GITHUB_ACTION: undefined, + }); +}); + +test('ci is recorded properly - false', async () => { + await withEnv(async () => { + // GIVEN + ioHost = CliIoHost.instance({ + logLevel: 'trace', + }); + + const client = new IoHostTelemetrySink({ ioHost }); + clientEmitSpy = jest.spyOn(client, 'emit'); + const ciSession = new TelemetrySession({ + ioHost, + client, + arguments: { _: ['deploy'], STACKS: ['MyStack'] }, + context: new Context(), + }); + await ciSession.begin(); + + // WHEN + await ciSession.emit({ + eventType: 'SYNTH', + duration: 1234, + }); + + // THEN + expect(clientEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + environment: expect.objectContaining({ + ci: false, + }), + })); + }, { + CI: 'false', + + // Our tests can run in these environments and we check for them too + CODEBUILD_BUILD_ID: undefined, + GITHUB_ACTION: undefined, + }); +});