From 3facfb790bff79b00ba1ab6dd8cb331989937da7 Mon Sep 17 00:00:00 2001 From: Dan Imhoff Date: Fri, 8 Jan 2021 10:54:45 -0800 Subject: [PATCH] feat(cli): opt-in anonymous usage data (#4022) --- cli/package.json | 1 + cli/src/index.ts | 109 +++++++++++++------- cli/src/ipc.ts | 89 ++++++++++++++++ cli/src/sysconfig.ts | 52 ++++++++++ cli/src/tasks/init.ts | 10 +- cli/src/tasks/telemetry.ts | 46 +++++++++ cli/src/telemetry.ts | 202 +++++++++++++++++++++++++++++++++++++ cli/src/util/cli.ts | 4 + cli/src/util/subprocess.ts | 3 +- cli/src/util/uuid.ts | 8 ++ 10 files changed, 483 insertions(+), 41 deletions(-) create mode 100644 cli/src/ipc.ts create mode 100644 cli/src/sysconfig.ts create mode 100644 cli/src/tasks/telemetry.ts create mode 100644 cli/src/telemetry.ts create mode 100644 cli/src/util/uuid.ts diff --git a/cli/package.json b/cli/package.json index 3009bc0c61..d2dda5c58a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -50,6 +50,7 @@ "@ionic/utils-terminal": "^2.3.0", "commander": "^6.0.0", "debug": "^4.2.0", + "env-paths": "^2.2.0", "kleur": "^4.1.1", "native-run": "^1.2.1", "open": "^7.1.0", diff --git a/cli/src/index.ts b/cli/src/index.ts index 403f161e20..d84237ba7b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -4,7 +4,9 @@ import c from './colors'; import { loadConfig } from './config'; import type { Config } from './definitions'; import { fatal, isFatal } from './errors'; +import { receive } from './ipc'; import { logger, output } from './log'; +import { telemetryAction } from './telemetry'; import { wrapAction } from './util/cli'; import { emoji as _e } from './util/emoji'; @@ -12,6 +14,8 @@ process.on('unhandledRejection', error => { console.error(c.failure('[fatal]'), error); }); +process.on('message', receive); + export async function run(): Promise { try { const config = await loadConfig(); @@ -54,10 +58,12 @@ export function runProgram(config: Config): void { 'Optional: Directory of your projects built web assets', ) .action( - wrapAction(async (appName, appId, { webDir }) => { - const { initCommand } = await import('./tasks/init'); - await initCommand(config, appName, appId, webDir); - }), + wrapAction( + telemetryAction(config, async (appName, appId, { webDir }) => { + const { initCommand } = await import('./tasks/init'); + await initCommand(config, appName, appId, webDir); + }), + ), ); program @@ -78,10 +84,12 @@ export function runProgram(config: Config): void { "Optional: if provided, Podfile.lock won't be deleted and pod install will use --deployment option", ) .action( - wrapAction(async (platform, { deployment }) => { - const { syncCommand } = await import('./tasks/sync'); - await syncCommand(config, platform, deployment); - }), + wrapAction( + telemetryAction(config, async (platform, { deployment }) => { + const { syncCommand } = await import('./tasks/sync'); + await syncCommand(config, platform, deployment); + }), + ), ); program @@ -96,20 +104,24 @@ export function runProgram(config: Config): void { "Optional: if provided, Podfile.lock won't be deleted and pod install will use --deployment option", ) .action( - wrapAction(async (platform, { deployment }) => { - const { updateCommand } = await import('./tasks/update'); - await updateCommand(config, platform, deployment); - }), + wrapAction( + telemetryAction(config, async (platform, { deployment }) => { + const { updateCommand } = await import('./tasks/update'); + await updateCommand(config, platform, deployment); + }), + ), ); program .command('copy [platform]') .description('copies the web app build into the native app') .action( - wrapAction(async platform => { - const { copyCommand } = await import('./tasks/copy'); - await copyCommand(config, platform); - }), + wrapAction( + telemetryAction(config, async platform => { + const { copyCommand } = await import('./tasks/copy'); + await copyCommand(config, platform); + }), + ), ); program @@ -123,52 +135,79 @@ export function runProgram(config: Config): void { .option('--target ', 'use a specific target') .option('--no-sync', `do not run ${c.input('sync')}`) .action( - wrapAction(async (platform, { list, target, sync }) => { - const { runCommand } = await import('./tasks/run'); - await runCommand(config, platform, { list, target, sync }); - }), + wrapAction( + telemetryAction(config, async (platform, { list, target, sync }) => { + const { runCommand } = await import('./tasks/run'); + await runCommand(config, platform, { list, target, sync }); + }), + ), ); program .command('open [platform]') .description('opens the native project workspace (Xcode for iOS)') .action( - wrapAction(async platform => { - const { openCommand } = await import('./tasks/open'); - await openCommand(config, platform); - }), + wrapAction( + telemetryAction(config, async platform => { + const { openCommand } = await import('./tasks/open'); + await openCommand(config, platform); + }), + ), ); program .command('add [platform]') .description('add a native platform project') .action( - wrapAction(async platform => { - const { addCommand } = await import('./tasks/add'); - await addCommand(config, platform); - }), + wrapAction( + telemetryAction(config, async platform => { + const { addCommand } = await import('./tasks/add'); + await addCommand(config, platform); + }), + ), ); program .command('ls [platform]') .description('list installed Cordova and Capacitor plugins') .action( - wrapAction(async platform => { - const { listCommand } = await import('./tasks/list'); - await listCommand(config, platform); - }), + wrapAction( + telemetryAction(config, async platform => { + const { listCommand } = await import('./tasks/list'); + await listCommand(config, platform); + }), + ), ); program .command('doctor [platform]') .description('checks the current setup for common errors') .action( - wrapAction(async platform => { - const { doctorCommand } = await import('./tasks/doctor'); - await doctorCommand(config, platform); + wrapAction( + telemetryAction(config, async platform => { + const { doctorCommand } = await import('./tasks/doctor'); + await doctorCommand(config, platform); + }), + ), + ); + + program + .command('telemetry [on|off]', { hidden: true }) + .description('enable or disable telemetry') + .action( + wrapAction(async onOrOff => { + const { telemetryCommand } = await import('./tasks/telemetry'); + await telemetryCommand(onOrOff); }), ); + program + .command('📡', { hidden: true }) + .description('IPC receiver command') + .action(() => { + // no-op: IPC messages are received via `process.on('message')` + }); + program .command('plugin:generate', { hidden: true }) .description('start a new Capacitor plugin') diff --git a/cli/src/ipc.ts b/cli/src/ipc.ts new file mode 100644 index 0000000000..2344ff054e --- /dev/null +++ b/cli/src/ipc.ts @@ -0,0 +1,89 @@ +import { open, mkdirp } from '@ionic/utils-fs'; +import { fork } from '@ionic/utils-subprocess'; +import Debug from 'debug'; +import { request } from 'https'; +import { resolve } from 'path'; + +import type { Metric } from './telemetry'; +import { ENV_PATHS } from './util/cli'; + +const debug = Debug('capacitor:ipc'); + +export interface TelemetryIPCMessage { + type: 'telemetry'; + data: Metric; +} + +export type IPCMessage = TelemetryIPCMessage; + +/** + * Send an IPC message to a forked process. + */ +export async function send(msg: IPCMessage): Promise { + const dir = ENV_PATHS.log; + await mkdirp(dir); + const logPath = resolve(dir, 'ipc.log'); + + debug( + 'Sending %O IPC message to forked process (logs: %O)', + msg.type, + logPath, + ); + + const fd = await open(logPath, 'a'); + const p = fork(process.argv[1], ['📡'], { stdio: ['ignore', fd, fd, 'ipc'] }); + + p.send(msg); + p.disconnect(); + p.unref(); +} + +/** + * Receive and handle an IPC message. + * + * Assume minimal context and keep external dependencies to a minimum. + */ +export async function receive(msg: IPCMessage): Promise { + debug('Received %O IPC message', msg.type); + + if (msg.type === 'telemetry') { + const now = new Date().toISOString(); + const { data } = msg; + + // This request is only made if telemetry is on. + const req = request( + { + hostname: 'api.ionicjs.com', + port: 443, + path: '/events/metrics', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + response => { + debug( + 'Sent %O metric to events service (status: %O)', + data.name, + response.statusCode, + ); + + if (response.statusCode !== 204) { + response.on('data', chunk => { + debug( + 'Bad response from events service. Request body: %O', + chunk.toString(), + ); + }); + } + }, + ); + + const body = { + metrics: [data], + sent_at: now, + }; + + req.end(JSON.stringify(body)); + } +} diff --git a/cli/src/sysconfig.ts b/cli/src/sysconfig.ts new file mode 100644 index 0000000000..4b3f7f3ed4 --- /dev/null +++ b/cli/src/sysconfig.ts @@ -0,0 +1,52 @@ +import { readJSON, writeJSON, mkdirp } from '@ionic/utils-fs'; +import Debug from 'debug'; +import { dirname, resolve } from 'path'; + +import { ENV_PATHS } from './util/cli'; +import { uuidv4 } from './util/uuid'; + +const debug = Debug('capacitor:sysconfig'); + +const SYSCONFIG_FILE = 'sysconfig.json'; +const SYSCONFIG_PATH = resolve(ENV_PATHS.config, SYSCONFIG_FILE); + +export interface SystemConfig { + /** + * A UUID that anonymously identifies this computer. + */ + readonly machine: string; + + /** + * Whether telemetry is enabled or not. + * + * If undefined, a choice has not yet been made. + */ + readonly telemetry?: boolean; +} + +export async function readConfig(): Promise { + debug('Reading from %O', SYSCONFIG_PATH); + + try { + return await readJSON(SYSCONFIG_PATH); + } catch (e) { + if (e.code !== 'ENOENT') { + throw e; + } + + const sysconfig: SystemConfig = { + machine: uuidv4(), + }; + + await writeConfig(sysconfig); + + return sysconfig; + } +} + +export async function writeConfig(sysconfig: SystemConfig): Promise { + debug('Writing to %O', SYSCONFIG_PATH); + + await mkdirp(dirname(SYSCONFIG_PATH)); + await writeJSON(SYSCONFIG_PATH, sysconfig, { spaces: '\t' }); +} diff --git a/cli/src/tasks/init.ts b/cli/src/tasks/init.ts index 2fd4979fa2..989f8295cf 100644 --- a/cli/src/tasks/init.ts +++ b/cli/src/tasks/init.ts @@ -58,12 +58,12 @@ export async function initCommand( printNextSteps(config); } catch (e) { - output.write( - 'Usage: npx cap init appName appId\n' + - 'Example: npx cap init "My App" "com.example.myapp"\n\n', - ); - if (!isFatal(e)) { + output.write( + 'Usage: npx cap init appName appId\n' + + 'Example: npx cap init "My App" "com.example.myapp"\n\n', + ); + fatal(e.stack ?? e); } diff --git a/cli/src/tasks/telemetry.ts b/cli/src/tasks/telemetry.ts new file mode 100644 index 0000000000..437909aea3 --- /dev/null +++ b/cli/src/tasks/telemetry.ts @@ -0,0 +1,46 @@ +import c from '../colors'; +import { fatal } from '../errors'; +import { logger, logSuccess, output } from '../log'; +import { readConfig, writeConfig } from '../sysconfig'; +import { THANK_YOU } from '../telemetry'; + +export async function telemetryCommand(onOrOff?: string): Promise { + const sysconfig = await readConfig(); + const enabled = interpretEnabled(onOrOff); + + if (typeof enabled === 'boolean') { + if (sysconfig.telemetry === enabled) { + logger.info(`Telemetry is already ${c.strong(enabled ? 'on' : 'off')}`); + } else { + await writeConfig({ ...sysconfig, telemetry: enabled }); + logSuccess( + `You have ${c.strong(`opted ${enabled ? 'in' : 'out'}`)} ${ + enabled ? 'for' : 'of' + } telemetry on this machine.`, + ); + + if (enabled) { + output.write(THANK_YOU); + } + } + } else { + logger.info(`Telemetry is ${c.strong(sysconfig.telemetry ? 'on' : 'off')}`); + } +} + +function interpretEnabled(onOrOff?: string): boolean | undefined { + switch (onOrOff) { + case 'on': + return true; + case 'off': + return false; + case undefined: + return undefined; + } + + fatal( + `Argument must be ${c.strong('on')} or ${c.strong( + 'off', + )} (or left unspecified)`, + ); +} diff --git a/cli/src/telemetry.ts b/cli/src/telemetry.ts new file mode 100644 index 0000000000..cef34c4a57 --- /dev/null +++ b/cli/src/telemetry.ts @@ -0,0 +1,202 @@ +import { Command } from 'commander'; +import Debug from 'debug'; + +import c from './colors'; +import type { Config } from './definitions'; +import { send } from './ipc'; +import { logPrompt, output } from './log'; +import type { SystemConfig } from './sysconfig'; +import { readConfig, writeConfig } from './sysconfig'; +import { getCommandOutput } from './util/subprocess'; +import { isInteractive } from './util/term'; + +const debug = Debug('capacitor:telemetry'); + +export const THANK_YOU = + `\nThank you for helping to make Capacitor better! 💖` + + `\nInformation about the data we collect is available on our website: ${c.strong( + 'https://capacitorjs.com/telemetry', + )}\n`; + +export interface CommandMetricData { + app_id: string; + command: string; + arguments: string; + options: string; + duration: number; + error: string | null; + node_version: string; + os: string; +} + +export interface Metric { + name: N; + timestamp: string; + session_id: string; + source: 'capacitor_cli'; + value: D; +} + +type CommanderAction = (...args: any[]) => void | Promise; + +export function telemetryAction( + config: Config, + action: CommanderAction, +): CommanderAction { + return async (...actionArgs: any[]): Promise => { + const start = new Date(); + // This is how commanderjs works--the command object is either the last + // element or second to last if there are additional options (via `.allowUnknownOption()`) + const lastArg = actionArgs[actionArgs.length - 1]; + const cmd: Command = + lastArg instanceof Command ? lastArg : actionArgs[actionArgs.length - 2]; + const command = getFullCommandName(cmd); + let error: any; + + try { + await action(...actionArgs); + } catch (e) { + error = e; + } + + const end = new Date(); + const duration = end.getTime() - start.getTime(); + + const packages = Object.entries({ + ...config.app.package.devDependencies, + ...config.app.package.dependencies, + }); + + // Only collect packages in the capacitor org: + // https://www.npmjs.com/org/capacitor + const capacitorPackages = packages.filter(([k]) => + k.startsWith('@capacitor/'), + ); + + const versions = capacitorPackages.map(([k, v]) => [ + `${k.replace(/^@capacitor\//, '').replace(/-/g, '_')}_version`, + v, + ]); + + const data: CommandMetricData = { + app_id: await getAppIdentifier(config), + command, + arguments: cmd.args.join(' '), + options: JSON.stringify(cmd.opts()), + duration, + error: error ? (error.message ? error.message : String(error)) : null, + node_version: process.version, + os: config.cli.os, + ...Object.fromEntries(versions), + }; + + if (isInteractive()) { + let sysconfig = await readConfig(); + + if (!error && typeof sysconfig.telemetry === 'undefined') { + const confirm = await promptForTelemetry(); + sysconfig = { ...sysconfig, telemetry: confirm }; + + await writeConfig(sysconfig); + } + + await sendMetric(sysconfig, 'capacitor_cli_command', data); + } + + if (error) { + throw error; + } + }; +} + +/** + * If telemetry is enabled, send a metric via IPC to a forked process for uploading. + */ +export async function sendMetric( + sysconfig: Pick, + name: string, + data: D, +): Promise { + if (sysconfig.telemetry && isInteractive()) { + const message: Metric = { + name, + timestamp: new Date().toISOString(), + session_id: sysconfig.machine, + source: 'capacitor_cli', + value: data, + }; + + await send({ type: 'telemetry', data: message }); + } else { + debug( + 'Telemetry is off (user choice, non-interactive terminal, or CI)--not sending metric', + ); + } +} + +async function promptForTelemetry(): Promise { + const { confirm } = await logPrompt( + `${c.strong( + 'Would you like to help improve Capacitor by sharing anonymous usage data? 💖', + )}\n` + + `Read more about what is being collected and why here: ${c.strong( + 'https://capacitorjs.com/telemetry', + )}. You can change your mind at any time by using the ${c.input( + 'npx cap telemetry', + )} command.`, + { + type: 'confirm', + name: 'confirm', + message: 'Share anonymous usage data?', + initial: true, + }, + ); + + if (confirm) { + output.write(THANK_YOU); + } + + return confirm; +} + +/** + * Get a unique anonymous identifier for this app. + */ +async function getAppIdentifier(config: Config): Promise { + const { createHash } = await import('crypto'); + + // get the first commit hash, which should be universally unique + const output = await getCommandOutput( + 'git', + ['rev-list', '--max-parents=0', 'HEAD'], + { cwd: config.app.rootDir }, + ); + + const firstLine = output?.split('\n')[0]; + + if (!firstLine) { + debug('Could not obtain unique app identifier'); + return null; + } + + // use sha1 to create a one-way hash to anonymize + const id = createHash('sha1').update(firstLine).digest('hex'); + + return id; +} + +/** + * Walk through the command's parent tree and construct a space-separated name. + * + * Probably overkill because we don't have nested commands, but whatever. + */ +function getFullCommandName(cmd: Command): string { + const names: string[] = []; + + while (cmd.parent !== null) { + names.push(cmd.name()); + cmd = cmd.parent; + } + + return names.reverse().join(' '); +} diff --git a/cli/src/util/cli.ts b/cli/src/util/cli.ts index 58f2cf2054..1cec6b31c7 100644 --- a/cli/src/util/cli.ts +++ b/cli/src/util/cli.ts @@ -1,6 +1,10 @@ +import envPaths from 'env-paths'; + import { isFatal } from '../errors'; import { logger } from '../log'; +export const ENV_PATHS = envPaths('capacitor', { suffix: '' }); + export type CommanderAction = (...args: any[]) => void | Promise; export function wrapAction(action: CommanderAction): CommanderAction { diff --git a/cli/src/util/subprocess.ts b/cli/src/util/subprocess.ts index 27c5ecd022..6db42009d7 100644 --- a/cli/src/util/subprocess.ts +++ b/cli/src/util/subprocess.ts @@ -26,9 +26,10 @@ export async function runCommand( export async function getCommandOutput( command: string, args: readonly string[], + options: RunCommandOptions = {}, ): Promise { try { - return (await runCommand(command, args)).trim(); + return (await runCommand(command, args, options)).trim(); } catch (e) { return null; } diff --git a/cli/src/util/uuid.ts b/cli/src/util/uuid.ts new file mode 100644 index 0000000000..9e66b50f06 --- /dev/null +++ b/cli/src/util/uuid.ts @@ -0,0 +1,8 @@ +export function uuidv4(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0; + const v = c == 'x' ? r : (r & 0x3) | 0x8; + + return v.toString(16); + }); +}