diff --git a/docs/design/analytics.md b/docs/design/analytics.md index 6ff986e389..e477a90243 100644 --- a/docs/design/analytics.md +++ b/docs/design/analytics.md @@ -115,14 +115,5 @@ There are 2 ways of disabling usage analytics: 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 - disable analytics for this run. If the string `"ci"` is used it will show up as a CI run (see - below). + disable analytics for this run. -# CI - -A special user named `ci` is used for analytics for tracking CI information. This is a convention -and is in no way enforced. - -Running on CI by default will disable analytics (because of a lack of TTY on STDIN/OUT). It can be -manually enabled using either a global configuration with a value of `ci`, or using the -`NG_CLI_ANALYTICS=ci` environment variable. diff --git a/packages/angular/cli/bin/postinstall/analytics-prompt.js b/packages/angular/cli/bin/postinstall/analytics-prompt.js index 2635b8483c..f5653da7be 100644 --- a/packages/angular/cli/bin/postinstall/analytics-prompt.js +++ b/packages/angular/cli/bin/postinstall/analytics-prompt.js @@ -17,10 +17,10 @@ try { var analytics = require('../../src/analytics/analytics'); analytics - .hasGlobalAnalyticsConfiguration() + .hasAnalyticsConfig('global') .then((hasGlobalConfig) => { if (!hasGlobalConfig) { - return analytics.promptGlobalAnalytics(); + return analytics.promptAnalytics(true /** global */); } }) .catch(() => {}); diff --git a/packages/angular/cli/src/analytics/analytics-environment-options.ts b/packages/angular/cli/src/analytics/analytics-environment-options.ts new file mode 100644 index 0000000000..e9e2ae797e --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-environment-options.ts @@ -0,0 +1,22 @@ +/** + * @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 + */ + +function isDisabled(variable: string): boolean { + return variable === '0' || variable.toLowerCase() === 'false'; +} + +function isPresent(variable: string | undefined): variable is string { + return typeof variable === 'string' && variable !== ''; +} + +const analyticsVariable = process.env['NG_CLI_ANALYTICS']; +export const analyticsDisabled = isPresent(analyticsVariable) && isDisabled(analyticsVariable); + +const analyticsShareVariable = process.env['NG_CLI_ANALYTICS_SHARE']; +export const analyticsShareDisabled = + isPresent(analyticsShareVariable) && isDisabled(analyticsShareVariable); diff --git a/packages/angular/cli/src/analytics/analytics.ts b/packages/angular/cli/src/analytics/analytics.ts index 3efa6a5e61..103f439340 100644 --- a/packages/angular/cli/src/analytics/analytics.ts +++ b/packages/angular/cli/src/analytics/analytics.ts @@ -15,6 +15,7 @@ import { getWorkspace, getWorkspaceRaw } from '../utilities/config'; import { isTTY } from '../utilities/tty'; import { VERSION } from '../utilities/version'; import { AnalyticsCollector } from './analytics-collector'; +import { analyticsDisabled, analyticsShareDisabled } from './analytics-environment-options'; /* eslint-disable no-console */ const analyticsDebug = debug('ng:analytics'); // Generate analytics, including settings and users. @@ -29,13 +30,11 @@ export const AnalyticsProperties = { } const v = VERSION.full; - // The logic is if it's a full version then we should use the prod GA property. - if (/^\d+\.\d+\.\d+$/.test(v) && v !== '0.0.0') { - _defaultAngularCliPropertyCache = AnalyticsProperties.AngularCliProd; - } else { - _defaultAngularCliPropertyCache = AnalyticsProperties.AngularCliStaging; - } + _defaultAngularCliPropertyCache = + /^\d+\.\d+\.\d+$/.test(v) && v !== '0.0.0' + ? AnalyticsProperties.AngularCliProd + : AnalyticsProperties.AngularCliStaging; return _defaultAngularCliPropertyCache; }, @@ -80,8 +79,6 @@ export function setAnalyticsConfig(global: boolean, value: string | boolean): vo throw new Error(`Invalid config found at ${configPath}. CLI should be an object.`); } - console.log(`Configured ${level} analytics to "${analyticsConfigValueToHumanFormat(value)}".`); - if (value === true) { value = uuidV4(); } @@ -92,81 +89,17 @@ export function setAnalyticsConfig(global: boolean, value: string | boolean): vo analyticsDebug('done'); } -export function analyticsConfigValueToHumanFormat(value: unknown): 'on' | 'off' | 'not set' | 'ci' { - if (value === false) { - return 'off'; - } else if (value === 'ci') { - return 'ci'; - } else if (typeof value === 'string' || value === true) { - return 'on'; - } else { - return 'not set'; - } -} - /** * Prompt the user for usage gathering permission. * @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 promptGlobalAnalytics(force = false) { - analyticsDebug('prompting global analytics.'); - if (force || isTTY()) { - const answers = await inquirer.prompt<{ analytics: boolean }>([ - { - type: 'confirm', - name: 'analytics', - message: tags.stripIndents` - Would you like to share anonymous usage data with the Angular Team at Google under - Google’s Privacy Policy at https://policies.google.com/privacy? For more details and - how to change this setting, see https://angular.io/analytics. - `, - default: false, - }, - ]); - - setAnalyticsConfig(true, answers.analytics); - - if (answers.analytics) { - console.log(''); - console.log(tags.stripIndent` - Thank you for sharing anonymous usage data. If you change your mind, the following - command will disable this feature entirely: - - ${colors.yellow('ng analytics off --global')} - `); - console.log(''); - - // Send back a ping with the user `optin`. - const ua = new AnalyticsCollector(AnalyticsProperties.AngularCliDefault, 'optin'); - ua.pageview('/telemetry/optin'); - await ua.flush(); - } else { - // Send back a ping with the user `optout`. This is the only thing we send. - const ua = new AnalyticsCollector(AnalyticsProperties.AngularCliDefault, 'optout'); - ua.pageview('/telemetry/optout'); - await ua.flush(); - } - - return true; - } else { - analyticsDebug('Either STDOUT or STDIN are not TTY and we skipped the prompt.'); - } - - return false; -} - -/** - * Prompt the user for usage gathering permission for the local project. Fails if there is no - * local workspace. - * @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 promptProjectAnalytics(force = false): Promise { +export async function promptAnalytics(global: boolean, force = false): Promise { analyticsDebug('prompting user'); - const [config, configPath] = getWorkspaceRaw('local'); + const level = global ? 'global' : 'local'; + const [config, configPath] = getWorkspaceRaw(level); if (!config || !configPath) { - throw new Error(`Could not find a local workspace. Are you in a project?`); + throw new Error(`Could not find a ${level} workspace. Are you in a project?`); } if (force || isTTY()) { @@ -176,7 +109,7 @@ export async function promptProjectAnalytics(force = false): Promise { name: 'analytics', message: tags.stripIndents` Would you like to share anonymous usage data about this project with the Angular Team at - Google under Google’s Privacy Policy at https://policies.google.com/privacy? For more + Google under Google’s Privacy Policy at https://policies.google.com/privacy. For more details and how to change this setting, see https://angular.io/analytics. `, @@ -184,16 +117,18 @@ export async function promptProjectAnalytics(force = false): Promise { }, ]); - setAnalyticsConfig(false, answers.analytics); + setAnalyticsConfig(global, answers.analytics); if (answers.analytics) { console.log(''); - console.log(tags.stripIndent` + console.log( + tags.stripIndent` Thank you for sharing anonymous usage data. Should you change your mind, the following command will disable this feature entirely: - ${colors.yellow('ng analytics off')} - `); + ${colors.yellow(`ng analytics disable${global ? ' --global' : ''}`)} + `, + ); console.log(''); // Send back a ping with the user `optin`. @@ -207,127 +142,39 @@ export async function promptProjectAnalytics(force = false): Promise { await ua.flush(); } + process.stderr.write(await getAnalyticsInfoString()); + return true; } return false; } -export async function hasGlobalAnalyticsConfiguration(): Promise { - try { - const globalWorkspace = await getWorkspace('global'); - const analyticsConfig: string | undefined | null | { uid?: string } = - globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analytics']; - - if (analyticsConfig !== null && analyticsConfig !== undefined) { - return true; - } - } catch {} - - return false; -} - /** - * Get the global analytics object for the user. This returns an instance of UniversalAnalytics, - * or undefined if analytics are disabled. - * - * If any problem happens, it is considered the user has been opting out of analytics. + * Get the analytics object for the user. */ -export async function getGlobalAnalytics(): Promise { - analyticsDebug('getGlobalAnalytics'); - const propertyId = AnalyticsProperties.AngularCliDefault; +export async function getAnalytics( + level: 'local' | 'global', +): Promise { + analyticsDebug('getAnalytics'); - if ('NG_CLI_ANALYTICS' in process.env) { - if (process.env['NG_CLI_ANALYTICS'] == 'false' || process.env['NG_CLI_ANALYTICS'] == '') { - analyticsDebug('NG_CLI_ANALYTICS is false'); - - return undefined; - } - if (process.env['NG_CLI_ANALYTICS'] === 'ci') { - analyticsDebug('Running in CI mode'); - - return new AnalyticsCollector(propertyId, 'ci'); - } - } - - // If anything happens we just keep the NOOP analytics. - try { - const globalWorkspace = await getWorkspace('global'); - const analyticsConfig: string | undefined | null | { uid?: string } = - globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analytics']; - analyticsDebug('Client Analytics config found: %j', analyticsConfig); - - if (analyticsConfig === false) { - analyticsDebug('Analytics disabled. Ignoring all analytics.'); - - return undefined; - } else if (analyticsConfig === undefined || analyticsConfig === null) { - analyticsDebug('Analytics settings not found. Ignoring all analytics.'); - - // globalWorkspace can be null if there is no file. analyticsConfig would be null in this - // case. Since there is no file, the user hasn't answered and the expected return value is - // undefined. - 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(propertyId, uid); - } - } catch (err) { - analyticsDebug('Error happened during reading of analytics config: %s', err.message); + if (analyticsDisabled) { + analyticsDebug('NG_CLI_ANALYTICS is false'); return undefined; } -} -export async function hasWorkspaceAnalyticsConfiguration(): Promise { try { - const globalWorkspace = await getWorkspace('local'); + const workspace = await getWorkspace(level); const analyticsConfig: string | undefined | null | { uid?: string } = - globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analytics']; - - if (analyticsConfig !== undefined) { - return true; - } - } catch {} - - return false; -} - -/** - * Get the workspace analytics object for the user. This returns an instance of AnalyticsCollector, - * or undefined if analytics are disabled. - * - * If any problem happens, it is considered the user has been opting out of analytics. - */ -export async function getWorkspaceAnalytics(): Promise { - analyticsDebug('getWorkspaceAnalytics'); - try { - const globalWorkspace = await getWorkspace('local'); - const analyticsConfig: string | undefined | null | { uid?: string } = - globalWorkspace?.getCli()['analytics']; + workspace?.getCli()['analytics']; analyticsDebug('Workspace Analytics config found: %j', analyticsConfig); - if (analyticsConfig === false) { - analyticsDebug('Analytics disabled. Ignoring all analytics.'); - - return undefined; - } else if (analyticsConfig === undefined || analyticsConfig === null) { - analyticsDebug('Analytics settings not found. Ignoring all analytics.'); - + if (!analyticsConfig) { return undefined; } else { let uid: string | undefined = undefined; + if (typeof analyticsConfig == 'string') { uid = analyticsConfig; } else if (typeof analyticsConfig == 'object' && typeof analyticsConfig['uid'] == 'string') { @@ -355,13 +202,10 @@ export async function getWorkspaceAnalytics(): Promise { analyticsDebug('getSharedAnalytics'); - const envVarName = 'NG_CLI_ANALYTICS_SHARE'; - if (envVarName in process.env) { - if (process.env[envVarName] == 'false' || process.env[envVarName] == '') { - analyticsDebug('NG_CLI_ANALYTICS is false'); + if (analyticsShareDisabled) { + analyticsDebug('NG_CLI_ANALYTICS is false'); - return undefined; - } + return undefined; } // If anything happens we just keep the NOOP analytics. @@ -387,21 +231,20 @@ export async function createAnalytics( workspace: boolean, skipPrompt = false, ): Promise { - let config = await getGlobalAnalytics(); + let config: analytics.Analytics | undefined; + const isDisabledGlobally = (await getWorkspace('global'))?.getCli()['analytics'] === false; // If in workspace and global analytics is enabled, defer to workspace level - if (workspace && config) { - const skipAnalytics = - skipPrompt || - (process.env['NG_CLI_ANALYTICS'] && - (process.env['NG_CLI_ANALYTICS'].toLowerCase() === 'false' || - process.env['NG_CLI_ANALYTICS'] === '0')); + if (workspace && !isDisabledGlobally) { + const skipAnalytics = skipPrompt || analyticsDisabled; // 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. - if (!skipAnalytics && !(await hasWorkspaceAnalyticsConfiguration())) { - await promptProjectAnalytics(); + if (!skipAnalytics && !(await hasAnalyticsConfig('local'))) { + await promptAnalytics(false); } - config = await getWorkspaceAnalytics(); + config = await getAnalytics('local'); + } else { + config = await getAnalytics('global'); } const maybeSharedAnalytics = await getSharedAnalytics(); @@ -416,3 +259,50 @@ export async function createAnalytics( return new analytics.NoopAnalytics(); } } + +function analyticsConfigValueToHumanFormat(value: unknown): 'enabled' | 'disabled' | 'not set' { + if (value === false) { + return 'disabled'; + } else if (typeof value === 'string' || value === true) { + return 'enabled'; + } else { + return 'not set'; + } +} + +export async function getAnalyticsInfoString(): Promise { + const [globalWorkspace] = getWorkspaceRaw('global'); + const [localWorkspace] = getWorkspaceRaw('local'); + const globalSetting = globalWorkspace?.get(['cli', 'analytics']); + const localSetting = localWorkspace?.get(['cli', 'analytics']); + + const analyticsInstance = await createAnalytics( + !!localWorkspace /** workspace */, + true /** skipPrompt */, + ); + + 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' + ); +} + +export async function hasAnalyticsConfig(level: 'local' | 'global'): Promise { + try { + const workspace = await getWorkspace(level); + if (workspace?.getCli()['analytics'] !== undefined) { + return true; + } + } catch {} + + return false; +} diff --git a/packages/angular/cli/src/commands/analytics/cli.ts b/packages/angular/cli/src/commands/analytics/cli.ts index b2e37eb9b2..ccfdf9d7df 100644 --- a/packages/angular/cli/src/commands/analytics/cli.ts +++ b/packages/angular/cli/src/commands/analytics/cli.ts @@ -18,9 +18,8 @@ import { } from '../../command-builder/utilities/command'; import { AnalyticsInfoCommandModule } from './info/cli'; import { - AnalyticsCIModule, - AnalyticsOffModule, - AnalyticsOnModule, + AnalyticsDisableModule, + AnalyticsEnableModule, AnalyticsPromptModule, } from './settings/cli'; @@ -32,12 +31,11 @@ export class AnalyticsCommandModule extends CommandModule implements CommandModu builder(localYargs: Argv): Argv { const subcommands = [ - AnalyticsCIModule, AnalyticsInfoCommandModule, - AnalyticsOffModule, - AnalyticsOnModule, + AnalyticsDisableModule, + AnalyticsEnableModule, AnalyticsPromptModule, - ].sort(); + ].sort(); // sort by class name. for (const module of subcommands) { localYargs = addCommandModuleToYargs(localYargs, module, this.context); diff --git a/packages/angular/cli/src/commands/analytics/info/cli.ts b/packages/angular/cli/src/commands/analytics/info/cli.ts index a9481a043f..fd692e0ad8 100644 --- a/packages/angular/cli/src/commands/analytics/info/cli.ts +++ b/packages/angular/cli/src/commands/analytics/info/cli.ts @@ -6,15 +6,13 @@ * found in the LICENSE file at https://angular.io/license */ -import { tags } from '@angular-devkit/core'; import { Argv } from 'yargs'; -import { analyticsConfigValueToHumanFormat, createAnalytics } from '../../../analytics/analytics'; +import { getAnalyticsInfoString } from '../../../analytics/analytics'; import { CommandModule, CommandModuleImplementation, Options, } from '../../../command-builder/command-module'; -import { getWorkspaceRaw } from '../../../utilities/config'; export class AnalyticsInfoCommandModule extends CommandModule @@ -29,24 +27,6 @@ export class AnalyticsInfoCommandModule } async run(_options: Options<{}>): Promise { - const [globalWorkspace] = getWorkspaceRaw('global'); - const [localWorkspace] = getWorkspaceRaw('local'); - const globalSetting = globalWorkspace?.get(['cli', 'analytics']); - const localSetting = localWorkspace?.get(['cli', 'analytics']); - - const effectiveSetting = await createAnalytics( - !!this.context.workspace /** workspace */, - true /** skipPrompt */, - ); - - this.context.logger.info(tags.stripIndents` - Global setting: ${analyticsConfigValueToHumanFormat(globalSetting)} - Local setting: ${ - this.context.workspace - ? analyticsConfigValueToHumanFormat(localSetting) - : 'No local workspace configuration file.' - } - Effective status: ${effectiveSetting ? 'enabled' : 'disabled'} - `); + this.context.logger.info(await getAnalyticsInfoString()); } } diff --git a/packages/angular/cli/src/commands/analytics/settings/cli.ts b/packages/angular/cli/src/commands/analytics/settings/cli.ts index 2018715b89..6a8324a23f 100644 --- a/packages/angular/cli/src/commands/analytics/settings/cli.ts +++ b/packages/angular/cli/src/commands/analytics/settings/cli.ts @@ -8,8 +8,8 @@ import { Argv } from 'yargs'; import { - promptGlobalAnalytics, - promptProjectAnalytics, + getAnalyticsInfoString, + promptAnalytics, setAnalyticsConfig, } from '../../../analytics/analytics'; import { @@ -39,7 +39,7 @@ abstract class AnalyticsSettingModule .strict(); } - abstract override run({ global }: Options): void; + abstract override run({ global }: Options): Promise; } export class AnalyticsOffModule @@ -49,8 +49,9 @@ export class AnalyticsOffModule command = 'off'; describe = 'Disables analytics gathering and reporting for the user.'; - run({ global }: Options): void { + async run({ global }: Options): Promise { setAnalyticsConfig(global, false); + process.stderr.write(await getAnalyticsInfoString()); } } @@ -60,21 +61,9 @@ export class AnalyticsOnModule { command = 'on'; describe = 'Enables analytics gathering and reporting for the user.'; - run({ global }: Options): void { + async run({ global }: Options): Promise { setAnalyticsConfig(global, true); - } -} - -export class AnalyticsCIModule - extends AnalyticsSettingModule - implements CommandModuleImplementation -{ - command = 'ci'; - describe = - 'Enables analytics and configures reporting for use with Continuous Integration, which uses a common CI user.'; - - run({ global }: Options): void { - setAnalyticsConfig(global, 'ci'); + process.stderr.write(await getAnalyticsInfoString()); } } @@ -86,10 +75,6 @@ export class AnalyticsPromptModule describe = 'Prompts the user to set the analytics gathering status interactively.'; async run({ global }: Options): Promise { - if (global) { - await promptGlobalAnalytics(true); - } else { - await promptProjectAnalytics(true); - } + await promptAnalytics(global, true); } } diff --git a/tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts b/tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts index d571b38cb6..b85e6c044d 100644 --- a/tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts +++ b/tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts @@ -1,13 +1,12 @@ import { execWithEnv, killAllProcesses, waitForAnyProcessOutputToMatch } from '../../utils/process'; import { expectToFail } from '../../utils/utils'; -export default async function() { +export default async function () { try { // Execute a command with TTY force enabled - const execution = execWithEnv('ng', ['version'], { + execWithEnv('ng', ['version'], { ...process.env, NG_FORCE_TTY: '1', - NG_CLI_ANALYTICS: 'ci', }); // Check if the prompt is shown @@ -18,7 +17,7 @@ export default async function() { try { // Execute a command with TTY force enabled - const execution = execWithEnv('ng', ['version'], { + execWithEnv('ng', ['version'], { ...process.env, NG_FORCE_TTY: '1', NG_CLI_ANALYTICS: 'false', @@ -35,10 +34,9 @@ export default async function() { // Should not show a prompt when using update try { // Execute a command with TTY force enabled - const execution = execWithEnv('ng', ['update'], { + execWithEnv('ng', ['update'], { ...process.env, NG_FORCE_TTY: '1', - NG_CLI_ANALYTICS: 'ci', }); // Check if the prompt is shown