From 76c7c1437488b81e4d709645231718b93de4b3c4 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 5 Aug 2026 13:46:37 -0400 Subject: [PATCH 01/17] feat(telemetry): add anonymous environment signal detection Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/lib/env-signals.test.ts | 124 ++++++++++++++++++ packages/cli-core/src/lib/env-signals.ts | 70 ++++++++++ 2 files changed, 194 insertions(+) create mode 100644 packages/cli-core/src/lib/env-signals.test.ts create mode 100644 packages/cli-core/src/lib/env-signals.ts diff --git a/packages/cli-core/src/lib/env-signals.test.ts b/packages/cli-core/src/lib/env-signals.test.ts new file mode 100644 index 00000000..0fc10ac7 --- /dev/null +++ b/packages/cli-core/src/lib/env-signals.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { + detectAiAgent, + detectInScreen, + detectInstallMethod, + detectInTmux, + detectTerminalProgram, +} from "./env-signals.ts"; + +describe("detectAiAgent", () => { + test.each([ + [{ ANTIGRAVITY_CLI_ALIAS: "1" }, "antigravity"], + [{ CLAUDECODE: "1" }, "claude_code"], + [{ CLINE_ACTIVE: "true" }, "cline"], + [{ CODEX_SANDBOX: "1" }, "codex_cli"], + [{ CODEX_THREAD_ID: "abc" }, "codex_cli"], + [{ CODEX_SANDBOX_NETWORK_DISABLED: "1" }, "codex_cli"], + [{ CODEX_CI: "1" }, "codex_cli"], + [{ CURSOR_AGENT: "1" }, "cursor"], + [{ GEMINI_CLI: "1" }, "gemini_cli"], + [{ OPENCODE: "1" }, "open_code"], + [{ OPENCLAW_SHELL: "1" }, "openclaw"], + ])("detects %o as %s", (env, expected) => { + expect(detectAiAgent(env)).toBe(expected); + }); + + test("returns empty string when nothing is set", () => { + expect(detectAiAgent({})).toBe(""); + }); + + test("ignores empty-string values", () => { + expect(detectAiAgent({ CLAUDECODE: "" })).toBe(""); + }); +}); + +describe("detectTerminalProgram", () => { + test("LC_TERMINAL wins and is returned verbatim", () => { + expect(detectTerminalProgram({ LC_TERMINAL: "iTerm2", TERM_PROGRAM: "Apple_Terminal" })).toBe( + "iTerm2", + ); + }); + + test.each([ + [{ WARP_CLIENT_VERSION: "1" }, "warp"], + [{ WT_SESSION: "guid" }, "windows_terminal"], + [{ KITTY_WINDOW_ID: "1" }, "kitty"], + [{ ALACRITTY_WINDOW_ID: "1" }, "alacritty"], + [{ ALACRITTY_LOG: "/tmp/x" }, "alacritty"], + [{ WEZTERM_EXECUTABLE: "/bin/wezterm" }, "wezterm"], + [{ WEZTERM_PANE: "0" }, "wezterm"], + [{ GHOSTTY_RESOURCES_DIR: "/x" }, "ghostty"], + ])("detects %o as %s", (env, expected) => { + expect(detectTerminalProgram(env)).toBe(expected); + }); + + test("falls back to TERM_PROGRAM verbatim, then empty string", () => { + expect(detectTerminalProgram({ TERM_PROGRAM: "vscode" })).toBe("vscode"); + expect(detectTerminalProgram({})).toBe(""); + }); +}); + +describe("detectInstallMethod", () => { + const noEnv = {}; + + test("CLERK_INSTALL_METHOD override wins", () => { + expect(detectInstallMethod({ CLERK_INSTALL_METHOD: "homebrew" }, "/anything")).toBe("homebrew"); + }); + + test.each([ + ["/opt/homebrew/Cellar/clerk/1.0/bin/clerk", "homebrew"], + ["/home/linuxbrew/.linuxbrew/bin/clerk", "homebrew"], + ["/Users/x/.npm/_npx/abc123/node_modules/@clerk/cli-darwin-arm64/bin/clerk", "npx"], + ["/private/tmp/bunx-501-clerk@latest/node_modules/.bin/clerk", "bunx"], + ])("classifies execPath %s as %s", (execPath, expected) => { + expect(detectInstallMethod(noEnv, execPath)).toBe(expected); + }); + + test("windows-style homebrew-less path with backslashes and node_modules is npm_global", () => { + expect( + detectInstallMethod( + noEnv, + "C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\@clerk\\cli-win32-x64\\bin\\clerk.exe", + ), + ).toBe("npm_global"); + }); + + test("npm_lifecycle_event means a package script", () => { + expect( + detectInstallMethod({ npm_lifecycle_event: "dev" }, "/repo/node_modules/.bin/clerk"), + ).toBe("npm_run"); + }); + + test("npm_command=exec means npx", () => { + expect(detectInstallMethod({ npm_command: "exec" }, "/somewhere/clerk")).toBe("npx"); + }); + + test("bun user agent without lifecycle event means bunx", () => { + expect( + detectInstallMethod( + { npm_config_user_agent: "bun/1.3.0 npm/? node/v24" }, + "/somewhere/clerk", + ), + ).toBe("bunx"); + }); + + test("bare node_modules path means npm_global", () => { + expect( + detectInstallMethod(noEnv, "/usr/local/lib/node_modules/@clerk/cli-linux-x64/bin/clerk"), + ).toBe("npm_global"); + }); + + test("anything else is unknown", () => { + expect(detectInstallMethod(noEnv, "/usr/local/bin/clerk")).toBe("unknown"); + }); +}); + +describe("tmux / screen", () => { + test("detects tmux via TMUX and screen via STY", () => { + expect(detectInTmux({ TMUX: "/tmp/tmux-1000/default" })).toBe(true); + expect(detectInTmux({})).toBe(false); + expect(detectInScreen({ STY: "1234.pts-0" })).toBe(true); + expect(detectInScreen({})).toBe(false); + }); +}); diff --git a/packages/cli-core/src/lib/env-signals.ts b/packages/cli-core/src/lib/env-signals.ts new file mode 100644 index 00000000..80779e81 --- /dev/null +++ b/packages/cli-core/src/lib/env-signals.ts @@ -0,0 +1,70 @@ +/** + * Anonymous environment signals for CLI telemetry (GROW-1200). + * Detection tables ported from stripe-cli's pkg/useragent (their field/value + * enums are the reference the ticket's proposed scope was written against). + * + * All functions take an injected env so tests never depend on the ambient + * environment (the dev machine may itself run inside an AI agent or tmux). + */ + +export type EnvLike = Record; + +export function detectAiAgent(env: EnvLike): string { + if (env.ANTIGRAVITY_CLI_ALIAS) return "antigravity"; + if (env.CLAUDECODE) return "claude_code"; + if (env.CLINE_ACTIVE) return "cline"; + if ( + env.CODEX_SANDBOX || + env.CODEX_THREAD_ID || + env.CODEX_SANDBOX_NETWORK_DISABLED || + env.CODEX_CI + ) { + return "codex_cli"; + } + if (env.CURSOR_AGENT) return "cursor"; + if (env.GEMINI_CLI) return "gemini_cli"; + if (env.OPENCODE) return "open_code"; + if (env.OPENCLAW_SHELL) return "openclaw"; + return ""; +} + +export function detectTerminalProgram(env: EnvLike): string { + if (env.LC_TERMINAL) return env.LC_TERMINAL; + if (env.WARP_CLIENT_VERSION) return "warp"; + if (env.WT_SESSION) return "windows_terminal"; + if (env.KITTY_WINDOW_ID) return "kitty"; + if (env.ALACRITTY_WINDOW_ID || env.ALACRITTY_LOG) return "alacritty"; + if (env.WEZTERM_EXECUTABLE || env.WEZTERM_PANE) return "wezterm"; + if (env.GHOSTTY_RESOURCES_DIR) return "ghostty"; + return env.TERM_PROGRAM ?? ""; +} + +/** + * How the CLI binary was installed/invoked. The npm wrapper and package + * runners leave `npm_*` vars in the child env; direct binary installs are + * classified by executable path. + */ +export function detectInstallMethod(env: EnvLike, execPath: string): string { + if (env.CLERK_INSTALL_METHOD) return env.CLERK_INSTALL_METHOD; + + const path = execPath.toLowerCase().replaceAll("\\", "/"); + if (path.includes("/cellar/") || path.includes("/homebrew/") || path.includes("/linuxbrew/")) { + return "homebrew"; + } + if (path.includes("/_npx/")) return "npx"; + if (path.includes("bunx-")) return "bunx"; + + if (env.npm_lifecycle_event) return "npm_run"; + if (env.npm_command === "exec") return "npx"; + if (env.npm_config_user_agent?.startsWith("bun")) return "bunx"; + if (path.includes("/node_modules/")) return "npm_global"; + return "unknown"; +} + +export function detectInTmux(env: EnvLike): boolean { + return Boolean(env.TMUX); +} + +export function detectInScreen(env: EnvLike): boolean { + return Boolean(env.STY); +} From 18fe95e11fac3641f7ce5e4ab0fb1830fbd4784b Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 5 Aug 2026 14:06:40 -0400 Subject: [PATCH 02/17] feat(telemetry): persist machine uuid and notice flag in config Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/lib/config.test.ts | 24 ++++++++++++++++++++++++ packages/cli-core/src/lib/config.ts | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/cli-core/src/lib/config.test.ts b/packages/cli-core/src/lib/config.test.ts index c8deae64..6eb14e96 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -16,6 +16,9 @@ const { resolveInstanceId, resolveAppContext, resolveFetchedApplicationInstance, + ensureMachineUuid, + markTelemetryNoticeShown, + setEnvironment, _setConfigDir, } = await import("./config.ts"); type Profile = @@ -331,4 +334,25 @@ describe("config", () => { }); }); }); + + describe("telemetry config", () => { + test("ensureMachineUuid generates once and persists", async () => { + const first = await ensureMachineUuid(); + expect(first).toMatch(/^[0-9a-f-]{36}$/); + const second = await ensureMachineUuid(); + expect(second).toBe(first); + }); + + test("machineUuid survives readConfig round-trip with other fields", async () => { + const uuid = await ensureMachineUuid(); + await setEnvironment("production"); + const config = await readConfig(); + expect(config.machineUuid).toBe(uuid); + }); + + test("markTelemetryNoticeShown returns true exactly once", async () => { + expect(await markTelemetryNoticeShown()).toBe(true); + expect(await markTelemetryNoticeShown()).toBe(false); + }); + }); }); diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index c0bccb29..c90dc638 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -55,6 +55,8 @@ interface ClerkConfig { auth?: Record; profiles: Record; relay?: Record; + machineUuid?: string; + telemetryNoticeShown?: boolean; } function defaultConfig(): ClerkConfig { @@ -71,6 +73,9 @@ function migrateRawConfig(raw: Record): ClerkConfig { profiles: (raw.profiles as Record) ?? {}, }; + if (typeof raw.machineUuid === "string") config.machineUuid = raw.machineUuid; + if (raw.telemetryNoticeShown === true) config.telemetryNoticeShown = true; + if (raw.relay && typeof raw.relay === "object" && !Array.isArray(raw.relay)) { const relay: Record = {}; for (const [key, val] of Object.entries(raw.relay as Record)) { @@ -207,6 +212,24 @@ export async function setRelayEntry(key: string, entry: RelayEntry): Promise { + const config = await readConfig(); + if (config.machineUuid) return config.machineUuid; + config.machineUuid = crypto.randomUUID(); + await writeConfig(config); + return config.machineUuid; +} + +/** Flip the one-time telemetry notice flag. Returns true only on the transition. */ +export async function markTelemetryNoticeShown(): Promise { + const config = await readConfig(); + if (config.telemetryNoticeShown) return false; + config.telemetryNoticeShown = true; + await writeConfig(config); + return true; +} + type ResolvedVia = "remote" | "git-common-dir" | "directory"; export async function resolveProfile(cwd: string): Promise< From 5828a5ce2c0b51f9a23aff26245891484a5541bd Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 5 Aug 2026 14:21:34 -0400 Subject: [PATCH 03/17] feat(telemetry): add anonymous command telemetry core Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/lib/constants.ts | 6 + packages/cli-core/src/lib/telemetry.test.ts | 128 ++++++++++++++ packages/cli-core/src/lib/telemetry.ts | 185 ++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 packages/cli-core/src/lib/telemetry.test.ts create mode 100644 packages/cli-core/src/lib/telemetry.ts diff --git a/packages/cli-core/src/lib/constants.ts b/packages/cli-core/src/lib/constants.ts index e6dbc1b8..f88439df 100644 --- a/packages/cli-core/src/lib/constants.ts +++ b/packages/cli-core/src/lib/constants.ts @@ -49,3 +49,9 @@ export const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour export const UPDATE_PACKAGE_NAME = "clerk"; export const UPDATE_CACHE_FILE = join(CLERK_CACHE_DIR, "update-check.json"); export const NPM_REGISTRY_URL = "https://registry.npmjs.org/"; + +// ── Telemetry ───────────────────────────────────────────────────────────── + +/** Event ingestion endpoint (telemetry-service worker → BigQuery). */ +export const DEFAULT_TELEMETRY_ENDPOINT = "https://clerk-telemetry.com/v1/event"; +export const TELEMETRY_TIMEOUT_MS = 1000; diff --git a/packages/cli-core/src/lib/telemetry.test.ts b/packages/cli-core/src/lib/telemetry.test.ts new file mode 100644 index 00000000..90b74136 --- /dev/null +++ b/packages/cli-core/src/lib/telemetry.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DEV_CLI_VERSION } from "./version.ts"; +import { _setConfigDir } from "./config.ts"; +import { + finalizeAndSendTelemetry, + startCommandTelemetry, + telemetryEnabled, + telemetryResultForError, + type TelemetryCommand, +} from "./telemetry.ts"; +import { ApiError, CliError, EXIT_CODE, UserAbortError } from "./errors.ts"; + +// Isolate config I/O (machine uuid, notice flag) from the real user config dir. +let configDir: string; +beforeEach(async () => { + configDir = await mkdtemp(join(tmpdir(), "clerk-telemetry-test-")); + _setConfigDir(configDir); +}); +afterEach(async () => { + _setConfigDir(undefined); + await rm(configDir, { recursive: true, force: true }); +}); + +describe("telemetryEnabled", () => { + const REAL = "1.2.3"; + + test("enabled for release builds by default", () => { + expect(telemetryEnabled({}, REAL)).toBe(true); + }); + + test.each([ + [{ CLERK_TELEMETRY_DISABLED: "1" }], + [{ CLERK_TELEMETRY_DISABLED: "true" }], + [{ DO_NOT_TRACK: "1" }], + [{ DO_NOT_TRACK: "TRUE" }], + ])("opt-out env %o disables", (env) => { + expect(telemetryEnabled(env, REAL)).toBe(false); + }); + + test("dev builds are disabled unless CLERK_TELEMETRY_URL is set", () => { + expect(telemetryEnabled({}, DEV_CLI_VERSION)).toBe(false); + expect(telemetryEnabled({ CLERK_TELEMETRY_URL: "http://localhost:9" }, DEV_CLI_VERSION)).toBe( + true, + ); + }); + + test("opt-out beats the URL escape hatch", () => { + expect( + telemetryEnabled({ CLERK_TELEMETRY_URL: "http://localhost:9", DO_NOT_TRACK: "1" }, REAL), + ).toBe(false); + }); +}); + +describe("telemetryResultForError", () => { + test("maps user aborts", () => { + expect(telemetryResultForError(new UserAbortError())).toEqual({ + outcome: "abort", + exitCode: EXIT_CODE.SUCCESS, + }); + }); + + test("maps CliError with code and exit code", () => { + const error = new CliError("nope", { code: "not_linked" }); + expect(telemetryResultForError(error)).toEqual({ + outcome: "error", + exitCode: error.exitCode, + errorCode: "not_linked", + }); + }); + + test("maps CliError without code", () => { + expect(telemetryResultForError(new CliError("nope")).errorCode).toBe("cli_error"); + }); + + test("maps ApiError (code is null for a non-JSON body → api_error fallback)", () => { + const error = new ApiError(500, "boom"); + expect(telemetryResultForError(error)).toEqual({ + outcome: "error", + exitCode: EXIT_CODE.GENERAL, + errorCode: "api_error", + }); + }); + + test("maps unknown errors", () => { + expect(telemetryResultForError(new Error("x"))).toEqual({ + outcome: "error", + exitCode: EXIT_CODE.GENERAL, + errorCode: "unexpected_error", + }); + }); +}); + +describe("finalizeAndSendTelemetry", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.CLERK_TELEMETRY_URL; + }); + + function fakeCommand(): TelemetryCommand { + return { name: () => "list", options: [], getOptionValueSource: () => undefined, parent: null }; + } + + test("no-op when telemetry is disabled (no fetch, no throw)", async () => { + let called = 0; + globalThis.fetch = (async () => { + called += 1; + return new Response("{}"); + }) as unknown as typeof fetch; + // dev version + no CLERK_TELEMETRY_URL → disabled + startCommandTelemetry(fakeCommand()); + await finalizeAndSendTelemetry({ outcome: "success", exitCode: 0 }); + expect(called).toBe(0); + }); + + test("swallows network failures", async () => { + process.env.CLERK_TELEMETRY_URL = "https://unreachable.invalid/v1/event"; + globalThis.fetch = (async () => { + throw new Error("network down"); + }) as unknown as typeof fetch; + startCommandTelemetry(fakeCommand()); + // Must resolve, not reject. + await finalizeAndSendTelemetry({ outcome: "success", exitCode: 0 }); + }); +}); diff --git a/packages/cli-core/src/lib/telemetry.ts b/packages/cli-core/src/lib/telemetry.ts new file mode 100644 index 00000000..be2aa49d --- /dev/null +++ b/packages/cli-core/src/lib/telemetry.ts @@ -0,0 +1,185 @@ +/** + * Anonymous per-invocation usage telemetry (GROW-1200). + * + * One CLI_COMMAND_EXECUTED event per command run, POSTed to the + * telemetry-service worker (BigQuery behind it). Opt out with + * CLERK_TELEMETRY_DISABLED=1 or DO_NOT_TRACK=1. Dev builds send nothing + * unless CLERK_TELEMETRY_URL overrides the endpoint (test escape hatch). + * + * Telemetry must never affect the command: every entry point swallows its + * own failures to log.debug and the send is capped at TELEMETRY_TIMEOUT_MS. + */ + +import { DEFAULT_TELEMETRY_ENDPOINT, TELEMETRY_TIMEOUT_MS } from "./constants.ts"; +import { ensureMachineUuid, markTelemetryNoticeShown, resolveProfile } from "./config.ts"; +import { + detectAiAgent, + detectInScreen, + detectInstallMethod, + detectInTmux, + detectTerminalProgram, + type EnvLike, +} from "./env-signals.ts"; +import { getCurrentEnvName } from "./environment.ts"; +import { ApiError, CliError, EXIT_CODE, UserAbortError, isPromptExitError } from "./errors.ts"; +import { loggedFetch } from "./fetch.ts"; +import { log } from "./log.ts"; +import { getMode, isHuman } from "../mode.ts"; +import { DEV_CLI_VERSION, resolveCliVersion } from "./version.ts"; + +export type TelemetryResult = { + outcome: "success" | "error" | "abort"; + exitCode: number; + errorCode?: string; +}; + +/** Structural slice of Commander's Command — avoids its generic types. */ +export type TelemetryCommand = { + name(): string; + options: readonly { name(): string; attributeName(): string }[]; + getOptionValueSource(key: string): string | undefined; + parent: TelemetryCommand | null; +}; + +type TelemetryContext = { + command: string; + flags: string; + startedAt: number; +}; + +let context: TelemetryContext | null = null; + +const isTruthyEnv = (value?: string) => value === "1" || value?.toLowerCase() === "true"; + +export function telemetryEnabled( + env: EnvLike = process.env, + version: string = resolveCliVersion() ?? DEV_CLI_VERSION, +): boolean { + if (isTruthyEnv(env.CLERK_TELEMETRY_DISABLED)) return false; + if (isTruthyEnv(env.DO_NOT_TRACK)) return false; + if (env.CLERK_TELEMETRY_URL) return true; + return version !== DEV_CLI_VERSION; +} + +/** "users list" for `clerk users list` — root name excluded, never raw argv. */ +function commandPathOf(cmd: TelemetryCommand): string { + const parts: string[] = []; + for (let c: TelemetryCommand | null = cmd; c && c.parent; c = c.parent) { + parts.unshift(c.name()); + } + return parts.join(" "); +} + +/** Names of flags explicitly set on the CLI (own + inherited), never values. */ +function collectSetFlagNames(cmd: TelemetryCommand): string[] { + const names: string[] = []; + for (let c: TelemetryCommand | null = cmd; c; c = c.parent) { + for (const option of c.options) { + if (c.getOptionValueSource(option.attributeName()) === "cli") { + names.push(option.name()); + } + } + } + return names; +} + +/** Called from the root preAction hook. Pure in-memory; never throws. */ +export function startCommandTelemetry(actionCommand: TelemetryCommand): void { + try { + context = { + command: commandPathOf(actionCommand), + flags: collectSetFlagNames(actionCommand).join(","), + startedAt: Date.now(), + }; + } catch (error) { + log.debug(`telemetry: failed to start context: ${error}`); + } +} + +export function telemetryResultForError(error: unknown): TelemetryResult { + if (error instanceof UserAbortError || isPromptExitError(error)) { + return { outcome: "abort", exitCode: EXIT_CODE.SUCCESS }; + } + if (error instanceof CliError) { + return { outcome: "error", exitCode: error.exitCode, errorCode: error.code ?? "cli_error" }; + } + if (error instanceof ApiError) { + return { outcome: "error", exitCode: EXIT_CODE.GENERAL, errorCode: error.code ?? "api_error" }; + } + return { outcome: "error", exitCode: EXIT_CODE.GENERAL, errorCode: "unexpected_error" }; +} + +/** + * Build + send the event, and surface the one-time disclosure notice. + * Awaited by runProgram before process.exit; must never throw or exceed + * TELEMETRY_TIMEOUT_MS by more than scheduling noise. + */ +export async function finalizeAndSendTelemetry(result: TelemetryResult): Promise { + const current = context; + context = null; + if (!current || !telemetryEnabled()) return; + + try { + await maybeShowTelemetryNotice(); + + const machineUuid = await ensureMachineUuid(); + const resolved = await resolveProfile(process.cwd()).catch(() => undefined); + const version = resolveCliVersion() ?? DEV_CLI_VERSION; + + const event = { + sdk: "clerk-cli", + sdkv: version, + event: "CLI_COMMAND_EXECUTED", + payload: { + command: current.command, + flags: current.flags, + outcome: result.outcome, + exit_code: result.exitCode, + error_code: result.errorCode ?? null, + duration_ms: Date.now() - current.startedAt, + machine_uuid: machineUuid, + install_method: detectInstallMethod(process.env, process.execPath), + ai_agent: detectAiAgent(process.env), + terminal_program: detectTerminalProgram(process.env), + mode: getMode(), + os: process.platform, + arch: process.arch, + ci: Boolean(process.env.CI), + in_tmux: detectInTmux(process.env), + in_screen: detectInScreen(process.env), + env: getCurrentEnvName(), + workspace_id: resolved?.profile.workspaceId ?? null, + app_id: resolved?.profile.appId ?? null, + }, + }; + + const url = process.env.CLERK_TELEMETRY_URL ?? DEFAULT_TELEMETRY_ENDPOINT; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS); + try { + await loggedFetch(url, { + tag: "telemetry", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ events: [event] }), + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } + } catch (error) { + log.debug(`telemetry: send failed: ${error}`); + } +} + +/** One-time stderr disclosure; human runs outside CI only. Docs cover the rest. */ +async function maybeShowTelemetryNotice(): Promise { + if (!isHuman() || process.env.CI) return; + if (!(await markTelemetryNoticeShown())) return; + log.blank(); + log.info("The Clerk CLI collects anonymous usage telemetry to help improve the CLI."); + log.info( + "Learn more or opt out: https://clerk.com/docs/telemetry (`CLERK_TELEMETRY_DISABLED=1`)", + ); + log.blank(); +} From cdb9adf032a95f28cf23506f3efdbc286efd2a7c Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 5 Aug 2026 14:35:35 -0400 Subject: [PATCH 04/17] test(telemetry): isolate telemetry unit tests from ambient env vars Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/lib/telemetry.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/cli-core/src/lib/telemetry.test.ts b/packages/cli-core/src/lib/telemetry.test.ts index 90b74136..0807edfa 100644 --- a/packages/cli-core/src/lib/telemetry.test.ts +++ b/packages/cli-core/src/lib/telemetry.test.ts @@ -95,9 +95,20 @@ describe("telemetryResultForError", () => { describe("finalizeAndSendTelemetry", () => { const originalFetch = globalThis.fetch; + + // Isolate from ambient env: clear before each test too (not just after) so a + // pre-set CLERK_TELEMETRY_DISABLED/DO_NOT_TRACK/CLERK_TELEMETRY_URL in the + // shell can't change whether telemetry is enabled for the first test. + beforeEach(() => { + delete process.env.CLERK_TELEMETRY_URL; + delete process.env.CLERK_TELEMETRY_DISABLED; + delete process.env.DO_NOT_TRACK; + }); afterEach(() => { globalThis.fetch = originalFetch; delete process.env.CLERK_TELEMETRY_URL; + delete process.env.CLERK_TELEMETRY_DISABLED; + delete process.env.DO_NOT_TRACK; }); function fakeCommand(): TelemetryCommand { From b983ba859cc29f787289986cf08ac3ee1d8c0fe3 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 5 Aug 2026 14:40:32 -0400 Subject: [PATCH 05/17] feat(telemetry): tag CLI User-Agent with detected AI agent Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/lib/user-agent.test.ts | 31 +++++++++++--------- packages/cli-core/src/lib/user-agent.ts | 12 +++++--- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/cli-core/src/lib/user-agent.test.ts b/packages/cli-core/src/lib/user-agent.test.ts index 98d038f0..388bb1d7 100644 --- a/packages/cli-core/src/lib/user-agent.test.ts +++ b/packages/cli-core/src/lib/user-agent.test.ts @@ -1,34 +1,37 @@ -import { test, expect, describe, afterEach } from "bun:test"; +import { test, expect, describe } from "bun:test"; import { buildUserAgent } from "./user-agent.ts"; describe("buildUserAgent", () => { - const originalCi = process.env.CI; - afterEach(() => { - if (originalCi === undefined) delete process.env.CI; - else process.env.CI = originalCi; - }); - test("starts with Clerk-CLI/", () => { - expect(buildUserAgent()).toMatch(/^Clerk-CLI\/\S+ /); + expect(buildUserAgent({})).toMatch(/^Clerk-CLI\/\S+ /); }); test("includes Bun/ and platform-arch", () => { - const ua = buildUserAgent(); + const ua = buildUserAgent({}); expect(ua).toContain(`Bun/${Bun.version}`); expect(ua).toContain(`${process.platform}-${process.arch}`); }); test("appends ci segment when CI env is set", () => { - process.env.CI = "1"; - expect(buildUserAgent()).toMatch(/; ci\)$/); + const ua = buildUserAgent({ CI: "1" }); + expect(ua).toMatch(/; ci\)$/); }); test("omits ci segment when CI env is unset", () => { - delete process.env.CI; - expect(buildUserAgent()).not.toMatch(/; ci\)/); + const ua = buildUserAgent({}); + expect(ua).not.toMatch(/; ci\)/); }); test("uses only printable ASCII characters", () => { - expect(buildUserAgent()).toMatch(/^[\x20-\x7e]+$/); + expect(buildUserAgent({})).toMatch(/^[\x20-\x7e]+$/); + }); + + test("appends AIAgent product token when an agent is detected", () => { + const ua = buildUserAgent({ CLAUDECODE: "1" }); + expect(ua).toMatch(/^Clerk-CLI\/.+\(.+\) AIAgent\/claude_code$/); + }); + + test("no AIAgent token when no agent env is present", () => { + expect(buildUserAgent({})).not.toContain("AIAgent/"); }); }); diff --git a/packages/cli-core/src/lib/user-agent.ts b/packages/cli-core/src/lib/user-agent.ts index 5b136fc2..28d22531 100644 --- a/packages/cli-core/src/lib/user-agent.ts +++ b/packages/cli-core/src/lib/user-agent.ts @@ -4,17 +4,21 @@ * we fall through to Bun's default `User-Agent: Bun/`, which is * indistinguishable from any other Bun-based client. * - * Format: `Clerk-CLI/ (Bun/; -[; ci])` + * Format: `Clerk-CLI/ (Bun/; -[; ci])[ AIAgent/]` * - : darwin | linux | win32 | … (process.platform) * - : arm64 | x64 | … (process.arch) * - `ci` segment is appended when running under a recognized CI environment. + * - ` AIAgent/` product token is appended when an AI agent is detected. */ +import { detectAiAgent, type EnvLike } from "./env-signals.ts"; import { DEV_CLI_VERSION, resolveCliVersion } from "./version.ts"; -export function buildUserAgent(): string { +export function buildUserAgent(env: EnvLike = process.env): string { const version = resolveCliVersion() ?? DEV_CLI_VERSION; const segments = [`Bun/${Bun.version}`, `${process.platform}-${process.arch}`]; - if (process.env.CI) segments.push("ci"); - return `Clerk-CLI/${version} (${segments.join("; ")})`; + if (env.CI) segments.push("ci"); + const agent = detectAiAgent(env); + const agentToken = agent ? ` AIAgent/${agent}` : ""; + return `Clerk-CLI/${version} (${segments.join("; ")})${agentToken}`; } From be746ac70dd665e9bd2310c9c4d28261066c51ee Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 5 Aug 2026 14:55:01 -0400 Subject: [PATCH 06/17] feat(telemetry): emit anonymous per-command telemetry event Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/cli-program.ts | 10 ++- .../src/test/integration/telemetry.test.ts | 85 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/cli-core/src/test/integration/telemetry.test.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 9ea89cb5..73857b72 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -45,6 +45,11 @@ import { isAgent } from "./mode.ts"; import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { registerExtras } from "@clerk/cli-extras"; +import { + finalizeAndSendTelemetry, + startCommandTelemetry, + telemetryResultForError, +} from "./lib/telemetry.ts"; /** * The root `clerk` program with its global options applied, so registrants @@ -100,7 +105,7 @@ export function createProgram(): Program { ) .option("--verbose", "Show detailed output (enables debug messages)") as Program; - program.hook("preAction", async () => { + program.hook("preAction", async (_thisCommand, actionCommand) => { // Reset log level at the start of each command invocation so a previous // --verbose doesn't leak into subsequent runs. setLogLevel("info"); @@ -135,6 +140,7 @@ export function createProgram(): Program { if (activeEnv !== "production") { process.stderr.write(`[${activeEnv.toUpperCase()}]\n`); } + startCommandTelemetry(actionCommand); }); // Show update notification after each command, except for commands that @@ -231,7 +237,9 @@ export async function runProgram( try { const { argv, from } = await resolveArgv(args, options?.from); await program.parseAsync(argv, { from }); + await finalizeAndSendTelemetry({ outcome: "success", exitCode: EXIT_CODE.SUCCESS }); } catch (error) { + await finalizeAndSendTelemetry(telemetryResultForError(error)); const verbose = program.opts().verbose ?? false; if (error instanceof UserAbortError || isPromptExitError(error)) { diff --git a/packages/cli-core/src/test/integration/telemetry.test.ts b/packages/cli-core/src/test/integration/telemetry.test.ts new file mode 100644 index 00000000..73105f42 --- /dev/null +++ b/packages/cli-core/src/test/integration/telemetry.test.ts @@ -0,0 +1,85 @@ +/** + * Telemetry is exercised via the CLERK_TELEMETRY_URL escape hatch (tests run + * as 0.0.0-dev, where telemetry is otherwise off). The harness mocks all + * fetch, so events are captured from http.requests. + */ + +import { afterEach, expect, test } from "bun:test"; +import { clerk, http, useIntegrationTestHarness } from "./lib/harness.ts"; + +useIntegrationTestHarness(); + +const TELEMETRY_URL = "https://test-telemetry.clerk.com/v1/event"; + +afterEach(() => { + delete process.env.CLERK_TELEMETRY_URL; +}); + +function telemetryEvents() { + const requests = http.requests.filter((r) => r.url.startsWith(TELEMETRY_URL)); + return requests.map((r) => JSON.parse(r.body ?? "{}") as { events: Record[] }); +} + +test("sends one anonymous event for a successful command", async () => { + process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; + http.mock({ "test-telemetry.clerk.com": {} }); + + await clerk("completion", "zsh"); + + const bodies = telemetryEvents(); + expect(bodies).toHaveLength(1); + expect(bodies[0]!.events).toHaveLength(1); + const event = bodies[0]!.events[0]!; + expect(event.sdk).toBe("clerk-cli"); + expect(event.event).toBe("CLI_COMMAND_EXECUTED"); + // Command path is subcommand NAMES only — "zsh" is an argument value and + // is deliberately excluded (spec: "resolved command path, never raw argv"). + expect(event.payload.command).toBe("completion"); + expect(event.payload.outcome).toBe("success"); + expect(event.payload.exit_code).toBe(0); + expect(event.payload.machine_uuid).toMatch(/^[0-9a-f-]{36}$/); + expect(typeof event.payload.duration_ms).toBe("number"); + // Env-dependent fields are strings, values depend on the host machine. + expect(typeof event.payload.ai_agent).toBe("string"); + expect(typeof event.payload.install_method).toBe("string"); + // Never collected: + expect(JSON.stringify(event)).not.toContain("zsh-value-of-an-option"); +}); + +test("records failures with error code and reuses the machine uuid", async () => { + process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; + http.mock({ "test-telemetry.clerk.com": {} }); + const first = await clerk("completion", "zsh"); + expect(first.exitCode).toBe(0); + const firstUuid = telemetryEvents()[0]!.events[0]!.payload.machine_uuid; + + // `apps list` fails because its PLAPI route is not mocked (the mock fetch + // throws) — a real error path through runProgram's catch, unlike Commander + // usage errors (invalid .choices() values), which exit inside Commander in + // production and never produce telemetry. + http.mock({ "test-telemetry.clerk.com": {} }); + const result = await clerk.raw("apps", "list"); + expect(result.exitCode).toBe(1); + + const bodies = telemetryEvents(); + expect(bodies).toHaveLength(1); + const event = bodies[0]!.events[0]!; + expect(event.payload.command).toBe("apps list"); + expect(event.payload.outcome).toBe("error"); + expect(event.payload.exit_code).toBe(1); + expect(event.payload.error_code).toBe("unexpected_error"); + expect(event.payload.machine_uuid).toBe(firstUuid); +}); + +test("command succeeds even when the telemetry endpoint is down", async () => { + process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; + http.mock(); // no routes: every fetch throws, including the telemetry send + const result = await clerk.raw("completion", "zsh"); + expect(result.exitCode).toBe(0); +}); + +test("no telemetry traffic without CLERK_TELEMETRY_URL (dev build guard)", async () => { + http.mock(); // guard mock: any fetch would throw + await clerk("completion", "zsh"); + expect(http.requests).toHaveLength(0); +}); From 6b6d0944871e57341d1cedb88cb92d514cd8c262 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 5 Aug 2026 15:07:48 -0400 Subject: [PATCH 07/17] test(telemetry): assert argument values never leak into telemetry events Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/test/integration/telemetry.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/test/integration/telemetry.test.ts b/packages/cli-core/src/test/integration/telemetry.test.ts index 73105f42..d00e5573 100644 --- a/packages/cli-core/src/test/integration/telemetry.test.ts +++ b/packages/cli-core/src/test/integration/telemetry.test.ts @@ -42,8 +42,9 @@ test("sends one anonymous event for a successful command", async () => { // Env-dependent fields are strings, values depend on the host machine. expect(typeof event.payload.ai_agent).toBe("string"); expect(typeof event.payload.install_method).toBe("string"); - // Never collected: - expect(JSON.stringify(event)).not.toContain("zsh-value-of-an-option"); + // Never collected: the argument value ("zsh") must not leak into any event + // field — fails if command path or flags ever start carrying argv values. + expect(JSON.stringify(event)).not.toContain("zsh"); }); test("records failures with error code and reuses the machine uuid", async () => { From 6575bcec214dea066573d9313ec1f5fea9af4902 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 5 Aug 2026 15:14:52 -0400 Subject: [PATCH 08/17] docs(telemetry): document anonymous telemetry and opt-out Co-Authored-By: Claude Fable 5 --- .changeset/anonymous-telemetry.md | 5 +++++ README.md | 9 +++++++++ 2 files changed, 14 insertions(+) create mode 100644 .changeset/anonymous-telemetry.md diff --git a/.changeset/anonymous-telemetry.md b/.changeset/anonymous-telemetry.md new file mode 100644 index 00000000..8e48f236 --- /dev/null +++ b/.changeset/anonymous-telemetry.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Collect anonymous usage telemetry (command name, flag names, duration, and outcome) to help improve the CLI; opt out with `CLERK_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1`. diff --git a/README.md b/README.md index ab88d248..0126cd80 100644 --- a/README.md +++ b/README.md @@ -56,3 +56,12 @@ Commands: help [command] Display help for command bird Play Clerk Bird, a Flappy Bird game in your terminal ``` + +## Telemetry + +The Clerk CLI collects anonymous usage telemetry (command name, flag names, duration, +outcome, and environment signals like OS, install method, and terminal). It never +collects command arguments, option values, file paths, or personal data. See +https://clerk.com/docs/telemetry for details. + +Opt out by setting `CLERK_TELEMETRY_DISABLED=1` (or the standard `DO_NOT_TRACK=1`). From 4bd99e441fd5136e76032d19f6adfdf083ce1436 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 5 Aug 2026 15:41:22 -0400 Subject: [PATCH 09/17] =?UTF-8?q?fix(telemetry):=20final=20review=20fixes?= =?UTF-8?q?=20=E2=80=94=20env=20isolation,=20soft-failure=20outcomes,=20di?= =?UTF-8?q?sclosure=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .changeset/anonymous-telemetry.md | 2 +- README.md | 3 +- packages/cli-core/src/cli-program.ts | 9 +++++- .../src/test/integration/telemetry.test.ts | 32 ++++++++++++++++++- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.changeset/anonymous-telemetry.md b/.changeset/anonymous-telemetry.md index 8e48f236..f238e951 100644 --- a/.changeset/anonymous-telemetry.md +++ b/.changeset/anonymous-telemetry.md @@ -2,4 +2,4 @@ "clerk": minor --- -Collect anonymous usage telemetry (command name, flag names, duration, and outcome) to help improve the CLI; opt out with `CLERK_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1`. +Collect anonymous usage telemetry (command name, flag names, duration, outcome, a random machine identifier, and — when a project is linked — the app and workspace IDs) to help improve the CLI; opt out with `CLERK_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1`. diff --git a/README.md b/README.md index 0126cd80..832ce996 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ Commands: ## Telemetry The Clerk CLI collects anonymous usage telemetry (command name, flag names, duration, -outcome, and environment signals like OS, install method, and terminal). It never +outcome, environment signals like OS, install method, and terminal, a random machine +identifier, and — when a project is linked — the app and workspace IDs). It never collects command arguments, option values, file paths, or personal data. See https://clerk.com/docs/telemetry for details. diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 73857b72..87b53605 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -237,7 +237,14 @@ export async function runProgram( try { const { argv, from } = await resolveArgv(args, options?.from); await program.parseAsync(argv, { from }); - await finalizeAndSendTelemetry({ outcome: "success", exitCode: EXIT_CODE.SUCCESS }); + // Some commands report failure via process.exitCode instead of throwing + // (e.g. api, deploy status, mcp, users) — read it back so telemetry + // doesn't record those as a success. + const softExitCode = Number(process.exitCode ?? EXIT_CODE.SUCCESS); + await finalizeAndSendTelemetry({ + outcome: softExitCode === EXIT_CODE.SUCCESS ? "success" : "error", + exitCode: softExitCode, + }); } catch (error) { await finalizeAndSendTelemetry(telemetryResultForError(error)); const verbose = program.opts().verbose ?? false; diff --git a/packages/cli-core/src/test/integration/telemetry.test.ts b/packages/cli-core/src/test/integration/telemetry.test.ts index d00e5573..8df0e786 100644 --- a/packages/cli-core/src/test/integration/telemetry.test.ts +++ b/packages/cli-core/src/test/integration/telemetry.test.ts @@ -4,15 +4,25 @@ * fetch, so events are captured from http.requests. */ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { clerk, http, useIntegrationTestHarness } from "./lib/harness.ts"; useIntegrationTestHarness(); const TELEMETRY_URL = "https://test-telemetry.clerk.com/v1/event"; +// Isolate from ambient env: clear before each test too (not just after) so a +// pre-set CLERK_TELEMETRY_URL/CLERK_TELEMETRY_DISABLED/DO_NOT_TRACK in the +// shell can't change whether telemetry is enabled for the first test. +beforeEach(() => { + delete process.env.CLERK_TELEMETRY_URL; + delete process.env.CLERK_TELEMETRY_DISABLED; + delete process.env.DO_NOT_TRACK; +}); afterEach(() => { delete process.env.CLERK_TELEMETRY_URL; + delete process.env.CLERK_TELEMETRY_DISABLED; + delete process.env.DO_NOT_TRACK; }); function telemetryEvents() { @@ -72,6 +82,26 @@ test("records failures with error code and reuses the machine uuid", async () => expect(event.payload.machine_uuid).toBe(firstUuid); }); +test("maps a soft failure (process.exitCode set without throwing) to outcome error", async () => { + process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; + http.mock({ "test-telemetry.clerk.com": {} }); + + try { + // Simulates commands (api, deploy status, mcp, users) that report + // failure by setting process.exitCode instead of throwing. + process.exitCode = 1; + await clerk.raw("completion", "zsh"); + + const bodies = telemetryEvents(); + expect(bodies).toHaveLength(1); + const event = bodies[0]!.events[0]!; + expect(event.payload.outcome).toBe("error"); + expect(event.payload.exit_code).toBe(1); + } finally { + process.exitCode = undefined; + } +}); + test("command succeeds even when the telemetry endpoint is down", async () => { process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; http.mock(); // no routes: every fetch throws, including the telemetry send From b671d675cbe0f8622111bd1a440ba8b9ac5daad8 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Fri, 7 Aug 2026 10:15:40 -0400 Subject: [PATCH 10/17] fix(telemetry): treat any non-false opt-out env value as an opt-out CLERK_TELEMETRY_DISABLED=yes silently keeping telemetry on is the worst failure mode for a privacy control; only "0"/"false" now keep it enabled. Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/lib/telemetry.test.ts | 17 +++++++++++++++++ packages/cli-core/src/lib/telemetry.ts | 12 +++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/lib/telemetry.test.ts b/packages/cli-core/src/lib/telemetry.test.ts index 0807edfa..6f5f4a76 100644 --- a/packages/cli-core/src/lib/telemetry.test.ts +++ b/packages/cli-core/src/lib/telemetry.test.ts @@ -31,15 +31,32 @@ describe("telemetryEnabled", () => { expect(telemetryEnabled({}, REAL)).toBe(true); }); + // Any non-empty value except an explicit "0"/"false" opts out — a user who + // sets CLERK_TELEMETRY_DISABLED=yes meant to disable; silently staying on is + // the worst failure mode for a privacy control. test.each([ [{ CLERK_TELEMETRY_DISABLED: "1" }], [{ CLERK_TELEMETRY_DISABLED: "true" }], + [{ CLERK_TELEMETRY_DISABLED: "yes" }], + [{ CLERK_TELEMETRY_DISABLED: "anything" }], [{ DO_NOT_TRACK: "1" }], [{ DO_NOT_TRACK: "TRUE" }], + [{ DO_NOT_TRACK: "on" }], ])("opt-out env %o disables", (env) => { expect(telemetryEnabled(env, REAL)).toBe(false); }); + test.each([ + [{ CLERK_TELEMETRY_DISABLED: "0" }], + [{ CLERK_TELEMETRY_DISABLED: "false" }], + [{ CLERK_TELEMETRY_DISABLED: "FALSE" }], + [{ CLERK_TELEMETRY_DISABLED: "" }], + [{ DO_NOT_TRACK: "0" }], + [{ DO_NOT_TRACK: "false" }], + ])("explicit-false env %o stays enabled", (env) => { + expect(telemetryEnabled(env, REAL)).toBe(true); + }); + test("dev builds are disabled unless CLERK_TELEMETRY_URL is set", () => { expect(telemetryEnabled({}, DEV_CLI_VERSION)).toBe(false); expect(telemetryEnabled({ CLERK_TELEMETRY_URL: "http://localhost:9" }, DEV_CLI_VERSION)).toBe( diff --git a/packages/cli-core/src/lib/telemetry.ts b/packages/cli-core/src/lib/telemetry.ts index be2aa49d..b3cf5bb0 100644 --- a/packages/cli-core/src/lib/telemetry.ts +++ b/packages/cli-core/src/lib/telemetry.ts @@ -49,14 +49,20 @@ type TelemetryContext = { let context: TelemetryContext | null = null; -const isTruthyEnv = (value?: string) => value === "1" || value?.toLowerCase() === "true"; +// A privacy control must fail toward "off": any non-empty value counts as an +// opt-out unless it is explicitly "0" or "false" (case-insensitive). +const isOptOutEnv = (value?: string): boolean => { + if (!value) return false; + const normalized = value.toLowerCase(); + return normalized !== "0" && normalized !== "false"; +}; export function telemetryEnabled( env: EnvLike = process.env, version: string = resolveCliVersion() ?? DEV_CLI_VERSION, ): boolean { - if (isTruthyEnv(env.CLERK_TELEMETRY_DISABLED)) return false; - if (isTruthyEnv(env.DO_NOT_TRACK)) return false; + if (isOptOutEnv(env.CLERK_TELEMETRY_DISABLED)) return false; + if (isOptOutEnv(env.DO_NOT_TRACK)) return false; if (env.CLERK_TELEMETRY_URL) return true; return version !== DEV_CLI_VERSION; } From 48943b812e6a17802ff8fa015720c21c929e18f4 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Fri, 7 Aug 2026 10:16:31 -0400 Subject: [PATCH 11/17] feat(telemetry): persist a telemetry opt-out flag in config Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/lib/config.test.ts | 24 ++++++++++++++++++++++++ packages/cli-core/src/lib/config.ts | 18 ++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/cli-core/src/lib/config.test.ts b/packages/cli-core/src/lib/config.test.ts index 6eb14e96..cf318b2c 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -17,7 +17,9 @@ const { resolveAppContext, resolveFetchedApplicationInstance, ensureMachineUuid, + getTelemetryDisabled, markTelemetryNoticeShown, + setTelemetryDisabled, setEnvironment, _setConfigDir, } = await import("./config.ts"); @@ -354,5 +356,27 @@ describe("config", () => { expect(await markTelemetryNoticeShown()).toBe(true); expect(await markTelemetryNoticeShown()).toBe(false); }); + + test("setTelemetryDisabled(true) persists and getTelemetryDisabled reads it", async () => { + expect(await getTelemetryDisabled()).toBe(false); + await setTelemetryDisabled(true); + expect(await getTelemetryDisabled()).toBe(true); + const config = await readConfig(); + expect(config.telemetryDisabled).toBe(true); + }); + + test("setTelemetryDisabled(false) removes the flag entirely", async () => { + await setTelemetryDisabled(true); + await setTelemetryDisabled(false); + expect(await getTelemetryDisabled()).toBe(false); + const config = await readConfig(); + expect(config.telemetryDisabled).toBeUndefined(); + }); + + test("telemetryDisabled survives a round-trip with other config writes", async () => { + await setTelemetryDisabled(true); + await setEnvironment("production"); + expect(await getTelemetryDisabled()).toBe(true); + }); }); }); diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index c90dc638..cb035bfc 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -57,6 +57,7 @@ interface ClerkConfig { relay?: Record; machineUuid?: string; telemetryNoticeShown?: boolean; + telemetryDisabled?: boolean; } function defaultConfig(): ClerkConfig { @@ -75,6 +76,7 @@ function migrateRawConfig(raw: Record): ClerkConfig { if (typeof raw.machineUuid === "string") config.machineUuid = raw.machineUuid; if (raw.telemetryNoticeShown === true) config.telemetryNoticeShown = true; + if (raw.telemetryDisabled === true) config.telemetryDisabled = true; if (raw.relay && typeof raw.relay === "object" && !Array.isArray(raw.relay)) { const relay: Record = {}; @@ -230,6 +232,22 @@ export async function markTelemetryNoticeShown(): Promise { return true; } +/** Persisted telemetry opt-out, set via `clerk telemetry disable`. */ +export async function getTelemetryDisabled(): Promise { + const config = await readConfig(); + return config.telemetryDisabled === true; +} + +export async function setTelemetryDisabled(disabled: boolean): Promise { + const config = await readConfig(); + if (disabled) { + config.telemetryDisabled = true; + } else { + delete config.telemetryDisabled; + } + await writeConfig(config); +} + type ResolvedVia = "remote" | "git-common-dir" | "directory"; export async function resolveProfile(cwd: string): Promise< From 285150bc3abf787ce9e10d8d68a980d7f4a21a6f Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Fri, 7 Aug 2026 10:19:52 -0400 Subject: [PATCH 12/17] feat(telemetry): no send on the disclosure run, persisted opt-out re-check, --verbose payload dump The first human run only shows the notice (and says nothing was sent); finalize re-checks enablement against persisted config so `clerk telemetry disable` itself never emits an event; the full payload is visible with --verbose before the POST. Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/lib/telemetry.test.ts | 122 +++++++++++++++++- packages/cli-core/src/lib/telemetry.ts | 78 +++++++++-- .../src/test/integration/telemetry.test.ts | 14 +- 3 files changed, 199 insertions(+), 15 deletions(-) diff --git a/packages/cli-core/src/lib/telemetry.test.ts b/packages/cli-core/src/lib/telemetry.test.ts index 6f5f4a76..1d4d9239 100644 --- a/packages/cli-core/src/lib/telemetry.test.ts +++ b/packages/cli-core/src/lib/telemetry.test.ts @@ -3,15 +3,18 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DEV_CLI_VERSION } from "./version.ts"; -import { _setConfigDir } from "./config.ts"; +import { _setConfigDir, markTelemetryNoticeShown, setTelemetryDisabled } from "./config.ts"; import { finalizeAndSendTelemetry, + getTelemetryStatus, startCommandTelemetry, telemetryEnabled, telemetryResultForError, type TelemetryCommand, } from "./telemetry.ts"; import { ApiError, CliError, EXIT_CODE, UserAbortError } from "./errors.ts"; +import { setLogLevel } from "./log.ts"; +import { useCaptureLog } from "../test/lib/stubs.ts"; // Isolate config I/O (machine uuid, notice flag) from the real user config dir. let configDir: string; @@ -71,6 +74,40 @@ describe("telemetryEnabled", () => { }); }); +describe("getTelemetryStatus", () => { + const REAL = "1.2.3"; + + test("reports env opt-out first, naming the winning variable", async () => { + expect( + await getTelemetryStatus({ CLERK_TELEMETRY_DISABLED: "1", DO_NOT_TRACK: "1" }, REAL), + ).toEqual({ enabled: false, reason: "env", envVar: "CLERK_TELEMETRY_DISABLED" }); + expect(await getTelemetryStatus({ DO_NOT_TRACK: "yes" }, REAL)).toEqual({ + enabled: false, + reason: "env", + envVar: "DO_NOT_TRACK", + }); + }); + + test("reports the persisted config opt-out", async () => { + await setTelemetryDisabled(true); + expect(await getTelemetryStatus({}, REAL)).toEqual({ enabled: false, reason: "config" }); + }); + + test("persisted opt-out beats the URL escape hatch", async () => { + await setTelemetryDisabled(true); + const status = await getTelemetryStatus({ CLERK_TELEMETRY_URL: "http://localhost:9" }, REAL); + expect(status.enabled).toBe(false); + }); + + test("reports dev builds, and enabled otherwise", async () => { + expect(await getTelemetryStatus({}, DEV_CLI_VERSION)).toEqual({ + enabled: false, + reason: "dev-build", + }); + expect(await getTelemetryStatus({}, REAL)).toEqual({ enabled: true }); + }); +}); + describe("telemetryResultForError", () => { test("maps user aborts", () => { expect(telemetryResultForError(new UserAbortError())).toEqual({ @@ -153,4 +190,87 @@ describe("finalizeAndSendTelemetry", () => { // Must resolve, not reject. await finalizeAndSendTelemetry({ outcome: "success", exitCode: 0 }); }); + + test("no send when telemetry is disabled via persisted config", async () => { + let called = 0; + globalThis.fetch = (async () => { + called += 1; + return new Response("{}"); + }) as unknown as typeof fetch; + process.env.CLERK_TELEMETRY_URL = "https://capture.invalid/v1/event"; + await setTelemetryDisabled(true); + startCommandTelemetry(fakeCommand()); + await finalizeAndSendTelemetry({ outcome: "success", exitCode: 0 }); + expect(called).toBe(0); + }); + + describe("verbose payload dump", () => { + const captured = useCaptureLog(); + + test("dumps the event payload at debug level before the POST", async () => { + globalThis.fetch = (async () => new Response("{}")) as unknown as typeof fetch; + process.env.CLERK_TELEMETRY_URL = "https://capture.invalid/v1/event"; + // Agent-mode run (no TTY in tests) → notice path skipped, event sends. + setLogLevel("debug"); + try { + startCommandTelemetry(fakeCommand()); + await finalizeAndSendTelemetry({ outcome: "success", exitCode: 0 }); + } finally { + setLogLevel("info"); + } + expect(captured.err).toContain("telemetry: event {"); + expect(captured.err).toContain('"machine_uuid"'); + }); + }); + + describe("first-run notice (human, non-CI)", () => { + const captured = useCaptureLog(); + let originalCi: string | undefined; + let originalMode: string | undefined; + + beforeEach(() => { + originalCi = process.env.CI; + originalMode = process.env.CLERK_MODE; + delete process.env.CI; + process.env.CLERK_MODE = "human"; + process.env.CLERK_TELEMETRY_URL = "https://capture.invalid/v1/event"; + }); + afterEach(() => { + if (originalCi === undefined) delete process.env.CI; + else process.env.CI = originalCi; + if (originalMode === undefined) delete process.env.CLERK_MODE; + else process.env.CLERK_MODE = originalMode; + }); + + test("the run that shows the notice sends nothing; the next run sends", async () => { + let called = 0; + globalThis.fetch = (async () => { + called += 1; + return new Response("{}"); + }) as unknown as typeof fetch; + + startCommandTelemetry(fakeCommand()); + await finalizeAndSendTelemetry({ outcome: "success", exitCode: 0 }); + expect(called).toBe(0); + expect(captured.err).toContain("Nothing has been sent during this run"); + + startCommandTelemetry(fakeCommand()); + await finalizeAndSendTelemetry({ outcome: "success", exitCode: 0 }); + expect(called).toBe(1); + }); + + test("no notice and no skip once it has already been shown", async () => { + await markTelemetryNoticeShown(); + let called = 0; + globalThis.fetch = (async () => { + called += 1; + return new Response("{}"); + }) as unknown as typeof fetch; + + startCommandTelemetry(fakeCommand()); + await finalizeAndSendTelemetry({ outcome: "success", exitCode: 0 }); + expect(called).toBe(1); + expect(captured.err).not.toContain("usage telemetry"); + }); + }); }); diff --git a/packages/cli-core/src/lib/telemetry.ts b/packages/cli-core/src/lib/telemetry.ts index b3cf5bb0..5e8edbad 100644 --- a/packages/cli-core/src/lib/telemetry.ts +++ b/packages/cli-core/src/lib/telemetry.ts @@ -3,15 +3,21 @@ * * One CLI_COMMAND_EXECUTED event per command run, POSTed to the * telemetry-service worker (BigQuery behind it). Opt out with - * CLERK_TELEMETRY_DISABLED=1 or DO_NOT_TRACK=1. Dev builds send nothing - * unless CLERK_TELEMETRY_URL overrides the endpoint (test escape hatch). + * `clerk telemetry disable` (persisted) or the CLERK_TELEMETRY_DISABLED / + * DO_NOT_TRACK env vars. Dev builds send nothing unless CLERK_TELEMETRY_URL + * overrides the endpoint (test escape hatch). * * Telemetry must never affect the command: every entry point swallows its * own failures to log.debug and the send is capped at TELEMETRY_TIMEOUT_MS. */ import { DEFAULT_TELEMETRY_ENDPOINT, TELEMETRY_TIMEOUT_MS } from "./constants.ts"; -import { ensureMachineUuid, markTelemetryNoticeShown, resolveProfile } from "./config.ts"; +import { + ensureMachineUuid, + getTelemetryDisabled, + markTelemetryNoticeShown, + resolveProfile, +} from "./config.ts"; import { detectAiAgent, detectInScreen, @@ -57,16 +63,46 @@ const isOptOutEnv = (value?: string): boolean => { return normalized !== "0" && normalized !== "false"; }; +type OptOutEnvVar = "CLERK_TELEMETRY_DISABLED" | "DO_NOT_TRACK"; + +function optOutEnvVar(env: EnvLike): OptOutEnvVar | null { + if (isOptOutEnv(env.CLERK_TELEMETRY_DISABLED)) return "CLERK_TELEMETRY_DISABLED"; + if (isOptOutEnv(env.DO_NOT_TRACK)) return "DO_NOT_TRACK"; + return null; +} + +/** Pure env + version check; the persisted opt-out lives in getTelemetryStatus. */ export function telemetryEnabled( env: EnvLike = process.env, version: string = resolveCliVersion() ?? DEV_CLI_VERSION, ): boolean { - if (isOptOutEnv(env.CLERK_TELEMETRY_DISABLED)) return false; - if (isOptOutEnv(env.DO_NOT_TRACK)) return false; + if (optOutEnvVar(env)) return false; if (env.CLERK_TELEMETRY_URL) return true; return version !== DEV_CLI_VERSION; } +export type TelemetryStatus = + | { enabled: true } + | { enabled: false; reason: "env"; envVar: OptOutEnvVar } + | { enabled: false; reason: "config" } + | { enabled: false; reason: "dev-build" }; + +/** + * Effective enablement with the winning reason, in precedence order: + * env opt-out > persisted `clerk telemetry disable` > dev-build guard. + * Source of truth for both the finalize path and `clerk telemetry status`. + */ +export async function getTelemetryStatus( + env: EnvLike = process.env, + version: string = resolveCliVersion() ?? DEV_CLI_VERSION, +): Promise { + const envVar = optOutEnvVar(env); + if (envVar) return { enabled: false, reason: "env", envVar }; + if (await getTelemetryDisabled()) return { enabled: false, reason: "config" }; + if (!telemetryEnabled(env, version)) return { enabled: false, reason: "dev-build" }; + return { enabled: true }; +} + /** "users list" for `clerk users list` — root name excluded, never raw argv. */ function commandPathOf(cmd: TelemetryCommand): string { const parts: string[] = []; @@ -123,10 +159,15 @@ export function telemetryResultForError(error: unknown): TelemetryResult { export async function finalizeAndSendTelemetry(result: TelemetryResult): Promise { const current = context; context = null; - if (!current || !telemetryEnabled()) return; + if (!current) return; try { - await maybeShowTelemetryNotice(); + // Re-checked here (not just at start) so `clerk telemetry disable` itself + // sees the freshly persisted opt-out and sends nothing. + if (!(await getTelemetryStatus()).enabled) return; + + // The run that first discloses telemetry sends nothing — the notice says so. + if (await maybeShowTelemetryNotice()) return; const machineUuid = await ensureMachineUuid(); const resolved = await resolveProfile(process.cwd()).catch(() => undefined); @@ -159,6 +200,8 @@ export async function finalizeAndSendTelemetry(result: TelemetryResult): Promise }, }; + log.debug(`telemetry: event ${JSON.stringify(event)}`); + const url = process.env.CLERK_TELEMETRY_URL ?? DEFAULT_TELEMETRY_ENDPOINT; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS); @@ -178,14 +221,23 @@ export async function finalizeAndSendTelemetry(result: TelemetryResult): Promise } } -/** One-time stderr disclosure; human runs outside CI only. Docs cover the rest. */ -async function maybeShowTelemetryNotice(): Promise { - if (!isHuman() || process.env.CI) return; - if (!(await markTelemetryNoticeShown())) return; +/** + * One-time stderr disclosure; human runs outside CI only. Docs cover the rest. + * Returns true when the notice was just shown — that run sends nothing, so + * disclosure always precedes the first event. + */ +async function maybeShowTelemetryNotice(): Promise { + if (!isHuman() || process.env.CI) return false; + if (!(await markTelemetryNoticeShown())) return false; log.blank(); - log.info("The Clerk CLI collects anonymous usage telemetry to help improve the CLI."); log.info( - "Learn more or opt out: https://clerk.com/docs/telemetry (`CLERK_TELEMETRY_DISABLED=1`)", + "The Clerk CLI collects usage telemetry to help improve the CLI: command name, flag names,", + ); + log.info( + "duration, outcome, a random machine identifier — and your workspace and app IDs when a", ); + log.info("project is linked. Nothing has been sent during this run."); + log.info("Opt out: `clerk telemetry disable` — details: https://clerk.com/docs/telemetry"); log.blank(); + return true; } diff --git a/packages/cli-core/src/test/integration/telemetry.test.ts b/packages/cli-core/src/test/integration/telemetry.test.ts index 8df0e786..83b03e56 100644 --- a/packages/cli-core/src/test/integration/telemetry.test.ts +++ b/packages/cli-core/src/test/integration/telemetry.test.ts @@ -30,7 +30,17 @@ function telemetryEvents() { return requests.map((r) => JSON.parse(r.body ?? "{}") as { events: Record[] }); } -test("sends one anonymous event for a successful command", async () => { +// The run that first shows the disclosure notice deliberately sends nothing, +// so tests that expect an event pre-mark the notice as already shown. +// Dynamic import per the harness rule: config.ts transitively imports mocked +// modules, so it must load after the harness registers its mocks. +async function markNoticeAlreadyShown() { + const { markTelemetryNoticeShown } = await import("../../lib/config.ts"); + await markTelemetryNoticeShown(); +} + +test("sends one event for a successful command", async () => { + await markNoticeAlreadyShown(); process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; http.mock({ "test-telemetry.clerk.com": {} }); @@ -58,6 +68,7 @@ test("sends one anonymous event for a successful command", async () => { }); test("records failures with error code and reuses the machine uuid", async () => { + await markNoticeAlreadyShown(); process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; http.mock({ "test-telemetry.clerk.com": {} }); const first = await clerk("completion", "zsh"); @@ -83,6 +94,7 @@ test("records failures with error code and reuses the machine uuid", async () => }); test("maps a soft failure (process.exitCode set without throwing) to outcome error", async () => { + await markNoticeAlreadyShown(); process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; http.mock({ "test-telemetry.clerk.com": {} }); From 7e02d2bc4a14bee2a72d024c61ac31313b490042 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Fri, 7 Aug 2026 10:22:03 -0400 Subject: [PATCH 13/17] feat(telemetry): add clerk telemetry status|disable|enable with a persisted opt-out status reports the winning reason (env var > clerk telemetry disable > dev-build guard); a run of `clerk telemetry disable` never sends an event. Co-Authored-By: Claude Fable 5 --- README.md | 1 + packages/cli-core/src/cli-program.ts | 2 + .../cli-core/src/commands/telemetry/README.md | 25 +++++++ .../cli-core/src/commands/telemetry/index.ts | 74 +++++++++++++++++++ .../src/test/integration/telemetry.test.ts | 74 +++++++++++++++++++ 5 files changed, 176 insertions(+) create mode 100644 packages/cli-core/src/commands/telemetry/README.md create mode 100644 packages/cli-core/src/commands/telemetry/index.ts diff --git a/README.md b/README.md index 832ce996..1da820db 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Commands: impersonate|imp [options] [user] Impersonate a Clerk user env Manage environment variables config Manage instance configuration + telemetry Control CLI usage telemetry (status, disable, enable) enable Enable Clerk features on the linked instance disable Disable Clerk features on the linked instance api [options] [endpoint] [filter] Make authenticated requests to the Clerk API diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 87b53605..03c17a76 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -13,6 +13,7 @@ import { registerUsers } from "./commands/users/index.ts"; import { registerImpersonate } from "./commands/impersonate/index.ts"; import { registerEnv } from "./commands/env/index.ts"; import { registerConfig } from "./commands/config/index.ts"; +import { registerTelemetry } from "./commands/telemetry/index.ts"; import { registerToggles } from "./commands/toggles/index.ts"; import { registerApi } from "./commands/api/index.ts"; import { registerDoctor } from "./commands/doctor/index.ts"; @@ -71,6 +72,7 @@ const registrants: CommandRegistrant[] = [ registerImpersonate, registerEnv, registerConfig, + registerTelemetry, registerToggles, registerApi, registerDoctor, diff --git a/packages/cli-core/src/commands/telemetry/README.md b/packages/cli-core/src/commands/telemetry/README.md new file mode 100644 index 00000000..f9485aa7 --- /dev/null +++ b/packages/cli-core/src/commands/telemetry/README.md @@ -0,0 +1,25 @@ +# clerk telemetry + +Control CLI usage telemetry. + +## Usage + +```sh +clerk telemetry status # Show whether telemetry is enabled and why +clerk telemetry disable # Persist an opt-out for this machine +clerk telemetry enable # Remove the persisted opt-out +``` + +`status` prints the effective state and the winning reason, in precedence order: the +`CLERK_TELEMETRY_DISABLED` / `DO_NOT_TRACK` environment variables, then the persisted +opt-out from `clerk telemetry disable`, then the automatic dev-build guard. In agent +mode it emits the status object as JSON on stdout. + +A run of `clerk telemetry disable` never sends a telemetry event itself — the opt-out +is re-checked after the command executes. + +## Clerk API endpoints + +None. These subcommands only read and write the local CLI config file +(`telemetryDisabled` flag). Telemetry events themselves are documented in the root +README's Telemetry section. diff --git a/packages/cli-core/src/commands/telemetry/index.ts b/packages/cli-core/src/commands/telemetry/index.ts new file mode 100644 index 00000000..bfafdc38 --- /dev/null +++ b/packages/cli-core/src/commands/telemetry/index.ts @@ -0,0 +1,74 @@ +import type { Program } from "../../cli-program.ts"; +import { setTelemetryDisabled } from "../../lib/config.ts"; +import { getTelemetryStatus, type TelemetryStatus } from "../../lib/telemetry.ts"; +import { log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; + +function describeDisabledReason(status: Exclude): string { + switch (status.reason) { + case "env": + return `Disabled by the \`${status.envVar}\` environment variable.`; + case "config": + return "Disabled via `clerk telemetry disable`. Re-enable with `clerk telemetry enable`."; + case "dev-build": + return "Disabled automatically for dev builds (`0.0.0-dev`)."; + } +} + +export async function telemetryStatus(): Promise { + const status = await getTelemetryStatus(); + if (isAgent()) { + log.data(JSON.stringify(status)); + return; + } + log.data(`Telemetry is ${status.enabled ? "enabled" : "disabled"}`); + if (status.enabled) { + log.info("Opt out with `clerk telemetry disable` (or `CLERK_TELEMETRY_DISABLED=1`)."); + } else { + log.info(describeDisabledReason(status)); + } +} + +export async function telemetryDisable(): Promise { + await setTelemetryDisabled(true); + log.success("Telemetry disabled. Nothing will be sent from this machine."); +} + +export async function telemetryEnable(): Promise { + await setTelemetryDisabled(false); + log.success("Telemetry enabled."); + const status = await getTelemetryStatus(); + if (!status.enabled && status.reason === "env") { + log.warn(`\`${status.envVar}\` is still set — telemetry stays disabled until it is unset.`); + } +} + +export function registerTelemetry(program: Program): void { + const telemetry = program + .command("telemetry") + .description("Control CLI usage telemetry (status, disable, enable)"); + + telemetry + .command("status") + .description("Show whether telemetry is enabled and why") + .setExamples([ + { command: "clerk telemetry status", description: "Show the current telemetry state" }, + ]) + .action(telemetryStatus); + + telemetry + .command("disable") + .description("Disable telemetry for this machine (persisted)") + .setExamples([ + { command: "clerk telemetry disable", description: "Opt out of usage telemetry" }, + ]) + .action(telemetryDisable); + + telemetry + .command("enable") + .description("Re-enable telemetry for this machine") + .setExamples([ + { command: "clerk telemetry enable", description: "Opt back in to usage telemetry" }, + ]) + .action(telemetryEnable); +} diff --git a/packages/cli-core/src/test/integration/telemetry.test.ts b/packages/cli-core/src/test/integration/telemetry.test.ts index 83b03e56..5fa48eaf 100644 --- a/packages/cli-core/src/test/integration/telemetry.test.ts +++ b/packages/cli-core/src/test/integration/telemetry.test.ts @@ -126,3 +126,77 @@ test("no telemetry traffic without CLERK_TELEMETRY_URL (dev build guard)", async await clerk("completion", "zsh"); expect(http.requests).toHaveLength(0); }); + +test("the first human run shows the notice and sends nothing; the next run sends", async () => { + const originalCi = process.env.CI; + delete process.env.CI; // the notice is suppressed in CI environments + try { + process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; + http.mock({ "test-telemetry.clerk.com": {} }); + + const first = await clerk("completion", "zsh"); + expect(first.stderr).toContain("Nothing has been sent during this run"); + expect(first.stderr).toContain("clerk telemetry disable"); + expect(telemetryEvents()).toHaveLength(0); + + const second = await clerk("completion", "zsh"); + expect(second.stderr).not.toContain("Nothing has been sent during this run"); + expect(telemetryEvents()).toHaveLength(1); + } finally { + if (originalCi === undefined) delete process.env.CI; + else process.env.CI = originalCi; + } +}); + +test("`clerk telemetry disable` itself sends nothing, and the opt-out persists", async () => { + await markNoticeAlreadyShown(); + process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; + http.mock(); // any fetch would record and throw — none may happen + + await clerk("telemetry", "disable"); + await clerk("completion", "zsh"); + expect(http.requests).toHaveLength(0); +}); + +test("`clerk telemetry enable` turns events back on", async () => { + await markNoticeAlreadyShown(); + process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; + http.mock({ "test-telemetry.clerk.com": {} }); + + await clerk("telemetry", "disable"); // sends nothing: opt-out visible at finalize + await clerk("telemetry", "enable"); // sends: the user just opted back in + await clerk("completion", "zsh"); + + const bodies = telemetryEvents(); + expect(bodies).toHaveLength(2); + expect(bodies[0]!.events[0]!.payload.command).toBe("telemetry enable"); + expect(bodies[1]!.events[0]!.payload.command).toBe("completion"); +}); + +test("`clerk telemetry status` reports the state and the winning reason", async () => { + await markNoticeAlreadyShown(); + process.env.CLERK_TELEMETRY_URL = TELEMETRY_URL; // lifts the dev-build guard + http.mock({ "test-telemetry.clerk.com": {} }); + + const enabled = await clerk("telemetry", "status"); + expect(enabled.stdout).toContain("Telemetry is enabled"); + + // Broadened env parsing honored end-to-end: "yes" opts out. + process.env.CLERK_TELEMETRY_DISABLED = "yes"; + const disabledByEnv = await clerk("telemetry", "status"); + expect(disabledByEnv.stdout).toContain("Telemetry is disabled"); + expect(disabledByEnv.stderr).toContain("CLERK_TELEMETRY_DISABLED"); + delete process.env.CLERK_TELEMETRY_DISABLED; + + await clerk("telemetry", "disable"); + const disabledByConfig = await clerk("telemetry", "status"); + expect(disabledByConfig.stdout).toContain("Telemetry is disabled"); + expect(disabledByConfig.stderr).toContain("clerk telemetry enable"); +}); + +test("`clerk telemetry status` explains the dev-build guard", async () => { + http.mock(); // dev build without the URL escape hatch: no network at all + const result = await clerk("telemetry", "status"); + expect(result.stdout).toContain("Telemetry is disabled"); + expect(result.stderr).toContain("dev build"); +}); From 6d898890f982b52dc877b686d3d2a34f34a1b023 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Fri, 7 Aug 2026 10:23:11 -0400 Subject: [PATCH 14/17] docs(telemetry): drop the anonymous claim; describe linked workspace/app ids honestly Co-Authored-By: Claude Fable 5 --- .changeset/anonymous-telemetry.md | 2 +- README.md | 15 +++++++++------ packages/cli-core/src/lib/config.ts | 2 +- packages/cli-core/src/lib/telemetry.ts | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.changeset/anonymous-telemetry.md b/.changeset/anonymous-telemetry.md index f238e951..9ff3eb7b 100644 --- a/.changeset/anonymous-telemetry.md +++ b/.changeset/anonymous-telemetry.md @@ -2,4 +2,4 @@ "clerk": minor --- -Collect anonymous usage telemetry (command name, flag names, duration, outcome, a random machine identifier, and — when a project is linked — the app and workspace IDs) to help improve the CLI; opt out with `CLERK_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1`. +Collect usage telemetry (command name, flag names, duration, outcome, a random machine identifier — and your workspace and app IDs when a project is linked; never arguments, option values, paths, or personal data). The first run only shows a disclosure notice and sends nothing, and `--verbose` prints every event before it is sent. Control it with the new `clerk telemetry status|disable|enable` subcommand, or the `CLERK_TELEMETRY_DISABLED` / `DO_NOT_TRACK` environment variables (any non-false value opts out). diff --git a/README.md b/README.md index 1da820db..6fc46cca 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,13 @@ Commands: ## Telemetry -The Clerk CLI collects anonymous usage telemetry (command name, flag names, duration, -outcome, environment signals like OS, install method, and terminal, a random machine -identifier, and — when a project is linked — the app and workspace IDs). It never -collects command arguments, option values, file paths, or personal data. See -https://clerk.com/docs/telemetry for details. +The Clerk CLI collects usage telemetry: command name, flag names, duration, outcome, +environment signals (OS, install method, terminal), a random machine identifier — and +your workspace and app IDs when a project is linked. It never collects command +arguments, option values, file paths, or personal data. The first run only shows a +notice and sends nothing, and `clerk --verbose` prints every event before it is sent. +See https://clerk.com/docs/telemetry for details. -Opt out by setting `CLERK_TELEMETRY_DISABLED=1` (or the standard `DO_NOT_TRACK=1`). +Opt out with `clerk telemetry disable`, or by setting `CLERK_TELEMETRY_DISABLED=1` +(the standard `DO_NOT_TRACK=1` also works). `clerk telemetry status` shows the +effective state and why. diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index cb035bfc..97472e55 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -214,7 +214,7 @@ export async function setRelayEntry(key: string, entry: RelayEntry): Promise { const config = await readConfig(); if (config.machineUuid) return config.machineUuid; diff --git a/packages/cli-core/src/lib/telemetry.ts b/packages/cli-core/src/lib/telemetry.ts index 5e8edbad..a02d9bf4 100644 --- a/packages/cli-core/src/lib/telemetry.ts +++ b/packages/cli-core/src/lib/telemetry.ts @@ -1,5 +1,5 @@ /** - * Anonymous per-invocation usage telemetry (GROW-1200). + * Per-invocation usage telemetry (GROW-1200). * * One CLI_COMMAND_EXECUTED event per command run, POSTed to the * telemetry-service worker (BigQuery behind it). Opt out with From 790232174e13e3a691000a83ccecd5351eb97479 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Fri, 7 Aug 2026 11:39:29 -0400 Subject: [PATCH 15/17] docs(telemetry): note verified agent marker values in detection comment Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/lib/env-signals.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli-core/src/lib/env-signals.ts b/packages/cli-core/src/lib/env-signals.ts index 80779e81..d14b864c 100644 --- a/packages/cli-core/src/lib/env-signals.ts +++ b/packages/cli-core/src/lib/env-signals.ts @@ -9,6 +9,9 @@ export type EnvLike = Record; +// Truthiness (not equality) is deliberate: harnesses use different marker +// values — gemini/opencode set "1", cline sets "true", openclaw sets a mode +// string like "tui-local" (verified against each tool's shipped code). export function detectAiAgent(env: EnvLike): string { if (env.ANTIGRAVITY_CLI_ALIAS) return "antigravity"; if (env.CLAUDECODE) return "claude_code"; From aa85a10d851a03317643cc20e92e26e282cac6bb Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Fri, 7 Aug 2026 11:52:36 -0400 Subject: [PATCH 16/17] docs(telemetry): rename changeset slug to match honest wording Co-Authored-By: Claude Fable 5 --- .changeset/{anonymous-telemetry.md => usage-telemetry.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changeset/{anonymous-telemetry.md => usage-telemetry.md} (100%) diff --git a/.changeset/anonymous-telemetry.md b/.changeset/usage-telemetry.md similarity index 100% rename from .changeset/anonymous-telemetry.md rename to .changeset/usage-telemetry.md From d07771f7a1be4ee76b2b7ccb357c6b4a8794ca51 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Fri, 7 Aug 2026 11:57:23 -0400 Subject: [PATCH 17/17] chore(telemetry): trim redundant code comments Co-Authored-By: Claude Fable 5 --- packages/cli-core/src/cli-program.ts | 5 ++--- packages/cli-core/src/lib/env-signals.ts | 8 ++++---- packages/cli-core/src/lib/telemetry.test.ts | 4 +--- packages/cli-core/src/lib/telemetry.ts | 12 +++++------- .../cli-core/src/test/integration/telemetry.test.ts | 7 +++---- 5 files changed, 15 insertions(+), 21 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 03c17a76..985df7d9 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -239,9 +239,8 @@ export async function runProgram( try { const { argv, from } = await resolveArgv(args, options?.from); await program.parseAsync(argv, { from }); - // Some commands report failure via process.exitCode instead of throwing - // (e.g. api, deploy status, mcp, users) — read it back so telemetry - // doesn't record those as a success. + // Some commands report failure via process.exitCode instead of throwing — + // read it back so telemetry doesn't record them as successes. const softExitCode = Number(process.exitCode ?? EXIT_CODE.SUCCESS); await finalizeAndSendTelemetry({ outcome: softExitCode === EXIT_CODE.SUCCESS ? "success" : "error", diff --git a/packages/cli-core/src/lib/env-signals.ts b/packages/cli-core/src/lib/env-signals.ts index d14b864c..2b0cc241 100644 --- a/packages/cli-core/src/lib/env-signals.ts +++ b/packages/cli-core/src/lib/env-signals.ts @@ -1,7 +1,7 @@ /** - * Anonymous environment signals for CLI telemetry (GROW-1200). - * Detection tables ported from stripe-cli's pkg/useragent (their field/value - * enums are the reference the ticket's proposed scope was written against). + * Environment signals for CLI telemetry: which AI agent, terminal, and + * install method a run came from. The returned strings are analytics keys — + * renaming one breaks downstream queries. * * All functions take an injected env so tests never depend on the ambient * environment (the dev machine may itself run inside an AI agent or tmux). @@ -11,7 +11,7 @@ export type EnvLike = Record; // Truthiness (not equality) is deliberate: harnesses use different marker // values — gemini/opencode set "1", cline sets "true", openclaw sets a mode -// string like "tui-local" (verified against each tool's shipped code). +// string like "tui-local". export function detectAiAgent(env: EnvLike): string { if (env.ANTIGRAVITY_CLI_ALIAS) return "antigravity"; if (env.CLAUDECODE) return "claude_code"; diff --git a/packages/cli-core/src/lib/telemetry.test.ts b/packages/cli-core/src/lib/telemetry.test.ts index 1d4d9239..74161122 100644 --- a/packages/cli-core/src/lib/telemetry.test.ts +++ b/packages/cli-core/src/lib/telemetry.test.ts @@ -34,9 +34,7 @@ describe("telemetryEnabled", () => { expect(telemetryEnabled({}, REAL)).toBe(true); }); - // Any non-empty value except an explicit "0"/"false" opts out — a user who - // sets CLERK_TELEMETRY_DISABLED=yes meant to disable; silently staying on is - // the worst failure mode for a privacy control. + // Any non-empty value except an explicit "0"/"false" opts out. test.each([ [{ CLERK_TELEMETRY_DISABLED: "1" }], [{ CLERK_TELEMETRY_DISABLED: "true" }], diff --git a/packages/cli-core/src/lib/telemetry.ts b/packages/cli-core/src/lib/telemetry.ts index a02d9bf4..997de2fe 100644 --- a/packages/cli-core/src/lib/telemetry.ts +++ b/packages/cli-core/src/lib/telemetry.ts @@ -1,5 +1,5 @@ /** - * Per-invocation usage telemetry (GROW-1200). + * Per-invocation usage telemetry. * * One CLI_COMMAND_EXECUTED event per command run, POSTed to the * telemetry-service worker (BigQuery behind it). Opt out with @@ -90,7 +90,6 @@ export type TelemetryStatus = /** * Effective enablement with the winning reason, in precedence order: * env opt-out > persisted `clerk telemetry disable` > dev-build guard. - * Source of truth for both the finalize path and `clerk telemetry status`. */ export async function getTelemetryStatus( env: EnvLike = process.env, @@ -125,7 +124,7 @@ function collectSetFlagNames(cmd: TelemetryCommand): string[] { return names; } -/** Called from the root preAction hook. Pure in-memory; never throws. */ +/** Pure in-memory; never throws. */ export function startCommandTelemetry(actionCommand: TelemetryCommand): void { try { context = { @@ -166,7 +165,6 @@ export async function finalizeAndSendTelemetry(result: TelemetryResult): Promise // sees the freshly persisted opt-out and sends nothing. if (!(await getTelemetryStatus()).enabled) return; - // The run that first discloses telemetry sends nothing — the notice says so. if (await maybeShowTelemetryNotice()) return; const machineUuid = await ensureMachineUuid(); @@ -222,9 +220,9 @@ export async function finalizeAndSendTelemetry(result: TelemetryResult): Promise } /** - * One-time stderr disclosure; human runs outside CI only. Docs cover the rest. - * Returns true when the notice was just shown — that run sends nothing, so - * disclosure always precedes the first event. + * One-time stderr disclosure; human runs outside CI only. Returns true when + * the notice was just shown — that run sends nothing, so disclosure always + * precedes the first event. */ async function maybeShowTelemetryNotice(): Promise { if (!isHuman() || process.env.CI) return false; diff --git a/packages/cli-core/src/test/integration/telemetry.test.ts b/packages/cli-core/src/test/integration/telemetry.test.ts index 5fa48eaf..76efa87c 100644 --- a/packages/cli-core/src/test/integration/telemetry.test.ts +++ b/packages/cli-core/src/test/integration/telemetry.test.ts @@ -52,8 +52,8 @@ test("sends one event for a successful command", async () => { const event = bodies[0]!.events[0]!; expect(event.sdk).toBe("clerk-cli"); expect(event.event).toBe("CLI_COMMAND_EXECUTED"); - // Command path is subcommand NAMES only — "zsh" is an argument value and - // is deliberately excluded (spec: "resolved command path, never raw argv"). + // Command path is subcommand names only — "zsh" is an argument value and + // is deliberately excluded. expect(event.payload.command).toBe("completion"); expect(event.payload.outcome).toBe("success"); expect(event.payload.exit_code).toBe(0); @@ -99,8 +99,7 @@ test("maps a soft failure (process.exitCode set without throwing) to outcome err http.mock({ "test-telemetry.clerk.com": {} }); try { - // Simulates commands (api, deploy status, mcp, users) that report - // failure by setting process.exitCode instead of throwing. + // Simulates commands that report failure via process.exitCode instead of throwing. process.exitCode = 1; await clerk.raw("completion", "zsh");