Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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>';
62 changes: 38 additions & 24 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
import { ZModelLanguageMetaData } from '@zenstackhq/language';
import colors from 'colors';
import { Command, CommanderError, Option } from 'commander';
import { Command, 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 +132,32 @@ export function createProgram() {
return program;
}

const program = createProgram();

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);
async function main() {
const program = createProgram();
await telemetry.trackCli(() => void program.parseAsync());

let exitCode = 0;

process.on('unhandledRejection', (reason) => {
if (reason instanceof Error) {
telemetry.trackError(reason);
}
exitCode = 1;
});

process.on('uncaughtException', (error) => {
telemetry.trackError(error);
exitCode = 1;
});

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 { createId } from '@paralleldrive/cuid2';
import { init, type Mixpanel } from 'mixpanel';
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 = createId();
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
Loading