Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
76c7c14
feat(telemetry): add anonymous environment signal detection
djgould Aug 5, 2026
18fe95e
feat(telemetry): persist machine uuid and notice flag in config
djgould Aug 5, 2026
5828a5c
feat(telemetry): add anonymous command telemetry core
djgould Aug 5, 2026
cdb9adf
test(telemetry): isolate telemetry unit tests from ambient env vars
djgould Aug 5, 2026
b983ba8
feat(telemetry): tag CLI User-Agent with detected AI agent
djgould Aug 5, 2026
be746ac
feat(telemetry): emit anonymous per-command telemetry event
djgould Aug 5, 2026
6b6d094
test(telemetry): assert argument values never leak into telemetry events
djgould Aug 5, 2026
6575bce
docs(telemetry): document anonymous telemetry and opt-out
djgould Aug 5, 2026
4bd99e4
fix(telemetry): final review fixes — env isolation, soft-failure outc…
djgould Aug 5, 2026
b671d67
fix(telemetry): treat any non-false opt-out env value as an opt-out
djgould Aug 7, 2026
48943b8
feat(telemetry): persist a telemetry opt-out flag in config
djgould Aug 7, 2026
285150b
feat(telemetry): no send on the disclosure run, persisted opt-out re-…
djgould Aug 7, 2026
7e02d2b
feat(telemetry): add clerk telemetry status|disable|enable with a per…
djgould Aug 7, 2026
6d89889
docs(telemetry): drop the anonymous claim; describe linked workspace/…
djgould Aug 7, 2026
7902321
docs(telemetry): note verified agent marker values in detection comment
djgould Aug 7, 2026
aa85a10
docs(telemetry): rename changeset slug to match honest wording
djgould Aug 7, 2026
d07771f
chore(telemetry): trim redundant code comments
djgould Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/usage-telemetry.md
Original file line number Diff line number Diff line change
@@ -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).
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
18 changes: 17 additions & 1 deletion packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -66,6 +72,7 @@ const registrants: CommandRegistrant[] = [
registerImpersonate,
registerEnv,
registerConfig,
registerTelemetry,
registerToggles,
registerApi,
registerDoctor,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
25 changes: 25 additions & 0 deletions packages/cli-core/src/commands/telemetry/README.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions packages/cli-core/src/commands/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -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<TelemetryStatus, { enabled: true }>): 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<void> {
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<void> {
await setTelemetryDisabled(true);
log.success("Telemetry disabled. Nothing will be sent from this machine.");
}

export async function telemetryEnable(): Promise<void> {
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);
}
48 changes: 48 additions & 0 deletions packages/cli-core/src/lib/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const {
resolveInstanceId,
resolveAppContext,
resolveFetchedApplicationInstance,
ensureMachineUuid,
getTelemetryDisabled,
markTelemetryNoticeShown,
setTelemetryDisabled,
setEnvironment,
_setConfigDir,
} = await import("./config.ts");
type Profile =
Expand Down Expand Up @@ -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);
});
});
});
41 changes: 41 additions & 0 deletions packages/cli-core/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ interface ClerkConfig {
auth?: Record<string, Auth>;
profiles: Record<string, Profile>;
relay?: Record<string, RelayEntry>;
machineUuid?: string;
telemetryNoticeShown?: boolean;
telemetryDisabled?: boolean;
}

function defaultConfig(): ClerkConfig {
Expand All @@ -71,6 +74,10 @@ function migrateRawConfig(raw: Record<string, unknown>): ClerkConfig {
profiles: (raw.profiles as Record<string, Profile>) ?? {},
};

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<string, RelayEntry> = {};
for (const [key, val] of Object.entries(raw.relay as Record<string, unknown>)) {
Expand Down Expand Up @@ -207,6 +214,40 @@ export async function setRelayEntry(key: string, entry: RelayEntry): Promise<voi
await writeConfig(config);
}

/** Persistent random machine id for telemetry. Generated on first use. */
export async function ensureMachineUuid(): Promise<string> {
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<boolean> {
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<boolean> {
const config = await readConfig();
return config.telemetryDisabled === true;
}

export async function setTelemetryDisabled(disabled: boolean): Promise<void> {
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<
Expand Down
6 changes: 6 additions & 0 deletions packages/cli-core/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading