Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
3a4da8d
fix(telemetry): render errors before awaiting the telemetry send
djgould Aug 10, 2026
d75fa06
fix(telemetry): bound the entire telemetry job with one deadline
djgould Aug 10, 2026
7614f4b
feat(telemetry): give agents the first-run notice and grace too
djgould Aug 10, 2026
d9e8033
fix(telemetry): bound the free-text payload dimensions
djgould Aug 10, 2026
d309c9d
fix(telemetry): capture hook-time failures like invalid --mode
djgould Aug 10, 2026
76167b1
fix(telemetry): pipeable status output — bare state on stdout, reason…
djgould Aug 10, 2026
08f747c
feat(telemetry): shed the machine uuid on opt-out
djgould Aug 10, 2026
720e16a
fix(telemetry): telemetry failures never trigger the sandbox warning
djgould Aug 10, 2026
2a450e4
Update packages/cli-core/src/lib/telemetry.ts
djgould Aug 10, 2026
3124921
fix(telemetry): AIAgent UA marker joins the segment list and respects…
djgould Aug 10, 2026
da37779
docs(telemetry): note why the disclosure run skips the send
djgould Aug 10, 2026
3fb8b9a
test(telemetry): prove mistyped or pasted secrets never reach an event
djgould Aug 10, 2026
6b9fe1c
refactor(cli): extract error rendering into a testable reportError
wyattjoh Aug 10, 2026
73d4649
fix(telemetry): AIAgent UA segment also waits for disclosure
djgould Aug 11, 2026
3f1f342
fix(telemetry): warn that dev builds stay disabled on telemetry enable
djgould Aug 11, 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 (CI environments send from the first run), 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).
15 changes: 15 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,17 @@ 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
disclosure notice and sends nothing (CI environments send from the first run), and
`clerk --verbose` prints every event before it is sent.
See https://clerk.com/docs/telemetry for details.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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.
221 changes: 218 additions & 3 deletions packages/cli-core/src/cli-program.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { test, expect, describe } from "bun:test";
import { createProgram, formatApiBody, outputJsonError } from "./cli-program.ts";
import { ApiError } from "./lib/errors.ts";
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
import { createProgram, formatApiBody, outputJsonError, reportError } from "./cli-program.ts";
import {
ApiError,
CliError,
ERROR_CODE,
EXIT_CODE,
PlapiError,
UserAbortError,
} from "./lib/errors.ts";
import { telemetryResultForError } from "./lib/telemetry.ts";
import { useCaptureLog } from "./test/lib/stubs.ts";

test("registers users as a top-level command", () => {
Expand Down Expand Up @@ -367,3 +375,210 @@ describe("outputJsonError", () => {
expect(parse().error).not.toHaveProperty("examples");
});
});

