Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
- main
- dev

env:
TELEMETRY_TRACKING_TOKEN: ${{ secrets.TELEMETRY_TRACKING_TOKEN }}
DO_NOT_TRACK: '1'

permissions:
contents: read

Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/actions/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ import { schema } from '${outputPath}/schema';
const client = new ZenStackClient(schema, {
dialect: { ... }
});
\`\`\``);
\`\`\`

Check documentation: https://zenstack.dev/docs/3.x`);
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// replaced at build time
export const TELEMETRY_TRACKING_TOKEN = '<TELEMETRY_TRACKING_TOKEN>';
63 changes: 42 additions & 21 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof actions.generate>[0]): Promise<void> => {
await actions.generate(options);
await telemetry.trackCommand('generate', () => actions.generate(options));
};

const migrateAction = async (command: string, options: any): Promise<void> => {
await actions.migrate(command, options);
const migrateAction = async (subCommand: string, options: any): Promise<void> => {
await telemetry.trackCommand(`migrate ${subCommand}`, () => actions.migrate(subCommand, options));
};

const dbAction = async (command: string, options: any): Promise<void> => {
await actions.db(command, options);
const dbAction = async (subCommand: string, options: any): Promise<void> => {
await telemetry.trackCommand(`db ${subCommand}`, () => actions.db(subCommand, options));
};

const infoAction = async (projectPath: string): Promise<void> => {
await actions.info(projectPath);
await telemetry.trackCommand('info', () => actions.info(projectPath));
};

const initAction = async (projectPath: string): Promise<void> => {
await actions.init(projectPath);
await telemetry.trackCommand('init', () => actions.init(projectPath));
};

const checkAction = async (options: Parameters<typeof actions.check>[0]): Promise<void> => {
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');
Expand Down Expand Up @@ -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();
139 changes: 139 additions & 0 deletions packages/cli/src/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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<T>(
startEvent: TelemetryEvents,
completeEvent: TelemetryEvents,
errorEvent: TelemetryEvents,
properties: Record<string, unknown>,
action: () => Promise<T> | 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> | void) {
await this.trackSpan('cli:command:start', 'cli:command:complete', 'cli:command:error', { command }, action);
}

async trackCli(action: () => Promise<void> | 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();
5 changes: 5 additions & 0 deletions packages/cli/src/utils/is-ci.ts
Original file line number Diff line number Diff line change
@@ -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_')));
23 changes: 23 additions & 0 deletions packages/cli/src/utils/is-container.ts
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 31 additions & 0 deletions packages/cli/src/utils/is-docker.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 18 additions & 0 deletions packages/cli/src/utils/is-wsl.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
Loading