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.
This commit is contained in:
Alan Agius 2022-10-06 12:50:17 +00:00 committed by Alan Agius
parent c969152de6
commit 124be1cc9c
14 changed files with 497 additions and 193 deletions

View File

@ -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
<!--USER_DIMENSIONS_TABLE_BEGIN-->
| Name | Parameter | Type |
@ -63,7 +63,7 @@ PROJECT NAME TO BUILD OR A MODULE NAME.**
| Optimization | `ep.ng_optimization` | `string` |
<!--USER_DIMENSIONS_TABLE_END-->
### List Of Event Custom Dimensions
### List of Event Custom Dimensions
<!--DIMENSIONS_TABLE_BEGIN-->
| Name | Parameter | Type |
@ -81,7 +81,7 @@ PROJECT NAME TO BUILD OR A MODULE NAME.**
| Optimization | `ep.ng_optimization` | `string` |
<!--DIMENSIONS_TABLE_END-->
### List Of Event Custom Metrics
### List of Event Custom Metrics
<!--METRICS_TABLE_BEGIN-->
| 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` |
<!--METRICS_TABLE_END-->
## Debugging

View File

@ -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

View File

@ -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;

View File

@ -34,6 +34,7 @@ export class AnalyticsCollector {
const requestParameters: Partial<Record<RequestParameter, PrimitiveTypes>> = {
[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

View File

@ -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',
}

View File

@ -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<T extends object>
? await this.getAnalytics()
: undefined;
analytics?.reportArchitectRunEvent({
let outputSubscription;
if (analytics) {
analytics.reportArchitectRunEvent({
[EventCustomDimension.BuilderTarget]: builderName,
});
let firstRun = true;
outputSubscription = run.output.subscribe(({ stats }) => {
const parameters = this.builderStatsToAnalyticsParameters(stats, builderName);
if (!parameters) {
return;
}
if (firstRun) {
firstRun = false;
analytics.reportBuildRunEvent(parameters);
} else {
analytics.reportRebuildRunEvent(parameters);
}
});
}
try {
const { error, success } = await run.output.toPromise();
await run.stop();
if (error) {
logger.error(error);
}
return success ? 0 : 1;
} finally {
await run.stop();
outputSubscription?.unsubscribe();
}
}
private builderStatsToAnalyticsParameters(
stats: json.JsonValue,
builderName: string,
): Partial<
| Record<EventCustomDimension & EventCustomMetric, string | number | undefined | boolean>
| 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;

View File

@ -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",

View File

@ -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,7 +202,11 @@ export function buildWebpackBrowser(
}
}),
}).pipe(
concatMap(async (buildEvent) => {
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;
@ -229,7 +237,10 @@ export function buildWebpackBrowser(
context.logger.error(statsErrorsToString(webpackStats, { colors: true }));
}
return { success };
return {
webpackStats: webpackRawStats,
output: { success: false },
};
} else {
outputPaths = ensureOutputPaths(baseOutputPath, i18n);
@ -250,7 +261,10 @@ export function buildWebpackBrowser(
options.i18nMissingTranslation,
);
if (!success) {
return { success: false };
return {
webpackStats: webpackRawStats,
output: { success: false },
};
}
}
@ -294,7 +308,13 @@ export function buildWebpackBrowser(
spinner.fail(colors.redBright('Copying of assets failed.'));
assertIsError(err);
return { success: false, error: 'Unable to copy assets: ' + err.message };
return {
output: {
success: false,
error: 'Unable to copy assets: ' + err.message,
},
webpackStats: webpackRawStats,
};
}
}
@ -338,20 +358,30 @@ export function buildWebpackBrowser(
spinner.start();
}
const indexOutput = path.join(outputPath, getIndexOutputFile(options.index));
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 { success: false, error: mapErrorToMessage(error) };
return {
webpackStats: webpackRawStats,
output: { success: false, error: error.message },
};
}
}
if (hasErrors) {
spinner.fail('Index html generation failed.');
return { success: false };
return {
webpackStats: webpackRawStats,
output: { success: false },
};
} else {
spinner.succeed('Index html generation complete.');
}
@ -370,8 +400,12 @@ export function buildWebpackBrowser(
);
} catch (error) {
spinner.fail('Service worker generation failed.');
assertIsError(error);
return { success: false, error: mapErrorToMessage(error) };
return {
webpackStats: webpackRawStats,
output: { success: false, error: error.message },
};
}
}
@ -381,13 +415,18 @@ export function buildWebpackBrowser(
webpackStatsLogger(context.logger, webpackStats, config, budgetFailures);
return { success: buildSuccess };
return {
webpackStats: webpackRawStats,
output: { 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(

View File

@ -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",

View File

@ -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;
}),
);
}),

View File

@ -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<Config
},
},
},
plugins: [new NamedChunksPlugin(), new DedupeModuleResolvePlugin({ verbose }), ...extraPlugins],
plugins: [
new NamedChunksPlugin(),
new OccurrencesPlugin({
aot,
scriptsOptimization,
}),
new DedupeModuleResolvePlugin({ verbose }),
...extraPlugins,
],
node: false,
};
}

View File

@ -0,0 +1,98 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { Compiler } from 'webpack';
const PLUGIN_NAME = 'angular-occurrences-plugin';
export interface OccurrencesPluginOptions {
aot?: boolean;
scriptsOptimization?: boolean;
}
export class OccurrencesPlugin {
constructor(private options: OccurrencesPluginOptions) {}
apply(compiler: Compiler) {
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
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;
}
}

View File

@ -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<string>();
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,

View File

@ -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<DevServerBuildOutput>;
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,