diff --git a/.changeset/usage-telemetry.md b/.changeset/usage-telemetry.md new file mode 100644 index 000000000..9ff3eb7bf --- /dev/null +++ b/.changeset/usage-telemetry.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +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 ab88d248d..6fc46ccaa 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 @@ -56,3 +57,16 @@ Commands: help [command] Display help for command bird Play Clerk Bird, a Flappy Bird game in your terminal ``` + +## Telemetry + +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 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/cli-program.ts b/packages/cli-core/src/cli-program.ts index 9ea89cb54..985df7d9b 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"; @@ -45,6 +46,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 @@ -66,6 +72,7 @@ const registrants: CommandRegistrant[] = [ registerImpersonate, registerEnv, registerConfig, + registerTelemetry, registerToggles, registerApi, registerDoctor, @@ -100,7 +107,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 +142,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 +239,15 @@ 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 — + // 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", + exitCode: softExitCode, + }); } 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/commands/telemetry/README.md b/packages/cli-core/src/commands/telemetry/README.md new file mode 100644 index 000000000..f9485aa7c --- /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 000000000..bfafdc38e --- /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/lib/config.test.ts b/packages/cli-core/src/lib/config.test.ts index c8deae64a..cf318b2c3 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -16,6 +16,11 @@ const { resolveInstanceId, resolveAppContext, resolveFetchedApplicationInstance, + ensureMachineUuid, + getTelemetryDisabled, + markTelemetryNoticeShown, + setTelemetryDisabled, + setEnvironment, _setConfigDir, } = await import("./config.ts"); type Profile = @@ -331,4 +336,47 @@ 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); + }); + + 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 c0bccb29f..97472e558 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -55,6 +55,9 @@ interface ClerkConfig { auth?: Record; profiles: Record; relay?: Record; + machineUuid?: string; + telemetryNoticeShown?: boolean; + telemetryDisabled?: boolean; } function defaultConfig(): ClerkConfig { @@ -71,6 +74,10 @@ 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.telemetryDisabled === true) config.telemetryDisabled = 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 +214,40 @@ 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; +} + +/** 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< diff --git a/packages/cli-core/src/lib/constants.ts b/packages/cli-core/src/lib/constants.ts index e6dbc1b81..f88439df2 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/env-signals.test.ts b/packages/cli-core/src/lib/env-signals.test.ts new file mode 100644 index 000000000..0fc10ac7a --- /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 000000000..2b0cc241a --- /dev/null +++ b/packages/cli-core/src/lib/env-signals.ts @@ -0,0 +1,73 @@ +/** + * 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). + */ + +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". +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); +} 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 000000000..74161122b --- /dev/null +++ b/packages/cli-core/src/lib/telemetry.test.ts @@ -0,0 +1,274 @@ +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, 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; +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); + }); + + // Any non-empty value except an explicit "0"/"false" opts out. + 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( + 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("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({ + 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; + + // 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 { + 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 }); + }); + + 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 new file mode 100644 index 000000000..997de2fef --- /dev/null +++ b/packages/cli-core/src/lib/telemetry.ts @@ -0,0 +1,241 @@ +/** + * Per-invocation usage telemetry. + * + * One CLI_COMMAND_EXECUTED event per command run, POSTed to the + * telemetry-service worker (BigQuery behind it). Opt out with + * `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, + getTelemetryDisabled, + 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; + +// 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"; +}; + +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 (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. + */ +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[] = []; + 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; +} + +/** 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) return; + + try { + // 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; + + if (await maybeShowTelemetryNotice()) return; + + 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, + }, + }; + + 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); + 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. 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 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/lib/user-agent.test.ts b/packages/cli-core/src/lib/user-agent.test.ts index 98d038f0b..388bb1d78 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 5b136fc2d..28d225318 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}`; } 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 000000000..76efa87c0 --- /dev/null +++ b/packages/cli-core/src/test/integration/telemetry.test.ts @@ -0,0 +1,201 @@ +/** + * 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, 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() { + const requests = http.requests.filter((r) => r.url.startsWith(TELEMETRY_URL)); + return requests.map((r) => JSON.parse(r.body ?? "{}") as { events: Record[] }); +} + +// 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": {} }); + + 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. + 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: 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 () => { + await markNoticeAlreadyShown(); + 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("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": {} }); + + try { + // Simulates commands that report failure via 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 + 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); +}); + +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"); +});