diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index b03091d5..8fc0495b 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -6,6 +6,10 @@ on: - main - dev +env: + TELEMETRY_TRACKING_TOKEN: ${{ secrets.TELEMETRY_TRACKING_TOKEN }} + DO_NOT_TRACK: '1' + permissions: contents: read diff --git a/packages/cli/package.json b/packages/cli/package.json index 84fc7e70..fd8d2e06 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,6 +35,7 @@ "colors": "1.4.0", "commander": "^8.3.0", "langium": "catalog:", + "mixpanel": "^0.18.1", "ora": "^5.4.1", "package-manager-detector": "^1.3.0", "ts-pattern": "catalog:" diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index 51291ff7..e80c219d 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -39,7 +39,9 @@ import { schema } from '${outputPath}/schema'; const client = new ZenStackClient(schema, { dialect: { ... } }); -\`\`\``); +\`\`\` + +Check documentation: https://zenstack.dev/docs/3.x`); } } diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 00000000..586537af --- /dev/null +++ b/packages/cli/src/constants.ts @@ -0,0 +1,2 @@ +// replaced at build time +export const TELEMETRY_TRACKING_TOKEN = ''; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ac55ae6c..4094fbb5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,33 +3,34 @@ import colors from 'colors'; import { Command, CommanderError, Option } from 'commander'; import * as actions from './actions'; import { CliError } from './cli-error'; +import { telemetry } from './telemetry'; import { getVersion } from './utils/version-utils'; const generateAction = async (options: Parameters[0]): Promise => { - await actions.generate(options); + await telemetry.trackCommand('generate', () => actions.generate(options)); }; -const migrateAction = async (command: string, options: any): Promise => { - await actions.migrate(command, options); +const migrateAction = async (subCommand: string, options: any): Promise => { + await telemetry.trackCommand(`migrate ${subCommand}`, () => actions.migrate(subCommand, options)); }; -const dbAction = async (command: string, options: any): Promise => { - await actions.db(command, options); +const dbAction = async (subCommand: string, options: any): Promise => { + await telemetry.trackCommand(`db ${subCommand}`, () => actions.db(subCommand, options)); }; const infoAction = async (projectPath: string): Promise => { - await actions.info(projectPath); + await telemetry.trackCommand('info', () => actions.info(projectPath)); }; const initAction = async (projectPath: string): Promise => { - await actions.init(projectPath); + await telemetry.trackCommand('init', () => actions.init(projectPath)); }; const checkAction = async (options: Parameters[0]): Promise => { - await actions.check(options); + await telemetry.trackCommand('check', () => actions.check(options)); }; -export function createProgram() { +function createProgram() { const program = new Command('zen'); program.version(getVersion()!, '-v --version', 'display CLI version'); @@ -132,18 +133,38 @@ export function createProgram() { return program; } -const program = createProgram(); +async function main() { + let exitCode = 0; + + const program = createProgram(); + program.exitOverride(); + + try { + await telemetry.trackCli(async () => { + await program.parseAsync(); + }); + } catch (e) { + if (e instanceof CommanderError) { + // ignore + exitCode = e.exitCode; + } else if (e instanceof CliError) { + // log + console.error(colors.red(e.message)); + exitCode = 1; + } else { + console.error(colors.red(`Unhandled error: ${e}`)); + exitCode = 1; + } + } -program.parseAsync().catch((err) => { - if (err instanceof CliError) { - console.error(colors.red(err.message)); - process.exit(1); - } else if (err instanceof CommanderError) { - // errors are already reported, just exit - process.exit(err.exitCode); + if (telemetry.isTracking) { + // give telemetry a chance to send events before exit + setTimeout(() => { + process.exit(exitCode); + }, 200); } else { - console.error(colors.red('An unexpected error occurred:')); - console.error(err); - process.exit(1); + process.exit(exitCode); } -}); +} + +main(); diff --git a/packages/cli/src/telemetry.ts b/packages/cli/src/telemetry.ts new file mode 100644 index 00000000..a078f62d --- /dev/null +++ b/packages/cli/src/telemetry.ts @@ -0,0 +1,139 @@ +import { init, type Mixpanel } from 'mixpanel'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import * as os from 'os'; +import { TELEMETRY_TRACKING_TOKEN } from './constants'; +import { isInCi } from './utils/is-ci'; +import { isInContainer } from './utils/is-container'; +import isDocker from './utils/is-docker'; +import { isWsl } from './utils/is-wsl'; +import { getMachineId } from './utils/machine-id-utils'; +import { getVersion } from './utils/version-utils'; + +/** + * Telemetry events + */ +export type TelemetryEvents = + | 'cli:start' + | 'cli:complete' + | 'cli:error' + | 'cli:command:start' + | 'cli:command:complete' + | 'cli:command:error' + | 'cli:plugin:start' + | 'cli:plugin:complete' + | 'cli:plugin:error'; + +/** + * Utility class for sending telemetry + */ +export class Telemetry { + private readonly mixpanel: Mixpanel | undefined; + private readonly hostId = getMachineId(); + private readonly sessionid = randomUUID(); + private readonly _os_type = os.type(); + private readonly _os_release = os.release(); + private readonly _os_arch = os.arch(); + private readonly _os_version = os.version(); + private readonly _os_platform = os.platform(); + private readonly version = getVersion(); + private readonly prismaVersion = this.getPrismaVersion(); + private readonly isDocker = isDocker(); + private readonly isWsl = isWsl(); + private readonly isContainer = isInContainer(); + private readonly isCi = isInCi; + + constructor() { + if (process.env['DO_NOT_TRACK'] !== '1' && TELEMETRY_TRACKING_TOKEN) { + this.mixpanel = init(TELEMETRY_TRACKING_TOKEN, { + geolocate: true, + }); + } + } + + get isTracking() { + return !!this.mixpanel; + } + + track(event: TelemetryEvents, properties: Record = {}) { + if (this.mixpanel) { + const payload = { + distinct_id: this.hostId, + session: this.sessionid, + time: new Date(), + $os: this._os_type, + osType: this._os_type, + osRelease: this._os_release, + osPlatform: this._os_platform, + osArch: this._os_arch, + osVersion: this._os_version, + nodeVersion: process.version, + version: this.version, + prismaVersion: this.prismaVersion, + isDocker: this.isDocker, + isWsl: this.isWsl, + isContainer: this.isContainer, + isCi: this.isCi, + ...properties, + }; + this.mixpanel.track(event, payload); + } + } + + trackError(err: Error) { + this.track('cli:error', { + message: err.message, + stack: err.stack, + }); + } + + async trackSpan( + startEvent: TelemetryEvents, + completeEvent: TelemetryEvents, + errorEvent: TelemetryEvents, + properties: Record, + action: () => Promise | T, + ) { + this.track(startEvent, properties); + const start = Date.now(); + let success = true; + try { + return await action(); + } catch (err: any) { + this.track(errorEvent, { + message: err.message, + stack: err.stack, + ...properties, + }); + success = false; + throw err; + } finally { + this.track(completeEvent, { + duration: Date.now() - start, + success, + ...properties, + }); + } + } + + async trackCommand(command: string, action: () => Promise | void) { + await this.trackSpan('cli:command:start', 'cli:command:complete', 'cli:command:error', { command }, action); + } + + async trackCli(action: () => Promise | void) { + await this.trackSpan('cli:start', 'cli:complete', 'cli:error', {}, action); + } + + getPrismaVersion() { + try { + const packageJsonPath = import.meta.resolve('prisma/package.json'); + const packageJsonUrl = new URL(packageJsonPath); + const packageJson = JSON.parse(fs.readFileSync(packageJsonUrl, 'utf8')); + return packageJson.version; + } catch { + return undefined; + } + } +} + +export const telemetry = new Telemetry(); diff --git a/packages/cli/src/utils/is-ci.ts b/packages/cli/src/utils/is-ci.ts new file mode 100644 index 00000000..7fcfa366 --- /dev/null +++ b/packages/cli/src/utils/is-ci.ts @@ -0,0 +1,5 @@ +import { env } from 'node:process'; +export const isInCi = + env['CI'] !== '0' && + env['CI'] !== 'false' && + ('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_'))); diff --git a/packages/cli/src/utils/is-container.ts b/packages/cli/src/utils/is-container.ts new file mode 100644 index 00000000..78c7937b --- /dev/null +++ b/packages/cli/src/utils/is-container.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +import isDocker from './is-docker'; + +let cachedResult: boolean | undefined; + +// Podman detection +const hasContainerEnv = () => { + try { + fs.statSync('/run/.containerenv'); + return true; + } catch { + return false; + } +}; + +export function isInContainer() { + // TODO: Use `??=` when targeting Node.js 16. + if (cachedResult === undefined) { + cachedResult = hasContainerEnv() || isDocker(); + } + + return cachedResult; +} diff --git a/packages/cli/src/utils/is-docker.ts b/packages/cli/src/utils/is-docker.ts new file mode 100644 index 00000000..c44a12ed --- /dev/null +++ b/packages/cli/src/utils/is-docker.ts @@ -0,0 +1,31 @@ +// Copied over from https://github.com/sindresorhus/is-docker for CJS compatibility + +import fs from 'node:fs'; + +let isDockerCached: boolean | undefined; + +function hasDockerEnv() { + try { + fs.statSync('/.dockerenv'); + return true; + } catch { + return false; + } +} + +function hasDockerCGroup() { + try { + return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker'); + } catch { + return false; + } +} + +export default function isDocker() { + // TODO: Use `??=` when targeting Node.js 16. + if (isDockerCached === undefined) { + isDockerCached = hasDockerEnv() || hasDockerCGroup(); + } + + return isDockerCached; +} diff --git a/packages/cli/src/utils/is-wsl.ts b/packages/cli/src/utils/is-wsl.ts new file mode 100644 index 00000000..5d3c0078 --- /dev/null +++ b/packages/cli/src/utils/is-wsl.ts @@ -0,0 +1,18 @@ +import process from 'node:process'; +import os from 'node:os'; +import fs from 'node:fs'; +export const isWsl = () => { + if (process.platform !== 'linux') { + return false; + } + + if (os.release().toLowerCase().includes('microsoft')) { + return true; + } + + try { + return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); + } catch { + return false; + } +}; diff --git a/packages/cli/src/utils/machine-id-utils.ts b/packages/cli/src/utils/machine-id-utils.ts new file mode 100644 index 00000000..e8a8801c --- /dev/null +++ b/packages/cli/src/utils/machine-id-utils.ts @@ -0,0 +1,76 @@ +// modified from https://github.com/automation-stack/node-machine-id + +import { execSync } from 'child_process'; +import { createHash, randomUUID } from 'node:crypto'; + +const { platform } = process; +const win32RegBinPath = { + native: '%windir%\\System32', + mixed: '%windir%\\sysnative\\cmd.exe /c %windir%\\System32', +}; +const guid = { + darwin: 'ioreg -rd1 -c IOPlatformExpertDevice', + win32: + `${win32RegBinPath[isWindowsProcessMixedOrNativeArchitecture()]}\\REG.exe ` + + 'QUERY HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography ' + + '/v MachineGuid', + linux: '( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname 2> /dev/null) | head -n 1 || :', + freebsd: 'kenv -q smbios.system.uuid || sysctl -n kern.hostuuid', +}; + +function isWindowsProcessMixedOrNativeArchitecture() { + // eslint-disable-next-line no-prototype-builtins + if (process.arch === 'ia32' && process.env.hasOwnProperty('PROCESSOR_ARCHITEW6432')) { + return 'mixed'; + } + return 'native'; +} + +function hash(guid: string): string { + return createHash('sha256').update(guid).digest('hex'); +} + +function expose(result: string): string | undefined { + switch (platform) { + case 'darwin': + return result + .split('IOPlatformUUID')[1] + ?.split('\n')[0] + ?.replace(/=|\s+|"/gi, '') + .toLowerCase(); + case 'win32': + return result + .toString() + .split('REG_SZ')[1] + ?.replace(/\r+|\n+|\s+/gi, '') + .toLowerCase(); + case 'linux': + return result + .toString() + .replace(/\r+|\n+|\s+/gi, '') + .toLowerCase(); + case 'freebsd': + return result + .toString() + .replace(/\r+|\n+|\s+/gi, '') + .toLowerCase(); + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } +} + +export function getMachineId() { + if (!(platform in guid)) { + return randomUUID(); + } + try { + const value = execSync(guid[platform as keyof typeof guid]); + const id = expose(value.toString()); + if (!id) { + return randomUUID(); + } + return hash(id); + } catch { + return randomUUID(); + } +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 2496f3ea..c1881d32 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { defineConfig } from 'tsup'; export default defineConfig({ @@ -10,4 +12,19 @@ export default defineConfig({ clean: true, dts: true, format: ['esm', 'cjs'], + onSuccess: async () => { + if (!process.env['TELEMETRY_TRACKING_TOKEN']) { + return; + } + const filesToProcess = ['dist/index.js', 'dist/index.cjs']; + for (const file of filesToProcess) { + console.log(`Processing ${file} for telemetry token...`); + const content = fs.readFileSync(path.join(__dirname, file), 'utf-8'); + const updatedContent = content.replace( + '', + process.env['TELEMETRY_TRACKING_TOKEN'], + ); + fs.writeFileSync(file, updatedContent, 'utf-8'); + } + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70b5b1db..e794f0cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,33 +6,18 @@ settings: catalogs: default: - '@types/node': - specifier: ^20.17.24 - version: 20.17.24 '@types/tmp': specifier: ^0.2.6 version: 0.2.6 - kysely: - specifier: ^0.27.6 - version: 0.27.6 langium: specifier: 3.5.0 version: 3.5.0 - langium-cli: - specifier: 3.5.0 - version: 3.5.0 - prisma: - specifier: ^6.10.0 - version: 6.14.0 tmp: specifier: ^0.2.3 version: 0.2.3 ts-pattern: specifier: ^5.7.1 version: 5.7.1 - typescript: - specifier: ^5.8.0 - version: 5.8.3 importers: @@ -95,6 +80,9 @@ importers: langium: specifier: 'catalog:' version: 3.5.0 + mixpanel: + specifier: ^0.18.1 + version: 0.18.1 ora: specifier: ^5.4.1 version: 5.4.1 @@ -1232,6 +1220,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1712,6 +1704,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + https-proxy-agent@5.0.0: + resolution: {integrity: sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==} + engines: {node: '>= 6'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1920,6 +1916,10 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mixpanel@0.18.1: + resolution: {integrity: sha512-YD1xfn6WP6ZLQ6Pmgh0KgdXhueJEsrodThMTsHzHMH0VbWa9ck8s+ynDtM83OSgt+yQ61W/SQNrH8Y4wIwocGg==} + engines: {node: '>=10.0'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -2291,6 +2291,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} @@ -3273,6 +3274,12 @@ snapshots: acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3815,6 +3822,13 @@ snapshots: dependencies: function-bind: 1.1.2 + https-proxy-agent@5.0.0: + dependencies: + agent-base: 6.0.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -3994,6 +4008,12 @@ snapshots: minipass@7.1.2: {} + mixpanel@0.18.1: + dependencies: + https-proxy-agent: 5.0.0 + transitivePeerDependencies: + - supports-color + mkdirp-classic@0.5.3: {} mlly@1.7.4: