From 639a3071c3630c1ccdf7e3c015e81e9423ab2678 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 5 Oct 2022 08:40:35 +0000 Subject: [PATCH] refactor: migrate analytics collector to use GA4 This change updates the analytics collector collector to use GA4 instead of UA. The motivation behind this change is that UA will stop collecting data in 2023. BREAKING CHANGE: `analyticsSharing` option in the global angular configuration has been removed without replacement. This option was used to configure the Angular CLI to access to your own users' CLI usage data. If this option is used, it can be removed using `ng config --global cli.analyticsSharing undefined`. --- docs/design/analytics.md | 127 +++--- goldens/circular-deps/packages.json | 8 + .../cli/lib/config/workspace-schema.json | 32 -- .../cli/src/analytics/analytics-collector.ts | 392 ++++++------------ .../cli/src/analytics/analytics-parameters.ts | 97 +++++ .../angular/cli/src/analytics/analytics.ts | 223 +++------- .../architect-base-command-module.ts | 22 +- .../cli/src/command-builder/command-module.ts | 104 ++--- .../schematics-command-module.ts | 24 +- .../command-builder/utilities/json-schema.ts | 4 +- packages/angular/cli/src/commands/add/cli.ts | 14 +- .../cli/src/commands/analytics/info/cli.ts | 2 +- .../src/commands/analytics/settings/cli.ts | 6 +- .../cli/src/utilities/environment-options.ts | 1 - .../src/builders/browser-esbuild/schema.json | 3 +- .../src/builders/browser/schema.json | 3 +- .../src/builders/server/schema.json | 2 +- .../angular/application/schema.json | 20 +- packages/schematics/angular/class/schema.json | 3 +- .../schematics/angular/component/schema.json | 20 +- .../schematics/angular/directive/schema.json | 11 +- packages/schematics/angular/guard/schema.json | 3 +- .../angular/interceptor/schema.json | 3 +- .../schematics/angular/module/schema.json | 2 +- .../schematics/angular/ng-new/schema.json | 20 +- packages/schematics/angular/pipe/schema.json | 11 +- .../schematics/angular/resolver/schema.json | 3 +- .../schematics/angular/service/schema.json | 3 +- .../schematics/angular/workspace/schema.json | 6 +- scripts/templates/user-analytics-table.ejs | 8 +- scripts/validate-user-analytics.ts | 172 ++++---- .../e2e/tests/commands/config/config-set.ts | 13 - 32 files changed, 555 insertions(+), 807 deletions(-) create mode 100644 packages/angular/cli/src/analytics/analytics-parameters.ts diff --git a/docs/design/analytics.md b/docs/design/analytics.md index 251c17af98a7..de136fd071c6 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,83 @@ 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` | -# 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/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/src/analytics/analytics-collector.ts b/packages/angular/cli/src/analytics/analytics-collector.ts index c8f932f51336..2d6a98b33775 100644 --- a/packages/angular/cli/src/analytics/analytics-collector.ts +++ b/packages/angular/cli/src/analytics/analytics-collector.ts @@ -6,330 +6,174 @@ * 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.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..471590b73381 --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-parameters.ts @@ -0,0 +1,97 @@ +/** + * @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', +} 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..017dfc57e0d2 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 } from '../analytics/analytics-collector'; 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,21 +53,17 @@ 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 analytics = isPackageNameSafeForAnalytics(builderName) + ? await this.getAnalytics() + : undefined; + + analytics?.reportArchitectRunEvent({ + [EventCustomDimension.BuilderTarget]: builderName, }); const { error, success } = await run.output.toPromise(); 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/build_angular/src/builders/browser-esbuild/schema.json b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json index ea0ec43e5598..c1174d0368ee 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,7 +141,6 @@ }, "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, "oneOf": [ { @@ -224,7 +223,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/schema.json b/packages/angular_devkit/build_angular/src/builders/browser/schema.json index 12c9d6792afe..248abe80037a 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser/schema.json @@ -133,7 +133,6 @@ }, "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, "oneOf": [ { @@ -216,7 +215,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/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/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)}".`, - ); - } }