// `reportError` is the whole error-dispatch cascade of `runProgram`'s catch,
// minus the `process.exit` call. Exercising it directly is the only way to
// cover every branch — going through `runProgram` needs a `process.exit` spy
// and can only reach one branch per invocation.
describe("reportError", () => {
const captured = useCaptureLog();

// `getMode()` falls back to TTY detection, which reports agent mode under the
// test runner. Drive the env var rather than `setMode()` so the override is
// restorable — `mode.ts` exposes no way to clear a forced mode.
const originalMode = process.env.CLERK_MODE;
const asHuman = () => {
process.env.CLERK_MODE = "human";
};
const asAgent = () => {
process.env.CLERK_MODE = "agent";
};

beforeEach(asHuman);
afterEach(() => {
if (originalMode === undefined) {
delete process.env.CLERK_MODE;
} else {
process.env.CLERK_MODE = originalMode;
}
});

const json = () => JSON.parse(captured.err.trim()) as { error: Record<string, any> };

/** Matches what `@inquirer/prompts` throws on Ctrl+C. */
const promptExitError = () => {
const error = new Error("User force closed the prompt with SIGINT");
error.name = "ExitPromptError";
return error;
};

describe("aborts", () => {
test("UserAbortError exits clean and prints nothing", () => {
expect(reportError(new UserAbortError(), false)).toBe(EXIT_CODE.SUCCESS);
expect(captured.err).toBe("");
expect(captured.out).toBe("");
});

test("a force-closed prompt exits clean and prints nothing", () => {
expect(reportError(promptExitError(), false)).toBe(EXIT_CODE.SUCCESS);
expect(captured.err).toBe("");
expect(captured.out).toBe("");
});

test("an ExitPromptError with a different message is not treated as an abort", () => {
const error = new Error("something else");
error.name = "ExitPromptError";
expect(reportError(error, false)).toBe(EXIT_CODE.GENERAL);
expect(captured.err).toContain("something else");
});
});

describe("CliError", () => {
test("returns the error's own exit code", () => {
const error = new CliError("bad flag", {
code: ERROR_CODE.USAGE_ERROR,
exitCode: EXIT_CODE.USAGE,
});
expect(reportError(error, false)).toBe(EXIT_CODE.USAGE);
});

test("human mode prints message, examples, and docs link", () => {
const error = new CliError("Not linked", {
code: ERROR_CODE.NOT_LINKED,
docsUrl: "https://example.com/docs/link",
examples: [{ command: "clerk link", description: "Link this directory" }],
});
reportError(error, false);
expect(captured.err).toContain("Not linked");
expect(captured.err).toContain("clerk link");
expect(captured.err).toContain("For more information, see: https://example.com/docs/link");
});

test("agent mode emits a JSON payload carrying code, docs URL, and examples", () => {
asAgent();
const error = new CliError("Not linked", {
code: ERROR_CODE.NOT_LINKED,
docsUrl: "https://example.com/docs/link",
examples: [{ command: "clerk link", description: "Link this directory" }],
});
expect(reportError(error, false)).toBe(EXIT_CODE.GENERAL);
expect(json().error).toMatchObject({
code: ERROR_CODE.NOT_LINKED,
message: "Not linked",
docsUrl: "https://example.com/docs/link",
examples: [{ command: "clerk link", description: "Link this directory" }],
});
});

test("agent mode falls back to human rendering when the error has no code", () => {
asAgent();
reportError(new CliError("uncoded failure"), false);
expect(captured.err).toContain("uncoded failure");
expect(() => json()).toThrow();
});
});

describe("ApiError", () => {
const body = JSON.stringify({
errors: [
{ code: "form_param_missing", message: "Missing param", meta: { param_name: "email" } },
],
clerk_trace_id: "trace_123",
});

// Only the prefix + status wiring is asserted here; the detail string is
// `formatStructuredError`'s job and is covered by the `formatApiBody` block.
test("human mode prefixes with the status", () => {
reportError(new ApiError(400, body), false);
expect(captured.err).toContain("Request failed (400): Missing param");
});

test("human mode prefers the error's context over the default prefix", () => {
const error = new ApiError(400, body);
error.context = "Platform API request failed";
reportError(error, false);
expect(captured.err).toContain("Platform API request failed (400):");
expect(captured.err).not.toContain("Request failed (400):");
});

test("verbose adds the request URL and trace id", () => {
reportError(new PlapiError(400, body, "https://api.clerk.com/v1/apps"), true);
expect(captured.err).toContain("URL: https://api.clerk.com/v1/apps");
expect(captured.err).toContain("Trace: trace_123");
});

test("agent mode emits the code and a structured errors array", () => {
asAgent();
reportError(new ApiError(400, body), false);
expect(json().error).toMatchObject({
code: "form_param_missing",
message: "Request failed (400): Missing param\n Parameter: email",
errors: [
{ code: "form_param_missing", message: "Missing param", meta: { param_name: "email" } },
],
});
});

test("agent mode falls back to api_error and omits errors when there is no code or meta", () => {
asAgent();
reportError(new ApiError(502, "upstream exploded"), false);
expect(json().error.code).toBe("api_error");
expect(json().error).not.toHaveProperty("errors");
});
});

describe("unexpected failures", () => {
test("a plain Error prints its message in human mode", () => {
expect(reportError(new Error("socket hang up"), false)).toBe(EXIT_CODE.GENERAL);
expect(captured.err).toContain("socket hang up");
});

test("a plain Error becomes unexpected_error in agent mode", () => {
asAgent();
reportError(new Error("socket hang up"), false);
expect(json().error).toMatchObject({
code: "unexpected_error",
message: "socket hang up",
});
});

test("a non-Error throw gets a generic message in human mode", () => {
expect(reportError("just a string", false)).toBe(EXIT_CODE.GENERAL);
expect(captured.err).toContain("An unexpected error occurred");
});

test("a non-Error throw gets a generic message in agent mode", () => {
asAgent();
expect(reportError({ nope: true }, false)).toBe(EXIT_CODE.GENERAL);
expect(json().error).toMatchObject({
code: "unexpected_error",
message: "An unexpected error occurred",
});
});
});

// `telemetryResultForError` runs the same instanceof cascade to decide what
// exit code the event reports. If the two drift, telemetry records a code the
// user never saw, and nothing else in the suite would notice.
describe("agrees with telemetryResultForError on the exit code", () => {
const fixtures: [string, unknown][] = [
["UserAbortError", new UserAbortError()],
["prompt exit", promptExitError()],
["CliError (default code)", new CliError("boom")],
[
"CliError (usage code)",
new CliError("bad flag", { code: ERROR_CODE.USAGE_ERROR, exitCode: EXIT_CODE.USAGE }),
],
["ApiError", new ApiError(404, "{}")],
["PlapiError", new PlapiError(500, "{}", "https://api.clerk.com/v1/apps")],
["plain Error", new Error("socket hang up")],
["non-Error throw", "just a string"],
];

for (const [name, error] of fixtures) {
test(name, () => {
expect(reportError(error, false)).toBe(telemetryResultForError(error).exitCode);
});
}
});
});
Loading