Skip to content

Commit 096bcbc

Browse files
committed
Merge remote-tracking branch 'origin/dev' into chore/bump-beta
2 parents 3d9f08e + 1d1f2c9 commit 096bcbc

File tree

15 files changed

+470
-32
lines changed

15 files changed

+470
-32
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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,18 @@
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",
41+
"semver": "^7.7.2",
4042
"ts-pattern": "catalog:"
4143
},
4244
"peerDependencies": {
4345
"prisma": "catalog:"
4446
},
4547
"devDependencies": {
4648
"@types/better-sqlite3": "^7.6.13",
49+
"@types/semver": "^7.7.0",
4750
"@types/tmp": "catalog:",
4851
"@zenstackhq/eslint-config": "workspace:*",
4952
"@zenstackhq/runtime": "workspace:*",

packages/cli/src/actions/generate.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ import { schema } from '${outputPath}/schema';
3939
const client = new ZenStackClient(schema, {
4040
dialect: { ... }
4141
});
42-
\`\`\``);
42+
\`\`\`
43+
44+
Check documentation: https://zenstack.dev/docs/3.x`);
4345
}
4446
}
4547

packages/cli/src/actions/info.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,15 @@ async function getZenStackPackages(projectPath: string): Promise<Array<{ pkg: st
5757
with: { type: 'json' },
5858
})
5959
).default;
60+
if (depPkgJson.private) {
61+
return undefined;
62+
}
6063
return { pkg, version: depPkgJson.version as string };
6164
} catch {
6265
return { pkg, version: undefined };
6366
}
6467
}),
6568
);
6669

67-
return result;
70+
return result.filter((p) => !!p);
6871
}

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: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,34 @@ import colors from 'colors';
33
import { Command, CommanderError, Option } from 'commander';
44
import * as actions from './actions';
55
import { CliError } from './cli-error';
6-
import { getVersion } from './utils/version-utils';
6+
import { telemetry } from './telemetry';
7+
import { checkNewVersion, getVersion } from './utils/version-utils';
78

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

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

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

2021
const infoAction = async (projectPath: string): Promise<void> => {
21-
await actions.info(projectPath);
22+
await telemetry.trackCommand('info', () => actions.info(projectPath));
2223
};
2324

2425
const initAction = async (projectPath: string): Promise<void> => {
25-
await actions.init(projectPath);
26+
await telemetry.trackCommand('init', () => actions.init(projectPath));
2627
};
2728

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

32-
export function createProgram() {
33+
function createProgram() {
3334
const program = new Command('zen');
3435

3536
program.version(getVersion()!, '-v --version', 'display CLI version');
@@ -50,10 +51,13 @@ export function createProgram() {
5051
`schema file (with extension ${schemaExtensions}). Defaults to "zenstack/schema.zmodel" unless specified in package.json.`,
5152
);
5253

54+
const noVersionCheckOption = new Option('--no-version-check', 'do not check for new version');
55+
5356
program
5457
.command('generate')
5558
.description('Run code generation plugins.')
5659
.addOption(schemaOption)
60+
.addOption(noVersionCheckOption)
5761
.addOption(new Option('-o, --output <path>', 'default output directory for code generation'))
5862
.addOption(new Option('--silent', 'suppress all output except errors').default(false))
5963
.action(generateAction);
@@ -64,6 +68,7 @@ export function createProgram() {
6468
migrateCommand
6569
.command('dev')
6670
.addOption(schemaOption)
71+
.addOption(noVersionCheckOption)
6772
.addOption(new Option('-n, --name <name>', 'migration name'))
6873
.addOption(new Option('--create-only', 'only create migration, do not apply'))
6974
.addOption(migrationsOption)
@@ -75,26 +80,30 @@ export function createProgram() {
7580
.addOption(schemaOption)
7681
.addOption(new Option('--force', 'skip the confirmation prompt'))
7782
.addOption(migrationsOption)
83+
.addOption(noVersionCheckOption)
7884
.description('Reset your database and apply all migrations, all data will be lost.')
7985
.action((options) => migrateAction('reset', options));
8086

8187
migrateCommand
8288
.command('deploy')
8389
.addOption(schemaOption)
90+
.addOption(noVersionCheckOption)
8491
.addOption(migrationsOption)
8592
.description('Deploy your pending migrations to your production/staging database.')
8693
.action((options) => migrateAction('deploy', options));
8794

8895
migrateCommand
8996
.command('status')
9097
.addOption(schemaOption)
98+
.addOption(noVersionCheckOption)
9199
.addOption(migrationsOption)
92100
.description('Check the status of your database migrations.')
93101
.action((options) => migrateAction('status', options));
94102

95103
migrateCommand
96104
.command('resolve')
97105
.addOption(schemaOption)
106+
.addOption(noVersionCheckOption)
98107
.addOption(migrationsOption)
99108
.addOption(new Option('--applied <migration>', 'record a specific migration as applied'))
100109
.addOption(new Option('--rolled-back <migration>', 'record a specific migration as rolled back'))
@@ -107,6 +116,7 @@ export function createProgram() {
107116
.command('push')
108117
.description('Push the state from your schema to your database.')
109118
.addOption(schemaOption)
119+
.addOption(noVersionCheckOption)
110120
.addOption(new Option('--accept-data-loss', 'ignore data loss warnings'))
111121
.addOption(new Option('--force-reset', 'force a reset of the database before push'))
112122
.action((options) => dbAction('push', options));
@@ -115,35 +125,64 @@ export function createProgram() {
115125
.command('info')
116126
.description('Get information of installed ZenStack packages.')
117127
.argument('[path]', 'project path', '.')
128+
.addOption(noVersionCheckOption)
118129
.action(infoAction);
119130

120131
program
121132
.command('init')
122133
.description('Initialize an existing project for ZenStack.')
123134
.argument('[path]', 'project path', '.')
135+
.addOption(noVersionCheckOption)
124136
.action(initAction);
125137

126138
program
127139
.command('check')
128140
.description('Check a ZModel schema for syntax or semantic errors.')
129141
.addOption(schemaOption)
142+
.addOption(noVersionCheckOption)
130143
.action(checkAction);
131144

145+
program.hook('preAction', async (_thisCommand, actionCommand) => {
146+
if (actionCommand.getOptionValue('versionCheck') !== false) {
147+
await checkNewVersion();
148+
}
149+
});
150+
132151
return program;
133152
}
134153

135-
const program = createProgram();
154+
async function main() {
155+
let exitCode = 0;
156+
157+
const program = createProgram();
158+
program.exitOverride();
159+
160+
try {
161+
await telemetry.trackCli(async () => {
162+
await program.parseAsync();
163+
});
164+
} catch (e) {
165+
if (e instanceof CommanderError) {
166+
// ignore
167+
exitCode = e.exitCode;
168+
} else if (e instanceof CliError) {
169+
// log
170+
console.error(colors.red(e.message));
171+
exitCode = 1;
172+
} else {
173+
console.error(colors.red(`Unhandled error: ${e}`));
174+
exitCode = 1;
175+
}
176+
}
136177

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);
178+
if (telemetry.isTracking) {
179+
// give telemetry a chance to send events before exit
180+
setTimeout(() => {
181+
process.exit(exitCode);
182+
}, 200);
144183
} else {
145-
console.error(colors.red('An unexpected error occurred:'));
146-
console.error(err);
147-
process.exit(1);
184+
process.exit(exitCode);
148185
}
149-
});
186+
}
187+
188+
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 { init, type Mixpanel } from 'mixpanel';
2+
import { randomUUID } from 'node:crypto';
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 = randomUUID();
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_')));

0 commit comments

Comments
 (0)