Hans Larsen 2e6d2e18b9 feat(@angular/cli): ping back when user opts out of analytics
On prompt only, does not contain any user info. Since prompt should be only
shown at install or first run, this is not going to ping more than once and
the users privacy is also protected.
2019-06-26 11:22:19 -07:00

592 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { analytics, json, tags } from '@angular-devkit/core';
import * as child_process from 'child_process';
import * as debug from 'debug';
import { writeFileSync } from 'fs';
import * as inquirer from 'inquirer';
import * as os from 'os';
import * as ua from 'universal-analytics';
import { v4 as uuidV4 } from 'uuid';
import { colors } from '../utilities/color';
import { getWorkspace, getWorkspaceRaw } from '../utilities/config';
import { isTTY } from '../utilities/tty';
const analyticsDebug = debug('ng:analytics'); // Generate analytics, including settings and users.
const analyticsLogDebug = debug('ng:analytics:log'); // Actual logs of events.
const BYTES_PER_MEGABYTES = 1024 * 1024;
let _defaultAngularCliPropertyCache: string;
export const AnalyticsProperties = {
AngularCliProd: 'UA-8594346-29',
AngularCliStaging: 'UA-8594346-32',
get AngularCliDefault(): string {
if (_defaultAngularCliPropertyCache) {
return _defaultAngularCliPropertyCache;
}
const v = require('../package.json').version;
// 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;
}
return _defaultAngularCliPropertyCache;
},
};
/**
* This is the ultimate safelist for checking if a package name is safe to report to analytics.
*/
export const analyticsPackageSafelist = [
/^@angular\//,
/^@angular-devkit\//,
/^@ngtools\//,
'@schematics/angular',
'@schematics/schematics',
'@schematics/update',
];
export function isPackageNameSafeForAnalytics(name: string) {
return analyticsPackageSafelist.some(pattern => {
if (typeof pattern == 'string') {
return pattern === name;
} else {
return pattern.test(name);
}
});
}
/**
* 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 child_process
.execSync('wmic.exe os get locale')
.toString()
.trim();
} catch (_) {}
return undefined;
}
/**
* 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() ||
'??'
); // ¯\_(ツ)_/¯
}
/**
* Return the number of CPUs.
* @private
*/
function _getCpuCount() {
const cpus = os.cpus();
// Return "(count)x(average speed)".
return 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.
* @private
*/
function _getCpuSpeed() {
const cpus = os.cpus();
return Math.floor(cpus[0].speed);
}
/**
* Get the amount of memory, in megabytes.
* @private
*/
function _getRamSize() {
// Report in megabytes. Otherwise it's too much noise.
return Math.floor(os.totalmem() / BYTES_PER_MEGABYTES);
}
/**
* Get the Node name and version. This returns a string like "Node 10.11", or "io.js 3.5".
* @private
*/
function _getNodeVersion() {
// We use any here because p.release is a new Node construct in Node 10 (and our typings are the
// minimal version of Node we support).
const p = process as any; // tslint:disable-line:no-any
const name =
(typeof p.release == 'object' && typeof p.release.name == 'string' && p.release.name) ||
process.argv0;
return name + ' ' + process.version;
}
/**
* Get a numerical MAJOR.MINOR version of node. We report this as a metric.
* @private
*/
function _getNumericNodeVersion() {
const p = process.version;
const m = p.match(/\d+\.\d+/);
return (m && m[0] && parseFloat(m[0])) || 0;
}
// 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: { [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 for OSX. This gets sent to Analytics so it shows the proper OS,
* versions and others.
* @private
*/
function _buildUserAgentStringForOsx() {
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()})`;
}
/**
* Build a fake User Agent string for Windows. This gets sent to Analytics so it shows the proper
* OS, versions and others.
* @private
*/
function _buildUserAgentStringForWindows() {
return `(Windows NT ${os.release()})`;
}
/**
* Build a fake User Agent string for Linux. This gets sent to Analytics so it shows the proper OS,
* versions and others.
* @private
*/
function _buildUserAgentStringForLinux() {
return `(X11; Linux i686; ${os.release()}; ${os.cpus()[0].model})`;
}
/**
* 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':
return _buildUserAgentStringForOsx();
case 'win32':
return _buildUserAgentStringForWindows();
case 'linux':
return _buildUserAgentStringForLinux();
default:
return os.platform() + ' ' + os.release();
}
}
/**
* Implementation of the Analytics interface for using `universal-analytics` package.
*/
export class UniversalAnalytics implements analytics.Analytics {
private _ua: ua.Visitor;
private _dirty = false;
private _metrics: (string | number)[] = [];
private _dimensions: (string | number)[] = [];
/**
* @param trackingId The Google Analytics ID.
* @param uid A User ID.
*/
constructor(trackingId: string, uid: string) {
this._ua = ua(trackingId, uid, {
enableBatching: true,
batchSize: 5,
});
// Add persistent params for appVersion.
this._ua.set('ds', 'cli');
this._ua.set('ua', _buildUserAgentString());
this._ua.set('ul', _getLanguage());
// @angular/cli with version.
this._ua.set('an', require('../package.json').name);
this._ua.set('av', require('../package.json').version);
// We use the application ID for the Node version. This should be "node 10.10.0".
// We also use a custom metrics, but
this._ua.set('aid', _getNodeVersion());
// We set custom metrics for values we care about.
this._dimensions[analytics.NgCliAnalyticsDimensions.CpuCount] = _getCpuCount();
this._dimensions[analytics.NgCliAnalyticsDimensions.CpuSpeed] = _getCpuSpeed();
this._dimensions[analytics.NgCliAnalyticsDimensions.RamInMegabytes] = _getRamSize();
this._dimensions[analytics.NgCliAnalyticsDimensions.NodeVersion] = _getNumericNodeVersion();
}
/**
* Creates the dimension and metrics variables to pass to universal-analytics.
* @private
*/
private _customVariables(options: analytics.CustomDimensionsAndMetricsOptions) {
const additionals: { [key: string]: boolean | number | string } = {};
this._dimensions.forEach((v, i) => (additionals['cd' + i] = v));
(options.dimensions || []).forEach((v, i) => (additionals['cd' + i] = v));
this._metrics.forEach((v, i) => (additionals['cm' + i] = v));
(options.metrics || []).forEach((v, i) => (additionals['cm' + i] = v));
return additionals;
}
event(ec: string, ea: string, options: analytics.EventOptions = {}) {
const vars = this._customVariables(options);
analyticsLogDebug('event ec=%j, ea=%j, %j', ec, ea, vars);
const { label: el, value: ev } = options;
this._dirty = true;
this._ua.event({ ec, ea, el, ev, ...vars });
}
screenview(cd: string, an: string, options: analytics.ScreenviewOptions = {}) {
const vars = this._customVariables(options);
analyticsLogDebug('screenview cd=%j, an=%j, %j', cd, an, vars);
const { appVersion: av, appId: aid, appInstallerId: aiid } = options;
this._dirty = true;
this._ua.screenview({ cd, an, av, aid, aiid, ...vars });
}
pageview(dp: string, options: analytics.PageviewOptions = {}) {
const vars = this._customVariables(options);
analyticsLogDebug('pageview dp=%j, %j', dp, vars);
const { hostname: dh, title: dt } = options;
this._dirty = true;
this._ua.pageview({ dp, dh, dt, ...vars });
}
timing(utc: string, utv: string, utt: string | number, options: analytics.TimingOptions = {}) {
const vars = this._customVariables(options);
analyticsLogDebug('timing utc=%j, utv=%j, utl=%j, %j', utc, utv, utt, vars);
const { label: utl } = options;
this._dirty = true;
this._ua.timing({ utc, utv, utt, utl, ...vars });
}
flush(): Promise<void> {
if (!this._dirty) {
return Promise.resolve();
}
this._dirty = false;
return new Promise(resolve => this._ua.send(resolve));
}
}
/**
* Set analytics settings. This does not work if the user is not inside a project.
* @param level Which config to use. "global" for user-level, and "local" for project-level.
* @param value Either a user ID, true to generate a new User ID, or false to disable analytics.
*/
export function setAnalyticsConfig(level: 'global' | 'local', value: string | boolean) {
analyticsDebug('setting %s level analytics to: %s', level, value);
const [config, configPath] = getWorkspaceRaw(level);
if (!config || !configPath) {
throw new Error(`Could not find ${level} workspace.`);
}
const configValue = config.value;
const cli: json.JsonValue = configValue['cli'] || (configValue['cli'] = {});
if (!json.isJsonObject(cli)) {
throw new Error(`Invalid config found at ${configPath}. CLI should be an object.`);
}
if (value === true) {
value = uuidV4();
}
cli['analytics'] = value;
const output = JSON.stringify(configValue, null, 2);
writeFileSync(configPath, output);
analyticsDebug('done');
}
/**
* 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 http://angular.io/analytics.
`,
default: false,
},
]);
setAnalyticsConfig('global', 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')}
`);
console.log('');
} else {
// Send back a ping with the user `optout`. This is the only thing we send.
const ua = new UniversalAnalytics(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> {
analyticsDebug('prompting user');
const [config, configPath] = getWorkspaceRaw('local');
if (!config || !configPath) {
throw new Error(`Could not find a local workspace. Are you in a project?`);
}
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 about this project 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 http://angular.io/analytics.
`,
default: false,
},
]);
setAnalyticsConfig('local', answers.analytics);
if (answers.analytics) {
console.log('');
console.log(tags.stripIndent`
Thank you for sharing anonymous usage data. Would you change your mind, the following
command will disable this feature entirely:
${colors.yellow('ng analytics project off')}
`);
console.log('');
} else {
// Send back a ping with the user `optout`. This is the only thing we send.
const ua = new UniversalAnalytics(AnalyticsProperties.AngularCliDefault, 'optout');
ua.pageview('/telemetry/project/optout');
await ua.flush();
}
return true;
}
return false;
}
export function hasGlobalAnalyticsConfiguration(): boolean {
try {
const globalWorkspace = 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.
*/
export function getGlobalAnalytics(): UniversalAnalytics | undefined {
analyticsDebug('getGlobalAnalytics');
const propertyId = AnalyticsProperties.AngularCliDefault;
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 UniversalAnalytics(propertyId, 'ci');
}
}
// If anything happens we just keep the NOOP analytics.
try {
const globalWorkspace = 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 UniversalAnalytics(propertyId, uid);
}
} catch (err) {
analyticsDebug('Error happened during reading of analytics config: %s', err.message);
return undefined;
}
}
/**
* Return the usage analytics sharing setting, which is either a property string (GA-XXXXXXX-XX),
* or undefined if no sharing.
*/
export function getSharedAnalytics(): UniversalAnalytics | 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');
return undefined;
}
}
// If anything happens we just keep the NOOP analytics.
try {
const globalWorkspace = getWorkspace('global');
const analyticsConfig =
globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analyticsSharing'];
if (!analyticsConfig || !analyticsConfig.tracking || !analyticsConfig.uuid) {
return undefined;
} else {
analyticsDebug('Analytics sharing info: %j', analyticsConfig);
return new UniversalAnalytics(analyticsConfig.tracking, analyticsConfig.uuid);
}
} catch (err) {
analyticsDebug('Error happened during reading of analytics sharing config: %s', err.message);
return undefined;
}
}