From 9ed218786dbabce6005785e169b489f122b5aeab Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Nov 2022 17:23:08 +0800 Subject: [PATCH 1/8] feat: implement CLI telemetry --- README.md | 2 + docs/ref/telemetry.md | 21 ++ package.json | 2 +- packages/internal/package.json | 2 +- packages/runtime/package.json | 2 +- packages/schema/build/bundle.js | 2 + packages/schema/build/env-plugin.js | 40 +++ packages/schema/package.json | 9 +- packages/schema/src/cli/cli-error.ts | 4 + packages/schema/src/cli/cli-util.ts | 9 +- packages/schema/src/cli/index.ts | 329 ++++++++++++++----------- packages/schema/src/generator/index.ts | 12 +- packages/schema/src/global.d.ts | 3 + packages/schema/src/telemetry.ts | 110 +++++++++ pnpm-lock.yaml | 52 +++- samples/todo/package.json | 2 +- 16 files changed, 449 insertions(+), 152 deletions(-) create mode 100644 docs/ref/telemetry.md create mode 100644 packages/schema/build/env-plugin.js create mode 100644 packages/schema/src/cli/cli-error.ts create mode 100644 packages/schema/src/global.d.ts create mode 100644 packages/schema/src/telemetry.ts diff --git a/README.md b/README.md index b0fd9ef25..d1e2875e3 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,8 @@ export const getServerSideProps: GetServerSideProps = async () => { ### [Setting up logging](/docs/ref/setup-logging.md) +### [Telemetry](/docs/ref/telemetry.md) + ## Reach out to us for issues, feedback and ideas! [Discord](https://go.zenstack.dev/chat) | [Twitter](https://twitter.com/zenstackhq) | diff --git a/docs/ref/telemetry.md b/docs/ref/telemetry.md new file mode 100644 index 000000000..bda22e2c2 --- /dev/null +++ b/docs/ref/telemetry.md @@ -0,0 +1,21 @@ +# Telemetry + +ZenStack CLI and VSCode extension sends anonymous telemetry for analyzing usage stats and finding bugs. + +The information collected includes: + +- OS +- Node.js version +- CLI version +- CLI command and arguments +- CLI errors +- Duration of command run +- Region (based on IP) + +We don't collect any telemetry at the runtime of apps using ZenStack. + +We appreciate that you keep the telemetry ON so we can keep improving the toolkit. We follow the [Console Do Not Track](https://consoledonottrack.com/) convention, and you can turn off the telemetry by setting environment variable `DO_NOT_TRACK` to `1`: + +```bash +DO_NOT_TRACK=1 npx zenstack ... +``` diff --git a/package.json b/package.json index 2ce698bd2..707867554 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "0.3.4", + "version": "0.3.5", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/internal/package.json b/packages/internal/package.json index 0ff3d9828..66b2a64be 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/internal", - "version": "0.3.4", + "version": "0.3.5", "displayName": "ZenStack Internal Library", "description": "ZenStack internal runtime library. This package is for supporting runtime functionality of ZenStack and not supposed to be used directly.", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 2d867a6dc..369777a0c 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "0.3.4", + "version": "0.3.5", "description": "This package contains runtime library for consuming client and server side code generated by ZenStack.", "repository": { "type": "git", diff --git a/packages/schema/build/bundle.js b/packages/schema/build/bundle.js index 049188b79..3813550ee 100644 --- a/packages/schema/build/bundle.js +++ b/packages/schema/build/bundle.js @@ -2,6 +2,7 @@ const watch = process.argv.includes('--watch'); const minify = process.argv.includes('--minify'); const success = watch ? 'Watch build succeeded' : 'Build succeeded'; const fs = require('fs'); +const envFilePlugin = require('./env-plugin'); require('esbuild') .build({ @@ -24,6 +25,7 @@ require('esbuild') } : false, minify, + plugins: [envFilePlugin], }) .then(() => { fs.cpSync('./src/res', 'bundle/res', { force: true, recursive: true }); diff --git a/packages/schema/build/env-plugin.js b/packages/schema/build/env-plugin.js new file mode 100644 index 000000000..af79416b3 --- /dev/null +++ b/packages/schema/build/env-plugin.js @@ -0,0 +1,40 @@ +// from: https://github.com/rw3iss/esbuild-envfile-plugin + +const path = require('path'); +const fs = require('fs'); + +const ENV = process.env.NODE_ENV || 'development'; + +module.exports = { + name: 'env', + + setup(build) { + function _findEnvFile(dir) { + if (!fs.existsSync(dir)) return false; + return fs.existsSync(`${dir}/.env.${ENV}`) + ? `${dir}/.env.${ENV}` + : fs.existsSync(`${dir}/.env`) + ? `${dir}/.env` + : _findEnvFile(path.resolve(dir, '../')); + } + + build.onResolve({ filter: /^env$/ }, async (args) => { + return { + path: _findEnvFile(args.resolveDir), + namespace: 'env-ns', + }; + }); + + build.onLoad({ filter: /.*/, namespace: 'env-ns' }, async (args) => { + // read in .env file contents and combine with regular .env: + let data = await fs.promises.readFile(args.path, 'utf8'); + const buf = Buffer.from(data); + const config = require('dotenv').parse(buf); + + return { + contents: JSON.stringify({ ...process.env, ...config }), + loader: 'json', + }; + }); + }, +}; diff --git a/packages/schema/package.json b/packages/schema/package.json index c56611dd5..13c3f01c5 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for modeling data and access policies in full-stack development with Next.js and Typescript", - "version": "0.3.4", + "version": "0.3.5", "author": { "name": "ZenStack Team" }, @@ -83,14 +83,19 @@ }, "dependencies": { "@zenstackhq/internal": "workspace:*", + "async-exit-hook": "^2.0.1", "change-case": "^4.1.2", "chevrotain": "^9.1.0", "colors": "1.4.0", "commander": "^8.3.0", + "cuid": "^2.1.8", "langium": "^0.5.0", + "mixpanel": "^0.17.0", + "node-machine-id": "^1.1.12", "pluralize": "^8.0.0", "prisma": "^4.5.0", "promisify": "^0.0.3", + "sleep-promise": "^9.1.0", "ts-morph": "^16.0.0", "uuid": "^9.0.0", "vscode-jsonrpc": "^8.0.2", @@ -101,6 +106,7 @@ }, "devDependencies": { "@prisma/internals": "^4.5.0", + "@types/async-exit-hook": "^2.0.0", "@types/jest": "^29.2.0", "@types/node": "^14.18.32", "@types/pluralize": "^0.0.29", @@ -110,6 +116,7 @@ "@typescript-eslint/eslint-plugin": "^5.42.0", "@typescript-eslint/parser": "^5.42.0", "concurrently": "^7.4.0", + "dotenv": "^16.0.3", "esbuild": "^0.15.12", "eslint": "^8.27.0", "jest": "^29.2.1", diff --git a/packages/schema/src/cli/cli-error.ts b/packages/schema/src/cli/cli-error.ts new file mode 100644 index 000000000..bf65c26a4 --- /dev/null +++ b/packages/schema/src/cli/cli-error.ts @@ -0,0 +1,4 @@ +/** + * Indicating an error during CLI execution + */ +export class CliError extends Error {} diff --git a/packages/schema/src/cli/cli-util.ts b/packages/schema/src/cli/cli-util.ts index 8c08aac62..ec1bbb283 100644 --- a/packages/schema/src/cli/cli-util.ts +++ b/packages/schema/src/cli/cli-util.ts @@ -10,6 +10,7 @@ import { ZenStackGenerator } from '../generator'; import { URI } from 'vscode-uri'; import { GENERATED_CODE_PATH } from '../generator/constants'; import { Context, GeneratorError } from '../generator/types'; +import { CliError } from './cli-error'; /** * Loads a zmodel document from a file. @@ -26,12 +27,12 @@ export async function loadDocument( console.error( colors.yellow(`Please choose a file with extension: ${extensions}.`) ); - process.exit(1); + throw new CliError('invalid schema file'); } if (!fs.existsSync(fileName)) { console.error(colors.red(`File ${fileName} does not exist.`)); - process.exit(1); + throw new CliError('schema file does not exist'); } // load standard library @@ -69,7 +70,7 @@ export async function loadDocument( ) ); } - process.exit(1); + throw new CliError('schema validation errors'); } return document.parseResult.value as Model; @@ -99,7 +100,7 @@ export async function runGenerator( } catch (err) { if (err instanceof GeneratorError) { console.error(colors.red(err.message)); - process.exit(1); + throw new CliError(err.message); } } } diff --git a/packages/schema/src/cli/index.ts b/packages/schema/src/cli/index.ts index b7dbcf37e..408bd153c 100644 --- a/packages/schema/src/cli/index.ts +++ b/packages/schema/src/cli/index.ts @@ -6,159 +6,206 @@ import { execSync } from '../utils/exec-utils'; import { paramCase } from 'change-case'; import path from 'path'; import { runGenerator } from './cli-util'; +import telemetry from '../telemetry'; +import { CliError } from './cli-error'; export const generateAction = async (options: { schema: string; }): Promise => { - await runGenerator(options); + await telemetry.trackSpan( + 'cli:command:start', + 'cli:command:complete', + 'cli:command:error', + { command: 'generate' }, + () => runGenerator(options) + ); }; function prismaAction(prismaCmd: string): (...args: any[]) => Promise { return async (options: any, command: Command) => { - const optStr = Array.from(Object.entries(options)) - .map(([k, v]) => { - let optVal = v; - if (k === 'schema') { - optVal = path.join(path.dirname(v), 'schema.prisma'); + await telemetry.trackSpan( + 'cli:command:start', + 'cli:command:complete', + 'cli:command:error', + { + command: prismaCmd + ? +(prismaCmd + ' ' + command.name()) + : command.name(), + }, + async () => { + const optStr = Array.from(Object.entries(options)) + .map(([k, v]) => { + let optVal = v; + if (k === 'schema') { + optVal = path.join( + path.dirname(v), + 'schema.prisma' + ); + } + return ( + '--' + + paramCase(k) + + (typeof optVal === 'string' ? ` ${optVal}` : '') + ); + }) + .join(' '); + + // regenerate prisma schema first + await runGenerator(options, ['prisma'], false); + + const prismaExec = `npx prisma ${prismaCmd} ${command.name()} ${optStr}`; + console.log(prismaExec); + try { + execSync(prismaExec); + } catch { + telemetry.track('cli:command:error', { + command: prismaCmd, + }); + console.error( + colors.red( + 'Prisma command failed to execute. See errors above.' + ) + ); + throw new CliError('prisma command run error'); } - return ( - '--' + - paramCase(k) + - (typeof optVal === 'string' ? ` ${optVal}` : '') - ); - }) - .join(' '); - - // regenerate prisma schema first - await runGenerator(options, ['prisma'], false); - - const prismaExec = `npx prisma ${prismaCmd} ${command.name()} ${optStr}`; - console.log(prismaExec); - try { - execSync(prismaExec); - } catch { - console.error( - colors.red( - 'Prisma command failed to execute. See errors above.' - ) - ); - process.exit(1); - } + } + ); }; } -export default function (): void { - const program = new Command('zenstack'); +export default async function (): Promise { + // try { + await telemetry.trackSpan( + 'cli:start', + 'cli:complete', + 'cli:error', + { args: process.argv }, + async () => { + const program = new Command('zenstack'); + + program.version( + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../../package.json').version, + '-v --version', + 'display CLI version' + ); - program.version( - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('../../package.json').version, - '-v --version', - 'display CLI version' - ); + const schemaExtensions = + ZModelLanguageMetaData.fileExtensions.join(', '); + + program + .description( + `${colors.bold.blue( + 'ζ' + )} ZenStack simplifies fullstack development by generating backend services and Typescript clients from a data model.\n\nDocumentation: https://go.zenstack.dev/doc.` + ) + .showHelpAfterError() + .showSuggestionAfterError(); + + const schemaOption = new Option( + '--schema ', + `schema file (with extension ${schemaExtensions})` + ).default('./zenstack/schema.zmodel'); + + //#region wraps Prisma commands + + program + .command('generate') + .description( + 'generates RESTful API and Typescript client for your data model' + ) + .addOption(schemaOption) + .action(generateAction); + + const migrate = program + .command('migrate') + .description( + `wraps Prisma's ${colors.cyan('migrate')} command` + ); + + migrate + .command('dev') + .description( + `alias for ${colors.cyan( + 'prisma migrate dev' + )}\nCreate a migration, apply it to the database, generate db client.` + ) + .addOption(schemaOption) + .option( + '--create-only', + 'Create a migration without applying it' + ) + .option('-n --name ', 'Name the migration') + .option('--skip-seed', 'Skip triggering seed') + .action(prismaAction('migrate')); + + migrate + .command('reset') + .description( + `alias for ${colors.cyan( + 'prisma migrate reset' + )}\nReset your database and apply all migrations.` + ) + .addOption(schemaOption) + .option('--force', 'Skip the confirmation prompt') + .action(prismaAction('migrate')); + + migrate + .command('deploy') + .description( + `alias for ${colors.cyan( + 'prisma migrate deploy' + )}\nApply pending migrations to the database in production/staging.` + ) + .addOption(schemaOption) + .action(prismaAction('migrate')); + + migrate + .command('status') + .description( + `alias for ${colors.cyan( + 'prisma migrate status' + )}\nCheck the status of migrations in the production/staging database.` + ) + .addOption(schemaOption) + .action(prismaAction('migrate')); + + const db = program + .command('db') + .description(`wraps Prisma's ${colors.cyan('db')} command`); + + db.command('push') + .description( + `alias for ${colors.cyan( + 'prisma db push' + )}\nPush the Prisma schema state to the database.` + ) + .addOption(schemaOption) + .option('--accept-data-loss', 'Ignore data loss warnings') + .action(prismaAction('db')); + + program + .command('studio') + .description( + `wraps Prisma's ${colors.cyan( + 'studio' + )} command. Browse your data with Prisma Studio.` + ) + .addOption(schemaOption) + .option('-p --port ', 'Port to start Studio in') + .option('-b --browser ', 'Browser to open Studio in') + .option( + '-n --hostname', + 'Hostname to bind the Express server to' + ) + .action(prismaAction('')); + + //#endregion - const schemaExtensions = ZModelLanguageMetaData.fileExtensions.join(', '); - - program - .description( - `${colors.bold.blue( - 'ζ' - )} ZenStack simplifies fullstack development by generating backend services and Typescript clients from a data model.\n\nDocumentation: https://go.zenstack.dev/doc.` - ) - .showHelpAfterError() - .showSuggestionAfterError(); - - const schemaOption = new Option( - '--schema ', - `schema file (with extension ${schemaExtensions})` - ).default('./zenstack/schema.zmodel'); - - //#region wraps Prisma commands - - program - .command('generate') - .description( - 'generates RESTful API and Typescript client for your data model' - ) - .addOption(schemaOption) - .action(generateAction); - - const migrate = program - .command('migrate') - .description(`wraps Prisma's ${colors.cyan('migrate')} command`); - - migrate - .command('dev') - .description( - `alias for ${colors.cyan( - 'prisma migrate dev' - )}\nCreate a migration, apply it to the database, generate db client.` - ) - .addOption(schemaOption) - .option('--create-only', 'Create a migration without applying it') - .option('-n --name ', 'Name the migration') - .option('--skip-seed', 'Skip triggering seed') - .action(prismaAction('migrate')); - - migrate - .command('reset') - .description( - `alias for ${colors.cyan( - 'prisma migrate reset' - )}\nReset your database and apply all migrations.` - ) - .addOption(schemaOption) - .option('--force', 'Skip the confirmation prompt') - .action(prismaAction('migrate')); - - migrate - .command('deploy') - .description( - `alias for ${colors.cyan( - 'prisma migrate deploy' - )}\nApply pending migrations to the database in production/staging.` - ) - .addOption(schemaOption) - .action(prismaAction('migrate')); - - migrate - .command('status') - .description( - `alias for ${colors.cyan( - 'prisma migrate status' - )}\nCheck the status of migrations in the production/staging database.` - ) - .addOption(schemaOption) - .action(prismaAction('migrate')); - - const db = program - .command('db') - .description(`wraps Prisma's ${colors.cyan('db')} command`); - - db.command('push') - .description( - `alias for ${colors.cyan( - 'prisma db push' - )}\nPush the Prisma schema state to the database.` - ) - .addOption(schemaOption) - .option('--accept-data-loss', 'Ignore data loss warnings') - .action(prismaAction('db')); - - program - .command('studio') - .description( - `wraps Prisma's ${colors.cyan( - 'studio' - )} command. Browse your data with Prisma Studio.` - ) - .addOption(schemaOption) - .option('-p --port ', 'Port to start Studio in') - .option('-b --browser ', 'Browser to open Studio in') - .option('-n --hostname', 'Hostname to bind the Express server to') - .action(prismaAction('')); - - //#endregion - - program.parse(process.argv); + // handle errors explicitly to ensure telemetry + program.exitOverride(); + + await program.parseAsync(process.argv); + } + ); } diff --git a/packages/schema/src/generator/index.ts b/packages/schema/src/generator/index.ts index 20f7b35ad..23265586f 100644 --- a/packages/schema/src/generator/index.ts +++ b/packages/schema/src/generator/index.ts @@ -8,6 +8,7 @@ import ReactHooksGenerator from './react-hooks'; import NextAuthGenerator from './next-auth'; import { TypescriptCompilation } from './tsc'; import FieldConstraintGenerator from './field-constraint'; +import telemetry from '../telemetry'; /** * ZenStack code generator @@ -57,7 +58,16 @@ export class ZenStackGenerator { ) { continue; } - await generator.generate(context); + + await telemetry.trackSpan( + 'cli:generator:start', + 'cli:generator:complete', + 'cli:generator:error', + { + generator: generator.name, + }, + () => generator.generate(context) + ); } console.log( diff --git a/packages/schema/src/global.d.ts b/packages/schema/src/global.d.ts new file mode 100644 index 000000000..16a5211ae --- /dev/null +++ b/packages/schema/src/global.d.ts @@ -0,0 +1,3 @@ +declare module 'env' { + export const TELEMETRY_TRACKING_TOKEN: string; +} diff --git a/packages/schema/src/telemetry.ts b/packages/schema/src/telemetry.ts new file mode 100644 index 000000000..ca4eeda13 --- /dev/null +++ b/packages/schema/src/telemetry.ts @@ -0,0 +1,110 @@ +import { Mixpanel, init } from 'mixpanel'; +import { TELEMETRY_TRACKING_TOKEN } from 'env'; +import { machineIdSync } from 'node-machine-id'; +import cuid from 'cuid'; +import * as os from 'os'; +import sleep from 'sleep-promise'; +import exitHook from 'async-exit-hook'; + +/** + * Telemetry events + */ +export type TelemetryEvents = + | 'cli:start' + | 'cli:complete' + | 'cli:error' + | 'cli:command:start' + | 'cli:command:complete' + | 'cli:command:error' + | 'cli:generator:start' + | 'cli:generator:complete' + | 'cli:generator:error'; + +/** + * Utility class for sending telemetry + */ +export class Telemetry { + private readonly mixpanel: Mixpanel | undefined; + private readonly hostId = machineIdSync(); + private readonly sessionid = cuid(); + private readonly trackingToken = TELEMETRY_TRACKING_TOKEN; + private readonly _os = os.platform(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + private readonly version = require('../package.json').version; + private exitWait = 200; + + constructor() { + if (process.env.DO_NOT_TRACK !== '1' && this.trackingToken) { + this.mixpanel = init(this.trackingToken, { + geolocate: true, + }); + } + + exitHook(async (callback) => { + if (this.mixpanel) { + // a small delay to ensure telemetry is sent + await sleep(this.exitWait); + } + callback(); + }); + + exitHook.uncaughtExceptionHandler(async (err) => { + this.track('cli:error', { + message: err.message, + stack: err.stack, + }); + if (this.mixpanel) { + // a small delay to ensure telemetry is sent + await sleep(this.exitWait); + } + process.exit(1); + }); + } + + track(event: TelemetryEvents, properties: Record = {}) { + if (this.mixpanel) { + const payload = { + distinct_id: this.hostId, + session: this.sessionid, + time: new Date(), + $os: this._os, + nodeVersion: process.version, + version: this.version, + ...properties, + }; + this.mixpanel.track(event, payload); + } + } + + async trackSpan( + startEvent: TelemetryEvents, + completeEvent: TelemetryEvents, + errorEvent: TelemetryEvents, + properties: Record, + action: () => Promise | void + ) { + this.track(startEvent, properties); + const start = Date.now(); + let success = true; + try { + await Promise.resolve(action()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } 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, + }); + } + } +} + +export default new Telemetry(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8abbbb208..8a388b076 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,7 @@ importers: packages/schema: specifiers: '@prisma/internals': ^4.5.0 + '@types/async-exit-hook': ^2.0.0 '@types/jest': ^29.2.0 '@types/node': ^14.18.32 '@types/pluralize': ^0.0.29 @@ -80,20 +81,26 @@ importers: '@typescript-eslint/eslint-plugin': ^5.42.0 '@typescript-eslint/parser': ^5.42.0 '@zenstackhq/internal': workspace:* + async-exit-hook: ^2.0.1 change-case: ^4.1.2 chevrotain: ^9.1.0 colors: 1.4.0 commander: ^8.3.0 concurrently: ^7.4.0 + cuid: ^2.1.8 + dotenv: ^16.0.3 esbuild: ^0.15.12 eslint: ^8.27.0 jest: ^29.2.1 langium: ^0.5.0 langium-cli: ^0.5.0 + mixpanel: ^0.17.0 + node-machine-id: ^1.1.12 pluralize: ^8.0.0 prisma: ^4.5.0 promisify: ^0.0.3 rimraf: ^3.0.2 + sleep-promise: ^9.1.0 tmp: ^0.2.1 ts-jest: ^29.0.3 ts-morph: ^16.0.0 @@ -110,14 +117,19 @@ importers: vscode-uri: ^3.0.6 dependencies: '@zenstackhq/internal': link:../internal + async-exit-hook: 2.0.1 change-case: 4.1.2 chevrotain: 9.1.0 colors: 1.4.0 commander: 8.3.0 + cuid: 2.1.8 langium: 0.5.0 + mixpanel: 0.17.0 + node-machine-id: 1.1.12 pluralize: 8.0.0 prisma: 4.5.0 promisify: 0.0.3 + sleep-promise: 9.1.0 ts-morph: 16.0.0 uuid: 9.0.0 vscode-jsonrpc: 8.0.2 @@ -127,6 +139,7 @@ importers: vscode-uri: 3.0.6 devDependencies: '@prisma/internals': 4.5.0 + '@types/async-exit-hook': 2.0.0 '@types/jest': 29.2.0 '@types/node': 14.18.32 '@types/pluralize': 0.0.29 @@ -136,6 +149,7 @@ importers: '@typescript-eslint/eslint-plugin': 5.42.0_ofgjrzjuekeo7s3hdyz2yuzw34 '@typescript-eslint/parser': 5.42.0_rmayb2veg2btbq6mbmnyivgasy concurrently: 7.4.0 + dotenv: 16.0.3 esbuild: 0.15.12 eslint: 8.27.0 jest: 29.2.1_4f2ldd7um3b3u4eyvetyqsphze @@ -1644,6 +1658,10 @@ packages: resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==} dev: true + /@types/async-exit-hook/2.0.0: + resolution: {integrity: sha512-RNjIyjnVZdcP5a1zeIPb5c0hq2nbJc/NOCLNKUAqeCw+J5z2zMcINISn9wybCWhczHnUu3VSUFy7ZCO6ir4ZRw==} + dev: true + /@types/babel__core/7.1.19: resolution: {integrity: sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==} dependencies: @@ -1977,7 +1995,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: true /aggregate-error/3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} @@ -2095,6 +2112,11 @@ packages: engines: {node: '>=8'} dev: true + /async-exit-hook/2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + dev: false + /async/3.2.4: resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} dev: true @@ -2877,6 +2899,11 @@ packages: engines: {node: '>=12'} dev: true + /dotenv/16.0.3: + resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} + engines: {node: '>=12'} + dev: true + /electron-to-chromium/1.4.284: resolution: {integrity: sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==} @@ -3656,6 +3683,16 @@ packages: - supports-color dev: true + /https-proxy-agent/5.0.0: + resolution: {integrity: sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: false + /https-proxy-agent/5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -5044,6 +5081,15 @@ packages: resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==} dev: true + /mixpanel/0.17.0: + resolution: {integrity: sha512-DY5WeOy/hmkPrNiiZugJpWR0iMuOwuj1a3u0bgwB2eUFRV6oIew/pIahhpawdbNjb+Bye4a8ID3gefeNPvL81g==} + engines: {node: '>=10.0'} + dependencies: + https-proxy-agent: 5.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /mkdirp-classic/0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} dev: true @@ -5170,6 +5216,10 @@ packages: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} dev: true + /node-machine-id/1.1.12: + resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} + dev: false + /node-releases/2.0.6: resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} diff --git a/samples/todo/package.json b/samples/todo/package.json index 1acd9958d..fd0394de4 100644 --- a/samples/todo/package.json +++ b/samples/todo/package.json @@ -1,6 +1,6 @@ { "name": "todo", - "version": "0.3.4", + "version": "0.3.5", "private": true, "scripts": { "dev": "next dev", From 5fc8efc8ed60a8387873e78a4cc4ab0504213acf Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Nov 2022 17:23:08 +0800 Subject: [PATCH 2/8] feat: implement CLI telemetry --- README.md | 2 + docs/ref/telemetry.md | 21 ++ package.json | 2 +- packages/internal/package.json | 2 +- packages/runtime/package.json | 2 +- packages/schema/build/bundle.js | 2 + packages/schema/build/env-plugin.js | 40 +++ packages/schema/package.json | 11 +- packages/schema/src/cli/cli-error.ts | 4 + packages/schema/src/cli/cli-util.ts | 9 +- packages/schema/src/cli/index.ts | 329 ++++++++++++++----------- packages/schema/src/generator/index.ts | 12 +- packages/schema/src/global.d.ts | 3 + packages/schema/src/telemetry.ts | 110 +++++++++ pnpm-lock.yaml | 52 +++- samples/todo/package.json | 2 +- samples/todo/pages/create-space.tsx | 6 +- tests/integration/tests/utils.ts | 6 +- 18 files changed, 459 insertions(+), 156 deletions(-) create mode 100644 docs/ref/telemetry.md create mode 100644 packages/schema/build/env-plugin.js create mode 100644 packages/schema/src/cli/cli-error.ts create mode 100644 packages/schema/src/global.d.ts create mode 100644 packages/schema/src/telemetry.ts diff --git a/README.md b/README.md index b0fd9ef25..d1e2875e3 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,8 @@ export const getServerSideProps: GetServerSideProps = async () => { ### [Setting up logging](/docs/ref/setup-logging.md) +### [Telemetry](/docs/ref/telemetry.md) + ## Reach out to us for issues, feedback and ideas! [Discord](https://go.zenstack.dev/chat) | [Twitter](https://twitter.com/zenstackhq) | diff --git a/docs/ref/telemetry.md b/docs/ref/telemetry.md new file mode 100644 index 000000000..bda22e2c2 --- /dev/null +++ b/docs/ref/telemetry.md @@ -0,0 +1,21 @@ +# Telemetry + +ZenStack CLI and VSCode extension sends anonymous telemetry for analyzing usage stats and finding bugs. + +The information collected includes: + +- OS +- Node.js version +- CLI version +- CLI command and arguments +- CLI errors +- Duration of command run +- Region (based on IP) + +We don't collect any telemetry at the runtime of apps using ZenStack. + +We appreciate that you keep the telemetry ON so we can keep improving the toolkit. We follow the [Console Do Not Track](https://consoledonottrack.com/) convention, and you can turn off the telemetry by setting environment variable `DO_NOT_TRACK` to `1`: + +```bash +DO_NOT_TRACK=1 npx zenstack ... +``` diff --git a/package.json b/package.json index 2ce698bd2..1b6b7169e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "0.3.4", + "version": "0.3.6", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/internal/package.json b/packages/internal/package.json index 0ff3d9828..6b3c7a4aa 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/internal", - "version": "0.3.4", + "version": "0.3.6", "displayName": "ZenStack Internal Library", "description": "ZenStack internal runtime library. This package is for supporting runtime functionality of ZenStack and not supposed to be used directly.", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 2d867a6dc..01fd54aa8 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "0.3.4", + "version": "0.3.6", "description": "This package contains runtime library for consuming client and server side code generated by ZenStack.", "repository": { "type": "git", diff --git a/packages/schema/build/bundle.js b/packages/schema/build/bundle.js index 049188b79..3813550ee 100644 --- a/packages/schema/build/bundle.js +++ b/packages/schema/build/bundle.js @@ -2,6 +2,7 @@ const watch = process.argv.includes('--watch'); const minify = process.argv.includes('--minify'); const success = watch ? 'Watch build succeeded' : 'Build succeeded'; const fs = require('fs'); +const envFilePlugin = require('./env-plugin'); require('esbuild') .build({ @@ -24,6 +25,7 @@ require('esbuild') } : false, minify, + plugins: [envFilePlugin], }) .then(() => { fs.cpSync('./src/res', 'bundle/res', { force: true, recursive: true }); diff --git a/packages/schema/build/env-plugin.js b/packages/schema/build/env-plugin.js new file mode 100644 index 000000000..af79416b3 --- /dev/null +++ b/packages/schema/build/env-plugin.js @@ -0,0 +1,40 @@ +// from: https://github.com/rw3iss/esbuild-envfile-plugin + +const path = require('path'); +const fs = require('fs'); + +const ENV = process.env.NODE_ENV || 'development'; + +module.exports = { + name: 'env', + + setup(build) { + function _findEnvFile(dir) { + if (!fs.existsSync(dir)) return false; + return fs.existsSync(`${dir}/.env.${ENV}`) + ? `${dir}/.env.${ENV}` + : fs.existsSync(`${dir}/.env`) + ? `${dir}/.env` + : _findEnvFile(path.resolve(dir, '../')); + } + + build.onResolve({ filter: /^env$/ }, async (args) => { + return { + path: _findEnvFile(args.resolveDir), + namespace: 'env-ns', + }; + }); + + build.onLoad({ filter: /.*/, namespace: 'env-ns' }, async (args) => { + // read in .env file contents and combine with regular .env: + let data = await fs.promises.readFile(args.path, 'utf8'); + const buf = Buffer.from(data); + const config = require('dotenv').parse(buf); + + return { + contents: JSON.stringify({ ...process.env, ...config }), + loader: 'json', + }; + }); + }, +}; diff --git a/packages/schema/package.json b/packages/schema/package.json index c56611dd5..17e0f4434 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for modeling data and access policies in full-stack development with Next.js and Typescript", - "version": "0.3.4", + "version": "0.3.6", "author": { "name": "ZenStack Team" }, @@ -70,7 +70,7 @@ "vscode:package": "vsce package --no-dependencies", "clean": "rimraf bundle", "build": "pnpm langium:generate && tsc --noEmit && pnpm bundle && cp -r src/res/* bundle/res/", - "bundle": "npm run clean && node build/bundle.js", + "bundle": "npm run clean && node build/bundle.js --minify", "bundle-watch": "node build/bundle.js --watch", "ts:watch": "tsc --watch --noEmit", "tsc-alias:watch": "tsc-alias --watch", @@ -83,14 +83,19 @@ }, "dependencies": { "@zenstackhq/internal": "workspace:*", + "async-exit-hook": "^2.0.1", "change-case": "^4.1.2", "chevrotain": "^9.1.0", "colors": "1.4.0", "commander": "^8.3.0", + "cuid": "^2.1.8", "langium": "^0.5.0", + "mixpanel": "^0.17.0", + "node-machine-id": "^1.1.12", "pluralize": "^8.0.0", "prisma": "^4.5.0", "promisify": "^0.0.3", + "sleep-promise": "^9.1.0", "ts-morph": "^16.0.0", "uuid": "^9.0.0", "vscode-jsonrpc": "^8.0.2", @@ -101,6 +106,7 @@ }, "devDependencies": { "@prisma/internals": "^4.5.0", + "@types/async-exit-hook": "^2.0.0", "@types/jest": "^29.2.0", "@types/node": "^14.18.32", "@types/pluralize": "^0.0.29", @@ -110,6 +116,7 @@ "@typescript-eslint/eslint-plugin": "^5.42.0", "@typescript-eslint/parser": "^5.42.0", "concurrently": "^7.4.0", + "dotenv": "^16.0.3", "esbuild": "^0.15.12", "eslint": "^8.27.0", "jest": "^29.2.1", diff --git a/packages/schema/src/cli/cli-error.ts b/packages/schema/src/cli/cli-error.ts new file mode 100644 index 000000000..bf65c26a4 --- /dev/null +++ b/packages/schema/src/cli/cli-error.ts @@ -0,0 +1,4 @@ +/** + * Indicating an error during CLI execution + */ +export class CliError extends Error {} diff --git a/packages/schema/src/cli/cli-util.ts b/packages/schema/src/cli/cli-util.ts index 8c08aac62..ec1bbb283 100644 --- a/packages/schema/src/cli/cli-util.ts +++ b/packages/schema/src/cli/cli-util.ts @@ -10,6 +10,7 @@ import { ZenStackGenerator } from '../generator'; import { URI } from 'vscode-uri'; import { GENERATED_CODE_PATH } from '../generator/constants'; import { Context, GeneratorError } from '../generator/types'; +import { CliError } from './cli-error'; /** * Loads a zmodel document from a file. @@ -26,12 +27,12 @@ export async function loadDocument( console.error( colors.yellow(`Please choose a file with extension: ${extensions}.`) ); - process.exit(1); + throw new CliError('invalid schema file'); } if (!fs.existsSync(fileName)) { console.error(colors.red(`File ${fileName} does not exist.`)); - process.exit(1); + throw new CliError('schema file does not exist'); } // load standard library @@ -69,7 +70,7 @@ export async function loadDocument( ) ); } - process.exit(1); + throw new CliError('schema validation errors'); } return document.parseResult.value as Model; @@ -99,7 +100,7 @@ export async function runGenerator( } catch (err) { if (err instanceof GeneratorError) { console.error(colors.red(err.message)); - process.exit(1); + throw new CliError(err.message); } } } diff --git a/packages/schema/src/cli/index.ts b/packages/schema/src/cli/index.ts index b7dbcf37e..408bd153c 100644 --- a/packages/schema/src/cli/index.ts +++ b/packages/schema/src/cli/index.ts @@ -6,159 +6,206 @@ import { execSync } from '../utils/exec-utils'; import { paramCase } from 'change-case'; import path from 'path'; import { runGenerator } from './cli-util'; +import telemetry from '../telemetry'; +import { CliError } from './cli-error'; export const generateAction = async (options: { schema: string; }): Promise => { - await runGenerator(options); + await telemetry.trackSpan( + 'cli:command:start', + 'cli:command:complete', + 'cli:command:error', + { command: 'generate' }, + () => runGenerator(options) + ); }; function prismaAction(prismaCmd: string): (...args: any[]) => Promise { return async (options: any, command: Command) => { - const optStr = Array.from(Object.entries(options)) - .map(([k, v]) => { - let optVal = v; - if (k === 'schema') { - optVal = path.join(path.dirname(v), 'schema.prisma'); + await telemetry.trackSpan( + 'cli:command:start', + 'cli:command:complete', + 'cli:command:error', + { + command: prismaCmd + ? +(prismaCmd + ' ' + command.name()) + : command.name(), + }, + async () => { + const optStr = Array.from(Object.entries(options)) + .map(([k, v]) => { + let optVal = v; + if (k === 'schema') { + optVal = path.join( + path.dirname(v), + 'schema.prisma' + ); + } + return ( + '--' + + paramCase(k) + + (typeof optVal === 'string' ? ` ${optVal}` : '') + ); + }) + .join(' '); + + // regenerate prisma schema first + await runGenerator(options, ['prisma'], false); + + const prismaExec = `npx prisma ${prismaCmd} ${command.name()} ${optStr}`; + console.log(prismaExec); + try { + execSync(prismaExec); + } catch { + telemetry.track('cli:command:error', { + command: prismaCmd, + }); + console.error( + colors.red( + 'Prisma command failed to execute. See errors above.' + ) + ); + throw new CliError('prisma command run error'); } - return ( - '--' + - paramCase(k) + - (typeof optVal === 'string' ? ` ${optVal}` : '') - ); - }) - .join(' '); - - // regenerate prisma schema first - await runGenerator(options, ['prisma'], false); - - const prismaExec = `npx prisma ${prismaCmd} ${command.name()} ${optStr}`; - console.log(prismaExec); - try { - execSync(prismaExec); - } catch { - console.error( - colors.red( - 'Prisma command failed to execute. See errors above.' - ) - ); - process.exit(1); - } + } + ); }; } -export default function (): void { - const program = new Command('zenstack'); +export default async function (): Promise { + // try { + await telemetry.trackSpan( + 'cli:start', + 'cli:complete', + 'cli:error', + { args: process.argv }, + async () => { + const program = new Command('zenstack'); + + program.version( + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../../package.json').version, + '-v --version', + 'display CLI version' + ); - program.version( - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('../../package.json').version, - '-v --version', - 'display CLI version' - ); + const schemaExtensions = + ZModelLanguageMetaData.fileExtensions.join(', '); + + program + .description( + `${colors.bold.blue( + 'ζ' + )} ZenStack simplifies fullstack development by generating backend services and Typescript clients from a data model.\n\nDocumentation: https://go.zenstack.dev/doc.` + ) + .showHelpAfterError() + .showSuggestionAfterError(); + + const schemaOption = new Option( + '--schema ', + `schema file (with extension ${schemaExtensions})` + ).default('./zenstack/schema.zmodel'); + + //#region wraps Prisma commands + + program + .command('generate') + .description( + 'generates RESTful API and Typescript client for your data model' + ) + .addOption(schemaOption) + .action(generateAction); + + const migrate = program + .command('migrate') + .description( + `wraps Prisma's ${colors.cyan('migrate')} command` + ); + + migrate + .command('dev') + .description( + `alias for ${colors.cyan( + 'prisma migrate dev' + )}\nCreate a migration, apply it to the database, generate db client.` + ) + .addOption(schemaOption) + .option( + '--create-only', + 'Create a migration without applying it' + ) + .option('-n --name ', 'Name the migration') + .option('--skip-seed', 'Skip triggering seed') + .action(prismaAction('migrate')); + + migrate + .command('reset') + .description( + `alias for ${colors.cyan( + 'prisma migrate reset' + )}\nReset your database and apply all migrations.` + ) + .addOption(schemaOption) + .option('--force', 'Skip the confirmation prompt') + .action(prismaAction('migrate')); + + migrate + .command('deploy') + .description( + `alias for ${colors.cyan( + 'prisma migrate deploy' + )}\nApply pending migrations to the database in production/staging.` + ) + .addOption(schemaOption) + .action(prismaAction('migrate')); + + migrate + .command('status') + .description( + `alias for ${colors.cyan( + 'prisma migrate status' + )}\nCheck the status of migrations in the production/staging database.` + ) + .addOption(schemaOption) + .action(prismaAction('migrate')); + + const db = program + .command('db') + .description(`wraps Prisma's ${colors.cyan('db')} command`); + + db.command('push') + .description( + `alias for ${colors.cyan( + 'prisma db push' + )}\nPush the Prisma schema state to the database.` + ) + .addOption(schemaOption) + .option('--accept-data-loss', 'Ignore data loss warnings') + .action(prismaAction('db')); + + program + .command('studio') + .description( + `wraps Prisma's ${colors.cyan( + 'studio' + )} command. Browse your data with Prisma Studio.` + ) + .addOption(schemaOption) + .option('-p --port ', 'Port to start Studio in') + .option('-b --browser ', 'Browser to open Studio in') + .option( + '-n --hostname', + 'Hostname to bind the Express server to' + ) + .action(prismaAction('')); + + //#endregion - const schemaExtensions = ZModelLanguageMetaData.fileExtensions.join(', '); - - program - .description( - `${colors.bold.blue( - 'ζ' - )} ZenStack simplifies fullstack development by generating backend services and Typescript clients from a data model.\n\nDocumentation: https://go.zenstack.dev/doc.` - ) - .showHelpAfterError() - .showSuggestionAfterError(); - - const schemaOption = new Option( - '--schema ', - `schema file (with extension ${schemaExtensions})` - ).default('./zenstack/schema.zmodel'); - - //#region wraps Prisma commands - - program - .command('generate') - .description( - 'generates RESTful API and Typescript client for your data model' - ) - .addOption(schemaOption) - .action(generateAction); - - const migrate = program - .command('migrate') - .description(`wraps Prisma's ${colors.cyan('migrate')} command`); - - migrate - .command('dev') - .description( - `alias for ${colors.cyan( - 'prisma migrate dev' - )}\nCreate a migration, apply it to the database, generate db client.` - ) - .addOption(schemaOption) - .option('--create-only', 'Create a migration without applying it') - .option('-n --name ', 'Name the migration') - .option('--skip-seed', 'Skip triggering seed') - .action(prismaAction('migrate')); - - migrate - .command('reset') - .description( - `alias for ${colors.cyan( - 'prisma migrate reset' - )}\nReset your database and apply all migrations.` - ) - .addOption(schemaOption) - .option('--force', 'Skip the confirmation prompt') - .action(prismaAction('migrate')); - - migrate - .command('deploy') - .description( - `alias for ${colors.cyan( - 'prisma migrate deploy' - )}\nApply pending migrations to the database in production/staging.` - ) - .addOption(schemaOption) - .action(prismaAction('migrate')); - - migrate - .command('status') - .description( - `alias for ${colors.cyan( - 'prisma migrate status' - )}\nCheck the status of migrations in the production/staging database.` - ) - .addOption(schemaOption) - .action(prismaAction('migrate')); - - const db = program - .command('db') - .description(`wraps Prisma's ${colors.cyan('db')} command`); - - db.command('push') - .description( - `alias for ${colors.cyan( - 'prisma db push' - )}\nPush the Prisma schema state to the database.` - ) - .addOption(schemaOption) - .option('--accept-data-loss', 'Ignore data loss warnings') - .action(prismaAction('db')); - - program - .command('studio') - .description( - `wraps Prisma's ${colors.cyan( - 'studio' - )} command. Browse your data with Prisma Studio.` - ) - .addOption(schemaOption) - .option('-p --port ', 'Port to start Studio in') - .option('-b --browser ', 'Browser to open Studio in') - .option('-n --hostname', 'Hostname to bind the Express server to') - .action(prismaAction('')); - - //#endregion - - program.parse(process.argv); + // handle errors explicitly to ensure telemetry + program.exitOverride(); + + await program.parseAsync(process.argv); + } + ); } diff --git a/packages/schema/src/generator/index.ts b/packages/schema/src/generator/index.ts index 20f7b35ad..23265586f 100644 --- a/packages/schema/src/generator/index.ts +++ b/packages/schema/src/generator/index.ts @@ -8,6 +8,7 @@ import ReactHooksGenerator from './react-hooks'; import NextAuthGenerator from './next-auth'; import { TypescriptCompilation } from './tsc'; import FieldConstraintGenerator from './field-constraint'; +import telemetry from '../telemetry'; /** * ZenStack code generator @@ -57,7 +58,16 @@ export class ZenStackGenerator { ) { continue; } - await generator.generate(context); + + await telemetry.trackSpan( + 'cli:generator:start', + 'cli:generator:complete', + 'cli:generator:error', + { + generator: generator.name, + }, + () => generator.generate(context) + ); } console.log( diff --git a/packages/schema/src/global.d.ts b/packages/schema/src/global.d.ts new file mode 100644 index 000000000..16a5211ae --- /dev/null +++ b/packages/schema/src/global.d.ts @@ -0,0 +1,3 @@ +declare module 'env' { + export const TELEMETRY_TRACKING_TOKEN: string; +} diff --git a/packages/schema/src/telemetry.ts b/packages/schema/src/telemetry.ts new file mode 100644 index 000000000..ca4eeda13 --- /dev/null +++ b/packages/schema/src/telemetry.ts @@ -0,0 +1,110 @@ +import { Mixpanel, init } from 'mixpanel'; +import { TELEMETRY_TRACKING_TOKEN } from 'env'; +import { machineIdSync } from 'node-machine-id'; +import cuid from 'cuid'; +import * as os from 'os'; +import sleep from 'sleep-promise'; +import exitHook from 'async-exit-hook'; + +/** + * Telemetry events + */ +export type TelemetryEvents = + | 'cli:start' + | 'cli:complete' + | 'cli:error' + | 'cli:command:start' + | 'cli:command:complete' + | 'cli:command:error' + | 'cli:generator:start' + | 'cli:generator:complete' + | 'cli:generator:error'; + +/** + * Utility class for sending telemetry + */ +export class Telemetry { + private readonly mixpanel: Mixpanel | undefined; + private readonly hostId = machineIdSync(); + private readonly sessionid = cuid(); + private readonly trackingToken = TELEMETRY_TRACKING_TOKEN; + private readonly _os = os.platform(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + private readonly version = require('../package.json').version; + private exitWait = 200; + + constructor() { + if (process.env.DO_NOT_TRACK !== '1' && this.trackingToken) { + this.mixpanel = init(this.trackingToken, { + geolocate: true, + }); + } + + exitHook(async (callback) => { + if (this.mixpanel) { + // a small delay to ensure telemetry is sent + await sleep(this.exitWait); + } + callback(); + }); + + exitHook.uncaughtExceptionHandler(async (err) => { + this.track('cli:error', { + message: err.message, + stack: err.stack, + }); + if (this.mixpanel) { + // a small delay to ensure telemetry is sent + await sleep(this.exitWait); + } + process.exit(1); + }); + } + + track(event: TelemetryEvents, properties: Record = {}) { + if (this.mixpanel) { + const payload = { + distinct_id: this.hostId, + session: this.sessionid, + time: new Date(), + $os: this._os, + nodeVersion: process.version, + version: this.version, + ...properties, + }; + this.mixpanel.track(event, payload); + } + } + + async trackSpan( + startEvent: TelemetryEvents, + completeEvent: TelemetryEvents, + errorEvent: TelemetryEvents, + properties: Record, + action: () => Promise | void + ) { + this.track(startEvent, properties); + const start = Date.now(); + let success = true; + try { + await Promise.resolve(action()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } 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, + }); + } + } +} + +export default new Telemetry(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8abbbb208..8a388b076 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,7 @@ importers: packages/schema: specifiers: '@prisma/internals': ^4.5.0 + '@types/async-exit-hook': ^2.0.0 '@types/jest': ^29.2.0 '@types/node': ^14.18.32 '@types/pluralize': ^0.0.29 @@ -80,20 +81,26 @@ importers: '@typescript-eslint/eslint-plugin': ^5.42.0 '@typescript-eslint/parser': ^5.42.0 '@zenstackhq/internal': workspace:* + async-exit-hook: ^2.0.1 change-case: ^4.1.2 chevrotain: ^9.1.0 colors: 1.4.0 commander: ^8.3.0 concurrently: ^7.4.0 + cuid: ^2.1.8 + dotenv: ^16.0.3 esbuild: ^0.15.12 eslint: ^8.27.0 jest: ^29.2.1 langium: ^0.5.0 langium-cli: ^0.5.0 + mixpanel: ^0.17.0 + node-machine-id: ^1.1.12 pluralize: ^8.0.0 prisma: ^4.5.0 promisify: ^0.0.3 rimraf: ^3.0.2 + sleep-promise: ^9.1.0 tmp: ^0.2.1 ts-jest: ^29.0.3 ts-morph: ^16.0.0 @@ -110,14 +117,19 @@ importers: vscode-uri: ^3.0.6 dependencies: '@zenstackhq/internal': link:../internal + async-exit-hook: 2.0.1 change-case: 4.1.2 chevrotain: 9.1.0 colors: 1.4.0 commander: 8.3.0 + cuid: 2.1.8 langium: 0.5.0 + mixpanel: 0.17.0 + node-machine-id: 1.1.12 pluralize: 8.0.0 prisma: 4.5.0 promisify: 0.0.3 + sleep-promise: 9.1.0 ts-morph: 16.0.0 uuid: 9.0.0 vscode-jsonrpc: 8.0.2 @@ -127,6 +139,7 @@ importers: vscode-uri: 3.0.6 devDependencies: '@prisma/internals': 4.5.0 + '@types/async-exit-hook': 2.0.0 '@types/jest': 29.2.0 '@types/node': 14.18.32 '@types/pluralize': 0.0.29 @@ -136,6 +149,7 @@ importers: '@typescript-eslint/eslint-plugin': 5.42.0_ofgjrzjuekeo7s3hdyz2yuzw34 '@typescript-eslint/parser': 5.42.0_rmayb2veg2btbq6mbmnyivgasy concurrently: 7.4.0 + dotenv: 16.0.3 esbuild: 0.15.12 eslint: 8.27.0 jest: 29.2.1_4f2ldd7um3b3u4eyvetyqsphze @@ -1644,6 +1658,10 @@ packages: resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==} dev: true + /@types/async-exit-hook/2.0.0: + resolution: {integrity: sha512-RNjIyjnVZdcP5a1zeIPb5c0hq2nbJc/NOCLNKUAqeCw+J5z2zMcINISn9wybCWhczHnUu3VSUFy7ZCO6ir4ZRw==} + dev: true + /@types/babel__core/7.1.19: resolution: {integrity: sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==} dependencies: @@ -1977,7 +1995,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: true /aggregate-error/3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} @@ -2095,6 +2112,11 @@ packages: engines: {node: '>=8'} dev: true + /async-exit-hook/2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + dev: false + /async/3.2.4: resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} dev: true @@ -2877,6 +2899,11 @@ packages: engines: {node: '>=12'} dev: true + /dotenv/16.0.3: + resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} + engines: {node: '>=12'} + dev: true + /electron-to-chromium/1.4.284: resolution: {integrity: sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==} @@ -3656,6 +3683,16 @@ packages: - supports-color dev: true + /https-proxy-agent/5.0.0: + resolution: {integrity: sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: false + /https-proxy-agent/5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -5044,6 +5081,15 @@ packages: resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==} dev: true + /mixpanel/0.17.0: + resolution: {integrity: sha512-DY5WeOy/hmkPrNiiZugJpWR0iMuOwuj1a3u0bgwB2eUFRV6oIew/pIahhpawdbNjb+Bye4a8ID3gefeNPvL81g==} + engines: {node: '>=10.0'} + dependencies: + https-proxy-agent: 5.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /mkdirp-classic/0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} dev: true @@ -5170,6 +5216,10 @@ packages: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} dev: true + /node-machine-id/1.1.12: + resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} + dev: false + /node-releases/2.0.6: resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} diff --git a/samples/todo/package.json b/samples/todo/package.json index 1acd9958d..e751bc195 100644 --- a/samples/todo/package.json +++ b/samples/todo/package.json @@ -1,6 +1,6 @@ { "name": "todo", - "version": "0.3.4", + "version": "0.3.6", "private": true, "scripts": { "dev": "next dev", diff --git a/samples/todo/pages/create-space.tsx b/samples/todo/pages/create-space.tsx index a438ee7ce..fd0dc2fc6 100644 --- a/samples/todo/pages/create-space.tsx +++ b/samples/todo/pages/create-space.tsx @@ -40,7 +40,7 @@ const CreateSpace: NextPage = () => { router.push(`/space/${space.slug}`); } }, 2000); - } catch (err) { + } catch (err: any) { console.error(err); if ( (err as HooksError).info?.code === @@ -48,7 +48,9 @@ const CreateSpace: NextPage = () => { ) { toast.error('Space slug alread in use'); } else { - toast.error(`Error occurred: ${err}`); + toast.error( + `Error occurred: ${err.info?.message || err.message}` + ); } } }; diff --git a/tests/integration/tests/utils.ts b/tests/integration/tests/utils.ts index bc93d8850..674692f6b 100644 --- a/tests/integration/tests/utils.ts +++ b/tests/integration/tests/utils.ts @@ -9,7 +9,11 @@ import { NextApiHandler } from 'next/types'; import supertest from 'supertest'; export function run(cmd: string) { - execSync(cmd, { stdio: 'pipe', encoding: 'utf-8' }); + execSync(cmd, { + stdio: 'pipe', + encoding: 'utf-8', + env: { ...process.env, DO_NOT_TRACK: '1' }, + }); } export async function setup(schemaFile: string) { From bb9b98f3a6729c3936e6b5626bd19b9ae556829e Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Nov 2022 18:26:36 +0800 Subject: [PATCH 3/8] fix: fix esbuild plugin --- packages/schema/.env | 1 + packages/schema/build/env-plugin.js | 40 +++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 packages/schema/.env diff --git a/packages/schema/.env b/packages/schema/.env new file mode 100644 index 000000000..f2199d46b --- /dev/null +++ b/packages/schema/.env @@ -0,0 +1 @@ +TELEMETRY_TRACKING_TOKEN= diff --git a/packages/schema/build/env-plugin.js b/packages/schema/build/env-plugin.js index af79416b3..b44bb5c27 100644 --- a/packages/schema/build/env-plugin.js +++ b/packages/schema/build/env-plugin.js @@ -10,26 +10,46 @@ module.exports = { setup(build) { function _findEnvFile(dir) { - if (!fs.existsSync(dir)) return false; - return fs.existsSync(`${dir}/.env.${ENV}`) - ? `${dir}/.env.${ENV}` - : fs.existsSync(`${dir}/.env`) - ? `${dir}/.env` - : _findEnvFile(path.resolve(dir, '../')); + if (!fs.existsSync(dir)) return undefined; + + if (fs.existsSync(`${dir}/.env.${ENV}`)) { + return `${dir}/.env.${ENV}`; + } else if (fs.existsSync(`${dir}/.env`)) { + return `${dir}/.env`; + } else { + const next = path.resolve(dir, '../'); + if (next === dir) { + // at root now, exit + return undefined; + } else { + return _findEnvFile(next); + } + } } build.onResolve({ filter: /^env$/ }, async (args) => { + const envPath = _findEnvFile(args.resolveDir); return { - path: _findEnvFile(args.resolveDir), + path: args.path, namespace: 'env-ns', + pluginData: { + ...args.pluginData, + envPath, + }, }; }); build.onLoad({ filter: /.*/, namespace: 'env-ns' }, async (args) => { // read in .env file contents and combine with regular .env: - let data = await fs.promises.readFile(args.path, 'utf8'); - const buf = Buffer.from(data); - const config = require('dotenv').parse(buf); + let config = {}; + if (args.pluginData && args.pluginData.envPath) { + let data = await fs.promises.readFile( + args.pluginData.envPath, + 'utf8' + ); + const buf = Buffer.from(data); + config = require('dotenv').parse(buf); + } return { contents: JSON.stringify({ ...process.env, ...config }), From 2454d8544252cf6089c51ac97bdea163f400e0b9 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Nov 2022 18:30:54 +0800 Subject: [PATCH 4/8] chore: fix token reading logic --- packages/schema/build/env-plugin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schema/build/env-plugin.js b/packages/schema/build/env-plugin.js index b44bb5c27..78b17385d 100644 --- a/packages/schema/build/env-plugin.js +++ b/packages/schema/build/env-plugin.js @@ -52,7 +52,7 @@ module.exports = { } return { - contents: JSON.stringify({ ...process.env, ...config }), + contents: JSON.stringify({ ...config, ...process.env }), loader: 'json', }; }); From 234f5d80dd6442fe970d2620698bd3481269aaeb Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Nov 2022 18:41:39 +0800 Subject: [PATCH 5/8] fix: set token secret in github action --- .github/workflows/build-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index f8d2b40fe..1708c210e 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -3,6 +3,9 @@ name: CI +env: + TELEMETRY_TRACKING_TOKEN: ${{ secrets.TELEMETRY_TRACKING_TOKEN }} + on: push: branches: ['dev', 'main'] @@ -30,5 +33,6 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'pnpm' - run: pnpm install --frozen-lockfile + - run: echo "TELEMETRY_TRACKING_TOKEN:" $TELEMETRY_TRACKING_TOKEN - run: pnpm run build - run: pnpm run test From 5df95acfde06b066e3bc49d7ee769003c5b2598a Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Nov 2022 18:43:33 +0800 Subject: [PATCH 6/8] chore: remove log in action --- .github/workflows/build-test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 1708c210e..09d11a0bd 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -33,6 +33,5 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'pnpm' - run: pnpm install --frozen-lockfile - - run: echo "TELEMETRY_TRACKING_TOKEN:" $TELEMETRY_TRACKING_TOKEN - run: pnpm run build - run: pnpm run test From b533c8c87ceb03b302c3adf0b3efa59f5a0987b9 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Nov 2022 18:57:51 +0800 Subject: [PATCH 7/8] fix: error in telemetry --- packages/schema/src/cli/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schema/src/cli/index.ts b/packages/schema/src/cli/index.ts index 408bd153c..d988d13cd 100644 --- a/packages/schema/src/cli/index.ts +++ b/packages/schema/src/cli/index.ts @@ -29,7 +29,7 @@ function prismaAction(prismaCmd: string): (...args: any[]) => Promise { 'cli:command:error', { command: prismaCmd - ? +(prismaCmd + ' ' + command.name()) + ? prismaCmd + ' ' + command.name() : command.name(), }, async () => { From f723e6153ebedab2edeef1534ad17c7bec4ad29f Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 18 Nov 2022 18:58:59 +0800 Subject: [PATCH 8/8] chore: bump version --- package.json | 2 +- packages/internal/package.json | 2 +- packages/runtime/package.json | 2 +- packages/schema/package.json | 2 +- samples/todo/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1b6b7169e..29ea676ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "0.3.6", + "version": "0.3.7", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/internal/package.json b/packages/internal/package.json index 6b3c7a4aa..1d70c87f6 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/internal", - "version": "0.3.6", + "version": "0.3.7", "displayName": "ZenStack Internal Library", "description": "ZenStack internal runtime library. This package is for supporting runtime functionality of ZenStack and not supposed to be used directly.", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 01fd54aa8..e505b919b 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "0.3.6", + "version": "0.3.7", "description": "This package contains runtime library for consuming client and server side code generated by ZenStack.", "repository": { "type": "git", diff --git a/packages/schema/package.json b/packages/schema/package.json index 17e0f4434..10299ff74 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for modeling data and access policies in full-stack development with Next.js and Typescript", - "version": "0.3.6", + "version": "0.3.7", "author": { "name": "ZenStack Team" }, diff --git a/samples/todo/package.json b/samples/todo/package.json index e751bc195..15d5e2965 100644 --- a/samples/todo/package.json +++ b/samples/todo/package.json @@ -1,6 +1,6 @@ { "name": "todo", - "version": "0.3.6", + "version": "0.3.7", "private": true, "scripts": { "dev": "next dev",