Skip to content

Commit a24294f

Browse files
committed
feat: CLI telemetry
1 parent 21404d0 commit a24294f

File tree

12 files changed

+389
-24
lines changed

12 files changed

+389
-24
lines changed

.github/workflows/build-test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ on:
66
- main
77
- dev
88

9+
env:
10+
TELEMETRY_TRACKING_TOKEN: ${{ secrets.TELEMETRY_TRACKING_TOKEN }}
11+
DO_NOT_TRACK: '1'
12+
913
permissions:
1014
contents: read
1115

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"colors": "1.4.0",
3636
"commander": "^8.3.0",
3737
"langium": "catalog:",
38+
"mixpanel": "^0.18.1",
3839
"ora": "^5.4.1",
3940
"package-manager-detector": "^1.3.0",
4041
"ts-pattern": "catalog:"

packages/cli/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// replaced at build time
2+
export const TELEMETRY_TRACKING_TOKEN = '<TELEMETRY_TRACKING_TOKEN>';

packages/cli/src/index.ts

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,35 @@
11
import { ZModelLanguageMetaData } from '@zenstackhq/language';
22
import colors from 'colors';
3-
import { Command, CommanderError, Option } from 'commander';
3+
import { Command, Option } from 'commander';
44
import * as actions from './actions';
5-
import { CliError } from './cli-error';
5+
import { telemetry } from './telemetry';
66
import { getVersion } from './utils/version-utils';
77

88
const generateAction = async (options: Parameters<typeof actions.generate>[0]): Promise<void> => {
9-
await actions.generate(options);
9+
await telemetry.trackCommand('generate', () => actions.generate(options));
1010
};
1111

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

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

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

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

2828
const checkAction = async (options: Parameters<typeof actions.check>[0]): Promise<void> => {
29-
await actions.check(options);
29+
await telemetry.trackCommand('check', () => actions.check(options));
3030
};
3131

