/** * @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, terminal } 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 { TimingOptions } from '../../../angular_devkit/core/src/analytics'; import { getWorkspace, getWorkspaceRaw } from '../utilities/config'; 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; /** * 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); } }); } /** * MAKE SURE TO KEEP THIS IN SYNC WITH THE TABLE AND CONTENT IN `/docs/design/analytics.md`. * WE LIST THOSE DIMENSIONS (AND MORE). */ export enum AnalyticsDimensions { NgAddCollection = 6, NgBuildBuildEventLog = 7, } /** * 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; /** * @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._ua.set('cm1', _getCpuCount()); this._ua.set('cm2', _getCpuSpeed()); this._ua.set('cm3', _getRamSize()); this._ua.set('cm4', _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 } = {}; (options.dimensions || []).forEach((v, i) => additionals['cd' + 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: 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 { 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 || (process.stdout.isTTY && process.stdin.isTTY)) { const answers = await inquirer.prompt<{ analytics: boolean }>([ { type: 'confirm', name: 'analytics', message: tags.stripIndents` === MESSAGE TO BE FINALIZED ==== DO_NOT_SUBMIT 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 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. Would you change your mind, the following command will disable this feature entirely: ${terminal.yellow('ng analytics off')} `); console.log(''); } return true; } 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 { 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 || (process.stdout.isTTY && process.stdin.isTTY)) { const answers = await inquirer.prompt<{ analytics: boolean }>([ { type: 'confirm', name: 'analytics', message: tags.stripIndents` === MESSAGE TO BE FINALIZED ==== DO_NOT_SUBMIT 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 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` === MESSAGE TO BE FINALIZED ==== DO_NOT_SUBMIT Thank you for sharing anonymous usage data. Would you change your mind, the following command will disable this feature entirely: ${terminal.yellow('ng analytics project off')} `); console.log(''); } return true; } return false; } /** * Get the global analytics setting for the user. This returns a string for UID, false if the user * opted out of analytics, true if the user wants to stay anonymous (no client id), and undefined * if the user has not been prompted yet. * * If any problem happens, it is considered the user has been opting out of analytics. */ export function getGlobalAnalytics(): string | boolean | undefined { analyticsDebug('getGlobalAnalytics'); 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 false; } if (process.env['NG_CLI_ANALYTICS'] === 'ci') { analyticsDebug('Running in CI mode'); return 'ci'; } } // If anything happens we just keep the NOOP analytics. try { const globalWorkspace = getWorkspace('global'); const analyticsConfig = globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analytics']; if (analyticsConfig === false) { return false; } else if (analyticsConfig === 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: %s', uid); return uid; } } catch { return false; } }