refactor(@angular/cli): clean up analytics methods

Re-use methods were possible.
This commit is contained in:
Alan Agius 2022-03-15 11:24:29 +01:00 committed by Charles
parent bb550436a4
commit 46a7be3af4
8 changed files with 132 additions and 268 deletions

View File

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

View File

@ -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(() => {});

View File

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

View File

@ -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
Googles 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<boolean> {
export async function promptAnalytics(global: boolean, force = false): Promise<boolean> {
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<boolean> {
name: 'analytics',
message: tags.stripIndents`
Would you like to share anonymous usage data about this project with the Angular Team at
Google under Googles Privacy Policy at https://policies.google.com/privacy? For more
Google under Googles 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<boolean> {
},
]);
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<boolean> {
await ua.flush();
}
process.stderr.write(await getAnalyticsInfoString());
return true;
}
return false;
}
export async function hasGlobalAnalyticsConfiguration(): Promise<boolean> {
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<AnalyticsCollector | undefined> {
analyticsDebug('getGlobalAnalytics');
const propertyId = AnalyticsProperties.AngularCliDefault;
export async function getAnalytics(
level: 'local' | 'global',
): Promise<AnalyticsCollector | undefined> {
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<boolean> {
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<AnalyticsCollector | undefined> {
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<AnalyticsCollector | unde
export async function getSharedAnalytics(): Promise<AnalyticsCollector | undefined> {
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<analytics.Analytics> {
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<string> {
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<boolean> {
try {
const workspace = await getWorkspace(level);
if (workspace?.getCli()['analytics'] !== undefined) {
return true;
}
} catch {}
return false;
}

View File

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

View File

@ -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<void> {
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());
}
}

View File

@ -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<AnalyticsCommandArgs>): void;
abstract override run({ global }: Options<AnalyticsCommandArgs>): Promise<void>;
}
export class AnalyticsOffModule
@ -49,8 +49,9 @@ export class AnalyticsOffModule
command = 'off';
describe = 'Disables analytics gathering and reporting for the user.';
run({ global }: Options<AnalyticsCommandArgs>): void {
async run({ global }: Options<AnalyticsCommandArgs>): Promise<void> {
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<AnalyticsCommandArgs>): void {
async run({ global }: Options<AnalyticsCommandArgs>): Promise<void> {
setAnalyticsConfig(global, true);
}
}
export class AnalyticsCIModule
extends AnalyticsSettingModule
implements CommandModuleImplementation<AnalyticsCommandArgs>
{
command = 'ci';
describe =
'Enables analytics and configures reporting for use with Continuous Integration, which uses a common CI user.';
run({ global }: Options<AnalyticsCommandArgs>): 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<AnalyticsCommandArgs>): Promise<void> {
if (global) {
await promptGlobalAnalytics(true);
} else {
await promptProjectAnalytics(true);
}
await promptAnalytics(global, true);
}
}

View File

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