From 124be1cc9c6b166a9266c13232ddff8dd9c195c7 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Thu, 6 Oct 2022 12:50:17 +0000 Subject: [PATCH] refactor: add build and rebuild related statistics and analytics The new build and rebuild statistics are used by the CLI to submit build related information to GA. --- docs/design/analytics.md | 9 +- .../angular_devkit/build_angular/index.md | 2 + .../angular_devkit/build_webpack/index.md | 1 + .../cli/src/analytics/analytics-collector.ts | 1 + .../cli/src/analytics/analytics-parameters.ts | 3 + .../architect-base-command-module.ts | 79 +++- .../src/builders/browser-esbuild/schema.json | 1 + .../src/builders/browser/index.ts | 373 ++++++++++-------- .../src/builders/browser/schema.json | 1 + .../src/builders/dev-server/index.ts | 18 +- .../src/webpack/configs/common.ts | 11 +- .../src/webpack/plugins/occurrences-plugin.ts | 98 +++++ .../build_angular/src/webpack/utils/stats.ts | 87 +++- .../src/webpack-dev-server/index.ts | 6 + 14 files changed, 497 insertions(+), 193 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/webpack/plugins/occurrences-plugin.ts diff --git a/docs/design/analytics.md b/docs/design/analytics.md index de136fd071..3eeb19bb3b 100644 --- a/docs/design/analytics.md +++ b/docs/design/analytics.md @@ -45,7 +45,7 @@ PROJECT NAME TO BUILD OR A MODULE NAME.** | User-scoped custom dimensions | 25 | | All custom metrics | 50 | -### List Of User Custom Dimensions +### List of User Custom Dimensions | Name | Parameter | Type | @@ -63,7 +63,7 @@ PROJECT NAME TO BUILD OR A MODULE NAME.** | Optimization | `ep.ng_optimization` | `string` | -### List Of Event Custom Dimensions +### List of Event Custom Dimensions | Name | Parameter | Type | @@ -81,7 +81,7 @@ PROJECT NAME TO BUILD OR A MODULE NAME.** | Optimization | `ep.ng_optimization` | `string` | -### List Of Event Custom Metrics +### List of Event Custom Metrics | Name | Parameter | Type | @@ -91,6 +91,9 @@ PROJECT NAME TO BUILD OR A MODULE NAME.** | InitialChunksCount | `epn.ng_initial_chunks_count` | `number` | | ChangedChunksCount | `epn.ng_changed_chunks_count` | `number` | | DurationInMs | `epn.ng_duration_ms` | `number` | +| CssSizeInBytes | `epn.ng_css_size_bytes` | `number` | +| JsSizeInBytes | `epn.ng_js_size_bytes` | `number` | +| NgComponentCount | `epn.ng_component_count` | `number` | ## Debugging diff --git a/goldens/public-api/angular_devkit/build_angular/index.md b/goldens/public-api/angular_devkit/build_angular/index.md index e34dfbd7ca..1fdb8b73e0 100644 --- a/goldens/public-api/angular_devkit/build_angular/index.md +++ b/goldens/public-api/angular_devkit/build_angular/index.md @@ -73,6 +73,7 @@ export interface BrowserBuilderOptions { // @public export type BrowserBuilderOutput = BuilderOutput & { + stats: BuildEventStats; baseOutputPath: string; outputPaths: string[]; outputPath: string; @@ -112,6 +113,7 @@ export type DevServerBuilderOptions = Schema; // @public export type DevServerBuilderOutput = DevServerBuildOutput & { baseUrl: string; + stats: BuildEventStats; }; // @public diff --git a/goldens/public-api/angular_devkit/build_webpack/index.md b/goldens/public-api/angular_devkit/build_webpack/index.md index 312a1a6d31..8a3fe489f1 100644 --- a/goldens/public-api/angular_devkit/build_webpack/index.md +++ b/goldens/public-api/angular_devkit/build_webpack/index.md @@ -49,6 +49,7 @@ export function runWebpack(config: webpack.Configuration, context: BuilderContex // @public (undocumented) export function runWebpackDevServer(config: webpack.Configuration, context: BuilderContext, options?: { + shouldProvideStats?: boolean; devServerConfig?: WebpackDevServer.Configuration; logging?: WebpackLoggingCallback; webpackFactory?: WebpackFactory; diff --git a/packages/angular/cli/src/analytics/analytics-collector.ts b/packages/angular/cli/src/analytics/analytics-collector.ts index 2d6a98b337..e80f23814f 100644 --- a/packages/angular/cli/src/analytics/analytics-collector.ts +++ b/packages/angular/cli/src/analytics/analytics-collector.ts @@ -34,6 +34,7 @@ export class AnalyticsCollector { const requestParameters: Partial> = { [RequestParameter.ProtocolVersion]: 2, [RequestParameter.ClientId]: userId, + [RequestParameter.UserId]: userId, [RequestParameter.TrackingId]: /^\d+\.\d+\.\d+$/.test(VERSION.full) && VERSION.full !== '0.0.0' ? TRACKING_ID_PROD diff --git a/packages/angular/cli/src/analytics/analytics-parameters.ts b/packages/angular/cli/src/analytics/analytics-parameters.ts index 471590b733..d05918e510 100644 --- a/packages/angular/cli/src/analytics/analytics-parameters.ts +++ b/packages/angular/cli/src/analytics/analytics-parameters.ts @@ -94,4 +94,7 @@ export enum EventCustomMetric { InitialChunksCount = 'epn.ng_initial_chunks_count', ChangedChunksCount = 'epn.ng_changed_chunks_count', DurationInMs = 'epn.ng_duration_ms', + CssSizeInBytes = 'epn.ng_css_size_bytes', + JsSizeInBytes = 'epn.ng_js_size_bytes', + NgComponentCount = 'epn.ng_component_count', } diff --git a/packages/angular/cli/src/command-builder/architect-base-command-module.ts b/packages/angular/cli/src/command-builder/architect-base-command-module.ts index 017dfc57e0..59e0852402 100644 --- a/packages/angular/cli/src/command-builder/architect-base-command-module.ts +++ b/packages/angular/cli/src/command-builder/architect-base-command-module.ts @@ -16,7 +16,7 @@ import { spawnSync } from 'child_process'; import { existsSync } from 'fs'; import { resolve } from 'path'; import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; -import { EventCustomDimension } from '../analytics/analytics-collector'; +import { EventCustomDimension, EventCustomMetric } from '../analytics/analytics-parameters'; import { assertIsError } from '../utilities/error'; import { askConfirmation, askQuestion } from '../utilities/prompt'; import { isTTY } from '../utilities/tty'; @@ -62,18 +62,79 @@ export abstract class ArchitectBaseCommandModule ? await this.getAnalytics() : undefined; - analytics?.reportArchitectRunEvent({ - [EventCustomDimension.BuilderTarget]: builderName, - }); + let outputSubscription; + if (analytics) { + analytics.reportArchitectRunEvent({ + [EventCustomDimension.BuilderTarget]: builderName, + }); - const { error, success } = await run.output.toPromise(); - await run.stop(); + let firstRun = true; + outputSubscription = run.output.subscribe(({ stats }) => { + const parameters = this.builderStatsToAnalyticsParameters(stats, builderName); + if (!parameters) { + return; + } - if (error) { - logger.error(error); + if (firstRun) { + firstRun = false; + analytics.reportBuildRunEvent(parameters); + } else { + analytics.reportRebuildRunEvent(parameters); + } + }); } - return success ? 0 : 1; + try { + const { error, success } = await run.output.toPromise(); + + if (error) { + logger.error(error); + } + + return success ? 0 : 1; + } finally { + await run.stop(); + outputSubscription?.unsubscribe(); + } + } + + private builderStatsToAnalyticsParameters( + stats: json.JsonValue, + builderName: string, + ): Partial< + | Record + | undefined + > { + if (!stats || typeof stats !== 'object' || !('durationInMs' in stats)) { + return undefined; + } + + const { + optimization, + allChunksCount, + aot, + lazyChunksCount, + initialChunksCount, + durationInMs, + changedChunksCount, + cssSizeInBytes, + jsSizeInBytes, + ngComponentCount, + } = stats; + + return { + [EventCustomDimension.BuilderTarget]: builderName, + [EventCustomDimension.Aot]: aot, + [EventCustomDimension.Optimization]: optimization, + [EventCustomMetric.AllChunksCount]: allChunksCount, + [EventCustomMetric.LazyChunksCount]: lazyChunksCount, + [EventCustomMetric.InitialChunksCount]: initialChunksCount, + [EventCustomMetric.ChangedChunksCount]: changedChunksCount, + [EventCustomMetric.DurationInMs]: durationInMs, + [EventCustomMetric.JsSizeInBytes]: jsSizeInBytes, + [EventCustomMetric.CssSizeInBytes]: cssSizeInBytes, + [EventCustomMetric.NgComponentCount]: ngComponentCount, + }; } private _architectHost: WorkspaceNodeModulesArchitectHost | undefined; diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json index c1174d0368..bd78395f31 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json @@ -142,6 +142,7 @@ "optimization": { "description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.io/guide/workspace-config#optimization-configuration.", "default": true, + "x-user-analytics": "ep.ng_optimization", "oneOf": [ { "type": "object", diff --git a/packages/angular_devkit/build_angular/src/builders/browser/index.ts b/packages/angular_devkit/build_angular/src/builders/browser/index.ts index ee338c0c34..30bf61c889 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/index.ts @@ -12,7 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { Observable, from } from 'rxjs'; import { concatMap, map, switchMap } from 'rxjs/operators'; -import webpack from 'webpack'; +import webpack, { StatsCompilation } from 'webpack'; import { ExecutionTransformer } from '../../transforms'; import { deleteOutputDir, @@ -51,6 +51,8 @@ import { getCommonConfig, getStylesConfig } from '../../webpack/configs'; import { markAsyncChunksNonInitial } from '../../webpack/utils/async-chunks'; import { normalizeExtraEntryPoints } from '../../webpack/utils/helpers'; import { + BuildEventStats, + generateBuildEventStats, statsErrorsToString, statsHasErrors, statsHasWarnings, @@ -63,6 +65,8 @@ import { Schema as BrowserBuilderSchema } from './schema'; * @experimental Direct usage of this type is considered experimental. */ export type BrowserBuilderOutput = BuilderOutput & { + stats: BuildEventStats; + baseOutputPath: string; /** * @deprecated in version 14. Use 'outputs' instead. @@ -198,196 +202,231 @@ export function buildWebpackBrowser( } }), }).pipe( - concatMap(async (buildEvent) => { - const spinner = new Spinner(); - spinner.enabled = options.progress !== false; + concatMap( + // eslint-disable-next-line max-lines-per-function + async ( + buildEvent, + ): Promise<{ output: BuilderOutput; webpackStats: StatsCompilation }> => { + const spinner = new Spinner(); + spinner.enabled = options.progress !== false; - const { success, emittedFiles = [], outputPath: webpackOutputPath } = buildEvent; - const webpackRawStats = buildEvent.webpackStats; - if (!webpackRawStats) { - throw new Error('Webpack stats build result is required.'); - } - - // Fix incorrectly set `initial` value on chunks. - const extraEntryPoints = [ - ...normalizeExtraEntryPoints(options.styles || [], 'styles'), - ...normalizeExtraEntryPoints(options.scripts || [], 'scripts'), - ]; - - const webpackStats = { - ...webpackRawStats, - chunks: markAsyncChunksNonInitial(webpackRawStats, extraEntryPoints), - }; - - if (!success) { - // If using bundle downleveling then there is only one build - // If it fails show any diagnostic messages and bail - if (statsHasWarnings(webpackStats)) { - context.logger.warn(statsWarningsToString(webpackStats, { colors: true })); - } - if (statsHasErrors(webpackStats)) { - context.logger.error(statsErrorsToString(webpackStats, { colors: true })); + const { success, emittedFiles = [], outputPath: webpackOutputPath } = buildEvent; + const webpackRawStats = buildEvent.webpackStats; + if (!webpackRawStats) { + throw new Error('Webpack stats build result is required.'); } - return { success }; - } else { - outputPaths = ensureOutputPaths(baseOutputPath, i18n); + // Fix incorrectly set `initial` value on chunks. + const extraEntryPoints = [ + ...normalizeExtraEntryPoints(options.styles || [], 'styles'), + ...normalizeExtraEntryPoints(options.scripts || [], 'scripts'), + ]; - const scriptsEntryPointName = normalizeExtraEntryPoints( - options.scripts || [], - 'scripts', - ).map((x) => x.bundleName); + const webpackStats = { + ...webpackRawStats, + chunks: markAsyncChunksNonInitial(webpackRawStats, extraEntryPoints), + }; - if (i18n.shouldInline) { - const success = await i18nInlineEmittedFiles( - context, - emittedFiles, - i18n, - baseOutputPath, - Array.from(outputPaths.values()), - scriptsEntryPointName, - webpackOutputPath, - options.i18nMissingTranslation, - ); - if (!success) { - return { success: false }; + if (!success) { + // If using bundle downleveling then there is only one build + // If it fails show any diagnostic messages and bail + if (statsHasWarnings(webpackStats)) { + context.logger.warn(statsWarningsToString(webpackStats, { colors: true })); } - } - - // Check for budget errors and display them to the user. - const budgets = options.budgets; - let budgetFailures: BudgetCalculatorResult[] | undefined; - if (budgets?.length) { - budgetFailures = [...checkBudgets(budgets, webpackStats)]; - for (const { severity, message } of budgetFailures) { - switch (severity) { - case ThresholdSeverity.Warning: - webpackStats.warnings?.push({ message }); - break; - case ThresholdSeverity.Error: - webpackStats.errors?.push({ message }); - break; - default: - assertNever(severity); - } + if (statsHasErrors(webpackStats)) { + context.logger.error(statsErrorsToString(webpackStats, { colors: true })); } - } - const buildSuccess = success && !statsHasErrors(webpackStats); - if (buildSuccess) { - // Copy assets - if (!options.watch && options.assets?.length) { - spinner.start('Copying assets...'); - try { - await copyAssets( - normalizeAssetPatterns( - options.assets, - context.workspaceRoot, - projectRoot, - projectSourceRoot, - ), - Array.from(outputPaths.values()), - context.workspaceRoot, - ); - spinner.succeed('Copying assets complete.'); - } catch (err) { - spinner.fail(colors.redBright('Copying of assets failed.')); - assertIsError(err); + return { + webpackStats: webpackRawStats, + output: { success: false }, + }; + } else { + outputPaths = ensureOutputPaths(baseOutputPath, i18n); - return { success: false, error: 'Unable to copy assets: ' + err.message }; + const scriptsEntryPointName = normalizeExtraEntryPoints( + options.scripts || [], + 'scripts', + ).map((x) => x.bundleName); + + if (i18n.shouldInline) { + const success = await i18nInlineEmittedFiles( + context, + emittedFiles, + i18n, + baseOutputPath, + Array.from(outputPaths.values()), + scriptsEntryPointName, + webpackOutputPath, + options.i18nMissingTranslation, + ); + if (!success) { + return { + webpackStats: webpackRawStats, + output: { success: false }, + }; } } - if (options.index) { - spinner.start('Generating index html...'); + // Check for budget errors and display them to the user. + const budgets = options.budgets; + let budgetFailures: BudgetCalculatorResult[] | undefined; + if (budgets?.length) { + budgetFailures = [...checkBudgets(budgets, webpackStats)]; + for (const { severity, message } of budgetFailures) { + switch (severity) { + case ThresholdSeverity.Warning: + webpackStats.warnings?.push({ message }); + break; + case ThresholdSeverity.Error: + webpackStats.errors?.push({ message }); + break; + default: + assertNever(severity); + } + } + } - const entrypoints = generateEntryPoints({ - scripts: options.scripts ?? [], - styles: options.styles ?? [], - }); - - const indexHtmlGenerator = new IndexHtmlGenerator({ - cache: cacheOptions, - indexPath: path.join(context.workspaceRoot, getIndexInputFile(options.index)), - entrypoints, - deployUrl: options.deployUrl, - sri: options.subresourceIntegrity, - optimization: normalizedOptimization, - crossOrigin: options.crossOrigin, - postTransform: transforms.indexHtml, - }); - - let hasErrors = false; - for (const [locale, outputPath] of outputPaths.entries()) { + const buildSuccess = success && !statsHasErrors(webpackStats); + if (buildSuccess) { + // Copy assets + if (!options.watch && options.assets?.length) { + spinner.start('Copying assets...'); try { - const { content, warnings, errors } = await indexHtmlGenerator.process({ - baseHref: getLocaleBaseHref(i18n, locale) ?? options.baseHref, - // i18nLocale is used when Ivy is disabled - lang: locale || undefined, - outputPath, - files: mapEmittedFilesToFileInfo(emittedFiles), - }); + await copyAssets( + normalizeAssetPatterns( + options.assets, + context.workspaceRoot, + projectRoot, + projectSourceRoot, + ), + Array.from(outputPaths.values()), + context.workspaceRoot, + ); + spinner.succeed('Copying assets complete.'); + } catch (err) { + spinner.fail(colors.redBright('Copying of assets failed.')); + assertIsError(err); - if (warnings.length || errors.length) { - spinner.stop(); - warnings.forEach((m) => context.logger.warn(m)); - errors.forEach((m) => { - context.logger.error(m); - hasErrors = true; + return { + output: { + success: false, + error: 'Unable to copy assets: ' + err.message, + }, + webpackStats: webpackRawStats, + }; + } + } + + if (options.index) { + spinner.start('Generating index html...'); + + const entrypoints = generateEntryPoints({ + scripts: options.scripts ?? [], + styles: options.styles ?? [], + }); + + const indexHtmlGenerator = new IndexHtmlGenerator({ + cache: cacheOptions, + indexPath: path.join(context.workspaceRoot, getIndexInputFile(options.index)), + entrypoints, + deployUrl: options.deployUrl, + sri: options.subresourceIntegrity, + optimization: normalizedOptimization, + crossOrigin: options.crossOrigin, + postTransform: transforms.indexHtml, + }); + + let hasErrors = false; + for (const [locale, outputPath] of outputPaths.entries()) { + try { + const { content, warnings, errors } = await indexHtmlGenerator.process({ + baseHref: getLocaleBaseHref(i18n, locale) ?? options.baseHref, + // i18nLocale is used when Ivy is disabled + lang: locale || undefined, + outputPath, + files: mapEmittedFilesToFileInfo(emittedFiles), }); - spinner.start(); - } - const indexOutput = path.join(outputPath, getIndexOutputFile(options.index)); - await fs.promises.mkdir(path.dirname(indexOutput), { recursive: true }); - await fs.promises.writeFile(indexOutput, content); - } catch (error) { + if (warnings.length || errors.length) { + spinner.stop(); + warnings.forEach((m) => context.logger.warn(m)); + errors.forEach((m) => { + context.logger.error(m); + hasErrors = true; + }); + spinner.start(); + } + + const indexOutput = path.join( + outputPath, + getIndexOutputFile(options.index), + ); + await fs.promises.mkdir(path.dirname(indexOutput), { recursive: true }); + await fs.promises.writeFile(indexOutput, content); + } catch (error) { + spinner.fail('Index html generation failed.'); + assertIsError(error); + + return { + webpackStats: webpackRawStats, + output: { success: false, error: error.message }, + }; + } + } + + if (hasErrors) { spinner.fail('Index html generation failed.'); - return { success: false, error: mapErrorToMessage(error) }; + return { + webpackStats: webpackRawStats, + output: { success: false }, + }; + } else { + spinner.succeed('Index html generation complete.'); } } - if (hasErrors) { - spinner.fail('Index html generation failed.'); + if (options.serviceWorker) { + spinner.start('Generating service worker...'); + for (const [locale, outputPath] of outputPaths.entries()) { + try { + await augmentAppWithServiceWorker( + projectRoot, + context.workspaceRoot, + outputPath, + getLocaleBaseHref(i18n, locale) ?? options.baseHref ?? '/', + options.ngswConfigPath, + ); + } catch (error) { + spinner.fail('Service worker generation failed.'); + assertIsError(error); - return { success: false }; - } else { - spinner.succeed('Index html generation complete.'); - } - } - - if (options.serviceWorker) { - spinner.start('Generating service worker...'); - for (const [locale, outputPath] of outputPaths.entries()) { - try { - await augmentAppWithServiceWorker( - projectRoot, - context.workspaceRoot, - outputPath, - getLocaleBaseHref(i18n, locale) ?? options.baseHref ?? '/', - options.ngswConfigPath, - ); - } catch (error) { - spinner.fail('Service worker generation failed.'); - - return { success: false, error: mapErrorToMessage(error) }; + return { + webpackStats: webpackRawStats, + output: { success: false, error: error.message }, + }; + } } - } - spinner.succeed('Service worker generation complete.'); + spinner.succeed('Service worker generation complete.'); + } } + + webpackStatsLogger(context.logger, webpackStats, config, budgetFailures); + + return { + webpackStats: webpackRawStats, + output: { success: buildSuccess }, + }; } - - webpackStatsLogger(context.logger, webpackStats, config, budgetFailures); - - return { success: buildSuccess }; - } - }), + }, + ), map( - (event) => + ({ output: event, webpackStats }) => ({ ...event, + stats: generateBuildEventStats(webpackStats, options), baseOutputPath, outputPath: baseOutputPath, outputPaths: (outputPaths && Array.from(outputPaths.values())) || [baseOutputPath], @@ -416,18 +455,6 @@ export function buildWebpackBrowser( } } -function mapErrorToMessage(error: unknown): string | undefined { - if (error instanceof Error) { - return error.message; - } - - if (typeof error === 'string') { - return error; - } - - return undefined; -} - function assertNever(input: never): never { throw new Error( `Unexpected call to assertNever() with input: ${JSON.stringify( diff --git a/packages/angular_devkit/build_angular/src/builders/browser/schema.json b/packages/angular_devkit/build_angular/src/builders/browser/schema.json index 248abe8003..c0dc1e719e 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser/schema.json @@ -134,6 +134,7 @@ "optimization": { "description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.io/guide/workspace-config#optimization-configuration.", "default": true, + "x-user-analytics": "ep.ng_optimization", "oneOf": [ { "type": "object", diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts index cf76d3610e..bf8b74cf31 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts @@ -39,7 +39,11 @@ import { addError, addWarning } from '../../utils/webpack-diagnostics'; import { getCommonConfig, getDevServerConfig, getStylesConfig } from '../../webpack/configs'; import { IndexHtmlWebpackPlugin } from '../../webpack/plugins/index-html-webpack-plugin'; import { ServiceWorkerPlugin } from '../../webpack/plugins/service-worker-plugin'; -import { createWebpackLoggingCallback } from '../../webpack/utils/stats'; +import { + BuildEventStats, + createWebpackLoggingCallback, + generateBuildEventStats, +} from '../../webpack/utils/stats'; import { Schema as BrowserBuilderSchema, OutputHashing } from '../browser/schema'; import { Schema } from './schema'; @@ -50,6 +54,7 @@ export type DevServerBuilderOptions = Schema; */ export type DevServerBuilderOutput = DevServerBuildOutput & { baseUrl: string; + stats: BuildEventStats; }; /** @@ -252,6 +257,11 @@ export function serveWebpackBrowser( webpackDevServerFactory: require('webpack-dev-server') as typeof webpackDevServer, }).pipe( concatMap(async (buildEvent, index) => { + const webpackRawStats = buildEvent.webpackStats; + if (!webpackRawStats) { + throw new Error('Webpack stats build result is required.'); + } + // Resolve serve address. const publicPath = webpackConfig.devServer?.devMiddleware?.publicPath; @@ -286,7 +296,11 @@ export function serveWebpackBrowser( logger.info(`\n${colors.redBright(colors.symbols.cross)} Failed to compile.`); } - return { ...buildEvent, baseUrl: serverAddress } as DevServerBuilderOutput; + return { + ...buildEvent, + baseUrl: serverAddress, + stats: generateBuildEventStats(webpackRawStats, browserOptions), + } as DevServerBuilderOutput; }), ); }), diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts index 6076a970c2..6b7c85c021 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts @@ -30,6 +30,7 @@ import { } from '../plugins'; import { DevToolsIgnorePlugin } from '../plugins/devtools-ignore-plugin'; import { NamedChunksPlugin } from '../plugins/named-chunks-plugin'; +import { OccurrencesPlugin } from '../plugins/occurrences-plugin'; import { ProgressPlugin } from '../plugins/progress-plugin'; import { TransferSizePlugin } from '../plugins/transfer-size-plugin'; import { createIvyPlugin } from '../plugins/typescript'; @@ -449,7 +450,15 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise { + compilation.hooks.processAssets.tapPromise( + { + name: PLUGIN_NAME, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + async (compilationAssets) => { + for (const assetName of Object.keys(compilationAssets)) { + if (!assetName.endsWith('.js')) { + continue; + } + + const scriptAsset = compilation.getAsset(assetName); + if (!scriptAsset || scriptAsset.source.size() <= 0) { + continue; + } + + const src = scriptAsset.source.source().toString('utf-8'); + + let ngComponentCount = 0; + + if (!this.options.aot) { + // Count the number of `Component({` strings (case sensitive), which happens in __decorate(). + ngComponentCount += this.countOccurrences(src, 'Component({'); + } + + if (this.options.scriptsOptimization) { + // for ascii_only true + ngComponentCount += this.countOccurrences(src, '.\\u0275cmp', false); + } else { + // For Ivy we just count ɵcmp.src + ngComponentCount += this.countOccurrences(src, '.ɵcmp', true); + } + + compilation.updateAsset( + assetName, + (s) => s, + (assetInfo) => ({ + ...assetInfo, + ngComponentCount, + }), + ); + } + }, + ); + }); + } + + private countOccurrences(source: string, match: string, wordBreak = false): number { + let count = 0; + + // We condition here so branch prediction happens out of the loop, not in it. + if (wordBreak) { + const re = /\w/; + for (let pos = source.lastIndexOf(match); pos >= 0; pos = source.lastIndexOf(match, pos)) { + if (!(re.test(source[pos - 1] || '') || re.test(source[pos + match.length] || ''))) { + count++; // 1 match, AH! AH! AH! 2 matches, AH! AH! AH! + } + + pos -= match.length; + if (pos < 0) { + break; + } + } + } else { + for (let pos = source.lastIndexOf(match); pos >= 0; pos = source.lastIndexOf(match, pos)) { + count++; // 1 match, AH! AH! AH! 2 matches, AH! AH! AH! + pos -= match.length; + if (pos < 0) { + break; + } + } + } + + return count; + } +} diff --git a/packages/angular_devkit/build_angular/src/webpack/utils/stats.ts b/packages/angular_devkit/build_angular/src/webpack/utils/stats.ts index f747b7bd6b..8b45ea38a0 100644 --- a/packages/angular_devkit/build_angular/src/webpack/utils/stats.ts +++ b/packages/angular_devkit/build_angular/src/webpack/utils/stats.ts @@ -8,10 +8,12 @@ import { WebpackLoggingCallback } from '@angular-devkit/build-webpack'; import { logging, tags } from '@angular-devkit/core'; +import assert from 'assert'; import * as path from 'path'; import textTable from 'text-table'; import { Configuration, StatsCompilation } from 'webpack'; import { Schema as BrowserBuilderOptions } from '../../builders/browser/schema'; +import { normalizeOptimization } from '../../utils'; import { BudgetCalculatorResult } from '../../utils/bundle-calculator'; import { colors as ansiColors, removeColor } from '../../utils/color'; import { markAsyncChunksNonInitial } from './async-chunks'; @@ -42,7 +44,14 @@ export interface BundleStats { stats: BundleStatsData; } -export function generateBundleStats(info: { +function getBuildDuration(webpackStats: StatsCompilation): number { + assert(webpackStats.builtAt, 'buildAt cannot be undefined'); + assert(webpackStats.time, 'time cannot be undefined'); + + return Date.now() - webpackStats.builtAt + webpackStats.time; +} + +function generateBundleStats(info: { rawSize?: number; estimatedTransferSize?: number; files?: string[]; @@ -289,10 +298,8 @@ function statsToString( // In some cases we do things outside of webpack context // Such us index generation, service worker augmentation etc... // This will correct the time and include these. - let time = 0; - if (json.builtAt !== undefined && json.time !== undefined) { - time = Date.now() - json.builtAt + json.time; - } + + const time = getBuildDuration(json); if (unchangedChunkNumber > 0) { return ( @@ -442,6 +449,76 @@ export function createWebpackLoggingCallback( }; } +export interface BuildEventStats { + aot: boolean; + optimization: boolean; + allChunksCount: number; + lazyChunksCount: number; + initialChunksCount: number; + changedChunksCount?: number; + durationInMs: number; + cssSizeInBytes: number; + jsSizeInBytes: number; + ngComponentCount: number; +} + +export function generateBuildEventStats( + webpackStats: StatsCompilation, + browserBuilderOptions: BrowserBuilderOptions, +): BuildEventStats { + const { chunks = [], assets = [] } = webpackStats; + + let jsSizeInBytes = 0; + let cssSizeInBytes = 0; + let initialChunksCount = 0; + let ngComponentCount = 0; + let changedChunksCount = 0; + + const allChunksCount = chunks.length; + const isFirstRun = !runsCache.has(webpackStats.outputPath || ''); + + const chunkFiles = new Set(); + for (const chunk of chunks) { + if (!isFirstRun && chunk.rendered) { + changedChunksCount++; + } + + if (chunk.initial) { + initialChunksCount++; + } + + for (const file of chunk.files ?? []) { + chunkFiles.add(file); + } + } + + for (const asset of assets) { + if (asset.name.endsWith('.map') || !chunkFiles.has(asset.name)) { + continue; + } + + if (asset.name.endsWith('.js')) { + jsSizeInBytes += asset.size; + ngComponentCount += asset.info.ngComponentCount ?? 0; + } else if (asset.name.endsWith('.css')) { + cssSizeInBytes += asset.size; + } + } + + return { + optimization: !!normalizeOptimization(browserBuilderOptions.optimization).scripts, + aot: browserBuilderOptions.aot !== false, + allChunksCount, + lazyChunksCount: allChunksCount - initialChunksCount, + initialChunksCount, + changedChunksCount, + durationInMs: getBuildDuration(webpackStats), + cssSizeInBytes, + jsSizeInBytes, + ngComponentCount, + }; +} + export function webpackStatsLogger( logger: logging.LoggerApi, json: StatsCompilation, diff --git a/packages/angular_devkit/build_webpack/src/webpack-dev-server/index.ts b/packages/angular_devkit/build_webpack/src/webpack-dev-server/index.ts index 43569f916a..8cd9febfd5 100644 --- a/packages/angular_devkit/build_webpack/src/webpack-dev-server/index.ts +++ b/packages/angular_devkit/build_webpack/src/webpack-dev-server/index.ts @@ -28,6 +28,7 @@ export function runWebpackDevServer( config: webpack.Configuration, context: BuilderContext, options: { + shouldProvideStats?: boolean; devServerConfig?: WebpackDevServer.Configuration; logging?: WebpackLoggingCallback; webpackFactory?: WebpackFactory; @@ -61,6 +62,8 @@ export function runWebpackDevServer( const log: WebpackLoggingCallback = options.logging || ((stats, config) => context.logger.info(stats.toString(config.stats))); + const shouldProvideStats = options.shouldProvideStats ?? true; + return createWebpack({ ...config, watch: false }).pipe( switchMap( (webpackCompiler) => @@ -70,11 +73,14 @@ export function runWebpackDevServer( let result: Partial; + const statsOptions = typeof config.stats === 'boolean' ? undefined : config.stats; + webpackCompiler.hooks.done.tap('build-webpack', (stats) => { // Log stats. log(stats, config); obs.next({ ...result, + webpackStats: shouldProvideStats ? stats.toJson(statsOptions) : undefined, emittedFiles: getEmittedFiles(stats.compilation), success: !stats.hasErrors(), outputPath: stats.compilation.outputOptions.path,