diff --git a/docs/design/analytics.md b/docs/design/analytics.md index 251c17af98a7..3eeb19bb3b4e 100644 --- a/docs/design/analytics.md +++ b/docs/design/analytics.md @@ -4,7 +4,7 @@ This document list exactly what is gathered and how. Any change to analytics should most probably include a change to this document. -# Pageview +## Pageview Each command creates a pageview with the path `/command/${commandName}/${subcommandName}`. IE. `ng generate component my-component --dryRun` would create a page view with the path @@ -16,7 +16,7 @@ Project names and target names will be removed. The command `ng run some-project:lint:some-configuration` will create a page view with the path `/command/run`. -# Dimensions +## Dimensions and Metrics Google Analytics Custom Dimensions are used to track system values and flag values. These dimensions are aggregated automatically on the backend. @@ -25,94 +25,86 @@ One dimension per flag, and although technically there can be an overlap between simplicity it should remain unique across all CLI commands. The dimension is the value of the `x-user-analytics` field in the `schema.json` files. -To create a new dimension (tracking a new flag): - -1. Create the dimension on analytics.google.com first. Dimensions are not tracked if they aren't - defined on GA. +### Adding dimension or metic. +1. Create the dimension or metric in (https://analytics.google.com/)[Google Analytics] first. These are not tracked if they aren't + defined in Google Analytics. 1. Use the ID of the dimension as the `x-user-analytics` value in the `schema.json` file. -1. Add a new row to the table below in the same PR as the one adding the dimension to the code. -1. New dimension PRs need to be approved by the tooling and DevRel leads. - **This is not negotiable.** +1. New dimension and metrics PRs need to be approved by the tooling lead and require a new (http://go/launch)[Launch]. + +### Deleting a dimension or metic. +1. Archive the dimension and metric in (https://analytics.google.com/)[Google Analytics]. + **DO NOT ADD `x-user-analytics` FOR VALUES THAT ARE USER IDENTIFIABLE (PII), FOR EXAMPLE A PROJECT NAME TO BUILD OR A MODULE NAME.** -Note: There's a limit of 20 custom dimensions. +### Limits +| Item | Standard property limits | +|-------------------------------- |-------------------------- | +| Event-scoped custom dimensions | 50 | +| User-scoped custom dimensions | 25 | +| All custom metrics | 50 | -### List Of All Dimensions +### List of User Custom Dimensions + + +| Name | Parameter | Type | +|:---:|:---|:---| +| Command | `ep.ng_command` | `string` | +| SchematicCollectionName | `ep.ng_schematic_collection_name` | `string` | +| SchematicName | `ep.ng_schematic_name` | `string` | +| Standalone | `ep.ng_standalone` | `string` | +| Style | `ep.ng_style` | `string` | +| Routing | `ep.ng_routing` | `string` | +| InlineTemplate | `ep.ng_inline_template` | `string` | +| InlineStyle | `ep.ng_inline_style` | `string` | +| BuilderTarget | `ep.ng_builder_target` | `string` | +| Aot | `ep.ng_aot` | `string` | +| Optimization | `ep.ng_optimization` | `string` | + + +### List of Event Custom Dimensions -| Id | Flag | Type | +| Name | Parameter | Type | |:---:|:---|:---| -| 1 | `CPU Count` | `number` | -| 2 | `CPU Speed` | `number` | -| 3 | `RAM (In GB)` | `number` | -| 4 | `Node Version` | `number` | -| 5 | `Flag: --style` | `string` | -| 6 | `--collection` | `string` | -| 7 | `Flag: --strict` | `boolean` | -| 8 | `Angular CLI Major Version` | `string` | -| 9 | `Flag: --inline-style` | `boolean` | -| 10 | `Flag: --inline-template` | `boolean` | -| 11 | `Flag: --view-encapsulation` | `string` | -| 12 | `Flag: --skip-tests` | `boolean` | -| 13 | `Flag: --aot` | `boolean` | -| 14 | `Flag: --minimal` | `boolean` | -| 15 | `Flag: --standalone` | `boolean` | -| 16 | `Flag: --optimization` | `boolean` | -| 17 | `Flag: --routing` | `boolean` | -| 18 | `Flag: --skip-import` | `boolean` | -| 19 | `Flag: --export` | `boolean` | -| 20 | `Build Errors (comma separated)` | `string` | +| Command | `ep.ng_command` | `string` | +| SchematicCollectionName | `ep.ng_schematic_collection_name` | `string` | +| SchematicName | `ep.ng_schematic_name` | `string` | +| Standalone | `ep.ng_standalone` | `string` | +| Style | `ep.ng_style` | `string` | +| Routing | `ep.ng_routing` | `string` | +| InlineTemplate | `ep.ng_inline_template` | `string` | +| InlineStyle | `ep.ng_inline_style` | `string` | +| BuilderTarget | `ep.ng_builder_target` | `string` | +| Aot | `ep.ng_aot` | `string` | +| Optimization | `ep.ng_optimization` | `string` | -# Metrics - -### List of All Metrics +### List of Event Custom Metrics -| Id | Flag | Type | +| Name | Parameter | Type | |:---:|:---|:---| -| 1 | `NgComponentCount` | `number` | -| 2 | `UNUSED_2` | `none` | -| 3 | `UNUSED_3` | `none` | -| 4 | `UNUSED_4` | `none` | -| 5 | `Build Time` | `number` | -| 6 | `NgOnInit Count` | `number` | -| 7 | `Initial Chunk Size` | `number` | -| 8 | `Total Chunk Count` | `number` | -| 9 | `Total Chunk Size` | `number` | -| 10 | `Lazy Chunk Count` | `number` | -| 11 | `Lazy Chunk Size` | `number` | -| 12 | `Asset Count` | `number` | -| 13 | `Asset Size` | `number` | -| 14 | ` Polyfill Size` | `number` | -| 15 | ` Css Size` | `number` | +| AllChunksCount | `epn.ng_all_chunks_count` | `number` | +| LazyChunksCount | `epn.ng_lazy_chunks_count` | `number` | +| 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` | -# Operating System and Node Version - -A User Agent string is built to "fool" Google Analytics into reading the Operating System and -version fields from it. The base dimensions are used for those. - -Node version is our App ID, but a dimension is also used to get the numeric MAJOR.MINOR of node. - -# Debugging - -Using `DEBUG=ng:analytics` will report additional information regarding initialization and -decisions made during the usage analytics process, e.g. if the user has analytics disabled. - -Using `DEBUG=ng:analytics:command` will show the decisions made by the command runner. - -Using `DEBUG=ng:analytics:log` will show what we actually send to GA. +## Debugging -See [the `debug` NPM library](https://www.npmjs.com/package/debug) for more information. +Using `NG_DEBUG=1` will enable Google Analytics debug mode, To view the debug events, in Google Analytics go to `Configure > DebugView`. -# Disabling Usage Analytics +## Disabling Usage Analytics There are 2 ways of disabling usage analytics: -1. using `ng analytics off --global` (or changing the global configuration file yourself). This is the same +1. using `ng analytics disable --global` (or changing the global configuration file yourself). This is the same as answering "No" to the prompt. 1. There is an `NG_CLI_ANALYTICS` environment variable that overrides the global configuration. That flag is a string that represents the User ID. If the string `"false"` is used it will diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json index f47d35c030b4..7cfa45ae1df0 100644 --- a/goldens/circular-deps/packages.json +++ b/goldens/circular-deps/packages.json @@ -2,5 +2,13 @@ [ "packages/angular_devkit/build_angular/src/utils/bundle-calculator.ts", "packages/angular_devkit/build_angular/src/webpack/utils/stats.ts" + ], + [ + "packages/angular/cli/src/analytics/analytics-collector.ts", + "packages/angular/cli/src/command-builder/command-module.ts" + ], + [ + "packages/angular/cli/src/analytics/analytics.ts", + "packages/angular/cli/src/command-builder/command-module.ts" ] ] diff --git a/goldens/public-api/angular_devkit/architect/index.md b/goldens/public-api/angular_devkit/architect/index.md index 0a7756b87f1f..89796d9bc3db 100644 --- a/goldens/public-api/angular_devkit/architect/index.md +++ b/goldens/public-api/angular_devkit/architect/index.md @@ -4,7 +4,6 @@ ```ts -import { analytics } from '@angular-devkit/core'; import { BaseException } from '@angular-devkit/core'; import { json } from '@angular-devkit/core'; import { JsonObject } from '@angular-devkit/core'; @@ -29,7 +28,6 @@ export class Architect { // @public export interface BuilderContext { addTeardown(teardown: () => Promise | void): void; - readonly analytics: analytics.Analytics; builder: BuilderInfo; currentDirectory: string; getBuilderNameForTarget(target: Target): Promise; @@ -435,8 +433,6 @@ interface ScheduleJobOptions { // @public (undocumented) export interface ScheduleOptions { - // (undocumented) - analytics?: analytics.Analytics; // (undocumented) logger?: logging.Logger; } diff --git a/goldens/public-api/angular_devkit/build_angular/index.md b/goldens/public-api/angular_devkit/build_angular/index.md index e34dfbd7cad7..1fdb8b73e0e3 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 312a1a6d319c..8a3fe489f1c1 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/goldens/public-api/angular_devkit/core/index.md b/goldens/public-api/angular_devkit/core/index.md index f3a2a1b5bc32..23cda881f1fb 100644 --- a/goldens/public-api/angular_devkit/core/index.md +++ b/goldens/public-api/angular_devkit/core/index.md @@ -28,130 +28,6 @@ class AliasHost extends ResolverHost { protected _resolve(path: Path): Path; } -// @public -interface Analytics { - // (undocumented) - event(category: string, action: string, options?: EventOptions): void; - // (undocumented) - flush(): Promise; - // (undocumented) - pageview(path: string, options?: PageviewOptions): void; - // (undocumented) - screenview(screenName: string, appName: string, options?: ScreenviewOptions): void; - // (undocumented) - timing(category: string, variable: string, time: string | number, options?: TimingOptions): void; -} - -declare namespace analytics { - export { - NgCliAnalyticsDimensions, - NgCliAnalyticsMetrics, - NgCliAnalyticsDimensionsFlagInfo, - NgCliAnalyticsMetricsFlagInfo, - CustomDimensionsAndMetricsOptions, - EventOptions, - ScreenviewOptions, - PageviewOptions, - TimingOptions, - Analytics, - AnalyticsReportKind, - AnalyticsReportBase, - AnalyticsReportEvent, - AnalyticsReportScreenview, - AnalyticsReportPageview, - AnalyticsReportTiming, - AnalyticsReport, - AnalyticsForwarderFn, - ForwardingAnalytics, - AnalyticsReporter, - LoggingAnalytics, - MultiAnalytics, - NoopAnalytics - } -} -export { analytics } - -// @public -type AnalyticsForwarderFn = (report: JsonObject & AnalyticsReport) => void; - -// @public (undocumented) -type AnalyticsReport = AnalyticsReportEvent | AnalyticsReportScreenview | AnalyticsReportPageview | AnalyticsReportTiming; - -// @public (undocumented) -interface AnalyticsReportBase extends JsonObject { - // (undocumented) - kind: AnalyticsReportKind; -} - -// @public (undocumented) -class AnalyticsReporter { - constructor(_analytics: Analytics); - // (undocumented) - protected _analytics: Analytics; - // (undocumented) - report(report: AnalyticsReport): void; -} - -// @public (undocumented) -interface AnalyticsReportEvent extends AnalyticsReportBase { - // (undocumented) - action: string; - // (undocumented) - category: string; - // (undocumented) - kind: AnalyticsReportKind.Event; - // (undocumented) - options: JsonObject & EventOptions; -} - -// @public (undocumented) -enum AnalyticsReportKind { - // (undocumented) - Event = "event", - // (undocumented) - Pageview = "pageview", - // (undocumented) - Screenview = "screenview", - // (undocumented) - Timing = "timing" -} - -// @public (undocumented) -interface AnalyticsReportPageview extends AnalyticsReportBase { - // (undocumented) - kind: AnalyticsReportKind.Pageview; - // (undocumented) - options: JsonObject & PageviewOptions; - // (undocumented) - path: string; -} - -// @public (undocumented) -interface AnalyticsReportScreenview extends AnalyticsReportBase { - // (undocumented) - appName: string; - // (undocumented) - kind: AnalyticsReportKind.Screenview; - // (undocumented) - options: JsonObject & ScreenviewOptions; - // (undocumented) - screenName: string; -} - -// @public (undocumented) -interface AnalyticsReportTiming extends AnalyticsReportBase { - // (undocumented) - category: string; - // (undocumented) - kind: AnalyticsReportKind.Timing; - // (undocumented) - options: JsonObject & TimingOptions; - // (undocumented) - time: string | number; - // (undocumented) - variable: string; -} - // @public (undocumented) export function asPosixPath(path: Path): PosixPath; @@ -318,14 +194,6 @@ function createSyncHost(handler: SyncHostHandler | null>; } -// @public (undocumented) -interface EventOptions extends CustomDimensionsAndMetricsOptions { - // (undocumented) - label?: string; - // (undocumented) - value?: string; -} - // @public (undocumented) export function extname(path: Path): string; @@ -397,23 +257,6 @@ export class FileDoesNotExistException extends BaseException { constructor(path: string); } -// @public -class ForwardingAnalytics implements Analytics { - constructor(_fn: AnalyticsForwarderFn); - // (undocumented) - event(category: string, action: string, options?: EventOptions): void; - // (undocumented) - flush(): Promise; - // (undocumented) - protected _fn: AnalyticsForwarderFn; - // (undocumented) - pageview(path: string, options?: PageviewOptions): void; - // (undocumented) - screenview(screenName: string, appName: string, options?: ScreenviewOptions): void; - // (undocumented) - timing(category: string, variable: string, time: string | number, options?: TimingOptions): void; -} - // @public (undocumented) export function fragment(path: string): PathFragment; @@ -690,23 +533,6 @@ declare namespace logging { } export { logging } -// @public -class LoggingAnalytics implements Analytics { - constructor(_logger: Logger); - // (undocumented) - event(category: string, action: string, options?: EventOptions): void; - // (undocumented) - flush(): Promise; - // (undocumented) - protected _logger: Logger; - // (undocumented) - pageview(path: string, options?: PageviewOptions): void; - // (undocumented) - screenview(screenName: string, appName: string, options?: ScreenviewOptions): void; - // (undocumented) - timing(category: string, variable: string, time: string | number, options?: TimingOptions): void; -} - // @public (undocumented) type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; @@ -718,104 +544,9 @@ export class MergeConflictException extends BaseException { // @public function mergeSchemas(...schemas: (JsonSchema | undefined)[]): JsonSchema; -// @public -class MultiAnalytics implements Analytics { - constructor(_backends?: Analytics[]); - // (undocumented) - protected _backends: Analytics[]; - // (undocumented) - event(category: string, action: string, options?: EventOptions): void; - // (undocumented) - flush(): Promise; - // (undocumented) - pageview(path: string, options?: PageviewOptions): void; - // (undocumented) - push(...backend: Analytics[]): void; - // (undocumented) - screenview(screenName: string, appName: string, options?: ScreenviewOptions): void; - // (undocumented) - timing(category: string, variable: string, time: string | number, options?: TimingOptions): void; -} - -// @public -enum NgCliAnalyticsDimensions { - // (undocumented) - AngularCLIMajorVersion = 8, - // (undocumented) - BuildErrors = 20, - // (undocumented) - CpuCount = 1, - // (undocumented) - CpuSpeed = 2, - // (undocumented) - NgAddCollection = 6, - // (undocumented) - NodeVersion = 4, - // (undocumented) - RamInGigabytes = 3 -} - -// @public (undocumented) -const NgCliAnalyticsDimensionsFlagInfo: { - [name: string]: [string, string]; -}; - -// @public (undocumented) -enum NgCliAnalyticsMetrics { - // (undocumented) - AssetCount = 12, - // (undocumented) - AssetSize = 13, - // (undocumented) - BuildTime = 5, - // (undocumented) - CssSize = 15, - // (undocumented) - InitialChunkSize = 7, - // (undocumented) - LazyChunkCount = 10, - // (undocumented) - LazyChunkSize = 11, - // (undocumented) - NgComponentCount = 1, - // (undocumented) - NgOnInitCount = 6, - // (undocumented) - PolyfillSize = 14, - // (undocumented) - TotalChunkCount = 8, - // (undocumented) - TotalChunkSize = 9, - // (undocumented) - UNUSED_2 = 2, - // (undocumented) - UNUSED_3 = 3, - // (undocumented) - UNUSED_4 = 4 -} - -// @public (undocumented) -const NgCliAnalyticsMetricsFlagInfo: { - [name: string]: [string, string]; -}; - // @public export function noCacheNormalize(path: string): Path; -// @public -class NoopAnalytics implements Analytics { - // (undocumented) - event(): void; - // (undocumented) - flush(): Promise; - // (undocumented) - pageview(): void; - // (undocumented) - screenview(): void; - // (undocumented) - timing(): void; -} - // @public export function normalize(path: string): Path; @@ -835,14 +566,6 @@ class NullLogger extends Logger { // @public (undocumented) function oneLine(strings: TemplateStringsArray, ...values: any[]): string; -// @public (undocumented) -interface PageviewOptions extends CustomDimensionsAndMetricsOptions { - // (undocumented) - hostname?: string; - // (undocumented) - title?: string; -} - // @public (undocumented) function parseJsonPointer(pointer: JsonPointer): string[]; @@ -1206,16 +929,6 @@ class ScopedHost extends ResolverHost { protected _root: Path; } -// @public (undocumented) -interface ScreenviewOptions extends CustomDimensionsAndMetricsOptions { - // (undocumented) - appId?: string; - // (undocumented) - appInstallerId?: string; - // (undocumented) - appVersion?: string; -} - // @public (undocumented) class SimpleMemoryHost implements Host<{}> { constructor(); @@ -1591,12 +1304,6 @@ namespace test { }; } -// @public (undocumented) -interface TimingOptions extends CustomDimensionsAndMetricsOptions { - // (undocumented) - label?: string; -} - // @public (undocumented) class TransformLogger extends Logger { constructor(name: string, transform: (stream: Observable) => Observable, parent?: Logger | null); diff --git a/package.json b/package.json index 57110c09fecc..a89effa32181 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,6 @@ "@types/babel__template": "7.4.1", "@types/browserslist": "^4.15.0", "@types/cacache": "^15.0.0", - "@types/debug": "^4.1.2", "@types/express": "^4.16.0", "@types/glob": "^8.0.0", "@types/http-proxy": "^1.17.4", diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index 119df1d848f3..e315e76af3dc 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -56,7 +56,6 @@ ts_library( "//packages/angular_devkit/schematics/tasks", "//packages/angular_devkit/schematics/tools", "@npm//@angular/core", - "@npm//@types/debug", "@npm//@types/ini", "@npm//@types/inquirer", "@npm//@types/node", diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json index 22ca397a958b..433fbea32501 100644 --- a/packages/angular/cli/lib/config/workspace-schema.json +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -74,22 +74,6 @@ "type": ["boolean", "string"], "description": "Share pseudonymous usage data with the Angular Team at Google." }, - "analyticsSharing": { - "type": "object", - "properties": { - "tracking": { - "description": "Analytics sharing info tracking ID.", - "type": "string", - "pattern": "^(GA|UA)?-\\d+-\\d+$" - }, - "uuid": { - "description": "Analytics sharing info universally unique identifier.", - "type": "string", - "format": "uuid" - } - }, - "additionalProperties": false - }, "cache": { "description": "Control disk cache.", "type": "object", @@ -149,22 +133,6 @@ "type": ["boolean", "string"], "description": "Share pseudonymous usage data with the Angular Team at Google." }, - "analyticsSharing": { - "type": "object", - "properties": { - "tracking": { - "description": "Analytics sharing info tracking ID.", - "type": "string", - "pattern": "^(GA|UA)?-\\d+-\\d+$" - }, - "uuid": { - "description": "Analytics sharing info universally unique identifier.", - "type": "string", - "format": "uuid" - } - }, - "additionalProperties": false - }, "completion": { "type": "object", "description": "Angular CLI completion settings.", diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json index 341cf0527d6d..6d6f2a84eee2 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -28,7 +28,6 @@ "@schematics/angular": "0.0.0-PLACEHOLDER", "@yarnpkg/lockfile": "1.1.0", "ansi-colors": "4.1.3", - "debug": "4.3.4", "ini": "3.0.1", "inquirer": "8.2.4", "jsonc-parser": "3.2.0", diff --git a/packages/angular/cli/src/analytics/analytics-collector.ts b/packages/angular/cli/src/analytics/analytics-collector.ts index c8f932f51336..e80f23814fa6 100644 --- a/packages/angular/cli/src/analytics/analytics-collector.ts +++ b/packages/angular/cli/src/analytics/analytics-collector.ts @@ -6,330 +6,175 @@ * found in the LICENSE file at https://angular.io/license */ -import { analytics } from '@angular-devkit/core'; -import { execSync } from 'child_process'; -import debug from 'debug'; +import { randomUUID } from 'crypto'; import * as https from 'https'; import * as os from 'os'; import * as querystring from 'querystring'; +import type { CommandContext } from '../command-builder/command-module'; +import { ngDebug } from '../utilities/environment-options'; +import { assertIsError } from '../utilities/error'; import { VERSION } from '../utilities/version'; +import { + EventCustomDimension, + EventCustomMetric, + PrimitiveTypes, + RequestParameter, + UserCustomDimension, +} from './analytics-parameters'; + +const TRACKING_ID_PROD = 'G-VETNJBW8L4'; +const TRACKING_ID_STAGING = 'G-TBMPRL1BTM'; + +export class AnalyticsCollector { + private trackingEventsQueue: Record[] | undefined; + private readonly requestParameterStringified: string; + private readonly userParameters: Record; + + constructor(private context: CommandContext, userId: string) { + 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 + : TRACKING_ID_STAGING, + + // Built-in user properties + [RequestParameter.SessionId]: randomUUID(), + [RequestParameter.UserAgentArchitecture]: os.arch(), + [RequestParameter.UserAgentPlatform]: os.platform(), + [RequestParameter.UserAgentPlatformVersion]: os.version(), + + // Set undefined to disable debug view. + [RequestParameter.DebugView]: ngDebug ? 1 : undefined, + }; -interface BaseParameters extends analytics.CustomDimensionsAndMetricsOptions { - [key: string]: string | number | boolean | undefined | (string | number | boolean | undefined)[]; -} - -interface ScreenviewParameters extends BaseParameters { - /** Screen Name */ - cd?: string; - /** Application Name */ - an?: string; - /** Application Version */ - av?: string; - /** Application ID */ - aid?: string; - /** Application Installer ID */ - aiid?: string; -} - -interface TimingParameters extends BaseParameters { - /** User timing category */ - utc?: string; - /** User timing variable name */ - utv?: string; - /** User timing time */ - utt?: string | number; - /** User timing label */ - utl?: string; -} - -interface PageviewParameters extends BaseParameters { - /** - * Document Path - * The path portion of the page URL. Should begin with '/'. - */ - dp?: string; - /** Document Host Name */ - dh?: string; - /** Document Title */ - dt?: string; - /** - * Document location URL - * Use this parameter to send the full URL (document location) of the page on which content resides. - */ - dl?: string; -} - -interface EventParameters extends BaseParameters { - /** Event Category */ - ec: string; - /** Event Action */ - ea: string; - /** Event Label */ - el?: string; - /** - * Event Value - * Specifies the event value. Values must be non-negative. - */ - ev?: string | number; - /** Page Path */ - p?: string; - /** Page */ - dp?: string; -} - -/** - * See: https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide - */ -export class AnalyticsCollector implements analytics.Analytics { - private trackingEventsQueue: Record[] = []; - private readonly parameters: Record = {}; - private readonly analyticsLogDebug = debug('ng:analytics:log'); - - constructor(trackingId: string, userId: string) { - // API Version - this.parameters['v'] = '1'; - // User ID - this.parameters['cid'] = userId; - // Tracking - this.parameters['tid'] = trackingId; - - this.parameters['ds'] = 'cli'; - this.parameters['ua'] = _buildUserAgentString(); - this.parameters['ul'] = _getLanguage(); - - // @angular/cli with version. - this.parameters['an'] = '@angular/cli'; - this.parameters['av'] = VERSION.full; + this.requestParameterStringified = querystring.stringify(requestParameters); + + // Remove the `v` at the beginning. + const nodeVersion = process.version.substring(1); + const packageManagerVersion = context.packageManager.version; + + this.userParameters = { + // While architecture is being collect by GA as UserAgentArchitecture. + // It doesn't look like there is a way to query this. Therefore we collect this as a custom user dimension too. + [UserCustomDimension.OsArchitecture]: os.arch(), + [UserCustomDimension.NodeVersion]: nodeVersion, + [UserCustomDimension.NodeMajorVersion]: +nodeVersion.split('.', 1)[0], + [UserCustomDimension.PackageManager]: context.packageManager.name, + [UserCustomDimension.PackageManagerVersion]: packageManagerVersion, + [UserCustomDimension.PackageManagerMajorVersion]: packageManagerVersion + ? +packageManagerVersion.split('.', 1)[0] + : undefined, + [UserCustomDimension.AngularCLIVersion]: VERSION.full, + [UserCustomDimension.AngularCLIMajorVersion]: VERSION.major, + }; + } - // We use the application ID for the Node version. This should be "node v12.10.0". - const nodeVersion = `node ${process.version}`; - this.parameters['aid'] = nodeVersion; + reportRebuildRunEvent( + parameters: Partial< + Record + >, + ): void { + this.event('run_rebuild', parameters); + } - // Custom dimentions - // We set custom metrics for values we care about. - this.parameters['cd' + analytics.NgCliAnalyticsDimensions.CpuCount] = os.cpus().length; - // Get the first CPU's speed. It's very rare to have multiple CPUs of different speed (in most - // non-ARM configurations anyway), so that's all we care about. - this.parameters['cd' + analytics.NgCliAnalyticsDimensions.CpuSpeed] = Math.floor( - os.cpus()[0].speed, - ); - this.parameters['cd' + analytics.NgCliAnalyticsDimensions.RamInGigabytes] = Math.round( - os.totalmem() / (1024 * 1024 * 1024), - ); - this.parameters['cd' + analytics.NgCliAnalyticsDimensions.NodeVersion] = nodeVersion; + reportBuildRunEvent( + parameters: Partial< + Record + >, + ): void { + this.event('run_build', parameters); + } - this.parameters['cd' + analytics.NgCliAnalyticsDimensions.AngularCLIMajorVersion] = - VERSION.major; + reportArchitectRunEvent(parameters: Partial>): void { + this.event('run_architect', parameters); } - event(ec: string, ea: string, options: analytics.EventOptions = {}): void { - const { label: el, value: ev, metrics, dimensions } = options; - this.addToQueue('event', { ec, ea, el, ev, metrics, dimensions }); + reportSchematicRunEvent(parameters: Partial>): void { + this.event('run_schematic', parameters); } - pageview(dp: string, options: analytics.PageviewOptions = {}): void { - const { hostname: dh, title: dt, metrics, dimensions } = options; - this.addToQueue('pageview', { dp, dh, dt, metrics, dimensions }); + reportCommandRunEvent(command: string): void { + this.event('run_command', { [EventCustomDimension.Command]: command }); } - timing( - utc: string, - utv: string, - utt: string | number, - options: analytics.TimingOptions = {}, - ): void { - const { label: utl, metrics, dimensions } = options; - this.addToQueue('timing', { utc, utv, utt, utl, metrics, dimensions }); + private event(eventName: string, parameters?: Record): void { + this.trackingEventsQueue ??= []; + this.trackingEventsQueue.push({ + ...this.userParameters, + ...parameters, + 'en': eventName, + }); } - screenview(cd: string, an: string, options: analytics.ScreenviewOptions = {}): void { - const { appVersion: av, appId: aid, appInstallerId: aiid, metrics, dimensions } = options; - this.addToQueue('screenview', { cd, an, av, aid, aiid, metrics, dimensions }); + /** + * Flush on an interval (if the event loop is waiting). + * + * @returns a method that when called will terminate the periodic + * flush and call flush one last time. + */ + periodFlush(): () => Promise { + let analyticsFlushPromise = Promise.resolve(); + const analyticsFlushInterval = setInterval(() => { + if (this.trackingEventsQueue?.length) { + analyticsFlushPromise = analyticsFlushPromise.then(() => this.flush()); + } + }, 4000); + + return () => { + clearInterval(analyticsFlushInterval); + + // Flush one last time. + return analyticsFlushPromise.then(() => this.flush()); + }; } async flush(): Promise { - const pending = this.trackingEventsQueue.length; - this.analyticsLogDebug(`flush queue size: ${pending}`); + const pendingTrackingEvents = this.trackingEventsQueue; + this.context.logger.debug(`Analytics flush size. ${pendingTrackingEvents?.length}.`); - if (!pending) { + if (!pendingTrackingEvents?.length) { return; } // The below is needed so that if flush is called multiple times, // we don't report the same event multiple times. - const pendingTrackingEvents = this.trackingEventsQueue; - this.trackingEventsQueue = []; + this.trackingEventsQueue = undefined; try { await this.send(pendingTrackingEvents); } catch (error) { // Failure to report analytics shouldn't crash the CLI. - this.analyticsLogDebug('send error: %j', error); + assertIsError(error); + this.context.logger.debug(`Send analytics error. ${error.message}.`); } } - private addToQueue(eventType: 'event', parameters: EventParameters): void; - private addToQueue(eventType: 'pageview', parameters: PageviewParameters): void; - private addToQueue(eventType: 'timing', parameters: TimingParameters): void; - private addToQueue(eventType: 'screenview', parameters: ScreenviewParameters): void; - private addToQueue( - eventType: 'event' | 'pageview' | 'timing' | 'screenview', - parameters: BaseParameters, - ): void { - const { metrics, dimensions, ...restParameters } = parameters; - const data = { - ...this.parameters, - ...restParameters, - ...this.customVariables({ metrics, dimensions }), - t: eventType, - }; - - this.analyticsLogDebug('add event to queue: %j', data); - this.trackingEventsQueue.push(data); - } - - private async send(data: Record[]): Promise { - this.analyticsLogDebug('send event: %j', data); - + private async send(data: Record[]): Promise { return new Promise((resolve, reject) => { const request = https.request( { host: 'www.google-analytics.com', method: 'POST', - path: data.length > 1 ? '/batch' : '/collect', + path: '/g/collect?' + this.requestParameterStringified, }, (response) => { - if (response.statusCode !== 200) { + if (response.statusCode !== 200 && response.statusCode !== 204) { reject( new Error(`Analytics reporting failed with status code: ${response.statusCode}.`), ); - - return; + } else { + resolve(); } }, ); request.on('error', reject); - const queryParameters = data.map((p) => querystring.stringify(p)).join('\n'); request.write(queryParameters); - request.end(resolve); + request.end(); }); } - - /** - * Creates the dimension and metrics variables to add to the queue. - * @private - */ - private customVariables( - options: analytics.CustomDimensionsAndMetricsOptions, - ): Record { - const additionals: Record = {}; - - const { dimensions, metrics } = options; - dimensions?.forEach((v, i) => (additionals[`cd${i}`] = v)); - metrics?.forEach((v, i) => (additionals[`cm${i}`] = v)); - - return additionals; - } -} - -// These are just approximations of UA strings. We just try to fool Google Analytics to give us the -// data we want. -// See https://developers.whatismybrowser.com/useragents/ -const osVersionMap: Readonly<{ [os: string]: { [release: string]: string } }> = { - darwin: { - '1.3.1': '10_0_4', - '1.4.1': '10_1_0', - '5.1': '10_1_1', - '5.2': '10_1_5', - '6.0.1': '10_2', - '6.8': '10_2_8', - '7.0': '10_3_0', - '7.9': '10_3_9', - '8.0': '10_4_0', - '8.11': '10_4_11', - '9.0': '10_5_0', - '9.8': '10_5_8', - '10.0': '10_6_0', - '10.8': '10_6_8', - // We stop here because we try to math out the version for anything greater than 10, and it - // works. Those versions are standardized using a calculation now. - }, - win32: { - '6.3.9600': 'Windows 8.1', - '6.2.9200': 'Windows 8', - '6.1.7601': 'Windows 7 SP1', - '6.1.7600': 'Windows 7', - '6.0.6002': 'Windows Vista SP2', - '6.0.6000': 'Windows Vista', - '5.1.2600': 'Windows XP', - }, -}; - -/** - * Build a fake User Agent string. This gets sent to Analytics so it shows the proper OS version. - * @private - */ -function _buildUserAgentString() { - switch (os.platform()) { - case 'darwin': { - let v = osVersionMap.darwin[os.release()]; - - if (!v) { - // Remove 4 to tie Darwin version to OSX version, add other info. - const x = parseFloat(os.release()); - if (x > 10) { - v = `10_` + (x - 4).toString().replace('.', '_'); - } - } - - const cpuModel = os.cpus()[0].model.match(/^[a-z]+/i); - const cpu = cpuModel ? cpuModel[0] : os.cpus()[0].model; - - return `(Macintosh; ${cpu} Mac OS X ${v || os.release()})`; - } - - case 'win32': - return `(Windows NT ${os.release()})`; - - case 'linux': - return `(X11; Linux i686; ${os.release()}; ${os.cpus()[0].model})`; - - default: - return os.platform() + ' ' + os.release(); - } -} - -/** - * Get a language code. - * @private - */ -function _getLanguage() { - // Note: Windows does not expose the configured language by default. - return ( - process.env.LANG || // Default Unix env variable. - process.env.LC_CTYPE || // For C libraries. Sometimes the above isn't set. - process.env.LANGSPEC || // For Windows, sometimes this will be set (not always). - _getWindowsLanguageCode() || - '??' - ); // ¯\_(ツ)_/¯ -} - -/** - * Attempt to get the Windows Language Code string. - * @private - */ -function _getWindowsLanguageCode(): string | undefined { - if (!os.platform().startsWith('win')) { - return undefined; - } - - try { - // This is true on Windows XP, 7, 8 and 10 AFAIK. Would return empty string or fail if it - // doesn't work. - return execSync('wmic.exe os get locale').toString().trim(); - } catch {} - - return undefined; } diff --git a/packages/angular/cli/src/analytics/analytics-parameters.ts b/packages/angular/cli/src/analytics/analytics-parameters.ts new file mode 100644 index 000000000000..d05918e51020 --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-parameters.ts @@ -0,0 +1,100 @@ +/** + * @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 + */ + +export type PrimitiveTypes = string | number | boolean; + +/** + * GA built-in request parameters + * @see https://www.thyngster.com/ga4-measurement-protocol-cheatsheet + * @see http://go/depot/google3/analytics/container_tag/templates/common/gold/mpv2_schema.js + */ +export enum RequestParameter { + ClientId = 'cid', + DebugView = '_dbg', + GtmVersion = 'gtm', + Language = 'ul', + NewToSite = '_nsi', + NonInteraction = 'ni', + PageLocation = 'dl', + PageTitle = 'dt', + ProtocolVersion = 'v', + SessionEngaged = 'seg', + SessionId = 'sid', + SessionNumber = 'sct', + SessionStart = '_ss', + TrackingId = 'tid', + TrafficType = 'tt', + UserAgentArchitecture = 'uaa', + UserAgentBitness = 'uab', + UserAgentFullVersionList = 'uafvl', + UserAgentMobile = 'uamb', + UserAgentModel = 'uam', + UserAgentPlatform = 'uap', + UserAgentPlatformVersion = 'uapv', + UserId = 'uid', +} + +/** + * User scoped custom dimensions. + * @notes + * - User custom dimensions limit is 25. + * - `up.*` string type. + * - `upn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum UserCustomDimension { + OsArchitecture = 'up.ng_os_architecture', + NodeVersion = 'up.ng_node_version', + NodeMajorVersion = 'upn.ng_node_major_version', + AngularCLIVersion = 'up.ng_cli_version', + AngularCLIMajorVersion = 'upn.ng_cli_major_version', + PackageManager = 'up.ng_package_manager', + PackageManagerVersion = 'up.ng_package_manager_version', + PackageManagerMajorVersion = 'upn.ng_package_manager_major_version', +} + +/** + * Event scoped custom dimensions. + * @notes + * - Event custom dimensions limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomDimension { + Command = 'ep.ng_command', + SchematicCollectionName = 'ep.ng_schematic_collection_name', + SchematicName = 'ep.ng_schematic_name', + Standalone = 'ep.ng_standalone', + Style = 'ep.ng_style', + Routing = 'ep.ng_routing', + InlineTemplate = 'ep.ng_inline_template', + InlineStyle = 'ep.ng_inline_style', + BuilderTarget = 'ep.ng_builder_target', + Aot = 'ep.ng_aot', + Optimization = 'ep.ng_optimization', +} + +/** + * Event scoped custom mertics. + * @notes + * - Event scoped custom mertics limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomMetric { + AllChunksCount = 'epn.ng_all_chunks_count', + LazyChunksCount = 'epn.ng_lazy_chunks_count', + 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/analytics/analytics.ts b/packages/angular/cli/src/analytics/analytics.ts index a177a71571cb..1f37d21bb50d 100644 --- a/packages/angular/cli/src/analytics/analytics.ts +++ b/packages/angular/cli/src/analytics/analytics.ts @@ -6,39 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import { analytics, json, tags } from '@angular-devkit/core'; +import { json, tags } from '@angular-devkit/core'; import { randomUUID } from 'crypto'; -import debug from 'debug'; +import type { CommandContext } from '../command-builder/command-module'; import { colors } from '../utilities/color'; import { getWorkspace } from '../utilities/config'; -import { analyticsDisabled, analyticsShareDisabled } from '../utilities/environment-options'; -import { assertIsError } from '../utilities/error'; +import { analyticsDisabled } from '../utilities/environment-options'; import { isTTY } from '../utilities/tty'; -import { VERSION } from '../utilities/version'; -import { AnalyticsCollector } from './analytics-collector'; /* eslint-disable no-console */ -const analyticsDebug = debug('ng:analytics'); // Generate analytics, including settings and users. - -let _defaultAngularCliPropertyCache: string; -export const AnalyticsProperties = { - AngularCliProd: 'UA-8594346-29', - AngularCliStaging: 'UA-8594346-32', - get AngularCliDefault(): string { - if (_defaultAngularCliPropertyCache) { - return _defaultAngularCliPropertyCache; - } - - const v = VERSION.full; - // The logic is if it's a full version then we should use the prod GA property. - _defaultAngularCliPropertyCache = - /^\d+\.\d+\.\d+$/.test(v) && v !== '0.0.0' - ? AnalyticsProperties.AngularCliProd - : AnalyticsProperties.AngularCliStaging; - - return _defaultAngularCliPropertyCache; - }, -}; /** * This is the ultimate safelist for checking if a package name is safe to report to analytics. @@ -46,7 +22,7 @@ export const AnalyticsProperties = { export const analyticsPackageSafelist = [ /^@angular\//, /^@angular-devkit\//, - /^@ngtools\//, + /^@nguniversal\//, '@schematics/angular', ]; @@ -67,7 +43,6 @@ export function isPackageNameSafeForAnalytics(name: string): boolean { */ export async function setAnalyticsConfig(global: boolean, value: string | boolean): Promise { const level = global ? 'global' : 'local'; - analyticsDebug('setting %s level analytics to: %s', level, value); const workspace = await getWorkspace(level); if (!workspace) { throw new Error(`Could not find ${level} workspace.`); @@ -80,7 +55,6 @@ export async function setAnalyticsConfig(global: boolean, value: string | boolea cli.analytics = value === true ? randomUUID() : value; await workspace.save(); - analyticsDebug('done'); } /** @@ -88,8 +62,11 @@ export async function setAnalyticsConfig(global: boolean, value: string | boolea * @param force Whether to ask regardless of whether or not the user is using an interactive shell. * @return Whether or not the user was shown a prompt. */ -export async function promptAnalytics(global: boolean, force = false): Promise { - analyticsDebug('prompting user'); +export async function promptAnalytics( + context: CommandContext, + global: boolean, + force = false, +): Promise { const level = global ? 'global' : 'local'; const workspace = await getWorkspace(level); if (!workspace) { @@ -103,11 +80,11 @@ export async function promptAnalytics(global: boolean, force = false): Promise { - analyticsDebug('getAnalytics'); - +): Promise { if (analyticsDisabled) { - analyticsDebug('NG_CLI_ANALYTICS is false'); - - return new analytics.NoopAnalytics(); - } - - try { - const workspace = await getWorkspace(level); - const analyticsConfig: string | undefined | null | { uid?: string } = - workspace?.getCli()?.['analytics']; - analyticsDebug('Workspace Analytics config found: %j', analyticsConfig); - - if (analyticsConfig === false) { - return new analytics.NoopAnalytics(); - } else if (analyticsConfig === undefined || analyticsConfig === null) { - return undefined; - } else { - let uid: string | undefined = undefined; - - if (typeof analyticsConfig == 'string') { - uid = analyticsConfig; - } else if (typeof analyticsConfig == 'object' && typeof analyticsConfig['uid'] == 'string') { - uid = analyticsConfig['uid']; - } - - analyticsDebug('client id: %j', uid); - if (uid == undefined) { - return undefined; - } - - return new AnalyticsCollector(AnalyticsProperties.AngularCliDefault, uid); - } - } catch (err) { - assertIsError(err); - analyticsDebug('Error happened during reading of analytics config: %s', err.message); - - return undefined; + return false; } -} - -/** - * Return the usage analytics sharing setting, which is either a property string (GA-XXXXXXX-XX), - * or undefined if no sharing. - */ -export async function getSharedAnalytics(): Promise { - analyticsDebug('getSharedAnalytics'); - if (analyticsShareDisabled) { - analyticsDebug('NG_CLI_ANALYTICS is false'); + const workspace = await getWorkspace(level); + const analyticsConfig: string | undefined | null | { uid?: string } = + workspace?.getCli()?.['analytics']; + if (analyticsConfig === false) { + return false; + } else if (analyticsConfig === undefined || analyticsConfig === null) { return undefined; - } - - // If anything happens we just keep the NOOP analytics. - try { - const globalWorkspace = await getWorkspace('global'); - const analyticsConfig = globalWorkspace?.getCli()?.['analyticsSharing']; - - if (!analyticsConfig || !analyticsConfig.tracking || !analyticsConfig.uuid) { - return undefined; - } else { - analyticsDebug('Analytics sharing info: %j', analyticsConfig); - - return new AnalyticsCollector(analyticsConfig.tracking, analyticsConfig.uuid); + } else { + if (typeof analyticsConfig == 'string') { + return analyticsConfig; + } else if (typeof analyticsConfig == 'object' && typeof analyticsConfig['uid'] == 'string') { + return analyticsConfig['uid']; } - } catch (err) { - assertIsError(err); - analyticsDebug('Error happened during reading of analytics sharing config: %s', err.message); return undefined; } } -export async function createAnalytics( - workspace: boolean, +export async function getAnalyticsUserId( + context: CommandContext, skipPrompt = false, -): Promise { +): Promise { + const { workspace } = context; // Global config takes precedence over local config only for the disabled check. // IE: // global: disabled & local: enabled = disabled // global: id: 123 & local: id: 456 = 456 // check global - const globalConfig = await getAnalytics('global'); - if (globalConfig instanceof analytics.NoopAnalytics) { - return globalConfig; + const globalConfig = await getAnalyticsUserIdForLevel('global'); + if (globalConfig === false) { + return undefined; } - let config = globalConfig; // Not disabled globally, check locally or not set globally and command is run outside of workspace example: `ng new` if (workspace || globalConfig === undefined) { const level = workspace ? 'local' : 'global'; - let localOrGlobalConfig = await getAnalytics(level); + let localOrGlobalConfig = await getAnalyticsUserIdForLevel(level); if (localOrGlobalConfig === undefined) { if (!skipPrompt) { // config is unset, prompt user. // TODO: This should honor the `no-interactive` option. // It is currently not an `ng` option but rather only an option for specific commands. // The concept of `ng`-wide options are needed to cleanly handle this. - await promptAnalytics(!workspace /** global */); - localOrGlobalConfig = await getAnalytics(level); + await promptAnalytics(context, !workspace /** global */); + localOrGlobalConfig = await getAnalyticsUserIdForLevel(level); } } - if (localOrGlobalConfig instanceof analytics.NoopAnalytics) { + if (localOrGlobalConfig === false) { + return undefined; + } else if (typeof localOrGlobalConfig === 'string') { return localOrGlobalConfig; - } else if (localOrGlobalConfig) { - // Favor local settings over global when defined. - config = localOrGlobalConfig; } } - // Get shared analytics - // TODO: evalute if this should be completly removed. - const maybeSharedAnalytics = await getSharedAnalytics(); - if (config && maybeSharedAnalytics) { - return new analytics.MultiAnalytics([config, maybeSharedAnalytics]); - } - - return config ?? maybeSharedAnalytics ?? new analytics.NoopAnalytics(); + return globalConfig || randomUUID(); } function analyticsConfigValueToHumanFormat(value: unknown): 'enabled' | 'disabled' | 'not set' { @@ -290,28 +197,22 @@ function analyticsConfigValueToHumanFormat(value: unknown): 'enabled' | 'disable } } -export async function getAnalyticsInfoString(): Promise { - const globalWorkspace = await getWorkspace('global'); - const localWorkspace = await getWorkspace('local'); - const globalSetting = globalWorkspace?.getCli()?.['analytics']; - const localSetting = localWorkspace?.getCli()?.['analytics']; +export async function getAnalyticsInfoString(context: CommandContext): Promise { + const analyticsInstance = await getAnalyticsUserId(context, true /** skipPrompt */); - const analyticsInstance = await createAnalytics( - !!localWorkspace /** workspace */, - true /** skipPrompt */, - ); + const { globalConfiguration, workspace: localWorkspace } = context; + const globalSetting = globalConfiguration?.getCli()?.['analytics']; + const localSetting = localWorkspace?.getCli()?.['analytics']; return ( tags.stripIndents` - Global setting: ${analyticsConfigValueToHumanFormat(globalSetting)} - Local setting: ${ - localWorkspace - ? analyticsConfigValueToHumanFormat(localSetting) - : 'No local workspace configuration file.' - } - Effective status: ${ - analyticsInstance instanceof analytics.NoopAnalytics ? 'disabled' : 'enabled' - } - ` + '\n' + Global setting: ${analyticsConfigValueToHumanFormat(globalSetting)} + Local setting: ${ + localWorkspace + ? analyticsConfigValueToHumanFormat(localSetting) + : 'No local workspace configuration file.' + } + Effective status: ${analyticsInstance ? 'enabled' : 'disabled'} + ` + '\n' ); } 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 a92083947eaf..59e0852402c4 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,6 +16,7 @@ import { spawnSync } from 'child_process'; import { existsSync } from 'fs'; import { resolve } from 'path'; import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; +import { EventCustomDimension, EventCustomMetric } from '../analytics/analytics-parameters'; import { assertIsError } from '../utilities/error'; import { askConfirmation, askQuestion } from '../utilities/prompt'; import { isTTY } from '../utilities/tty'; @@ -38,7 +39,6 @@ export abstract class ArchitectBaseCommandModule implements CommandModuleImplementation { override scope = CommandScope.In; - protected override shouldReportAnalytics = false; protected readonly missingTargetChoices: MissingTargetChoice[] | undefined; protected async runSingleTarget(target: Target, options: OtherOptions): Promise { @@ -53,31 +53,88 @@ export abstract class ArchitectBaseCommandModule return this.onMissingTarget(e.message); } - await this.reportAnalytics( - { - ...(await architectHost.getOptionsForTarget(target)), - ...options, - }, - undefined /** paths */, - undefined /** dimensions */, - builderName, - ); - const { logger } = this.context; - const run = await this.getArchitect().scheduleTarget(target, options as json.JsonObject, { logger, - analytics: isPackageNameSafeForAnalytics(builderName) ? await this.getAnalytics() : undefined, }); - const { error, success } = await run.output.toPromise(); - await run.stop(); + const analytics = isPackageNameSafeForAnalytics(builderName) + ? await this.getAnalytics() + : undefined; + + 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(); + + if (error) { + logger.error(error); + } + + return success ? 0 : 1; + } finally { + await run.stop(); + outputSubscription?.unsubscribe(); + } + } - if (error) { - logger.error(error); + private builderStatsToAnalyticsParameters( + stats: json.JsonValue, + builderName: string, + ): Partial< + | Record + | undefined + > { + if (!stats || typeof stats !== 'object' || !('durationInMs' in stats)) { + return undefined; } - return success ? 0 : 1; + 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/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts index 81135cd27ebc..8cb7469fa4a5 100644 --- a/packages/angular/cli/src/command-builder/command-module.ts +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import { analytics, logging, schema, strings } from '@angular-devkit/core'; +import { logging, schema, strings } from '@angular-devkit/core'; import { readFileSync } from 'fs'; import * as path from 'path'; -import { +import yargs, { Arguments, ArgumentsCamelCase, Argv, @@ -19,7 +19,9 @@ import { Options as YargsOptions, } from 'yargs'; import { Parser as yargsParser } from 'yargs/helpers'; -import { createAnalytics } from '../analytics/analytics'; +import { getAnalyticsUserId } from '../analytics/analytics'; +import { AnalyticsCollector } from '../analytics/analytics-collector'; +import { EventCustomDimension, EventCustomMetric } from '../analytics/analytics-parameters'; import { considerSettingUpAutocompletion } from '../utilities/completion'; import { AngularWorkspace } from '../utilities/config'; import { memoize } from '../utilities/memoize'; @@ -82,7 +84,7 @@ export abstract class CommandModule implements CommandModuleI protected readonly shouldReportAnalytics: boolean = true; readonly scope: CommandScope = CommandScope.Both; - private readonly optionsWithAnalytics = new Map(); + private readonly optionsWithAnalytics = new Map(); constructor(protected readonly context: CommandContext) {} @@ -140,20 +142,29 @@ export abstract class CommandModule implements CommandModuleI // Gather and report analytics. const analytics = await this.getAnalytics(); - let stopPeriodicFlushes: (() => Promise) | undefined; - - if (this.shouldReportAnalytics) { - await this.reportAnalytics(camelCasedOptions); - stopPeriodicFlushes = this.periodicAnalyticsFlush(analytics); - } + const stopPeriodicFlushes = analytics && analytics.periodFlush(); let exitCode: number | void | undefined; try { // Run and time command. - const startTime = Date.now(); + if (analytics) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const internalMethods = (yargs as any).getInternalMethods(); + // $0 generate component [name] -> generate_component + // $0 add -> add + const fullCommand = (internalMethods.getUsageInstance().getUsage()[0][0] as string) + .split(' ') + .filter((x) => { + const code = x.charCodeAt(0); + + return code >= 97 && code <= 122; + }) + .join('_'); + + analytics.reportCommandRunEvent(fullCommand); + } + exitCode = await this.run(camelCasedOptions as Options & OtherOptions); - const endTime = Date.now(); - analytics.timing(this.commandName, 'duration', endTime - startTime); } catch (e) { if (e instanceof schema.SchemaValidationException) { this.context.logger.fatal(`Error: ${e.message}`); @@ -170,35 +181,19 @@ export abstract class CommandModule implements CommandModuleI } } - async reportAnalytics( - options: (Options & OtherOptions) | OtherOptions, - paths: string[] = [], - dimensions: (boolean | number | string)[] = [], - title?: string, - ): Promise { - for (const [name, ua] of this.optionsWithAnalytics) { - const value = options[name]; - - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - dimensions[ua] = value; - } + @memoize + protected async getAnalytics(): Promise { + if (!this.shouldReportAnalytics) { + return undefined; } - const analytics = await this.getAnalytics(); - analytics.pageview('/command/' + [this.commandName, ...paths].join('/'), { - dimensions, - metrics: [], - title, - }); - } - - @memoize - protected getAnalytics(): Promise { - return createAnalytics( - !!this.context.workspace, + const userId = await getAnalyticsUserId( + this.context, // Don't prompt for `ng update` and `ng analytics` commands. ['update', 'analytics'].includes(this.commandName), ); + + return userId ? new AnalyticsCollector(this.context, userId) : undefined; } /** @@ -288,18 +283,29 @@ export abstract class CommandModule implements CommandModuleI * @returns a method that when called will terminate the periodic * flush and call flush one last time. */ - private periodicAnalyticsFlush(analytics: analytics.Analytics): () => Promise { - let analyticsFlushPromise = Promise.resolve(); - const analyticsFlushInterval = setInterval(() => { - analyticsFlushPromise = analyticsFlushPromise.then(() => analytics.flush()); - }, 2000); - - return () => { - clearInterval(analyticsFlushInterval); - - // Flush one last time. - return analyticsFlushPromise.then(() => analytics.flush()); - }; + protected getAnalyticsParameters( + options: (Options & OtherOptions) | OtherOptions, + ): Partial> { + const parameters: Partial< + Record + > = {}; + + const validEventCustomDimensionAndMetrics = new Set([ + ...Object.values(EventCustomDimension), + ...Object.values(EventCustomMetric), + ]); + + for (const [name, ua] of this.optionsWithAnalytics) { + const value = options[name]; + if ( + (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') && + validEventCustomDimensionAndMetrics.has(ua as EventCustomDimension | EventCustomMetric) + ) { + parameters[ua as EventCustomDimension | EventCustomMetric] = value; + } + } + + return parameters; } } diff --git a/packages/angular/cli/src/command-builder/schematics-command-module.ts b/packages/angular/cli/src/command-builder/schematics-command-module.ts index 4439616b1d3e..59f4b1cda89f 100644 --- a/packages/angular/cli/src/command-builder/schematics-command-module.ts +++ b/packages/angular/cli/src/command-builder/schematics-command-module.ts @@ -16,6 +16,8 @@ import { import type { CheckboxQuestion, Question } from 'inquirer'; import { relative, resolve } from 'path'; import { Argv } from 'yargs'; +import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; +import { EventCustomDimension } from '../analytics/analytics-parameters'; import { getProjectByCwd, getSchematicDefaults } from '../utilities/config'; import { assertIsError } from '../utilities/error'; import { memoize } from '../utilities/memoize'; @@ -50,7 +52,6 @@ export abstract class SchematicsCommandModule { override scope = CommandScope.In; protected readonly allowPrivateSchematics: boolean = false; - protected override readonly shouldReportAnalytics = false; async builder(argv: Argv): Promise> { return argv @@ -144,15 +145,24 @@ export abstract class SchematicsCommandModule let shouldReportAnalytics = true; workflow.engineHost.registerOptionsTransform(async (schematic, options) => { + // Report analytics if (shouldReportAnalytics) { shouldReportAnalytics = false; - await this.reportAnalytics( - options as {}, - undefined /** paths */, - undefined /** dimensions */, - schematic.collection.name + ':' + schematic.name, - ); + const { + collection: { name: collectionName }, + name: schematicName, + } = schematic; + + const analytics = isPackageNameSafeForAnalytics(collectionName) + ? await this.getAnalytics() + : undefined; + + analytics?.reportSchematicRunEvent({ + [EventCustomDimension.SchematicCollectionName]: collectionName, + [EventCustomDimension.SchematicName]: schematicName, + ...this.getAnalyticsParameters(options as unknown as {}), + }); } return options; diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema.ts b/packages/angular/cli/src/command-builder/utilities/json-schema.ts index 8146cd71dbfd..b62619ced20d 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-schema.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-schema.ts @@ -42,7 +42,7 @@ export interface Option extends yargs.Options { * Whether or not to report this option to the Angular Team, and which custom field to use. * If this is falsey, do not report this option. */ - userAnalytics?: number; + userAnalytics?: string; } export async function parseJsonSchemaToOptions( @@ -172,7 +172,7 @@ export async function parseJsonSchemaToOptions( const hidden = !!current.hidden || !visible; const xUserAnalytics = current['x-user-analytics']; - const userAnalytics = typeof xUserAnalytics == 'number' ? xUserAnalytics : undefined; + const userAnalytics = typeof xUserAnalytics === 'string' ? xUserAnalytics : undefined; // Deprecated is set only if it's true or a string. const xDeprecated = current['x-deprecated']; diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index 3e62345b9b89..e861a45ab76f 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { analytics, tags } from '@angular-devkit/core'; +import { tags } from '@angular-devkit/core'; import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools'; import { createRequire } from 'module'; import npa from 'npm-package-arg'; @@ -14,7 +14,6 @@ import { dirname, join } from 'path'; import { compare, intersects, prerelease, satisfies, valid } from 'semver'; import { Argv } from 'yargs'; import { PackageManager } from '../../../lib/config/workspace-schema'; -import { isPackageNameSafeForAnalytics } from '../../analytics/analytics'; import { CommandModuleImplementation, Options, @@ -323,17 +322,6 @@ export class AddCommandModule return validVersion; } - override async reportAnalytics(options: OtherOptions, paths: string[]): Promise { - const collection = await this.getCollectionName(); - const dimensions: string[] = []; - // Add the collection if it's safe listed. - if (collection && isPackageNameSafeForAnalytics(collection)) { - dimensions[analytics.NgCliAnalyticsDimensions.NgAddCollection] = collection; - } - - return super.reportAnalytics(options, paths, dimensions); - } - private async getCollectionName(): Promise { const [, collectionName] = this.context.args.positional; diff --git a/packages/angular/cli/src/commands/analytics/info/cli.ts b/packages/angular/cli/src/commands/analytics/info/cli.ts index 271cbb210328..bfcba4a3da0e 100644 --- a/packages/angular/cli/src/commands/analytics/info/cli.ts +++ b/packages/angular/cli/src/commands/analytics/info/cli.ts @@ -27,6 +27,6 @@ export class AnalyticsInfoCommandModule } async run(_options: Options<{}>): Promise { - this.context.logger.info(await getAnalyticsInfoString()); + this.context.logger.info(await getAnalyticsInfoString(this.context)); } } diff --git a/packages/angular/cli/src/commands/analytics/settings/cli.ts b/packages/angular/cli/src/commands/analytics/settings/cli.ts index f37d1b948618..ff965e228781 100644 --- a/packages/angular/cli/src/commands/analytics/settings/cli.ts +++ b/packages/angular/cli/src/commands/analytics/settings/cli.ts @@ -52,7 +52,7 @@ export class AnalyticsDisableModule async run({ global }: Options): Promise { await setAnalyticsConfig(global, false); - process.stderr.write(await getAnalyticsInfoString()); + process.stderr.write(await getAnalyticsInfoString(this.context)); } } @@ -65,7 +65,7 @@ export class AnalyticsEnableModule describe = 'Enables analytics gathering and reporting for the user.'; async run({ global }: Options): Promise { await setAnalyticsConfig(global, true); - process.stderr.write(await getAnalyticsInfoString()); + process.stderr.write(await getAnalyticsInfoString(this.context)); } } @@ -77,6 +77,6 @@ export class AnalyticsPromptModule describe = 'Prompts the user to set the analytics gathering status interactively.'; async run({ global }: Options): Promise { - await promptAnalytics(global, true); + await promptAnalytics(this.context, global, true); } } diff --git a/packages/angular/cli/src/utilities/environment-options.ts b/packages/angular/cli/src/utilities/environment-options.ts index 7febd351b06e..264984bb432a 100644 --- a/packages/angular/cli/src/utilities/environment-options.ts +++ b/packages/angular/cli/src/utilities/environment-options.ts @@ -27,7 +27,6 @@ function optional(variable: string | undefined): boolean | undefined { } export const analyticsDisabled = isDisabled(process.env['NG_CLI_ANALYTICS']); -export const analyticsShareDisabled = isDisabled(process.env['NG_CLI_ANALYTICS_SHARE']); export const isCI = isEnabled(process.env['CI']); export const disableVersionCheck = isEnabled(process.env['NG_DISABLE_VERSION_CHECK']); export const ngDebug = isEnabled(process.env['NG_DEBUG']); diff --git a/packages/angular_devkit/architect/src/api.ts b/packages/angular_devkit/architect/src/api.ts index ebe538a99dbf..0b0bd9d2f131 100644 --- a/packages/angular_devkit/architect/src/api.ts +++ b/packages/angular_devkit/architect/src/api.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { analytics, json, logging } from '@angular-devkit/core'; +import { json, logging } from '@angular-devkit/core'; import { Observable, SubscribableOrPromise, Subscriber, from } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { Schema as RealBuilderInput, Target as RealTarget } from './input-schema'; @@ -239,12 +239,6 @@ export interface BuilderContext { */ reportProgress(current: number, total?: number, status?: string): void; - /** - * API to report analytics. This might be undefined if the feature is unsupported. This might - * not be undefined, but the backend could also not report anything. - */ - readonly analytics: analytics.Analytics; - /** * Add teardown logic to this Context, so that when it's being stopped it will execute teardown. */ diff --git a/packages/angular_devkit/architect/src/architect.ts b/packages/angular_devkit/architect/src/architect.ts index d8ced621b008..407e01c541e9 100644 --- a/packages/angular_devkit/architect/src/architect.ts +++ b/packages/angular_devkit/architect/src/architect.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { analytics, json, logging } from '@angular-devkit/core'; +import { json, logging } from '@angular-devkit/core'; import { Observable, from, merge, of, onErrorResumeNext } from 'rxjs'; import { concatMap, @@ -142,7 +142,6 @@ function _createJobHandlerFromBuilderInfo( export interface ScheduleOptions { logger?: logging.Logger; - analytics?: analytics.Analytics; } /** @@ -407,7 +406,6 @@ export class Architect { logger: scheduleOptions.logger || new logging.NullLogger(), currentDirectory: this._host.getCurrentDirectory(), workspaceRoot: this._host.getWorkspaceRoot(), - analytics: scheduleOptions.analytics, }); } scheduleTarget( @@ -420,7 +418,6 @@ export class Architect { logger: scheduleOptions.logger || new logging.NullLogger(), currentDirectory: this._host.getCurrentDirectory(), workspaceRoot: this._host.getWorkspaceRoot(), - analytics: scheduleOptions.analytics, }); } } diff --git a/packages/angular_devkit/architect/src/create-builder.ts b/packages/angular_devkit/architect/src/create-builder.ts index ad1ad5073950..b52b3d970123 100644 --- a/packages/angular_devkit/architect/src/create-builder.ts +++ b/packages/angular_devkit/architect/src/create-builder.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { analytics, json, logging } from '@angular-devkit/core'; +import { json, logging } from '@angular-devkit/core'; import { Observable, Subscription, from, isObservable, of, throwError } from 'rxjs'; import { mergeMap, tap } from 'rxjs/operators'; import { @@ -37,7 +37,6 @@ export function createBuilder PromiseLike | void> = []; let tearingDown = false; @@ -194,7 +193,6 @@ export function createBuilder analyticsChannel.next(report)), addTeardown(teardown: () => Promise | void): void { teardownLogics.push(teardown); }, diff --git a/packages/angular_devkit/architect/src/schedule-by-name.ts b/packages/angular_devkit/architect/src/schedule-by-name.ts index 8e86aa836035..f28138a6d5fb 100644 --- a/packages/angular_devkit/architect/src/schedule-by-name.ts +++ b/packages/angular_devkit/architect/src/schedule-by-name.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import { analytics, json, logging } from '@angular-devkit/core'; +import { json, logging } from '@angular-devkit/core'; import { EMPTY, Subscription } from 'rxjs'; import { catchError, first, ignoreElements, map, shareReplay } from 'rxjs/operators'; import { @@ -33,7 +33,6 @@ export async function scheduleByName( logger: logging.LoggerApi; workspaceRoot: string | Promise; currentDirectory: string | Promise; - analytics?: analytics.Analytics; }, ): Promise { const childLoggerName = options.target ? `{${targetStringFromTarget(options.target)}}` : name; @@ -100,13 +99,6 @@ export async function scheduleByName( shareReplay(), ); - // If there's an analytics object, take the job channel and report it to the analytics. - if (options.analytics) { - const reporter = new analytics.AnalyticsReporter(options.analytics); - job - .getChannel('analytics') - .subscribe((report) => reporter.report(report)); - } // Start the builder. output.pipe(first()).subscribe({ error() {}, @@ -144,7 +136,6 @@ export async function scheduleByTarget( logger: logging.LoggerApi; workspaceRoot: string | Promise; currentDirectory: string | Promise; - analytics?: analytics.Analytics; }, ): Promise { return scheduleByName(`{${targetStringFromTarget(target)}}`, overrides, { 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 ea0ec43e5598..bd78395f3128 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 @@ -141,8 +141,8 @@ }, "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.", - "x-user-analytics": 16, "default": true, + "x-user-analytics": "ep.ng_optimization", "oneOf": [ { "type": "object", @@ -224,7 +224,7 @@ "aot": { "type": "boolean", "description": "Build using Ahead of Time compilation.", - "x-user-analytics": 13, + "x-user-analytics": "ep.ng_aot", "default": true }, "sourceMap": { 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 6a310a4d45a6..30bf61c8896c 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, @@ -47,10 +47,12 @@ import { getIndexInputFile, getIndexOutputFile, } from '../../utils/webpack-browser-config'; -import { getAnalyticsConfig, getCommonConfig, getStylesConfig } from '../../webpack/configs'; +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. @@ -105,7 +109,6 @@ async function initialize( await generateI18nBrowserWebpackConfigFromContext(adjustedOptions, context, (wco) => [ getCommonConfig(wco), getStylesConfig(wco), - getAnalyticsConfig(wco, context), ]); // Validate asset option values if processed directly @@ -199,196 +202,231 @@ export function buildWebpackBrowser( } }), }).pipe( - concatMap(async (buildEvent) => { - 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 })); + 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.'); } - return { success }; - } else { - outputPaths = ensureOutputPaths(baseOutputPath, i18n); - - 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 { success: false }; + // 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 })); } - } - // 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); + return { + webpackStats: webpackRawStats, + output: { success: false }, + }; + } else { + outputPaths = ensureOutputPaths(baseOutputPath, i18n); + + 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 }, + }; } } - } - 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 { success: false, error: 'Unable to copy assets: ' + err.message }; + // 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 (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()) { + 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), - }); - - if (warnings.length || errors.length) { - spinner.stop(); - warnings.forEach((m) => context.logger.warn(m)); - errors.forEach((m) => { - context.logger.error(m); - hasErrors = true; + 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 { + 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(); + + 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 }, + }; } + } - 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 (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 { + webpackStats: webpackRawStats, + output: { success: false, error: error.message }, + }; + } + } - return { success: false }; - } else { - spinner.succeed('Index html generation complete.'); + spinner.succeed('Service worker 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) }; - } - } + webpackStatsLogger(context.logger, webpackStats, config, budgetFailures); - spinner.succeed('Service worker generation complete.'); - } + 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], @@ -417,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 12c9d6792afe..c0dc1e719e54 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser/schema.json @@ -133,8 +133,8 @@ }, "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.", - "x-user-analytics": 16, "default": true, + "x-user-analytics": "ep.ng_optimization", "oneOf": [ { "type": "object", @@ -216,7 +216,7 @@ "aot": { "type": "boolean", "description": "Build using Ahead of Time compilation.", - "x-user-analytics": 13, + "x-user-analytics": "ep.ng_aot", "default": true }, "sourceMap": { 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 cdcb41122498..bf8b74cf315b 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 @@ -36,15 +36,14 @@ import { getIndexOutputFile, } from '../../utils/webpack-browser-config'; import { addError, addWarning } from '../../utils/webpack-diagnostics'; -import { - getAnalyticsConfig, - getCommonConfig, - getDevServerConfig, - getStylesConfig, -} from '../../webpack/configs'; +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'; @@ -55,6 +54,7 @@ export type DevServerBuilderOptions = Schema; */ export type DevServerBuilderOutput = DevServerBuildOutput & { baseUrl: string; + stats: BuildEventStats; }; /** @@ -167,12 +167,7 @@ export function serveWebpackBrowser( const { config, projectRoot, i18n } = await generateI18nBrowserWebpackConfigFromContext( browserOptions, context, - (wco) => [ - getDevServerConfig(wco), - getCommonConfig(wco), - getStylesConfig(wco), - getAnalyticsConfig(wco, context), - ], + (wco) => [getDevServerConfig(wco), getCommonConfig(wco), getStylesConfig(wco)], options, ); @@ -262,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; @@ -296,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/builders/server/schema.json b/packages/angular_devkit/build_angular/src/builders/server/schema.json index 283d25363f37..196af202b80e 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/server/schema.json @@ -36,8 +36,8 @@ }, "optimization": { "description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking and dead-code elimination. For more information, see https://angular.io/guide/workspace-config#optimization-configuration.", - "x-user-analytics": 16, "default": true, + "x-user-analytics": "ep.ng_optimization", "oneOf": [ { "type": "object", diff --git a/packages/angular_devkit/build_angular/src/testing/builder-harness.ts b/packages/angular_devkit/build_angular/src/testing/builder-harness.ts index 4a930039d33e..3bfbb686244e 100644 --- a/packages/angular_devkit/build_angular/src/testing/builder-harness.ts +++ b/packages/angular_devkit/build_angular/src/testing/builder-harness.ts @@ -21,7 +21,7 @@ import { } from '@angular-devkit/architect'; import { WorkspaceHost } from '@angular-devkit/architect/node'; import { TestProjectHost } from '@angular-devkit/architect/testing'; -import { analytics, getSystemPath, join, json, logging, normalize } from '@angular-devkit/core'; +import { getSystemPath, join, json, logging, normalize } from '@angular-devkit/core'; import { Observable, Subject, from as observableFrom, of as observableOf } from 'rxjs'; import { catchError, finalize, first, map, mergeMap, shareReplay } from 'rxjs/operators'; import { BuilderWatcherFactory, WatcherNotifier } from './file-watching'; @@ -379,11 +379,6 @@ class HarnessBuilderContext implements BuilderContext { this.workspaceRoot = this.currentDirectory = basePath; } - get analytics(): analytics.Analytics { - // Can be undefined even though interface does not allow it - return undefined as unknown as analytics.Analytics; - } - addTeardown(teardown: () => Promise | void): void { this.teardowns.push(teardown); } diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/analytics.ts b/packages/angular_devkit/build_angular/src/webpack/configs/analytics.ts deleted file mode 100644 index 05a8e7aa3831..000000000000 --- a/packages/angular_devkit/build_angular/src/webpack/configs/analytics.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @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 { BuilderContext } from '@angular-devkit/architect'; -import { Configuration } from 'webpack'; -import { WebpackConfigOptions } from '../../utils/build-options'; -import { NgBuildAnalyticsPlugin } from '../plugins/analytics'; - -export function getAnalyticsConfig( - wco: WebpackConfigOptions, - context: BuilderContext, -): Configuration { - if (!context.analytics) { - return {}; - } - - // If there's analytics, add our plugin. Otherwise no need to slow down the build. - let category = 'build'; - if (context.builder) { - // We already vetted that this is a "safe" package, otherwise the analytics would be noop. - category = context.builder.builderName.split(':')[1] || context.builder.builderName || 'build'; - } - - // The category is the builder name if it's an angular builder. - return { - plugins: [new NgBuildAnalyticsPlugin(wco.projectRoot, context.analytics, category)], - }; -} 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 6076a970c2f3..6b7c85c021f3 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= 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; -} - -/** - * Holder of statistics related to the build. - */ -class AnalyticsBuildStats { - public errors: string[] = []; - public numberOfNgOnInit = 0; - public numberOfComponents = 0; - public initialChunkSize = 0; - public totalChunkCount = 0; - public totalChunkSize = 0; - public lazyChunkCount = 0; - public lazyChunkSize = 0; - public assetCount = 0; - public assetSize = 0; - public polyfillSize = 0; - public cssSize = 0; -} - -/** - * Analytics plugin that reports the analytics we want from the CLI. - */ -export class NgBuildAnalyticsPlugin { - protected _built = false; - protected _stats = new AnalyticsBuildStats(); - - constructor( - protected _projectRoot: string, - protected _analytics: analytics.Analytics, - protected _category: string, - ) {} - - protected _reset() { - this._stats = new AnalyticsBuildStats(); - } - - protected _getMetrics(stats: Stats) { - const startTime = +(stats.startTime || 0); - const endTime = +(stats.endTime || 0); - const metrics: (string | number)[] = []; - metrics[analytics.NgCliAnalyticsMetrics.BuildTime] = endTime - startTime; - metrics[analytics.NgCliAnalyticsMetrics.NgOnInitCount] = this._stats.numberOfNgOnInit; - metrics[analytics.NgCliAnalyticsMetrics.NgComponentCount] = this._stats.numberOfComponents; - metrics[analytics.NgCliAnalyticsMetrics.InitialChunkSize] = this._stats.initialChunkSize; - metrics[analytics.NgCliAnalyticsMetrics.TotalChunkCount] = this._stats.totalChunkCount; - metrics[analytics.NgCliAnalyticsMetrics.TotalChunkSize] = this._stats.totalChunkSize; - metrics[analytics.NgCliAnalyticsMetrics.LazyChunkCount] = this._stats.lazyChunkCount; - metrics[analytics.NgCliAnalyticsMetrics.LazyChunkSize] = this._stats.lazyChunkSize; - metrics[analytics.NgCliAnalyticsMetrics.AssetCount] = this._stats.assetCount; - metrics[analytics.NgCliAnalyticsMetrics.AssetSize] = this._stats.assetSize; - metrics[analytics.NgCliAnalyticsMetrics.PolyfillSize] = this._stats.polyfillSize; - metrics[analytics.NgCliAnalyticsMetrics.CssSize] = this._stats.cssSize; - - return metrics; - } - protected _getDimensions() { - const dimensions: (string | number | boolean)[] = []; - - if (this._stats.errors.length) { - // Adding commas before and after so the regex are easier to define filters. - dimensions[analytics.NgCliAnalyticsDimensions.BuildErrors] = `,${this._stats.errors.join()},`; - } - - return dimensions; - } - - protected _reportBuildMetrics(stats: Stats) { - const dimensions = this._getDimensions(); - const metrics = this._getMetrics(stats); - this._analytics.event(this._category, 'build', { dimensions, metrics }); - } - - protected _reportRebuildMetrics(stats: Stats) { - const dimensions = this._getDimensions(); - const metrics = this._getMetrics(stats); - this._analytics.event(this._category, 'rebuild', { dimensions, metrics }); - } - - protected _checkTsNormalModule(module: NormalModule) { - const originalSource = module.originalSource(); - if (!originalSource) { - return; - } - - const originalContent = originalSource.source().toString(); - - // PLEASE REMEMBER: - // We're dealing with ES5 _or_ ES2015 JavaScript at this point (we don't know for sure). - - // Just count the ngOnInit occurences. Comments/Strings/calls occurences should be sparse - // so we just consider them within the margin of error. We do break on word break though. - this._stats.numberOfNgOnInit += countOccurrences(originalContent, 'ngOnInit', true); - - // Count the number of `Component({` strings (case sensitive), which happens in __decorate(). - this._stats.numberOfComponents += countOccurrences(originalContent, 'Component({'); - // For Ivy we just count ɵcmp. - this._stats.numberOfComponents += countOccurrences(originalContent, '.ɵcmp', true); - // for ascii_only true - this._stats.numberOfComponents += countOccurrences(originalContent, '.\u0275cmp', true); - } - - protected _collectErrors(stats: Stats) { - if (stats.hasErrors()) { - for (const errObject of stats.compilation.errors) { - if (errObject instanceof Error) { - const allErrors = errObject.message.match(webpackAllErrorMessageRe); - for (const err of [...(allErrors || [])].slice(1)) { - const message = (err.match(webpackTsErrorMessageRe) || [])[1]; - if (message) { - // At this point this should be a TS1234. - this._stats.errors.push(message); - } - } - } - } - } - } - - protected _collectBundleStats(compilation: Compilation) { - const chunkAssets = new Set(); - for (const chunk of compilation.chunks) { - if (!chunk.rendered || chunk.files.size === 0) { - continue; - } - - const firstFile = Array.from(chunk.files)[0]; - const size = compilation.getAsset(firstFile)?.source.size() ?? 0; - chunkAssets.add(firstFile); - - if (chunk.canBeInitial()) { - this._stats.initialChunkSize += size; - } else { - this._stats.lazyChunkCount++; - this._stats.lazyChunkSize += size; - } - - this._stats.totalChunkCount++; - this._stats.totalChunkSize += size; - - if (firstFile.endsWith('.css')) { - this._stats.cssSize += size; - } - } - - for (const asset of compilation.getAssets()) { - // Only count non-JavaScript related files - if (chunkAssets.has(asset.name)) { - continue; - } - - this._stats.assetSize += asset.source.size(); - this._stats.assetCount++; - - if (asset.name == 'polyfill') { - this._stats.polyfillSize += asset.source.size(); - } - } - } - - /** ********************************************************************************************** - * The next section is all the different Webpack hooks for this plugin. - */ - - /** - * Reports a succeed module. - * @private - */ - protected _succeedModule(module: Module) { - // Only report NormalModule instances. - if (!(module instanceof NormalModule)) { - return; - } - - // Only reports modules that are part of the user's project. We also don't do node_modules. - // There is a chance that someone name a file path `hello_node_modules` or something and we - // will ignore that file for the purpose of gathering, but we're willing to take the risk. - if ( - !module.resource || - !module.resource.startsWith(this._projectRoot) || - module.resource.indexOf('node_modules') >= 0 - ) { - return; - } - - // Check that it's a source file from the project. - if (module.resource.endsWith('.ts')) { - this._checkTsNormalModule(module); - } - } - - protected _compilation(compiler: Compiler, compilation: Compilation) { - this._reset(); - compilation.hooks.succeedModule.tap('NgBuildAnalyticsPlugin', this._succeedModule.bind(this)); - } - - protected _done(stats: Stats) { - this._collectErrors(stats); - this._collectBundleStats(stats.compilation); - if (this._built) { - this._reportRebuildMetrics(stats); - } else { - this._reportBuildMetrics(stats); - this._built = true; - } - } - - apply(compiler: Compiler): void { - compiler.hooks.compilation.tap( - 'NgBuildAnalyticsPlugin', - this._compilation.bind(this, compiler), - ); - compiler.hooks.done.tap('NgBuildAnalyticsPlugin', this._done.bind(this)); - } -} diff --git a/packages/angular_devkit/build_angular/src/webpack/plugins/analytics_spec.ts b/packages/angular_devkit/build_angular/src/webpack/plugins/analytics_spec.ts deleted file mode 100644 index a19c020fc63c..000000000000 --- a/packages/angular_devkit/build_angular/src/webpack/plugins/analytics_spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @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 { countOccurrences } from './analytics'; - -function _randomString(len: number) { - const charSpace = `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ`; - - let s = ''; - for (let i = 0; i < len; i++) { - s += charSpace[Math.floor(Math.random() * charSpace.length)]; - } - - return s; -} - -describe('countOccurrences', () => { - // Every use cases is a text, search, word break or not, and expected result. - const useCases: [string, string, boolean, number][] = [ - ['abc1def1ghi1jkl1mno1pqrs1tuvw1xyz', '1', false, 7], // 0 - ['abc1def12ghi1jkl1mno12pqrs12tuvw1xyz12', '12', false, 4], // 1 - ['abc', 'abc', false, 1], // 2 - ['abc', 'abc', true, 1], // 3 - ['aaaaa', 'aaa', false, 1], // 4 - ['aa aaa', 'aaa', true, 1], // 5 - ['aaaaaa', 'aaa', false, 2], // 6 - ['aaa aaa', 'aaa', true, 2], // 7 - ['a', 'a', false, 1], // 8 - ['a', 'a', true, 1], // 9 - ]; - - useCases.forEach(([text, search, wordBreak, expected], i) => { - it(`works (${i})`, () => { - expect(countOccurrences(text, search, wordBreak)).toBe(expected); - }); - }); - - // Random testing. - it('can count (random, wordBreak=false)', () => { - // Generate a random string with injected search strings in it. - let text = _randomString(10000); - const search = _randomString(100).toLowerCase(); - const nb = Math.floor(Math.random() * 200 + 100); - - // Insert nb search string in. - new Array(nb) - .fill(0) - // Map it with a random position. - .map(() => Math.floor(Math.random() * text.length)) - // Sort from highest to lowest. - .sort((a, b) => b - a) - // Insert the search string for each position created this way. - .forEach((pos) => { - text = text.slice(0, pos) + search + text.slice(pos); - }); - - expect(countOccurrences(text, search, false)).toBe(nb); - expect(countOccurrences(text, search, true)).toBe(0); - }); - - it('can count (random, wordBreak=true)', () => { - // Generate a random string with injected search strings in it. - let text = _randomString(10000); - const search = _randomString(100).toLowerCase(); - let nb = Math.floor(Math.random() * 200 + 100); - - // Insert nb search string in. - new Array(nb) - .fill(0) - // Map it with a random position. - .map(() => Math.floor(Math.random() * text.length)) - // Sort from highest to lowest. - .sort((a, b) => b - a) - // Insert the search string for each position created this way. - .forEach((pos) => { - switch (Math.floor(Math.random() * 5)) { - case 0: - // Do not insert a wordbreak. - text = text.slice(0, pos) + search + text.slice(pos); - nb--; - break; - - case 1: - text = text.slice(0, pos) + ' ' + search + ' ' + text.slice(pos); - break; - case 2: - text = text.slice(0, pos) + '(' + search + '$' + text.slice(pos); - break; - case 3: - text = text.slice(0, pos) + '|' + search + ')' + text.slice(pos); - break; - case 4: - text = text.slice(0, pos) + '-' + search + '.' + text.slice(pos); - break; - } - }); - - expect(countOccurrences(text, search, true)).toBe(nb); - }); -}); diff --git a/packages/angular_devkit/build_angular/src/webpack/plugins/occurrences-plugin.ts b/packages/angular_devkit/build_angular/src/webpack/plugins/occurrences-plugin.ts new file mode 100644 index 000000000000..56ba37f8fe16 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/webpack/plugins/occurrences-plugin.ts @@ -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; + } +} 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 f747b7bd6bb1..8b45ea38a0d7 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 43569f916a9e..8cd9febfd58e 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, diff --git a/packages/angular_devkit/core/src/analytics/api.ts b/packages/angular_devkit/core/src/analytics/api.ts deleted file mode 100644 index 5bce57099418..000000000000 --- a/packages/angular_devkit/core/src/analytics/api.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @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 - */ - -export interface CustomDimensionsAndMetricsOptions { - dimensions?: (boolean | number | string)[]; - metrics?: (boolean | number | string)[]; -} - -export interface EventOptions extends CustomDimensionsAndMetricsOptions { - label?: string; - value?: string; -} - -export interface ScreenviewOptions extends CustomDimensionsAndMetricsOptions { - appVersion?: string; - appId?: string; - appInstallerId?: string; -} - -export interface PageviewOptions extends CustomDimensionsAndMetricsOptions { - hostname?: string; - title?: string; -} - -export interface TimingOptions extends CustomDimensionsAndMetricsOptions { - label?: string; -} - -/** - * Interface for managing analytics. This is highly platform dependent, and mostly matches - * Google Analytics. The reason the interface is here is to remove the dependency to an - * implementation from most other places. - * - * The methods exported from this interface more or less match those needed by us in the - * universal analytics package, see https://unpkg.com/@types/universal-analytics@0.4.2/index.d.ts - * for typings. We mostly named arguments to make it easier to follow, but didn't change or - * add any semantics to those methods. They're mapping GA and u-a one for one. - * - * The Angular CLI (or any other kind of backend) should forward it to some compatible backend. - */ -export interface Analytics { - event(category: string, action: string, options?: EventOptions): void; - screenview(screenName: string, appName: string, options?: ScreenviewOptions): void; - pageview(path: string, options?: PageviewOptions): void; - timing(category: string, variable: string, time: string | number, options?: TimingOptions): void; - - flush(): Promise; -} diff --git a/packages/angular_devkit/core/src/analytics/forwarder.ts b/packages/angular_devkit/core/src/analytics/forwarder.ts deleted file mode 100644 index 31e5fb373a28..000000000000 --- a/packages/angular_devkit/core/src/analytics/forwarder.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * @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 { JsonObject } from '../json'; -import { Analytics, EventOptions, PageviewOptions, ScreenviewOptions, TimingOptions } from './api'; - -export enum AnalyticsReportKind { - Event = 'event', - Screenview = 'screenview', - Pageview = 'pageview', - Timing = 'timing', -} - -export interface AnalyticsReportBase extends JsonObject { - kind: AnalyticsReportKind; -} - -export interface AnalyticsReportEvent extends AnalyticsReportBase { - kind: AnalyticsReportKind.Event; - options: JsonObject & EventOptions; - category: string; - action: string; -} -export interface AnalyticsReportScreenview extends AnalyticsReportBase { - kind: AnalyticsReportKind.Screenview; - options: JsonObject & ScreenviewOptions; - screenName: string; - appName: string; -} -export interface AnalyticsReportPageview extends AnalyticsReportBase { - kind: AnalyticsReportKind.Pageview; - options: JsonObject & PageviewOptions; - path: string; -} -export interface AnalyticsReportTiming extends AnalyticsReportBase { - kind: AnalyticsReportKind.Timing; - options: JsonObject & TimingOptions; - category: string; - variable: string; - time: string | number; -} - -export type AnalyticsReport = - | AnalyticsReportEvent - | AnalyticsReportScreenview - | AnalyticsReportPageview - | AnalyticsReportTiming; - -/** - * A function that can forward analytics along some stream. AnalyticsReport is already a - * JsonObject descendant, but we force it here so the user knows it's safe to serialize. - */ -export type AnalyticsForwarderFn = (report: JsonObject & AnalyticsReport) => void; - -/** - * A class that follows the Analytics interface and forwards analytic reports (JavaScript objects). - * AnalyticsReporter is the counterpart which takes analytic reports and report them to another - * Analytics interface. - */ -export class ForwardingAnalytics implements Analytics { - constructor(protected _fn: AnalyticsForwarderFn) {} - - event(category: string, action: string, options?: EventOptions) { - this._fn({ - kind: AnalyticsReportKind.Event, - category, - action, - options: { ...options } as JsonObject, - }); - } - screenview(screenName: string, appName: string, options?: ScreenviewOptions) { - this._fn({ - kind: AnalyticsReportKind.Screenview, - screenName, - appName, - options: { ...options } as JsonObject, - }); - } - pageview(path: string, options?: PageviewOptions) { - this._fn({ - kind: AnalyticsReportKind.Pageview, - path, - options: { ...options } as JsonObject, - }); - } - timing(category: string, variable: string, time: string | number, options?: TimingOptions): void { - this._fn({ - kind: AnalyticsReportKind.Timing, - category, - variable, - time, - options: { ...options } as JsonObject, - }); - } - - // We do not support flushing. - flush() { - return Promise.resolve(); - } -} - -export class AnalyticsReporter { - constructor(protected _analytics: Analytics) {} - - report(report: AnalyticsReport) { - switch (report.kind) { - case AnalyticsReportKind.Event: - this._analytics.event(report.category, report.action, report.options); - break; - case AnalyticsReportKind.Screenview: - this._analytics.screenview(report.screenName, report.appName, report.options); - break; - case AnalyticsReportKind.Pageview: - this._analytics.pageview(report.path, report.options); - break; - case AnalyticsReportKind.Timing: - this._analytics.timing(report.category, report.variable, report.time, report.options); - break; - - default: - throw new Error('Unexpected analytics report: ' + JSON.stringify(report)); - } - } -} diff --git a/packages/angular_devkit/core/src/analytics/index.ts b/packages/angular_devkit/core/src/analytics/index.ts deleted file mode 100644 index 223c79b93aca..000000000000 --- a/packages/angular_devkit/core/src/analytics/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * @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 - */ - -export * from './api'; -export * from './forwarder'; -export * from './logging'; -export * from './multi'; -export * from './noop'; - -/** - * MAKE SURE TO KEEP THIS IN SYNC WITH THE TABLE AND CONTENT IN `/docs/design/analytics.md`. - * WE LIST THOSE DIMENSIONS (AND MORE). - * - * These cannot be in their respective schema.json file because we either change the type - * (e.g. --buildEventLog is string, but we want to know the usage of it, not its value), or - * some validation needs to be done (we cannot record ng add --collection if it's not marked as - * allowed). - */ -export enum NgCliAnalyticsDimensions { - CpuCount = 1, - CpuSpeed = 2, - RamInGigabytes = 3, - NodeVersion = 4, - NgAddCollection = 6, - AngularCLIMajorVersion = 8, - BuildErrors = 20, -} - -export enum NgCliAnalyticsMetrics { - NgComponentCount = 1, - UNUSED_2 = 2, - UNUSED_3 = 3, - UNUSED_4 = 4, - BuildTime = 5, - NgOnInitCount = 6, - InitialChunkSize = 7, - TotalChunkCount = 8, - TotalChunkSize = 9, - LazyChunkCount = 10, - LazyChunkSize = 11, - AssetCount = 12, - AssetSize = 13, - PolyfillSize = 14, - CssSize = 15, -} - -// This table is used when generating the analytics.md file. It should match the enum above -// or the validate-user-analytics script will fail. -export const NgCliAnalyticsDimensionsFlagInfo: { [name: string]: [string, string] } = { - CpuCount: ['CPU Count', 'number'], - CpuSpeed: ['CPU Speed', 'number'], - RamInGigabytes: ['RAM (In GB)', 'number'], - NodeVersion: ['Node Version', 'number'], - NgAddCollection: ['--collection', 'string'], - AngularCLIMajorVersion: ['Angular CLI Major Version', 'string'], - BuildErrors: ['Build Errors (comma separated)', 'string'], -}; - -// This table is used when generating the analytics.md file. It should match the enum above -// or the validate-user-analytics script will fail. -export const NgCliAnalyticsMetricsFlagInfo: { [name: string]: [string, string] } = { - NgComponentCount: ['NgComponentCount', 'number'], - UNUSED_2: ['UNUSED_2', 'none'], - UNUSED_3: ['UNUSED_3', 'none'], - UNUSED_4: ['UNUSED_4', 'none'], - BuildTime: ['Build Time', 'number'], - NgOnInitCount: ['NgOnInit Count', 'number'], - InitialChunkSize: ['Initial Chunk Size', 'number'], - TotalChunkCount: ['Total Chunk Count', 'number'], - TotalChunkSize: ['Total Chunk Size', 'number'], - LazyChunkCount: ['Lazy Chunk Count', 'number'], - LazyChunkSize: ['Lazy Chunk Size', 'number'], - AssetCount: ['Asset Count', 'number'], - AssetSize: ['Asset Size', 'number'], - PolyfillSize: [' Polyfill Size', 'number'], - CssSize: [' Css Size', 'number'], -}; diff --git a/packages/angular_devkit/core/src/analytics/logging.ts b/packages/angular_devkit/core/src/analytics/logging.ts deleted file mode 100644 index 2811d8b47717..000000000000 --- a/packages/angular_devkit/core/src/analytics/logging.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @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 { Logger } from '../logger'; -import { Analytics, EventOptions, PageviewOptions, ScreenviewOptions, TimingOptions } from './api'; - -/** - * Analytics implementation that logs analytics events to a logger. This should be used for - * debugging mainly. - */ -export class LoggingAnalytics implements Analytics { - constructor(protected _logger: Logger) {} - - event(category: string, action: string, options?: EventOptions): void { - this._logger.info('event ' + JSON.stringify({ category, action, ...options })); - } - screenview(screenName: string, appName: string, options?: ScreenviewOptions): void { - this._logger.info('screenview ' + JSON.stringify({ screenName, appName, ...options })); - } - pageview(path: string, options?: PageviewOptions): void { - this._logger.info('pageview ' + JSON.stringify({ path, ...options })); - } - timing(category: string, variable: string, time: string | number, options?: TimingOptions): void { - this._logger.info('timing ' + JSON.stringify({ category, variable, time, ...options })); - } - - flush(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/angular_devkit/core/src/analytics/multi.ts b/packages/angular_devkit/core/src/analytics/multi.ts deleted file mode 100644 index d5019ba3fd99..000000000000 --- a/packages/angular_devkit/core/src/analytics/multi.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @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 { Analytics, EventOptions, PageviewOptions, ScreenviewOptions, TimingOptions } from './api'; - -/** - * Analytics implementation that reports to multiple analytics backend. - */ -export class MultiAnalytics implements Analytics { - constructor(protected _backends: Analytics[] = []) {} - - push(...backend: Analytics[]) { - this._backends.push(...backend); - } - - event(category: string, action: string, options?: EventOptions): void { - this._backends.forEach((be) => be.event(category, action, options)); - } - screenview(screenName: string, appName: string, options?: ScreenviewOptions): void { - this._backends.forEach((be) => be.screenview(screenName, appName, options)); - } - pageview(path: string, options?: PageviewOptions): void { - this._backends.forEach((be) => be.pageview(path, options)); - } - timing(category: string, variable: string, time: string | number, options?: TimingOptions): void { - this._backends.forEach((be) => be.timing(category, variable, time, options)); - } - - flush(): Promise { - return Promise.all(this._backends.map((x) => x.flush())).then(() => {}); - } -} diff --git a/packages/angular_devkit/core/src/analytics/noop.ts b/packages/angular_devkit/core/src/analytics/noop.ts deleted file mode 100644 index cba315a3dd2c..000000000000 --- a/packages/angular_devkit/core/src/analytics/noop.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @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 { Analytics } from './api'; - -/** - * Analytics implementation that does nothing. - */ -export class NoopAnalytics implements Analytics { - event() {} - screenview() {} - pageview() {} - timing() {} - flush(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/angular_devkit/core/src/index.ts b/packages/angular_devkit/core/src/index.ts index d06b06019d44..4f85dca195e7 100644 --- a/packages/angular_devkit/core/src/index.ts +++ b/packages/angular_devkit/core/src/index.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -import * as analytics from './analytics'; import * as json from './json/index'; import * as logging from './logger/index'; import * as workspaces from './workspace'; @@ -16,4 +15,4 @@ export * from './json/index'; export * from './utils/index'; export * from './virtual-fs/index'; -export { analytics, json, logging, workspaces }; +export { json, logging, workspaces }; diff --git a/packages/schematics/angular/application/schema.json b/packages/schematics/angular/application/schema.json index b4ffa981cb18..42cf17920d5f 100644 --- a/packages/schematics/angular/application/schema.json +++ b/packages/schematics/angular/application/schema.json @@ -24,26 +24,25 @@ "description": "Include styles inline in the root component.ts file. Only CSS styles can be included inline. Default is false, meaning that an external styles file is created and referenced in the root component.ts file.", "type": "boolean", "alias": "s", - "x-user-analytics": 9 + "x-user-analytics": "ep.ng_inline_style" }, "inlineTemplate": { "description": "Include template inline in the root component.ts file. Default is false, meaning that an external template file is created and referenced in the root component.ts file. ", "type": "boolean", "alias": "t", - "x-user-analytics": 10 + "x-user-analytics": "ep.ng_inline_template" }, "viewEncapsulation": { "description": "The view encapsulation strategy to use in the new application.", "enum": ["Emulated", "None", "ShadowDom"], - "type": "string", - "x-user-analytics": 11 + "type": "string" }, "routing": { "type": "boolean", "description": "Create a routing NgModule.", "default": false, "x-prompt": "Would you like to add Angular routing?", - "x-user-analytics": 17 + "x-user-analytics": "ep.ng_routing" }, "prefix": { "type": "string", @@ -76,14 +75,13 @@ } ] }, - "x-user-analytics": 5 + "x-user-analytics": "ep.ng_style" }, "skipTests": { "description": "Do not create \"spec.ts\" test files for the application.", "type": "boolean", "default": false, - "alias": "S", - "x-user-analytics": 12 + "alias": "S" }, "skipPackageJson": { "type": "boolean", @@ -93,8 +91,7 @@ "minimal": { "description": "Create a bare-bones project without any testing frameworks. (Use for learning purposes only.)", "type": "boolean", - "default": false, - "x-user-analytics": 14 + "default": false }, "skipInstall": { "description": "Skip installing dependency packages.", @@ -104,8 +101,7 @@ "strict": { "description": "Creates an application with stricter bundle budgets settings.", "type": "boolean", - "default": true, - "x-user-analytics": 7 + "default": true } }, "required": ["name"] diff --git a/packages/schematics/angular/class/schema.json b/packages/schematics/angular/class/schema.json index b74602a06ac7..97f24d8baf10 100644 --- a/packages/schematics/angular/class/schema.json +++ b/packages/schematics/angular/class/schema.json @@ -34,8 +34,7 @@ "skipTests": { "type": "boolean", "description": "Do not create \"spec.ts\" test files for the new class.", - "default": false, - "x-user-analytics": 12 + "default": false }, "type": { "type": "string", diff --git a/packages/schematics/angular/component/schema.json b/packages/schematics/angular/component/schema.json index 6973b36ccfc8..30ed7facecf8 100644 --- a/packages/schematics/angular/component/schema.json +++ b/packages/schematics/angular/component/schema.json @@ -42,27 +42,26 @@ "type": "boolean", "default": false, "alias": "s", - "x-user-analytics": 9 + "x-user-analytics": "ep.ng_inline_style" }, "inlineTemplate": { "description": "Include template inline in the component.ts file. By default, an external template file is created and referenced in the component.ts file.", "type": "boolean", "default": false, "alias": "t", - "x-user-analytics": 10 + "x-user-analytics": "ep.ng_inline_template" }, "standalone": { "description": "Whether the generated component is standalone.", "type": "boolean", "default": false, - "x-user-analytics": 15 + "x-user-analytics": "ep.ng_standalone" }, "viewEncapsulation": { "description": "The view encapsulation strategy to use in the new component.", "enum": ["Emulated", "None", "ShadowDom"], "type": "string", - "alias": "v", - "x-user-analytics": 11 + "alias": "v" }, "changeDetection": { "description": "The change detection strategy to use in the new component.", @@ -90,7 +89,7 @@ "type": "string", "default": "css", "enum": ["css", "scss", "sass", "less", "none"], - "x-user-analytics": 5 + "x-user-analytics": "ep.ng_style" }, "type": { "type": "string", @@ -100,8 +99,7 @@ "skipTests": { "type": "boolean", "description": "Do not create \"spec.ts\" test files for the new component.", - "default": false, - "x-user-analytics": 12 + "default": false }, "flat": { "type": "boolean", @@ -111,8 +109,7 @@ "skipImport": { "type": "boolean", "description": "Do not import this component into the owning NgModule.", - "default": false, - "x-user-analytics": 18 + "default": false }, "selector": { "type": "string", @@ -132,8 +129,7 @@ "export": { "type": "boolean", "default": false, - "description": "The declaring NgModule exports this component.", - "x-user-analytics": 19 + "description": "The declaring NgModule exports this component." } }, "required": ["name", "project"] diff --git a/packages/schematics/angular/directive/schema.json b/packages/schematics/angular/directive/schema.json index 7f48375d9843..bc754b45a9ca 100644 --- a/packages/schematics/angular/directive/schema.json +++ b/packages/schematics/angular/directive/schema.json @@ -48,14 +48,12 @@ "skipTests": { "type": "boolean", "description": "Do not create \"spec.ts\" test files for the new class.", - "default": false, - "x-user-analytics": 12 + "default": false }, "skipImport": { "type": "boolean", "description": "Do not import this directive into the owning NgModule.", - "default": false, - "x-user-analytics": 18 + "default": false }, "selector": { "type": "string", @@ -66,7 +64,7 @@ "description": "Whether the generated directive is standalone.", "type": "boolean", "default": false, - "x-user-analytics": 15 + "x-user-analytics": "ep.ng_standalone" }, "flat": { "type": "boolean", @@ -81,8 +79,7 @@ "export": { "type": "boolean", "default": false, - "description": "The declaring NgModule exports this directive.", - "x-user-analytics": 19 + "description": "The declaring NgModule exports this directive." } }, "required": ["name", "project"] diff --git a/packages/schematics/angular/guard/schema.json b/packages/schematics/angular/guard/schema.json index 8ecbe1659e75..ed79e62ee560 100644 --- a/packages/schematics/angular/guard/schema.json +++ b/packages/schematics/angular/guard/schema.json @@ -18,8 +18,7 @@ "skipTests": { "type": "boolean", "description": "Do not create \"spec.ts\" test files for the new guard.", - "default": false, - "x-user-analytics": 12 + "default": false }, "flat": { "type": "boolean", diff --git a/packages/schematics/angular/interceptor/schema.json b/packages/schematics/angular/interceptor/schema.json index 78aa0de4b94a..d8be90335908 100755 --- a/packages/schematics/angular/interceptor/schema.json +++ b/packages/schematics/angular/interceptor/schema.json @@ -39,8 +39,7 @@ "skipTests": { "type": "boolean", "description": "Do not create \"spec.ts\" test files for the new interceptor.", - "default": false, - "x-user-analytics": 12 + "default": false } }, "required": ["name", "project"] diff --git a/packages/schematics/angular/module/schema.json b/packages/schematics/angular/module/schema.json index f0f1268089a6..c58176674142 100644 --- a/packages/schematics/angular/module/schema.json +++ b/packages/schematics/angular/module/schema.json @@ -35,7 +35,7 @@ "type": "boolean", "description": "Create a routing module.", "default": false, - "x-user-analytics": 17 + "x-user-analytics": "ep.ng_routing" }, "routingScope": { "enum": ["Child", "Root"], diff --git a/packages/schematics/angular/ng-new/schema.json b/packages/schematics/angular/ng-new/schema.json index 3ed52ab150d9..4e069a3ce2af 100644 --- a/packages/schematics/angular/ng-new/schema.json +++ b/packages/schematics/angular/ng-new/schema.json @@ -68,19 +68,18 @@ "description": "Include styles inline in the component TS file. By default, an external styles file is created and referenced in the component TypeScript file.", "type": "boolean", "alias": "s", - "x-user-analytics": 9 + "x-user-analytics": "ep.ng_inline_style" }, "inlineTemplate": { "description": "Include template inline in the component TS file. By default, an external template file is created and referenced in the component TypeScript file.", "type": "boolean", "alias": "t", - "x-user-analytics": 10 + "x-user-analytics": "ep.ng_inline_template" }, "viewEncapsulation": { "description": "The view encapsulation strategy to use in the initial project.", "enum": ["Emulated", "None", "ShadowDom"], - "type": "string", - "x-user-analytics": 11 + "type": "string" }, "version": { "type": "string", @@ -93,7 +92,7 @@ "routing": { "type": "boolean", "description": "Generate a routing module for the initial project.", - "x-user-analytics": 17 + "x-user-analytics": "ep.ng_routing" }, "prefix": { "type": "string", @@ -107,14 +106,13 @@ "description": "The file extension or preprocessor to use for style files.", "type": "string", "enum": ["css", "scss", "sass", "less"], - "x-user-analytics": 5 + "x-user-analytics": "ep.ng_style" }, "skipTests": { "description": "Do not generate \"spec.ts\" test files for the new project.", "type": "boolean", "default": false, - "alias": "S", - "x-user-analytics": 12 + "alias": "S" }, "createApplication": { "description": "Create a new initial application project in the 'src' folder of the new workspace. When false, creates an empty workspace with no initial application. You can then use the generate application command so that all applications are created in the projects folder.", @@ -124,14 +122,12 @@ "minimal": { "description": "Create a workspace without any testing frameworks. (Use for learning purposes only.)", "type": "boolean", - "default": false, - "x-user-analytics": 14 + "default": false }, "strict": { "description": "Creates a workspace with stricter type checking and stricter bundle budgets settings. This setting helps improve maintainability and catch bugs ahead of time. For more information, see https://angular.io/guide/strict-mode", "type": "boolean", - "default": true, - "x-user-analytics": 7 + "default": true }, "packageManager": { "description": "The package manager used to install dependencies.", diff --git a/packages/schematics/angular/pipe/schema.json b/packages/schematics/angular/pipe/schema.json index 1e595e73a95f..ded30bbeb50a 100644 --- a/packages/schematics/angular/pipe/schema.json +++ b/packages/schematics/angular/pipe/schema.json @@ -39,20 +39,18 @@ "skipTests": { "type": "boolean", "description": "Do not create \"spec.ts\" test files for the new pipe.", - "default": false, - "x-user-analytics": 12 + "default": false }, "skipImport": { "type": "boolean", "default": false, - "description": "Do not import this pipe into the owning NgModule.", - "x-user-analytics": 18 + "description": "Do not import this pipe into the owning NgModule." }, "standalone": { "description": "Whether the generated pipe is standalone.", "type": "boolean", "default": false, - "x-user-analytics": 15 + "x-user-analytics": "ep.ng_standalone" }, "module": { "type": "string", @@ -62,8 +60,7 @@ "export": { "type": "boolean", "default": false, - "description": "The declaring NgModule exports this pipe.", - "x-user-analytics": 19 + "description": "The declaring NgModule exports this pipe." } }, "required": ["name", "project"] diff --git a/packages/schematics/angular/resolver/schema.json b/packages/schematics/angular/resolver/schema.json index bb14a38ac7a6..72b5620630c1 100644 --- a/packages/schematics/angular/resolver/schema.json +++ b/packages/schematics/angular/resolver/schema.json @@ -18,8 +18,7 @@ "skipTests": { "type": "boolean", "description": "Do not create \"spec.ts\" test files for the new resolver.", - "default": false, - "x-user-analytics": 12 + "default": false }, "flat": { "type": "boolean", diff --git a/packages/schematics/angular/service/schema.json b/packages/schematics/angular/service/schema.json index ddcafb020512..f14420631a59 100644 --- a/packages/schematics/angular/service/schema.json +++ b/packages/schematics/angular/service/schema.json @@ -38,8 +38,7 @@ "skipTests": { "type": "boolean", "description": "Do not create \"spec.ts\" test files for the new service.", - "default": false, - "x-user-analytics": 12 + "default": false } }, "required": ["name", "project"] diff --git a/packages/schematics/angular/workspace/schema.json b/packages/schematics/angular/workspace/schema.json index 4477294bd5c9..4944eddf14fd 100644 --- a/packages/schematics/angular/workspace/schema.json +++ b/packages/schematics/angular/workspace/schema.json @@ -30,14 +30,12 @@ "minimal": { "description": "Create a workspace without any testing frameworks. (Use for learning purposes only.)", "type": "boolean", - "default": false, - "x-user-analytics": 14 + "default": false }, "strict": { "description": "Create a workspace with stricter type checking options. This setting helps improve maintainability and catch bugs ahead of time. For more information, see https://angular.io/strict", "type": "boolean", - "default": true, - "x-user-analytics": 7 + "default": true }, "packageManager": { "description": "The package manager used to install dependencies.", diff --git a/scripts/templates/user-analytics-table.ejs b/scripts/templates/user-analytics-table.ejs index c6dda68bfed9..2d62d0d301be 100644 --- a/scripts/templates/user-analytics-table.ejs +++ b/scripts/templates/user-analytics-table.ejs @@ -1,9 +1,5 @@ <% -%>| Id | Flag | Type | +%>| Name | Parameter | Type | |:---:|:---|:---| -<% for (const flag of flags) { - if (flag === undefined) { - continue; - } -%>| <%= flag.userAnalytics %> | `<%= flag.name %>` | `<%= flag.type %>` | +<% for (const { parameter, name, type } of data) {%>| <%= name %> | `<%= parameter %>` | `<%= type %>` | <%}%> diff --git a/scripts/validate-user-analytics.ts b/scripts/validate-user-analytics.ts index ced9ab28460e..dec90139e35b 100644 --- a/scripts/validate-user-analytics.ts +++ b/scripts/validate-user-analytics.ts @@ -6,80 +6,76 @@ * found in the LICENSE file at https://angular.io/license */ -import { analytics, logging, schema, strings, tags } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import assert from 'assert'; import * as fs from 'fs'; import { glob as globCb } from 'glob'; import * as path from 'path'; import { promisify } from 'util'; import { packages } from '../lib/packages'; +import { + EventCustomDimension, + EventCustomMetric, + UserCustomDimension, +} from '../packages/angular/cli/src/analytics/analytics-parameters'; const userAnalyticsTable = require('./templates/user-analytics-table').default; const dimensionsTableRe = /([\s\S]*)/m; +const userDimensionsTableRe = + /([\s\S]*)/m; const metricsTableRe = /([\s\S]*)/m; -async function _checkDimensions(dimensionsTable: string, logger: logging.Logger) { - const data: { userAnalytics: number; type: string; name: string }[] = new Array(200); +async function _checkUserDimensions(dimensionsTable: string, logger: logging.Logger) { + logger.info('Gathering user dimensions from @angular/cli...'); + const eventCustomDimensionValues = new Set(Object.values(UserCustomDimension)); - function updateData(userAnalytics: number, name: string, type: string) { - if (data[userAnalytics]) { - if (data[userAnalytics].name !== name) { - logger.error(tags.stripIndents` - User analytics clash with the same name: ${data[userAnalytics].name} and - ${name} both have userAnalytics of ${userAnalytics} - `); + const data = Object.entries(EventCustomDimension).map(([key, value]) => ({ + parameter: value, + name: key, + type: value.charAt(2) === 'n' ? 'number' : 'string', + })); - return 2; - } - } else { - data[userAnalytics] = { userAnalytics, name, type }; - } + if (data.length > 25) { + throw new Error( + 'GA has a limit of 25 custom user dimensions. Delete and archive the ones that are not needed.', + ); } - logger.info('Gathering fixed dimension from @angular-devkit/core...'); + const generatedTable = userAnalyticsTable({ data }).trim(); + if (dimensionsTable !== generatedTable) { + logger.error( + 'Expected user dimensions table to be the same as generated. Copy the lines below:', + ); + logger.error(generatedTable); - // Create the data with dimensions missing from schema.json: - const allFixedDimensions = Object.keys(analytics.NgCliAnalyticsDimensions) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .filter((x) => typeof analytics.NgCliAnalyticsDimensions[x as any] === 'number'); + return 3; + } - for (const name of allFixedDimensions) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const userAnalytics = analytics.NgCliAnalyticsDimensions[name as any]; - if (!(name in analytics.NgCliAnalyticsDimensionsFlagInfo)) { - throw new Error( - `Flag ${name} is in NgCliAnalyticsDimensions but not NgCliAnalyticsDimensionsFlagInfo`, - ); - } + return 0; +} - const [flagName, type] = analytics.NgCliAnalyticsDimensionsFlagInfo[name]; - if (typeof userAnalytics !== 'number') { - throw new Error( - `Invalid value found in enum AnalyticsDimensions: ${JSON.stringify(userAnalytics)}`, - ); - } - updateData(userAnalytics, flagName, type); - } +async function _checkDimensions(dimensionsTable: string, logger: logging.Logger) { + logger.info('Gathering event dimensions from @angular/cli...'); + const eventCustomDimensionValues = new Set(Object.values(EventCustomDimension)); logger.info('Gathering options for user-analytics...'); - - const userAnalyticsGatherer = (obj: Object) => { + const schemaUserAnalyticsValidator = (obj: Object) => { for (const [key, value] of Object.entries(obj)) { if (value && typeof value === 'object') { - if ('x-user-analytics' in value) { - const type = - [...schema.getTypesOfSchema(value)].find((type) => type !== 'object') ?? 'string'; - - updateData(value['x-user-analytics'], 'Flag: --' + strings.dasherize(key), type); + const userAnalytics = value['x-user-analytics']; + if (userAnalytics && !eventCustomDimensionValues.has(userAnalytics)) { + throw new Error( + `Invalid value found in enum AnalyticsDimensions: ${JSON.stringify(userAnalytics)}`, + ); } else { - userAnalyticsGatherer(value); + schemaUserAnalyticsValidator(value); } } } }; const glob = promisify(globCb); - // Find all the schemas const packagesPaths = Object.values(packages).map(({ root }) => root); for (const packagePath of packagesPaths) { @@ -87,13 +83,27 @@ async function _checkDimensions(dimensionsTable: string, logger: logging.Logger) for (const schemaPath of schemasPaths) { const schema = await fs.promises.readFile(path.join(packagePath, schemaPath), 'utf8'); - userAnalyticsGatherer(JSON.parse(schema)); + schemaUserAnalyticsValidator(JSON.parse(schema)); } } - const generatedTable = userAnalyticsTable({ flags: data }).trim(); + const data = Object.entries(EventCustomDimension).map(([key, value]) => ({ + parameter: value, + name: key, + type: value.charAt(2) === 'n' ? 'number' : 'string', + })); + + if (data.length > 50) { + throw new Error( + 'GA has a limit of 50 custom event dimensions. Delete and archive the ones that are not needed.', + ); + } + + const generatedTable = userAnalyticsTable({ data }).trim(); if (dimensionsTable !== generatedTable) { - logger.error('Expected dimensions table to be the same as generated. Copy the lines below:'); + logger.error( + 'Expected event dimensions table to be the same as generated. Copy the lines below:', + ); logger.error(generatedTable); return 3; @@ -103,49 +113,20 @@ async function _checkDimensions(dimensionsTable: string, logger: logging.Logger) } async function _checkMetrics(metricsTable: string, logger: logging.Logger) { - const data: { userAnalytics: number; type: string; name: string }[] = new Array(200); - - function _updateData(userAnalytics: number, name: string, type: string) { - if (data[userAnalytics]) { - if (data[userAnalytics].name !== name) { - logger.error(tags.stripIndents` - User analytics clash with the same name: ${data[userAnalytics].name} and - ${name} both have userAnalytics of ${userAnalytics} - `); - - return 2; - } - } else { - data[userAnalytics] = { userAnalytics, name, type }; - } - } - - logger.info('Gathering fixed metrics from @angular-devkit/core...'); - - // Create the data with dimensions missing from schema.json: - const allFixedMetrics = Object.keys(analytics.NgCliAnalyticsMetrics) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .filter((x) => typeof analytics.NgCliAnalyticsMetrics[x as any] === 'number'); - - for (const name of allFixedMetrics) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const userAnalytics = analytics.NgCliAnalyticsMetrics[name as any]; - if (!(name in analytics.NgCliAnalyticsMetricsFlagInfo)) { - throw new Error( - `Flag ${name} is in NgCliAnalyticsMetrics but not NgCliAnalyticsMetricsFlagInfo`, - ); - } - - const [flagName, type] = analytics.NgCliAnalyticsMetricsFlagInfo[name]; - if (typeof userAnalytics !== 'number') { - throw new Error( - `Invalid value found in enum NgCliAnalyticsMetrics: ${JSON.stringify(userAnalytics)}`, - ); - } - _updateData(userAnalytics, flagName, type); + logger.info('Gathering metrics from @angular/cli...'); + const data = Object.entries(EventCustomMetric).map(([key, value]) => ({ + parameter: value, + name: key, + type: value.charAt(2) === 'n' ? 'number' : 'string', + })); + + if (data.length > 50) { + throw new Error( + 'GA has a limit of 50 custom metrics. Delete and archive the ones that are not needed.', + ); } - const generatedTable = userAnalyticsTable({ flags: data }).trim(); + const generatedTable = userAnalyticsTable({ data }).trim(); if (metricsTable !== generatedTable) { logger.error('Expected metrics table to be the same as generated. Copy the lines below:'); logger.error(generatedTable); @@ -164,16 +145,21 @@ export default async function (_options: {}, logger: logging.Logger): Promise ng('config', 'schematics')); - - /** - * `ng config cli.analyticsSharing.uuid ""` should generate new random user ID. - * @see: https://angular.io/cli/usage-analytics-gathering#per-user-tracking - */ - await ng('config', 'cli.analyticsSharing.uuid', ''); - const { stdout: stdout4 } = await ng('config', 'cli.analyticsSharing.uuid'); - console.log(stdout4); - if (!/(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}/i.test(stdout4)) { - throw new Error( - `Expected "cli.analyticsSharing.uuid" to be a UUID, received "${JSON.stringify(stdout4)}".`, - ); - } } diff --git a/yarn.lock b/yarn.lock index 5e1e983cb515..3c52a375478c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2189,13 +2189,6 @@ resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== -"@types/debug@^4.1.2": - version "4.1.7" - resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" - integrity sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg== - dependencies: - "@types/ms" "*" - "@types/eslint-scope@^3.7.3": version "3.7.4" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" @@ -2341,11 +2334,6 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== -"@types/ms@*": - version "0.7.31" - resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" - integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== - "@types/node-fetch@*", "@types/node-fetch@^2.1.6": version "2.6.2" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da"