diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts index a4dcf1df7..2e0922665 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts @@ -261,10 +261,7 @@ export function writeContextToEnv(env: Env, context: Context, completeness: 'add * * @param assembly - the assembly to check */ -async function checkContextOverflowSupport(assembly: CloudAssembly, ioHelper: IoHelper): Promise { - const traceFn = (msg: string) => ioHelper.defaults.trace(msg); - const tree = await loadTree(assembly, traceFn); - +async function checkContextOverflowSupport(tree: ConstructTreeNode | undefined, ioHelper: IoHelper): Promise { // We're dealing with an old version of the framework here. It is unaware of the temporary // file, which means that it will ignore the context overflow. if (!frameworkSupportsContextOverflow(tree)) { @@ -273,19 +270,31 @@ async function checkContextOverflowSupport(assembly: CloudAssembly, ioHelper: Io } /** - * Checks if the framework supports context overflow + * Find the `aws-cdk-lib` library version by inspecting construct tree debug information */ -export function frameworkSupportsContextOverflow(tree: ConstructTreeNode | undefined): boolean { - return !some(tree, node => { +export function findConstructLibraryVersion(tree: ConstructTreeNode | undefined): string | undefined { + let ret: string | undefined = undefined; + + some(tree, node => { const fqn = node.constructInfo?.fqn; const version = node.constructInfo?.version; - return ( - fqn === 'aws-cdk-lib.App' // v2 app - && version !== '0.0.0' // ignore developer builds - && version != null && lte(version, '2.38.0') // last version not supporting large context - ) // v2 - || fqn === '@aws-cdk/core.App'; // v1 app => not supported + if (fqn?.startsWith('aws-cdk-lib.') || fqn?.startsWith('@aws-cdk/core.')) { + ret = version; + return true; + } + return false; }); + + return ret !== '0.0.0' ? ret : undefined; +} + +/** + * Checks if the framework supports context overflow + */ +export function frameworkSupportsContextOverflow(tree: ConstructTreeNode | undefined): boolean { + const version = findConstructLibraryVersion(tree); + + return !version || !lte(version, '2.38.0'); } /** @@ -299,7 +308,10 @@ export async function assemblyFromDirectory(assemblyDir: string, ioHelper: IoHel // We sort as we deploy topoSort: false, }); - await checkContextOverflowSupport(assembly, ioHelper); + + const tree = await loadTree(assembly, ioHelper.defaults.trace.bind(ioHelper.defaults)); + await checkContextOverflowSupport(tree, ioHelper); + return assembly; } catch (err: any) { if (err.message.includes(cxschema.VERSION_MISMATCH)) { diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/span.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/span.ts index c06043645..b8a22b2e4 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/span.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/span.ts @@ -7,8 +7,12 @@ import { formatTime } from '../../../util'; import type { IActionAwareIoHost } from '../io-host'; import type { IoDefaultMessages } from './io-default-messages'; +/** + * These data fields are automatically added by ending a span + */ export interface SpanEnd { readonly duration: number; + readonly counters?: Record; } /** @@ -23,29 +27,35 @@ export interface SpanDefinition { readonly end: make.IoMessageMaker; } +/** + * Arguments to the span.end() function + * + * `SpanEnd` represents fields that are added by the underlying `end` function. + * + * - In principle, the intersection of the underlying type and the SpanEnd keys + * are made optional, the rest is the same as the underlying type. + * - If the expected type is subsumed by the SpanEnd type, then either void or + * their intersection with all fields optional. + */ +type SpanEndArguments = keyof T extends keyof SpanEnd + ? (Pick, keyof T & keyof SpanEnd> | void) + : Optional; + /** * Used in conditional types to check if a type (e.g. after omitting fields) is an empty object * This is needed because counter-intuitive neither `object` nor `{}` represent that. */ -type EmptyObject = { - [index: string | number | symbol]: never; -}; +type EmptyObject = Record; /** * Helper type to force a parameter to be not present of the computed type is an empty object */ type VoidWhenEmpty = T extends EmptyObject ? void : T; -/** - * Helper type to force a parameter to be an empty object if the computed type is an empty object - * This is weird, but some computed types (e.g. using `Omit`) don't end up enforcing this. - */ -type ForceEmpty = T extends EmptyObject ? EmptyObject : T; - /** * Make some properties optional */ -type Optional = Pick, K> & Omit; +type Optional = Omit & Pick, K>; /** * Ending the span returns the observed duration @@ -67,6 +77,7 @@ export interface IMessageSpan extends IActionAwareIoHost { * An IoDefaultMessages wrapped around the span. */ readonly defaults: IoDefaultMessages; + /** * Get the time elapsed since the start */ @@ -79,15 +90,34 @@ export interface IMessageSpan extends IActionAwareIoHost { /** * End the span with a payload */ - end(payload: VoidWhenEmpty>): Promise; + end(payload: SpanEndArguments): Promise; /** - * End the span with a payload, overwriting + * End the span with a message and payload */ - end(payload: VoidWhenEmpty>): Promise; + end(message: string, payload: SpanEndArguments): Promise; + /** - * End the span with a message and payload + * Increment a counter */ - end(message: string, payload: ForceEmpty>): Promise; + incCounter(name: string, delta?: number): void; + + /** + * Return a new timer object + * + * It will be added into the span data when it's stopped. All open timers are + * automatically stopped when the span is ended. + * + * Timers are ultimately added to the `counters` array with `_ms` and + * `_cnt` keys. + */ + startTimer(name: string): ITimer; +} + +/** + * A timer to time an operation in a span. + */ +export interface ITimer { + stop(): void; } /** @@ -133,6 +163,8 @@ class MessageSpan implements IMessageSpan = {}; + private readonly openTimers = new Set(); public constructor(ioHelper: IoHelper, definition: SpanDefinition, makeHelper: (ioHost: IActionAwareIoHost) => IoHelper) { this.definition = definition; @@ -161,26 +193,50 @@ class MessageSpan implements IMessageSpan): Promise { return this.ioHelper.notify(withSpanId(this.spanId, msg)); } - public async end(x: any, y?: ForceEmpty>): Promise { + public async end(x: any, y?: SpanEndArguments): Promise { const duration = this.time(); - const endInput = parseArgs>>(x, y); + for (const t of this.openTimers) { + t.stop(); + } + this.openTimers.clear(); + + const endInput = parseArgs>(x, y); const endMsg = endInput.message ?? util.format(this.timingMsgTemplate, this.definition.name, duration.asSec); const endPayload = endInput.payload; await this.notify(this.definition.end.msg( endMsg, { duration: duration.asMs, + ...(Object.keys(this.counters).length > 0 ? { counters: this.counters } : {}), ...endPayload, } as E)); return duration; } + public incCounter(name: string, delta: number = 1): void { + this.counters[name] = (this.counters[name] ?? 0) + delta; + } + public async requestResponse(msg: ActionLessRequest): Promise { return this.ioHelper.requestResponse(withSpanId(this.spanId, msg)); } + public startTimer(name: string): ITimer { + const start = Date.now(); + + const t: ITimer = { + stop: () => { + this.openTimers.delete(t); + this.incCounter(`${name}_ms`, Math.floor(Date.now() - start) / 1000); + this.incCounter(`${name}_cnt`, 1); + }, + }; + this.openTimers.add(t); + return t; + } + private time() { const elapsedTime = new Date().getTime() - this.startTime; return { @@ -190,7 +246,7 @@ class MessageSpan implements IMessageSpan(first: any, second?: S): { message: string | undefined; payload: S } { +function parseArgs(first: any, second?: S): { message: string | undefined; payload: S } { const firstIsMessage = typeof first === 'string'; // When the first argument is a string or we have a second argument, then the first arg is the message diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/tree.ts b/packages/@aws-cdk/toolkit-lib/lib/api/tree.ts index 13f7ffcaa..41e81f5ae 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/tree.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/tree.ts @@ -40,7 +40,7 @@ export async function loadTree(assembly: CloudAssembly, trace: (msg: string) => try { const outdir = assembly.directory; const fileName = assembly.tree()?.file; - return fileName ? fs.readJSONSync(path.join(outdir, fileName)).tree : ({} as ConstructTreeNode); + return fileName ? fs.readJSONSync(path.join(outdir, fileName)).tree : undefined; } catch (e) { await trace(`Failed to get tree.json file: ${e}. Proceeding with empty tree.`); return undefined; diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index c721f49c7..2468ab691 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -1,6 +1,7 @@ import '../private/dispose-polyfill'; import * as path from 'node:path'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; +import { SynthesisMessageLevel } from '@aws-cdk/cloud-assembly-api'; import type { FeatureFlagReportProperties } from '@aws-cdk/cloud-assembly-schema'; import { ArtifactType } from '@aws-cdk/cloud-assembly-schema'; import type { TemplateDiff } from '@aws-cdk/cloudformation-diff'; @@ -71,7 +72,7 @@ import { DiffFormatter } from '../api/diff'; import { detectStackDrift } from '../api/drift'; import { DriftFormatter } from '../api/drift/drift-formatter'; import type { IIoHost, IoMessageLevel, ToolkitAction } from '../api/io'; -import type { ElapsedTime, IoHelper } from '../api/io/private'; +import type { ElapsedTime, IMessageSpan, IoHelper } from '../api/io/private'; import { asIoHelper, IO, SPAN, withoutColor, withoutEmojis, withTrimmedWhitespace } from '../api/io/private'; import { CloudWatchLogEventMonitor, findCloudWatchLogGroups } from '../api/logs-monitor'; import { Mode, PluginHost } from '../api/plugin'; @@ -596,7 +597,8 @@ export class Toolkit extends CloudAssemblySourceBuilder { } // The generated stack has no resources - if (Object.keys(stack.template.Resources || {}).length === 0) { + const resourceCount = Object.keys(stack.template.Resources || {}).length; + if (resourceCount === 0) { // stack is empty and doesn't exist => do nothing const stackExists = await deployments.stackExists({ stack }); if (!stackExists) { @@ -661,6 +663,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { current: stackIndex, stack, }); + deploySpan.incCounter('resources', resourceCount); let tags = options.tags; if (!tags || tags.length === 0) { @@ -1417,7 +1420,9 @@ async function synthAndMeasure( const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: selectStacks }); try { const ret = await assemblyFromSource(synthSpan.asHelper, cx); + countAssemblyResults(synthSpan, ret.assembly); const synthDuration = await synthSpan.end({}); + return Object.assign(ret, { synthDuration }); } catch (error: any) { // End the span even if we had a failure @@ -1429,3 +1434,19 @@ async function synthAndMeasure( function zeroTime(): ElapsedTime { return { asMs: 0, asSec: 0 }; } + +function countAssemblyResults(span: IMessageSpan, assembly: cxapi.CloudAssembly) { + const stacksRecursively = assembly.stacksRecursively; + span.incCounter('stacks', stacksRecursively.length); + span.incCounter('assemblies', asmCount(assembly)); + span.incCounter('errorAnns', sum(stacksRecursively.map(s => s.messages.filter(m => m.level === SynthesisMessageLevel.ERROR).length))); + span.incCounter('warnings', sum(stacksRecursively.map(s => s.messages.filter(m => m.level === SynthesisMessageLevel.WARNING).length))); + + function asmCount(x: cxapi.CloudAssembly): number { + return 1 + x.nestedAssemblies.reduce((acc, asm) => acc + asmCount(asm.nestedAssembly), 0); + } +} + +function sum(xs: number[]) { + return xs.reduce((a, b) => a + b, 0); +} diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts index ab2a6c76b..3ecbe3fef 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts @@ -41,6 +41,22 @@ describe('deploy', () => { successfulDeployment(); }); + test('emits resource counters', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.deploy(cx); + + // THEN + const deploy = ioHost.notifySpy.mock.calls.map(cs => cs[0]).filter(c => c.code === 'CDK_TOOLKIT_I5001'); + expect(deploy).toContainEqual(expect.objectContaining({ + data: expect.objectContaining({ + counters: expect.objectContaining({ + resources: 1, + }), + }), + })); + }); + test('request response contains security diff', async () => { // WHEN const cx = await builderFixture(toolkit, 'stack-with-role'); diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/synth.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/synth.test.ts index 5ee8cfe6c..70aef1fc7 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/synth.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/synth.test.ts @@ -23,6 +23,22 @@ describe('synth', () => { })); }); + test('emits stack counters', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.synth(cx); + + // Separate tests as colorizing hampers detection + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + counters: expect.objectContaining({ + assemblies: 1, + stacks: 2, + }), + }), + })); + }); + test('synth from app', async () => { // WHEN const cx = await appFixture(toolkit, 'two-empty-stacks'); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/prepare-source.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/prepare-source.test.ts index 0a5cf2e1c..df3276787 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/prepare-source.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/prepare-source.test.ts @@ -100,19 +100,11 @@ describe('frameworkSupportsContextOverflow', () => { expect(frameworkSupportsContextOverflow(tree)).toBe(true); }); - test('returns false if any node in the tree is a v1 App', () => { + test('returns false if @aws-cdk/core constructs in the tree indicate v1 App', () => { const tree: ConstructTreeNode = { id: 'root', path: '', children: { - stack1: { - id: 'stack1', - path: 'stack1', - constructInfo: { - fqn: 'aws-cdk-lib.Stack', - version: '2.50.0', - }, - }, nested: { id: 'nested', path: 'nested', @@ -132,19 +124,11 @@ describe('frameworkSupportsContextOverflow', () => { expect(frameworkSupportsContextOverflow(tree)).toBe(false); }); - test('returns false if any node in the tree is a v2 App with version <= 2.38.0', () => { + test('returns false if aws-cdk-lib constructs v2 App with version <= 2.38.0', () => { const tree: ConstructTreeNode = { id: 'root', path: '', children: { - stack1: { - id: 'stack1', - path: 'stack1', - constructInfo: { - fqn: 'aws-cdk-lib.Stack', - version: '2.50.0', - }, - }, nested: { id: 'nested', path: 'nested', diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 3ccdc8547..605825593 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -475,7 +475,8 @@ export class CdkToolkit { ); } - if (Object.keys(stack.template.Resources || {}).length === 0) { + const resourceCount = Object.keys(stack.template.Resources || {}).length; + if (resourceCount === 0) { // The generated stack has no resources if (!(await this.props.deployments.stackExists({ stack }))) { await this.ioHost.asIoHelper().defaults.warn('%s: stack has no resources, skipping deployment.', chalk.bold(stack.displayName)); @@ -542,6 +543,7 @@ export class CdkToolkit { // There is already a startDeployTime constant, but that does not work with telemetry. // We should integrate the two in the future const deploySpan = await this.ioHost.asIoHelper().span(CLI_PRIVATE_SPAN.DEPLOY).begin({}); + deploySpan.incCounter('resources', resourceCount); let error: ErrorDetails | undefined; let elapsedDeployTime = 0; try { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index a0775adb0..8660bb2f2 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -16,7 +16,7 @@ import type { Command } from './user-configuration'; import { Configuration } from './user-configuration'; import { asIoHelper } from '../../lib/api-private'; import type { IReadLock } from '../api'; -import { ToolkitInfo, Notices } from '../api'; +import { ToolkitInfo, Notices, loadTree, findConstructLibraryVersion } from '../api'; import { SdkProvider, IoHostSdkLogger, setSdkTracing, sdkRequestHandler } from '../api/aws-auth'; import type { BootstrapSource } from '../api/bootstrap'; import { Bootstrapper } from '../api/bootstrap'; @@ -37,6 +37,8 @@ import type { ErrorDetails } from './telemetry/schema'; import { isCI } from './util/ci'; import { isDeveloperBuildVersion, versionWithBuild, versionNumber } from './version'; import { getLanguageFromAlias } from '../commands/language'; +import { guessAgent } from './util/guess-agent'; +import { guessLanguage } from './util/guess-language'; export async function exec(args: string[], synthesizer?: Synthesizer): Promise { const argv = await parseCommandLineArguments(args); @@ -112,6 +114,9 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): TelemetryEvent | undefined { eventType, duration: m.data.duration, error: m.data.error, + counters: m.data.counters, }; } } diff --git a/packages/aws-cdk/lib/cli/telemetry/messages.ts b/packages/aws-cdk/lib/cli/telemetry/messages.ts index 3682be875..2f683bb26 100644 --- a/packages/aws-cdk/lib/cli/telemetry/messages.ts +++ b/packages/aws-cdk/lib/cli/telemetry/messages.ts @@ -5,6 +5,11 @@ import type { SpanDefinition } from '../../api-private'; export interface EventResult extends Duration { error?: ErrorDetails; + + /** + * Counts of noteworthy things in this event + */ + counters?: Record; } export interface EventStart { diff --git a/packages/aws-cdk/lib/cli/telemetry/schema.ts b/packages/aws-cdk/lib/cli/telemetry/schema.ts index 5951d8b37..2df6973a0 100644 --- a/packages/aws-cdk/lib/cli/telemetry/schema.ts +++ b/packages/aws-cdk/lib/cli/telemetry/schema.ts @@ -38,6 +38,7 @@ export interface SessionEnvironment { }; readonly ci: boolean; readonly nodeVersion: string; + readonly agent?: boolean; } interface Environment extends SessionEnvironment { @@ -72,6 +73,7 @@ interface Dependency { interface SessionProject { readonly dependencies?: Dependency[]; + readonly language?: string; } interface Project extends SessionProject { diff --git a/packages/aws-cdk/lib/cli/telemetry/session.ts b/packages/aws-cdk/lib/cli/telemetry/session.ts index 1f6087051..cf18273a3 100644 --- a/packages/aws-cdk/lib/cli/telemetry/session.ts +++ b/packages/aws-cdk/lib/cli/telemetry/session.ts @@ -27,6 +27,22 @@ export interface TelemetryEvent { readonly eventType: EventType; readonly duration: number; readonly error?: ErrorDetails; + counters?: Record; +} + +/** + * Timer of a single event + */ +export interface Timing { + /** + * Total time spent in this operation + */ + totalMs: number; + + /** + * Count of operations that together took `totalMs`. + */ + count: number; } export class TelemetrySession { @@ -97,6 +113,52 @@ export class TelemetrySession { }; } + /** + * Attach a language guess + */ + public attachLanguage(language: string | undefined) { + // Don't want to crash accidentally + if (!this._sessionInfo) { + return; + } + + if (language) { + mutable(this.sessionInfo.project).language = language; + } + } + + /** + * Attach our best guess at running under an agent or not + */ + public attachAgent(isAgent: boolean | undefined) { + // Don't want to crash accidentally + if (!this._sessionInfo) { + return; + } + + mutable(this.sessionInfo.environment).agent = isAgent; + } + + /** + * Attach the CDK library version + * + * By default the telemetry will guess at the CDK library version if it so + * happens that the CDK project is an NPM project and the CDK CLI is executed + * in the root of NPM project with `aws-cdk-lib` available in `node_modules`. + * This may succeed or may fail. + * + * Once we have produced and loaded the cloud assembly more accurate + * information becomes available that we can add in. + */ + public attachCdkLibVersion(libVersion: string) { + // Don't want to crash accidentally + if (!this._sessionInfo) { + return; + } + + mutable(this.sessionInfo.identifiers).cdkLibraryVersion = libVersion; + } + /** * 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. @@ -110,6 +172,7 @@ export class TelemetrySession { public async emit(event: TelemetryEvent): Promise { this.count += 1; + return this.client.emit({ event: { command: this.sessionInfo.event.command, @@ -131,6 +194,7 @@ export class TelemetrySession { name: event.error.name, }, } : {}), + ...(event.counters && Object.keys(event.counters).length > 0 ? { counters: event.counters } : {}), }); } @@ -155,3 +219,7 @@ function isAbortedError(error?: ErrorDetails) { } return false; } + +function mutable(x: A): { -readonly [k in keyof A]: A[k] } { + return x; +} diff --git a/packages/aws-cdk/lib/cli/util/guess-agent.ts b/packages/aws-cdk/lib/cli/util/guess-agent.ts new file mode 100644 index 000000000..f87e8f29a --- /dev/null +++ b/packages/aws-cdk/lib/cli/util/guess-agent.ts @@ -0,0 +1,34 @@ + +/** + * Guess whether we're being executed by an AI agent + * + * It's hard for us to say `false` for sure, so we only respond + * with `yes` or `don't know`. + */ +export function guessAgent(): true | undefined { + if ((process.env.AWS_EXECUTION_ENV ?? '').toLocaleLowerCase().includes('amazonq')) { + return true; + } + + if (process.env.CLAUDECODE) { + return true; + } + + // Expecting CODEX_SANDBOX, CODEX_THREAD_ID + if (Object.keys(process.env).some(x => x.startsWith('CODEX_'))) { + return true; + } + + if (process.env.CURSOR_AGENT) { + return true; + } + + // Cline -- not sure if it sets these, but users might to configure Cline. + if (Object.keys(process.env).some(x => x.startsWith('CLINE_'))) { + return true; + } + + // Copilot doesn't set an envvar (at least not in VS Code) + + return undefined; +} diff --git a/packages/aws-cdk/lib/cli/util/guess-language.ts b/packages/aws-cdk/lib/cli/util/guess-language.ts new file mode 100644 index 000000000..0bd0a18e6 --- /dev/null +++ b/packages/aws-cdk/lib/cli/util/guess-language.ts @@ -0,0 +1,61 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; + +/** + * Guess the CDK app language based on the files in the given directory + * + * Returns `undefined` if our guess fails. + */ +export async function guessLanguage(dir: string): Promise { + try { + const files = new Set(await listFiles(dir, 2)); + + if (files.has('package.json')) { + const pjContents = await JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf-8')); + const deps = new Set([ + ...Object.keys(pjContents.dependencies ?? {}), + ...Object.keys(pjContents.devDependencies ?? {}), + ]); + if (deps.has('typescript') || deps.has('ts-node') || deps.has('tsx') || deps.has('swc')) { + return 'typescript'; + } else { + return 'javascript'; + } + } + + if (files.has('requirements.txt') || files.has('setup.py') || files.has('pyproject.toml')) { + return 'python'; + } + + if (files.has('pom.xml') || files.has('build.xml') || files.has('settings.gradle')) { + return 'java'; + } + + if (Array.from(files).some(n => n.endsWith('.sln') || n.endsWith('.csproj') || n.endsWith('.fsproj') || n.endsWith('.vbproj'))) { + return 'dotnet'; + } + + if (files.has('go.mod')) { + return 'go'; + } + } catch { + // Swallow failure + } + return undefined; + + async function listFiles(dirName: string, depth: number): Promise { + const ret = await fs.readdir(dirName, { encoding: 'utf-8', withFileTypes: true }); + + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + return (await Promise.all(ret.map(async (f) => { + if (f.isDirectory()) { + if (depth <= 1) { + return Promise.resolve([]); + } + return listFiles(path.join(dirName, f.name), depth - 1); + } else { + return Promise.resolve([f.name]); + } + }))).flatMap(xs => xs); + } +} diff --git a/packages/aws-cdk/lib/cxapp/cloud-executable.ts b/packages/aws-cdk/lib/cxapp/cloud-executable.ts index acb1c6390..6c692914f 100644 --- a/packages/aws-cdk/lib/cxapp/cloud-executable.ts +++ b/packages/aws-cdk/lib/cxapp/cloud-executable.ts @@ -1,8 +1,9 @@ import type * as cxapi from '@aws-cdk/cloud-assembly-api'; +import { SynthesisMessageLevel } from '@aws-cdk/cloud-assembly-api'; import { ToolkitError } from '@aws-cdk/toolkit-lib'; import { CloudAssembly } from './cloud-assembly'; import type { ICloudAssemblySource, IReadableCloudAssembly } from '../../lib/api'; -import type { IoHelper } from '../../lib/api-private'; +import type { IMessageSpan, 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'; @@ -110,26 +111,33 @@ export class CloudExecutable implements ICloudAssemblySource { previouslyMissingKeys = missingKeys; if (tryLookup) { - await this.props.ioHelper.defaults.debug('Some context information is missing. Fetching...'); - - const updates = await contextproviders.provideContextValues( - assembly.manifest.missing, - this.props.sdkProvider, - GLOBAL_PLUGIN_HOST, - this.props.ioHelper, - ); - - for (const [key, value] of Object.entries(updates)) { - this.props.configuration.context.set(key, value); + const lookupsTimer = synthSpan.startTimer('lookups'); + try { + await this.props.ioHelper.defaults.debug('Some context information is missing. Fetching...'); + + const updates = await contextproviders.provideContextValues( + assembly.manifest.missing, + this.props.sdkProvider, + GLOBAL_PLUGIN_HOST, + this.props.ioHelper, + ); + + for (const [key, value] of Object.entries(updates)) { + this.props.configuration.context.set(key, value); + } + + // Cache the new context to disk + await this.props.configuration.saveContext(); + } finally { + lookupsTimer.stop(); } - // Cache the new context to disk - await this.props.configuration.saveContext(); - // Execute again continue; } } + + countAssemblyResults(synthSpan, assembly); return new CloudAssembly(assembly, this.props.ioHelper); } } catch (e: any) { @@ -165,3 +173,21 @@ function setsEqual(a: Set, b: Set) { } return true; } + +function countAssemblyResults(span: IMessageSpan, assembly: cxapi.CloudAssembly) { + const stacksRecursively = assembly.stacksRecursively; + + span.incCounter('stacks', stacksRecursively.length); + span.incCounter('errorAnns', sum(stacksRecursively.map(s => s.messages.filter(m => m.level === SynthesisMessageLevel.ERROR).length))); + span.incCounter('warnings', sum(stacksRecursively.map(s => s.messages.filter(m => m.level === SynthesisMessageLevel.WARNING).length))); + + span.incCounter('assemblies', asmCount(assembly)); + + function asmCount(x: cxapi.CloudAssembly): number { + return 1 + x.nestedAssemblies.reduce((acc, asm) => acc + asmCount(asm.nestedAssembly), 0); + } +} + +function sum(xs: number[]) { + return xs.reduce((a, b) => a + b, 0); +} diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index f43942cea..7ade34186 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -571,6 +571,27 @@ describe('deploy', () => { }); }); + test('emits resource counters', async () => { + // GIVEN + const toolkit = defaultToolkitSetup(); + + // WHEN + await toolkit.deploy({ + selector: { patterns: ['Test-Stack-B'] }, + deploymentMethod: { method: 'change-set' }, + }); + + // THEN + const deploy = notifySpy.mock.calls.map(cs => cs[0]).filter(c => c.code === 'CDK_CLI_I3001'); + expect(deploy).toContainEqual(expect.objectContaining({ + data: expect.objectContaining({ + counters: expect.objectContaining({ + resources: 1, + }), + }), + })); + }); + test('globless bootstrap uses environment without question', async () => { // GIVEN const toolkit = defaultToolkitSetup(); 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 2305a94bb..e71850f31 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 @@ -7,6 +7,7 @@ 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'; +import { CLI_PRIVATE_IO } from '../../../lib/cli/telemetry/messages'; let passThrough: PassThrough; @@ -382,6 +383,28 @@ describe('CliIoHost', () => { expect(telemetryEmitSpy).not.toHaveBeenCalled(); }); + test('emit telemetry with counters', async () => { + // Create a message that should trigger telemetry using the actual message code + const message = { + ...CLI_PRIVATE_IO.CDK_CLI_I1001.msg('telemetry message', { + duration: 123, + counters: { + tests: 15, + }, + }), + action: 'synth' as const, + }; + + // 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', + counters: { tests: 15 }, + })); + }); + test('emit telemetry with error name', async () => { // Create a message that should trigger telemetry using the actual message code const message: IoMessage = { diff --git a/packages/aws-cdk/test/cli/telemetry/session.test.ts b/packages/aws-cdk/test/cli/telemetry/session.test.ts index 009b504f7..5343161ff 100644 --- a/packages/aws-cdk/test/cli/telemetry/session.test.ts +++ b/packages/aws-cdk/test/cli/telemetry/session.test.ts @@ -137,6 +137,54 @@ describe('TelemetrySession', () => { // THEN expect(clientFlushSpy).toHaveBeenCalledTimes(1); }); + + test('attach cdk library version', async () => { + session.attachCdkLibVersion('1.2.3'); + + await session.emit({ + eventType: 'SYNTH', + duration: 1234, + }); + + // THEN + expect(clientEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + identifiers: expect.objectContaining({ + cdkLibraryVersion: '1.2.3', + }), + })); + }); + + test('attach language', async () => { + session.attachLanguage('basic'); + + await session.emit({ + eventType: 'SYNTH', + duration: 1234, + }); + + // THEN + expect(clientEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + project: expect.objectContaining({ + language: 'basic', + }), + })); + }); + + test('attack agent', async () => { + session.attachAgent(true); + + await session.emit({ + eventType: 'SYNTH', + duration: 1234, + }); + + // THEN + expect(clientEmitSpy).toHaveBeenCalledWith(expect.objectContaining({ + environment: expect.objectContaining({ + agent: true, + }), + })); + }); }); test('ci is recorded properly - true', async () => { diff --git a/packages/aws-cdk/test/cxapp/cloud-executable.test.ts b/packages/aws-cdk/test/cxapp/cloud-executable.test.ts index ff1323065..8a13f128a 100644 --- a/packages/aws-cdk/test/cxapp/cloud-executable.test.ts +++ b/packages/aws-cdk/test/cxapp/cloud-executable.test.ts @@ -3,6 +3,13 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import { DefaultSelection } from '../../lib/cxapp/cloud-assembly'; import { registerContextProvider } from '../../lib/context-providers'; import { MockCloudExecutable } from '../_helpers/assembly'; +import { TestIoHost } from '../_helpers/io-host'; + +let ioHost: TestIoHost; +beforeEach(() => { + jest.restoreAllMocks(); + ioHost = new TestIoHost('trace'); +}); describe('AWS::CDK::Metadata', () => { test('is not generated for new frameworks', async () => { @@ -27,13 +34,19 @@ test('stop executing if context providers are not making progress', async () => }); const cloudExecutable = await MockCloudExecutable.create({ - stacks: [{ - stackName: 'thestack', - template: { resource: 'noerrorresource' }, - }], + stacks: [ + { + stackName: 'thestack', + template: { resource: 'noerrorresource' }, + }, + ], // Always return the same missing keys, synthesis should still finish. missing: [ - { key: 'abcdef', props: { account: '1324', region: 'us-east-1' }, provider: cxschema.ContextProvider.AVAILABILITY_ZONE_PROVIDER }, + { + key: 'abcdef', + props: { account: '1324', region: 'us-east-1' }, + provider: cxschema.ContextProvider.AVAILABILITY_ZONE_PROVIDER, + }, ], }); const cxasm = await cloudExecutable.synthesize(); @@ -47,13 +60,19 @@ test('stop executing if context providers are not making progress', async () => test('fails if lookups are disabled and missing context is synthesized', async () => { // GIVEN const cloudExecutable = await MockCloudExecutable.create({ - stacks: [{ - stackName: 'thestack', - template: { resource: 'noerrorresource' }, - }], + stacks: [ + { + stackName: 'thestack', + template: { resource: 'noerrorresource' }, + }, + ], // Always return the same missing keys, synthesis should still finish. missing: [ - { key: 'abcdef', props: { account: '1324', region: 'us-east-1' }, provider: cxschema.ContextProvider.AVAILABILITY_ZONE_PROVIDER }, + { + key: 'abcdef', + props: { account: '1324', region: 'us-east-1' }, + provider: cxschema.ContextProvider.AVAILABILITY_ZONE_PROVIDER, + }, ], }); cloudExecutable.configuration.settings.set(['lookups'], false); @@ -62,31 +81,57 @@ test('fails if lookups are disabled and missing context is synthesized', async ( await expect(cloudExecutable.synthesize()).rejects.toThrow(/Context lookups have been disabled/); }); -async function testCloudExecutable( - { env, versionReporting = true, schemaVersion }: - { env?: string; versionReporting?: boolean; schemaVersion?: string } = {}, -) { - const cloudExec = await MockCloudExecutable.create({ - stacks: [{ - stackName: 'withouterrors', - env, - template: { resource: 'noerrorresource' }, - }, +test('emits stack counters', async () => { + const cx = await testCloudExecutable({ + env: 'aws://012345678912/us-east-1', + versionReporting: true, + schemaVersion: '8.0.0', + }); + await cx.synthesize(); + + // Separate tests as colorizing hampers detection + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + counters: expect.objectContaining({ + assemblies: 1, + stacks: 2, + }), + }), + })); +}); + +async function testCloudExecutable({ + env, + versionReporting = true, + schemaVersion, +}: { env?: string; versionReporting?: boolean; schemaVersion?: string } = {}) { + const cloudExec = await MockCloudExecutable.create( { - stackName: 'witherrors', - env, - template: { resource: 'errorresource' }, - metadata: { - '/resource': [ - { - type: cxschema.ArtifactMetadataEntryType.ERROR, - data: 'this is an error', + stacks: [ + { + stackName: 'withouterrors', + env, + template: { resource: 'noerrorresource' }, + }, + { + stackName: 'witherrors', + env, + template: { resource: 'errorresource' }, + metadata: { + '/resource': [ + { + type: cxschema.ArtifactMetadataEntryType.ERROR, + data: 'this is an error', + }, + ], }, - ], - }, - }], - schemaVersion, - }); + }, + ], + schemaVersion, + }, + undefined, + ioHost, + ); cloudExec.configuration.settings.set(['versionReporting'], versionReporting); return cloudExec;