32-
export function createProgram() {
32+
function createProgram() {
3333
const program = new Command('zen');
3434

3535
program.version(getVersion()!, '-v --version', 'display CLI version');
@@ -132,18 +132,32 @@ export function createProgram() {
132132
return program;
133133
}
134134

135-
const program = createProgram();
136-
137-
program.parseAsync().catch((err) => {
138-
if (err instanceof CliError) {
139-
console.error(colors.red(err.message));
140-
process.exit(1);
141-
} else if (err instanceof CommanderError) {
142-
// errors are already reported, just exit
143-
process.exit(err.exitCode);
135+
async function main() {
136+
const program = createProgram();
137+
await telemetry.trackCli(() => void program.parseAsync());
138+
139+
let exitCode = 0;
140+
141+
process.on('unhandledRejection', (reason) => {
142+
if (reason instanceof Error) {
143+
telemetry.trackError(reason);
144+
}
145+
exitCode = 1;
146+
});
147+
148+
process.on('uncaughtException', (error) => {
149+
telemetry.trackError(error);
150+
exitCode = 1;
151+
});
152+
153+
if (telemetry.isTracking) {
154+
// give telemetry a chance to send events before exit
155+
setTimeout(() => {
156+
process.exit(exitCode);
157+
}, 200);
144158
} else {
145-
console.error(colors.red('An unexpected error occurred:'));
146-
console.error(err);
147-
process.exit(1);
159+
process.exit(exitCode);
148160
}
149-
});
161+
}
162+
163+
main();

packages/cli/src/telemetry.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { createId } from '@paralleldrive/cuid2';
2+
import { init, type Mixpanel } from 'mixpanel';
3+
import fs from 'node:fs';
4+
import * as os from 'os';
5+
import { TELEMETRY_TRACKING_TOKEN } from './constants';
6+
import { isInCi } from './utils/is-ci';
7+
import { isInContainer } from './utils/is-container';
8+
import isDocker from './utils/is-docker';
9+
import { isWsl } from './utils/is-wsl';
10+
import { getMachineId } from './utils/machine-id-utils';
11+
import { getVersion } from './utils/version-utils';
12+
13+
/**
14+
* Telemetry events
15+
*/
16+
export type TelemetryEvents =
17+
| 'cli:start'
18+
| 'cli:complete'
19+
| 'cli:error'
20+
| 'cli:command:start'
21+
| 'cli:command:complete'
22+
| 'cli:command:error'
23+
| 'cli:plugin:start'
24+
| 'cli:plugin:complete'
25+
| 'cli:plugin:error';
26+
27+
/**
28+
* Utility class for sending telemetry
29+
*/
30+
export class Telemetry {
31+
private readonly mixpanel: Mixpanel | undefined;
32+
private readonly hostId = getMachineId();
33+
private readonly sessionid = createId();
34+
private readonly _os_type = os.type();
35+
private readonly _os_release = os.release();
36+
private readonly _os_arch = os.arch();
37+
private readonly _os_version = os.version();
38+
private readonly _os_platform = os.platform();
39+
private readonly version = getVersion();
40+
private readonly prismaVersion = this.getPrismaVersion();
41+
private readonly isDocker = isDocker();
42+
private readonly isWsl = isWsl();
43+
private readonly isContainer = isInContainer();
44+
private readonly isCi = isInCi;
45+
46+
constructor() {
47+
if (process.env['DO_NOT_TRACK'] !== '1' && TELEMETRY_TRACKING_TOKEN) {
48+
this.mixpanel = init(TELEMETRY_TRACKING_TOKEN, {
49+
geolocate: true,
50+
});
51+
}
52+
}
53+
54+
get isTracking() {
55+
return !!this.mixpanel;
56+
}
57+
58+
track(event: TelemetryEvents, properties: Record<string, unknown> = {}) {
59+
if (this.mixpanel) {
60+
const payload = {
61+
distinct_id: this.hostId,
62+
session: this.sessionid,
63+
time: new Date(),
64+
$os: this._os_type,
65+
osType: this._os_type,
66+
osRelease: this._os_release,
67+
osPlatform: this._os_platform,
68+
osArch: this._os_arch,
69+
osVersion: this._os_version,
70+
nodeVersion: process.version,
71+
version: this.version,
72+
prismaVersion: this.prismaVersion,
73+
isDocker: this.isDocker,
74+
isWsl: this.isWsl,
75+
isContainer: this.isContainer,
76+
isCi: this.isCi,
77+
...properties,
78+
};
79+
this.mixpanel.track(event, payload);
80+
}
81+
}
82+
83+
trackError(err: Error) {
84+
this.track('cli:error', {
85+
message: err.message,
86+
stack: err.stack,
87+
});
88+
}
89+
90+
async trackSpan<T>(
91+
startEvent: TelemetryEvents,
92+
completeEvent: TelemetryEvents,
93+
errorEvent: TelemetryEvents,
94+
properties: Record<string, unknown>,
95+
action: () => Promise<T> | T,
96+
) {
97+
this.track(startEvent, properties);
98+
const start = Date.now();
99+
let success = true;
100+
try {
101+
return await action();
102+
} catch (err: any) {
103+
this.track(errorEvent, {
104+
message: err.message,
105+
stack: err.stack,
106+
...properties,
107+
});
108+
success = false;
109+
throw err;
110+
} finally {
111+
this.track(completeEvent, {
112+
duration: Date.now() - start,
113+
success,
114+
...properties,
115+
});
116+
}
117+
}
118+
119+
async trackCommand(command: string, action: () => Promise<void> | void) {
120+
await this.trackSpan('cli:command:start', 'cli:command:complete', 'cli:command:error', { command }, action);
121+
}
122+
123+
async trackCli(action: () => Promise<void> | void) {
124+
await this.trackSpan('cli:start', 'cli:complete', 'cli:error', {}, action);
125+
}
126+
127+
getPrismaVersion() {
128+
try {
129+
const packageJsonPath = import.meta.resolve('prisma/package.json');
130+
const packageJsonUrl = new URL(packageJsonPath);
131+
const packageJson = JSON.parse(fs.readFileSync(packageJsonUrl, 'utf8'));
132+
return packageJson.version;
133+
} catch {
134+
return undefined;
135+
}
136+
}
137+
}
138+
139+
export const telemetry = new Telemetry();

packages/cli/src/utils/is-ci.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { env } from 'node:process';
2+
export const isInCi =
3+
env['CI'] !== '0' &&
4+
env['CI'] !== 'false' &&
5+
('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_')));
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import fs from 'node:fs';
2+
import isDocker from './is-docker';
3+
4+
let cachedResult: boolean | undefined;
5+
6+
// Podman detection
7+
const hasContainerEnv = () => {
8+
try {
9+
fs.statSync('/run/.containerenv');
10+
return true;
11+
} catch {
12+
return false;
13+
}
14+
};
15+
16+
export function isInContainer() {
17+
// TODO: Use `??=` when targeting Node.js 16.
18+
if (cachedResult === undefined) {
19+
cachedResult = hasContainerEnv() || isDocker();
20+
}
21+
22+
return cachedResult;
23+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copied over from https://github.com/sindresorhus/is-docker for CJS compatibility
2+
3+
import fs from 'node:fs';
4+
5+
let isDockerCached: boolean | undefined;
6+
7+
function hasDockerEnv() {
8+
try {
9+
fs.statSync('/.dockerenv');
10+
return true;
11+
} catch {
12+
return false;
13+
}
14+
}
15+
16+
function hasDockerCGroup() {
17+
try {
18+
return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker');
19+
} catch {
20+
return false;
21+
}
22+
}
23+
24+
export default function isDocker() {
25+
// TODO: Use `??=` when targeting Node.js 16.
26+
if (isDockerCached === undefined) {
27+
isDockerCached = hasDockerEnv() || hasDockerCGroup();
28+
}
29+
30+
return isDockerCached;
31+
}

packages/cli/src/utils/is-wsl.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import process from 'node:process';
2+
import os from 'node:os';
3+
import fs from 'node:fs';
4+
export const isWsl = () => {
5+
if (process.platform !== 'linux') {
6+
return false;
7+
}
8+
9+
if (os.release().toLowerCase().includes('microsoft')) {
10+
return true;
11+
}
12+
13+
try {
14+
return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
15+
} catch {
16+
return false;
17+
}
18+
};

0 commit comments

Comments
 (0)