From 6015acb914690a389b8fb65b135e3a62d551af82 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 14 Mar 2025 11:40:56 +0100 Subject: [PATCH 1/2] added basic cli telemetry --- app/package.json | 1 + app/src/cli/commands/create/create.ts | 36 ++++++++++++- app/src/cli/index.ts | 21 ++++++-- app/src/cli/utils/telemetry.ts | 75 +++++++++++++++++++++++++++ app/src/core/env.ts | 11 +++- bun.lock | 3 ++ 6 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 app/src/cli/utils/telemetry.ts diff --git a/app/package.json b/app/package.json index 442c9a0a..fad1abbb 100644 --- a/app/package.json +++ b/app/package.json @@ -88,6 +88,7 @@ "postcss": "^8.5.3", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", + "posthog-js-lite": "^3.4.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts index f1f9a5e2..299afbda 100644 --- a/app/src/cli/commands/create/create.ts +++ b/app/src/cli/commands/create/create.ts @@ -9,6 +9,7 @@ import { env } from "core"; import color from "picocolors"; import { overridePackageJson, updateBkndPackages } from "./npm"; import { type Template, templates } from "./templates"; +import { createScoped, flush } from "cli/utils/telemetry"; const config = { types: { @@ -48,8 +49,16 @@ function errorOutro() { process.exit(1); } +async function onExit() { + await flush(); +} + async function action(options: { template?: string; dir?: string; integration?: string }) { console.log(""); + const $t = createScoped("create"); + $t.capture("start", { + options, + }); const downloadOpts = { dir: options.dir || "./", @@ -68,6 +77,7 @@ async function action(options: { template?: string; dir?: string; integration?: })(), ); + $t.properties.at = "dir"; if (!options.dir) { const dir = await $p.text({ message: "Where to create your project?", @@ -75,24 +85,29 @@ async function action(options: { template?: string; dir?: string; integration?: initialValue: downloadOpts.dir, }); if ($p.isCancel(dir)) { + await onExit(); process.exit(1); } downloadOpts.dir = dir || "./"; } + $t.properties.at = "dir"; if (fs.existsSync(downloadOpts.dir)) { const clean = await $p.confirm({ message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`, initialValue: false, }); if ($p.isCancel(clean)) { + await onExit(); process.exit(1); } downloadOpts.clean = clean; + $t.properties.clean = clean; } + // don't track name for privacy let name = downloadOpts.dir.includes("/") ? downloadOpts.dir.split("/").pop() : downloadOpts.dir.replace(/[./]/g, ""); @@ -100,13 +115,17 @@ async function action(options: { template?: string; dir?: string; integration?: if (!name || name.length === 0) name = "bknd"; let template: Template | undefined; + if (options.template) { + $t.properties.at = "template"; template = templates.find((t) => t.key === options.template) as Template; if (!template) { + await onExit(); $p.log.error(`Template ${color.cyan(options.template)} not found`); process.exit(1); } } else { + $t.properties.at = "integration"; let integration: string | undefined = options.integration; if (!integration) { await $p.stream.info( @@ -128,8 +147,10 @@ async function action(options: { template?: string; dir?: string; integration?: }); if ($p.isCancel(type)) { + await onExit(); process.exit(1); } + $t.properties.type = type; const _integration = await $p.select({ message: `Which ${color.cyan(config.types[type])} do you want to continue with?`, @@ -139,11 +160,14 @@ async function action(options: { template?: string; dir?: string; integration?: })) as any, }); if ($p.isCancel(_integration)) { + await onExit(); process.exit(1); } integration = String(_integration); + $t.properties.integration = integration; } if (!integration) { + await onExit(); $p.log.error("No integration selected"); process.exit(1); } @@ -152,15 +176,18 @@ async function action(options: { template?: string; dir?: string; integration?: const choices = templates.filter((t) => t.integration === integration); if (choices.length === 0) { + await onExit(); $p.log.error(`No templates found for "${color.cyan(String(integration))}"`); process.exit(1); } else if (choices.length > 1) { + $t.properties.at = "template"; const selected_template = await $p.select({ message: "Pick a template", options: choices.map((t) => ({ value: t.key, label: t.title, hint: t.description })), }); if ($p.isCancel(selected_template)) { + await onExit(); process.exit(1); } @@ -170,10 +197,12 @@ async function action(options: { template?: string; dir?: string; integration?: } } if (!template) { + await onExit(); $p.log.error("No template selected"); process.exit(1); } + $t.properties.template = template.key; const ctx = { template, dir: downloadOpts.dir, name }; { @@ -182,6 +211,8 @@ async function action(options: { template?: string; dir?: string; integration?: $p.log.warn(color.dim("[DEV] Using local ref: ") + color.yellow(given)); }, }); + $t.properties.ref = ref; + $t.capture("used"); const prefix = template.ref === true @@ -191,7 +222,6 @@ async function action(options: { template?: string; dir?: string; integration?: : ""; const url = `${template.path}${prefix}`; - //console.log("url", url); const s = $p.spinner(); await s.start("Downloading template..."); try { @@ -234,8 +264,10 @@ async function action(options: { template?: string; dir?: string; integration?: }); if ($p.isCancel(install)) { + await onExit(); process.exit(1); } else if (install) { + $t.properties.install = true; const install_cmd = template.scripts?.install || "npm install"; const s = $p.spinner(); @@ -259,6 +291,7 @@ async function action(options: { template?: string; dir?: string; integration?: await template.postinstall(ctx); } } else { + $t.properties.install = false; await $p.stream.warn( (async function* () { yield* typewriter( @@ -291,5 +324,6 @@ If you need help, check ${color.cyan("https://docs.bknd.io")} or join our Discor })(), ); + $t.capture("complete"); $p.outro(color.green("Setup complete.")); } diff --git a/app/src/cli/index.ts b/app/src/cli/index.ts index d749df81..352cdd47 100644 --- a/app/src/cli/index.ts +++ b/app/src/cli/index.ts @@ -4,21 +4,36 @@ import { Command } from "commander"; import color from "picocolors"; import * as commands from "./commands"; import { getVersion } from "./utils/sys"; +import { capture, flush, init } from "cli/utils/telemetry"; const program = new Command(); export async function main() { + await init(); + capture("start"); + const version = await getVersion(); program .name("bknd") .description(color.yellowBright("⚡") + " bknd cli " + color.bold(color.cyan(`v${version}`))) - .version(version); + .version(version) + .hook("preAction", (thisCommand, actionCommand) => { + capture(`cmd_${actionCommand.name()}`); + }) + .hook("postAction", async () => { + await flush(); + }); // register commands for (const command of Object.values(commands)) { command(program); } - program.parse(); + await program.parseAsync(); } -main().then(null).catch(console.error); +main() + .then(null) + .catch(async (e) => { + await flush(); + console.error(e); + }); diff --git a/app/src/cli/utils/telemetry.ts b/app/src/cli/utils/telemetry.ts new file mode 100644 index 00000000..ce819b39 --- /dev/null +++ b/app/src/cli/utils/telemetry.ts @@ -0,0 +1,75 @@ +import { PostHog } from "posthog-js-lite"; +import { getVersion } from "cli/utils/sys"; +import { $console, env } from "core"; + +type Properties = { [p: string]: any }; + +let posthog: PostHog | null = null; +let version: string | null = null; + +const enabled = env("cli_telemetry"); + +export async function init() { + try { + if (!enabled) { + $console.debug("Telemetry disabled"); + return; + } + + $console.debug("Init telemetry"); + if (!posthog) { + posthog = new PostHog(process.env.POSTHOG_KEY!, { + host: process.env.POSTHOG_HOST!, + disabled: !enabled, + }); + } + version = await getVersion(); + } catch (e) { + $console.debug("Failed to initialize telemetry", e); + } +} + +export function client(): PostHog { + if (!posthog) { + throw new Error("PostHog client not initialized. Call init() first."); + } + + return posthog; +} + +export function capture(event: string, properties: Properties = {}): void { + try { + if (!enabled) return; + + const name = `cli_${event}`; + const props = { + ...properties, + version: version!, + }; + $console.debug("Capture", name, props); + client().capture(name, props); + } catch (e) { + $console.debug("Failed to capture telemetry", e); + } +} + +export function createScoped(scope: string, p: Properties = {}) { + const properties = p; + const _capture = (event: string, props: Properties = {}) => { + return capture(`${scope}_${event}`, { ...properties, ...props }); + }; + return { capture: _capture, properties }; +} + +export async function flush() { + try { + if (!enabled) return; + + $console.debug("Flush telemetry"); + if (posthog) { + await posthog.flush(); + } + } catch (e) { + $console.debug("Failed to flush telemetry", e); + } +} diff --git a/app/src/core/env.ts b/app/src/core/env.ts index 0dd60ec0..08e29021 100644 --- a/app/src/core/env.ts +++ b/app/src/core/env.ts @@ -1,7 +1,7 @@ export type Env = {}; -export const is_toggled = (given: unknown): boolean => { - return typeof given === "string" ? [1, "1", "true"].includes(given) : Boolean(given); +export const is_toggled = (given: unknown, fallback?: boolean): boolean => { + return typeof given === "string" ? [1, "1", "true"].includes(given) : Boolean(fallback); }; export function isDebug(): boolean { @@ -34,6 +34,13 @@ const envs = { return typeof v === "string" ? v : undefined; }, }, + // cli telemetry + cli_telemetry: { + key: "BKND_CLI_TELEMETRY", + validate: (v: unknown) => { + return is_toggled(v, true); + }, + }, // module manager debug: { modules_debug: { key: "BKND_MODULES_DEBUG", diff --git a/bun.lock b/bun.lock index 6af2967a..111bd7d4 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,7 @@ "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", "picocolors": "^1.1.1", + "posthog-js-lite": "^3.4.2", "radix-ui": "^1.1.3", "swr": "^2.3.3", }, @@ -2694,6 +2695,8 @@ "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="], + "posthog-js-lite": ["posthog-js-lite@3.4.2", "", {}, "sha512-YYXZORZHDmgD4OnSCyfRgPnBdHPxFDtgybBGmrQV5fOyCd0Bc12fYs2KlGwrRkIs2jYftT3CdfS0jTNo5RjB7Q=="], + "prelude-ls": ["prelude-ls@1.1.2", "", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="], "prettier": ["prettier@1.19.1", "", { "bin": { "prettier": "./bin-prettier.js" } }, "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew=="], From 181443b32054704b513ba2527ed9563a69ba92ee Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 14 Mar 2025 11:45:02 +0100 Subject: [PATCH 2/2] fix is_toggled test --- app/__test__/core/env.spec.ts | 1 + app/src/core/env.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/__test__/core/env.spec.ts b/app/__test__/core/env.spec.ts index d4c5ba33..b3d13ab8 100644 --- a/app/__test__/core/env.spec.ts +++ b/app/__test__/core/env.spec.ts @@ -14,6 +14,7 @@ describe("env", () => { expect(is_toggled(1)).toBe(true); expect(is_toggled(0)).toBe(false); expect(is_toggled("anything else")).toBe(false); + expect(is_toggled(undefined, true)).toBe(true); }); test("env()", () => { diff --git a/app/src/core/env.ts b/app/src/core/env.ts index 08e29021..a32427c8 100644 --- a/app/src/core/env.ts +++ b/app/src/core/env.ts @@ -1,7 +1,7 @@ export type Env = {}; export const is_toggled = (given: unknown, fallback?: boolean): boolean => { - return typeof given === "string" ? [1, "1", "true"].includes(given) : Boolean(fallback); + return typeof given === "string" ? [1, "1", "true"].includes(given) : Boolean(given || fallback); }; export function isDebug(): boolean {