diff --git a/.github/workflows/cli-binaries.yml b/.github/workflows/cli-binaries.yml index b69a7ad..5405040 100644 --- a/.github/workflows/cli-binaries.yml +++ b/.github/workflows/cli-binaries.yml @@ -59,6 +59,9 @@ jobs: - name: Generate version file run: node scripts/generate-version.mjs + env: + # Public PostHog project token for anonymous CLI telemetry (repo variable). + RB_POSTHOG_KEY: ${{ vars.RB_POSTHOG_KEY }} - name: Compile binary run: | diff --git a/README.md b/README.md index f389317..dacef01 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,19 @@ rb update Self-replaces the binary in-place with checksum verification and automatic rollback if the new binary fails. +## Telemetry + +The CLI sends anonymous usage stats so we can see which commands matter and make it better. Each run reports one event: the command name, whether it succeeded, how long it took, the CLI version, and your OS. + +It never sends your file names, arguments, URLs, credentials, or account identity. The identifier is a random per-machine id, not tied to your account. + +It is off in CI automatically. To turn it off anywhere: + +```bash +rb telemetry off # or: rb telemetry status +export DO_NOT_TRACK=1 # or RENDOBAR_TELEMETRY=0 +``` + ## What is Rendobar? Rendobar is a serverless media processing platform. Run FFmpeg jobs and burn captions with one API call. Credit-based billing. MCP-native for AI agents. diff --git a/scripts/generate-version.mjs b/scripts/generate-version.mjs index fb98135..c3cb265 100644 --- a/scripts/generate-version.mjs +++ b/scripts/generate-version.mjs @@ -16,10 +16,17 @@ if (typeof pkg.version !== "string") { throw new Error(`package.json at ${pkgPath} has no string "version" field`); } +// Public PostHog project token for anonymous CLI telemetry. Injected at release +// build via RB_POSTHOG_KEY; empty in dev/source (telemetry stays disabled). +// A PostHog project token is a write-only ingestion key — safe to embed in a +// distributed binary, same as it is embedded in a web page. +const telemetryKey = process.env.RB_POSTHOG_KEY ?? ""; + const content = `// AUTO-GENERATED by scripts/generate-version.mjs — DO NOT EDIT. export const VERSION = ${JSON.stringify(pkg.version)}; +export const TELEMETRY_KEY = ${JSON.stringify(telemetryKey)}; `; mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, content); -console.log(`Wrote ${outPath} with VERSION="${pkg.version}"`); +console.log(`Wrote ${outPath} with VERSION="${pkg.version}"${telemetryKey ? " + telemetry key" : ""}`); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index e0a79aa..1dbe786 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from "bun:test"; import { COMMANDS, GROUP_ORDER, commandNames, toSubCommands } from "../registry.js"; describe("registry", () => { - it("lists the seven commands", () => { - expect(commandNames().sort()).toEqual(["doctor", "ffmpeg", "ffprobe", "login", "logout", "update", "whoami"]); + it("lists the eight commands", () => { + expect(commandNames().sort()).toEqual(["doctor", "ffmpeg", "ffprobe", "login", "logout", "telemetry", "update", "whoami"]); }); it("every command's group is in GROUP_ORDER", () => { for (const c of COMMANDS) expect(GROUP_ORDER).toContain(c.group); diff --git a/src/commands/telemetry.ts b/src/commands/telemetry.ts new file mode 100644 index 0000000..66e4eee --- /dev/null +++ b/src/commands/telemetry.ts @@ -0,0 +1,51 @@ +/** + * `rb telemetry [on|off|status]` -- Control anonymous usage telemetry. + * + * Anonymous by design: no files, arguments, URLs, credentials, or account + * identity are ever sent. See src/lib/telemetry.ts for exactly what is collected. + */ +import { defineCommand } from "citty"; +import pc from "picocolors"; +import { setTelemetryEnabled, telemetryStatus } from "../lib/telemetry.js"; + +export default defineCommand({ + meta: { name: "telemetry", description: "Control anonymous usage telemetry (on|off|status)" }, + args: { + action: { + type: "positional", + required: false, + description: "on | off | status (default: status)", + }, + }, + run({ args }) { + const action = (args.action ?? "status").toString().toLowerCase(); + + if (action === "on") { + setTelemetryEnabled(true); + process.stderr.write(` ${pc.green("✓")} Anonymous telemetry enabled. Thank you for helping improve Rendobar.\n`); + return; + } + if (action === "off") { + setTelemetryEnabled(false); + process.stderr.write(` ${pc.green("✓")} Telemetry disabled. Nothing further will be sent.\n`); + return; + } + if (action === "status") { + const s = telemetryStatus(); + const state = s.enabled ? pc.green("on") : pc.dim("off"); + process.stderr.write(` Telemetry: ${state}\n`); + if (s.optedOutByEnv) { + process.stderr.write(pc.dim(" Disabled by environment (DO_NOT_TRACK / RENDOBAR_TELEMETRY / CI).\n")); + } + if (!s.keyPresent) { + process.stderr.write(pc.dim(" No telemetry endpoint configured in this build.\n")); + } + process.stderr.write(pc.dim(` Anonymous id: ${s.anonymousId}\n`)); + process.stderr.write(pc.dim(" Anonymous only: no files, arguments, or credentials are collected.\n")); + return; + } + + process.stderr.write(pc.red(` Unknown action "${action}". Use: on | off | status\n`)); + process.exitCode = 2; + }, +}); diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts new file mode 100644 index 0000000..d65591d --- /dev/null +++ b/src/lib/telemetry.ts @@ -0,0 +1,173 @@ +// Anonymous, opt-out CLI telemetry. +// +// What it is: a single anonymous event per command run (which command, whether +// it succeeded, how long it took, CLI version, OS). That is all. It exists so we +// can see which commands matter and make the CLI better. +// +// What it is NOT: it never sends your file names, arguments, URLs, API keys, +// user id, org, or any file contents. The identifier is a random per-machine id, +// not tied to your account. +// +// Off switches (any one disables it): `rb telemetry off`, DO_NOT_TRACK=1, +// RENDOBAR_TELEMETRY=0, or CI environments (skipped automatically). +// +// Mirrors the platform's snake_case event convention. Kept self-contained per +// the cross-repo rule (the CLI can't import @rendobar/shared). + +import * as fs from "node:fs"; +import { getConfigDir } from "./auth.js"; +import { VERSION, TELEMETRY_KEY } from "../generated/version.js"; + +const TELEMETRY_FILE = "telemetry.json"; +// First-party reverse proxy (same host the dashboard uses). Overridable for dev. +const HOST = process.env.RENDOBAR_TELEMETRY_HOST ?? "https://e.rendobar.com"; +// Public write-only project token, injected at build. Env override for testing. +const KEY = process.env.RENDOBAR_TELEMETRY_KEY ?? TELEMETRY_KEY ?? ""; +// Bounded so a slow network never delays the user's command by more than this. +const FLUSH_TIMEOUT_MS = 1200; + +interface TelemetryState { + anonymousId: string; + enabled: boolean; + noticeShown: boolean; +} + +function statePath(): string { + return `${getConfigDir()}/${TELEMETRY_FILE}`; +} + +// A random, non-identifying id. Not derived from anything about the user or +// machine — just a stable key so repeat runs group together in aggregate. +function newAnonymousId(): string { + return `cli_anon_${crypto.randomUUID()}`; +} + +function readState(): TelemetryState { + try { + const raw: unknown = JSON.parse(fs.readFileSync(statePath(), "utf8")); + if (raw && typeof raw === "object") { + const r = raw as Record; + return { + anonymousId: + typeof r.anonymousId === "string" ? r.anonymousId : newAnonymousId(), + enabled: r.enabled !== false, // default on + noticeShown: r.noticeShown === true, + }; + } + } catch { + /* missing/corrupt -> fresh state */ + } + return { anonymousId: newAnonymousId(), enabled: true, noticeShown: false }; +} + +function writeState(state: TelemetryState): void { + try { + const dir = getConfigDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(statePath(), JSON.stringify(state, null, 2)); + try { + fs.chmodSync(statePath(), 0o600); + } catch { + /* Windows */ + } + } catch { + /* telemetry state is best-effort; never block the CLI */ + } +} + +// Env-level opt-outs. DO_NOT_TRACK is the community standard (consoledonottrack.com). +function optedOutByEnv(): boolean { + if (process.env.DO_NOT_TRACK && process.env.DO_NOT_TRACK !== "0") return true; + const flag = (process.env.RENDOBAR_TELEMETRY ?? "").toLowerCase(); + if (flag === "0" || flag === "false" || flag === "off" || flag === "no") return true; + if (process.env.RENDOBAR_NO_TELEMETRY || process.env.RENDOBAR_DISABLE_TELEMETRY) return true; + if (process.env.CI === "true") return true; // CI runs are noise, and not a person + return false; +} + +export function telemetryEnabled(): boolean { + if (!KEY) return false; // no token baked in -> disabled (dev/source) + if (optedOutByEnv()) return false; + return readState().enabled; +} + +// One-time, respectful first-run notice. Printed to stderr so it never pollutes +// piped stdout. Shown once, then remembered. +export function maybeShowFirstRunNotice(): void { + if (!telemetryEnabled()) return; + const state = readState(); + if (state.noticeShown) return; + process.stderr.write( + "\n" + + "Rendobar CLI sends anonymous usage stats (which commands run, the CLI\n" + + "version, and your OS) so we can make it better. It never sends your files,\n" + + "arguments, URLs, or credentials. Turn it off anytime with `rb telemetry off`\n" + + "or DO_NOT_TRACK=1.\n\n", + ); + writeState({ ...state, noticeShown: true }); +} + +export function setTelemetryEnabled(enabled: boolean): void { + const state = readState(); + writeState({ ...state, enabled, noticeShown: true }); +} + +export interface TelemetryStatus { + enabled: boolean; + optedOutByEnv: boolean; + keyPresent: boolean; + anonymousId: string; +} + +export function telemetryStatus(): TelemetryStatus { + const state = readState(); + return { + enabled: telemetryEnabled(), + optedOutByEnv: optedOutByEnv(), + keyPresent: Boolean(KEY), + anonymousId: state.anonymousId, + }; +} + +/** + * Capture one anonymous command event. Best-effort and bounded: awaited by the + * caller so it flushes before the process exits, but never longer than + * FLUSH_TIMEOUT_MS and never throws. + */ +export async function captureCommand( + command: string, + success: boolean, + durationMs: number, +): Promise { + if (!telemetryEnabled()) return; + const { anonymousId } = readState(); + + const body = { + api_key: KEY, + event: "cli_command", + distinct_id: anonymousId, + properties: { + command, + success, + duration_ms: durationMs, + cli_version: VERSION, + os: process.platform, + arch: process.arch, + // $process_person_profile:false keeps this an anonymous event in PostHog + // (cheaper, and never creates a person profile for the machine id). + $process_person_profile: false, + }, + timestamp: new Date().toISOString(), + }; + + try { + await fetch(`${HOST}/i/v0/e/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(FLUSH_TIMEOUT_MS), + }); + } catch { + // Never let telemetry surface an error or delay the user. + } +} diff --git a/src/main.ts b/src/main.ts index 5d6a41c..9be7c68 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,11 +5,43 @@ * from the central registry. Unknown commands and stray ffmpeg flags are hinted. */ import { spawn } from "node:child_process"; -import { defineCommand, runMain, renderUsage, type CommandDef } from "citty"; +import { defineCommand, runMain, renderUsage, type CommandDef, type SubCommandsDef } from "citty"; import { VERSION } from "./generated/version.js"; import { toSubCommands, commandNames, COMMANDS } from "./registry.js"; import { buildState, renderWelcome, renderHelp, renderWelcomeJson } from "./lib/welcome.js"; import { checkForUpdate, isRefreshDue } from "./lib/update-check.js"; +import { captureCommand, maybeShowFirstRunNotice } from "./lib/telemetry.js"; + +// Wrap each subcommand's run() so we emit ONE anonymous `cli_command` event per +// invocation (command name, success, duration — never args or files). Awaited in +// finally so it flushes before the process exits; bounded + never throws, so it +// can't delay or break a command. See src/lib/telemetry.ts. +function instrumentSubCommands(subs: SubCommandsDef): SubCommandsDef { + const out: SubCommandsDef = {}; + for (const [name, resolvable] of Object.entries(subs)) { + out[name] = async () => { + const cmd: CommandDef = + typeof resolvable === "function" ? await resolvable() : await resolvable; + const origRun = cmd.run; + if (typeof origRun !== "function") return cmd; + const run: typeof origRun = async (ctx) => { + maybeShowFirstRunNotice(); + const start = Date.now(); + let ok = true; + try { + return await origRun(ctx); + } catch (err) { + ok = false; + throw err; + } finally { + await captureCommand(name, ok, Date.now() - start); + } + }; + return { ...cmd, run }; + }; + } + return out; +} // Compile-time define from `bun build --compile --define`. Undefined in dev mode // (`bun run src/main.ts`), where process.execPath is the bun runtime, not `rb`. @@ -87,7 +119,7 @@ const main = defineCommand({ "no-wait": { type: "boolean", description: "Submit and exit immediately", default: false }, }, subCommands: { - ...toSubCommands(), + ...instrumentSubCommands(toSubCommands()), help: helpCommand, }, async run() { diff --git a/src/registry.ts b/src/registry.ts index 43d5614..4e842fd 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -18,6 +18,7 @@ export const COMMANDS: readonly CommandEntry[] = [ { name: "whoami", summary: "Show identity, plan, and balance", group: "ACCOUNT", load: () => import("./commands/whoami.js") }, { name: "update", summary: "Self-update to the latest version", group: "SYSTEM", load: () => import("./commands/update.js") }, { name: "doctor", summary: "Diagnose environment + auth", group: "SYSTEM", load: () => import("./commands/doctor.js") }, + { name: "telemetry", summary: "Control anonymous usage telemetry", group: "SYSTEM", load: () => import("./commands/telemetry.js") }, ]; export const GROUP_ORDER: readonly Group[] = ["CORE", "ACCOUNT", "SYSTEM"];