Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/cli-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion scripts/generate-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" : ""}`);
4 changes: 2 additions & 2 deletions src/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions src/commands/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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;
},
});
173 changes: 173 additions & 0 deletions src/lib/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<void> {
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.
}
}
36 changes: 34 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down