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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/migrate-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Record in usage telemetry which platform a `clerk migrate` run's users come from, how far the run got, and the Clerk app and instance it acted on. The first-run telemetry notice now names these fields.
5 changes: 5 additions & 0 deletions .changeset/migrate-user-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Mark the requests `clerk migrate` sends with a `migrate` segment in the `User-Agent`, so Clerk can count the users each migration creates. Like usage telemetry, the CLI leaves it out when telemetry is off.
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,14 @@ Commands:
## 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, except the text you choose to
send with `clerk feedback`, which is scrubbed on a best-effort basis for common secret
formats, home-directory paths and email addresses before it leaves your machine;
environment signals (OS, install method, terminal), a random machine identifier, the
platform a migration's users come from, and the workspace, app, and instance IDs of the
Clerk app a command acts on or the project is linked to. It doesn't collect raw command
arguments, option values, file paths, or personal data, with two exceptions. `clerk migrate`
reports its source as a built-in platform name or `custom`, and the IDs it acts on can come
from `--app` and `--instance`. `clerk feedback` sends the text you choose to send, and
scrubs it on a best-effort basis for common secret formats, home-directory paths, and email
addresses before it leaves your machine;
`clerk feedback --dry-run` shows exactly what would be sent. The first run only shows a
disclosure notice and sends nothing (CI environments send from the first run), except
`clerk feedback`, which shows the notice and then sends the report you asked it to send
Expand Down
30 changes: 30 additions & 0 deletions packages/cli-core/src/commands/migrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1510,6 +1510,36 @@ stamping every user with today's.
| `skipLegalChecks` | `boolean` | Skip legal acceptance checks |
| `skipPasswordChecks` | `boolean` | Skip password requirements on import |

## What telemetry records

`clerk migrate` adds two fields to the ones every command sends, `migrate_source` and `instance_id`, and gives `app_id` and `stage` values of its own:

| Field | What it holds |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `migrate_source` | Where the users come from: a built-in source or export platform (`auth0`, `clerk`, …), or `custom` for a source you wrote. Never its path or its key. Null when the run stopped before it knew the source. |
| `instance_id` | The Clerk instance the command acted on (`ins_…`). For `export clerk`, the instance it read. Before the run resolves its instance, the `--instance` you passed, unchecked, when it's an `ins_…` ID. Null when there's neither, or when Clerk didn't confirm the instance. |
| `app_id` | Its app (`app_…`): from `--app`, or from the linked profile when that profile owns the instance. Before the run resolves its target, the `--app` you passed, unchecked, when it's an `app_…` ID. When the run learned nothing about its target, the linked project's app, as for every command. Null for a key for another app's instance, or one whose instance Clerk didn't confirm. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
| `stage` | How far the run got: one funnel per subcommand, below. Null for `runs` and `sources`, which are one step each. |

`workspace_id` is the linked profile's only when `app_id` is that profile's app. Telemetry never sends a `key_…` stand-in (the hash a run records when `GET /v1/instance` fails), an `--instance` alias, or an app label.

Stages, in the order each subcommand runs them. Telemetry sends the last stage set, so a failure or a Ctrl-C reports where the run stopped:

- `import`: `migrate_source`, `migrate_target_setup` (sign-in, link), `migrate_input`, `migrate_target`, `migrate_resume`, `migrate_load`, `migrate_checks`, `migrate_confirm`, `migrate_import`, `done`. Terminal branches: `migrate_already_imported`, `migrate_dry_run`, `migrate_nothing_to_import`.
- `export`: `migrate_pick_platform` (bare `clerk migrate export` only), `migrate_export_read` (credentials and reading users), `migrate_export_write`, `done`.
- `undo`: `migrate_run_lookup`, `migrate_target`, `migrate_inspect`, `migrate_confirm`, `migrate_delete`, `done`. Terminal branch: `migrate_dry_run`.

A run that finishes part-way exits nonzero without throwing, and carries a code so it is not an uncoded error:

| Ending | `error_code` | Exit |
| ------------------------------------------ | -------------------------- | ---- |
| `import --dry-run` the checks would refuse | `migrate_checks_rejected` | 2 |
| `import` where some users failed | `migrate_import_partial` | 1 |
| `undo` with deletes left | `migrate_undo_incomplete` | 1 |
| `export` the platform cut short | `migrate_export_truncated` | 1 |

`clerk migrate` also adds a `migrate` segment to the `User-Agent` of the requests it sends (`Clerk-CLI/<version> (…; migrate)`), so Clerk's logs can count the users each migration creates. The CLI follows the same rules as for telemetry: it leaves the segment out when telemetry is off and, outside CI, until the telemetry notice has shown.

## API endpoints

| Method | Path | Used by |
Expand Down
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/migrate/export/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from "../lib/target.ts";
import { resolveClerkSource } from "./clerk-source.ts";
import { finishExport, startExportRun } from "./shared.ts";
import { recordMigrateTarget } from "../telemetry.ts";

/** BAPI's maximum page size for `GET /v1/users`. */
const PAGE_SIZE = 500;
Expand Down Expand Up @@ -296,6 +297,13 @@ export async function exportClerk(options: ExportClerkOptions): Promise<void> {
instanceId: identity.instanceId,
...(keySource ? { keySource } : {}),
};
// The source instance: for this command, the one it acts on.
// `--app` named the app unless a key overrode it; a linked profile's app
// is filled in from its instances.
recordMigrateTarget({
appId: keyFrom ? undefined : options.app,
instanceId: identity.instanceId,
});
if (!options.json) printTarget(target);

const users = await withSpinner("Fetching users from Clerk...", async (spinner) =>
Expand Down
10 changes: 10 additions & 0 deletions packages/cli-core/src/commands/migrate/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { exportWorkOs } from "./workos.ts";
import type { DbExportOptions } from "./db-options.ts";
import { RUNS_DIR_DESCRIPTION, RUNS_DIR_FLAG } from "../lib/run-store.ts";
import { exportPlatformKeys, exportPlatforms, getExportPlatform } from "./registry.ts";
import { setTelemetryStage } from "../../../lib/telemetry.ts";
import { recordMigrateSource } from "../telemetry.ts";

/**
* Bare `clerk migrate export` — pick a platform, then run its export.
Expand All @@ -33,6 +35,7 @@ export async function exportPicker(options: Record<string, unknown> = {}): Promi
);
}

setTelemetryStage("migrate_pick_platform");
const platform = await select<string>({
message: "Which platform are you exporting from?",
choices: exportPlatforms.map((entry) => ({
Expand All @@ -46,6 +49,8 @@ export async function exportPicker(options: Record<string, unknown> = {}): Promi
// Unreachable via the picker; a guard so a registry edit cannot silently
// produce a choice with nothing behind it.
if (!entry) throwUsageError(`Unknown export platform "${platform}".`);
recordMigrateSource(platform);
setTelemetryStage("migrate_export_read");

await entry.run(options);
}
Expand Down Expand Up @@ -118,6 +123,11 @@ export function registerMigrateExport(migrateCommand: Command<[], Record<string,
// extension: an envelope written to users.ndjson could not be imported by
// run ID. Checked before anything is read.
exportCommand.hook("preAction", (_thisCommand, actionCommand) => {
// The bare picker records both once a platform is chosen.
if (actionCommand !== exportCommand) {
recordMigrateSource(actionCommand.name());
setTelemetryStage("migrate_export_read");
}
const { output } = actionCommand.opts() as { output?: unknown };
if (typeof output === "string" && !output.toLowerCase().endsWith(".json")) {
throwUsageError(
Expand Down
29 changes: 28 additions & 1 deletion packages/cli-core/src/commands/migrate/export/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { quoteArg } from "../../../lib/json-body.ts";
import { useCaptureLog } from "../../../test/lib/stubs.ts";
import { captureTelemetryPayload, useCaptureLog } from "../../../test/lib/stubs.ts";
import { readEnvelope } from "../lib/export-file.ts";
import { latestUserLines, readRun } from "../lib/run-store.ts";
import { finishExport, formatImportCommand, startExportRun } from "./shared.ts";
Expand Down Expand Up @@ -196,6 +196,33 @@ describe("finishExport", () => {
}
});

test("a finished export ends at done", async () => {
const { payload } = await captureTelemetryPayload("migrate export auth0", async () => {
const run = await startExportRun({ runsDir }, { platform: "auth0" });
finishExport({ run, options: {}, users, coverage });
});
expect(payload).toMatchObject({ outcome: "success", stage: "done" });
});

test("a truncated export is coded", async () => {
const { payload } = await captureTelemetryPayload("migrate export auth0", async () => {
const run = await startExportRun({ runsDir }, { platform: "auth0" });
finishExport({ run, options: {}, users, coverage, truncated: true });
});
expect(payload).toMatchObject({
outcome: "error",
stage: "done",
error_code: "migrate_export_truncated",
});
});

test("starting the export run records migrate_export_write", async () => {
const { payload } = await captureTelemetryPayload("migrate export auth0", async () => {
await startExportRun({ runsDir }, { platform: "auth0" });
});
expect(payload.stage).toBe("migrate_export_write");
});

test("prints the import command by run ID", async () => {
const run = await startExportRun({ runsDir }, { platform: "clerk" });

Expand Down
8 changes: 7 additions & 1 deletion packages/cli-core/src/commands/migrate/export/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import fs from "node:fs";
import path from "node:path";
import { dim, green, yellow } from "../../../lib/color.ts";
import { CliError } from "../../../lib/errors.ts";
import { declareSoftExitOutcome, setTelemetryStage } from "../../../lib/telemetry.ts";
import { log } from "../../../lib/log.ts";
import { carryFlags, withFlags } from "../lib/carry-flags.ts";
import { ENVELOPE_VERSION, type ExportEnvelope } from "../lib/export-file.ts";
Expand Down Expand Up @@ -45,6 +46,7 @@ export async function startExportRun(
options: ExportCommonOptions,
target: RunTarget,
): Promise<Run> {
setTelemetryStage("migrate_export_write");
const runsDir = await resolveRunsDir(options.runsDir, { write: true });
return startRun(runsDir, { kind: "export", target, source: target.platform });
}
Expand Down Expand Up @@ -199,7 +201,11 @@ export function finishExport(input: FinishExportInput): FinishedExport {
const skipped = record.counts.skipped ?? 0;
// Partial, like an import with failures: the file holds what came back, and
// the exit says it is not everyone, where `truncated` alone is easy to miss.
if (input.truncated) process.exitCode = 1;
setTelemetryStage("done");
if (input.truncated) {
process.exitCode = 1;
declareSoftExitOutcome("error", "migrate_export_truncated");
}

if (options.json) {
log.data(
Expand Down
59 changes: 59 additions & 0 deletions packages/cli-core/src/commands/migrate/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getMode, setMode } from "../../mode.ts";
import { createProgram } from "../../cli-program.ts";
import { exportPlatformKeys } from "./export/registry.ts";
import { isAssumeYes, setAssumeYes } from "./lib/assume-yes.ts";
import { captureTelemetryPayload } from "../../test/lib/stubs.ts";

let saved: string | undefined;
beforeAll(() => {
Expand Down Expand Up @@ -302,3 +303,61 @@ describe("the migrate group's -y hook", () => {
expect(await parse(["migrate", "import", missing, "--secret-key", "sk_test_x"])).toBe(false);
});
});

describe("what the migrate hooks record for telemetry", () => {
let missing: string;
let missingDb: string;
beforeAll(() => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-telemetry-"));
missing = path.join(dir, "none.json");
missingDb = path.join(dir, "none.sqlite");
});

// The action is allowed to fail: only what the hooks record is under test.
async function payloadOf(argv: string[]) {
const program = createProgram();
program.exitOverride();
const { payload } = await captureTelemetryPayload(
"migrate",
() =>
program.parseAsync(["node", "clerk", ...argv]).then(
() => undefined,
() => undefined,
),
{ result: { outcome: "error", exitCode: 1 } },
);
return payload;
}

test("--app and --instance IDs are recorded before anything resolves", async () => {
const payload = await payloadOf([
"migrate",
"import",
missing,
"--app",
"app_x",
"--instance",
"ins_y",
]);
expect(payload).toMatchObject({ app_id: "app_x", instance_id: "ins_y" });
});

test("an --instance alias is not an ID, and is dropped", async () => {
const payload = await payloadOf([
"migrate",
"import",
missing,
"--app",
"app_x",
"--instance",
"dev",
]);
expect(payload).toMatchObject({ app_id: "app_x", instance_id: null });
});

test("an export subcommand records its platform as the source", async () => {
const payload = await payloadOf(["migrate", "export", "supabase", "--db-url", missingDb]);
expect(payload.migrate_source).toBe("supabase");
expect(payload.stage).toBe("migrate_export_read");
});
});
10 changes: 10 additions & 0 deletions packages/cli-core/src/commands/migrate/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Program } from "../../cli-program.ts";
import { isExperimentEnabled, requireExperiment } from "../../lib/experimental.ts";
import { markMigrateRequests } from "../../lib/fetch.ts";
import { parseIntegerOption } from "../../lib/option-parsers.ts";
import { setMode } from "../../mode.ts";
import { setAssumeYes } from "./lib/assume-yes.ts";
import { recordMigrateTarget } from "./telemetry.ts";
import { registerMigrateExport } from "./export/index.ts";
import { RUNS_DIR_DESCRIPTION, RUNS_DIR_FLAG } from "./lib/run-store.ts";
import { run } from "./run.ts";
Expand Down Expand Up @@ -68,8 +70,16 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce
// With globals: `export auth0 --json` lands on the export group's own
// --json, which the subcommand's opts() never sees.
const opts = actionCommand.optsWithGlobals();
// So the Backend API logs can tell a migration's creates from `clerk users create`.
markMigrateRequests();
setAssumeYes(Boolean(opts.yes));
if (opts.json && !opts.mode) setMode("agent");
// A hint, so a run that fails before its target resolves still names it.
// Aliases like `--instance dev` and app labels are dropped.
recordMigrateTarget({
appId: typeof opts.app === "string" ? opts.app : undefined,
instanceId: typeof opts.instance === "string" ? opts.instance : undefined,
});
});

// Named, not `isDefault`. `import` and `export` are the two directions this
Expand Down
20 changes: 19 additions & 1 deletion packages/cli-core/src/commands/migrate/run-interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { listageStubs, useCaptureLog } from "../../test/lib/stubs.ts";
import { captureTelemetryPayload, listageStubs, useCaptureLog } from "../../test/lib/stubs.ts";

const mockSelect = mock(async () => "clerk" as unknown);
const mockText = mock(async () => "export.json" as unknown);
Expand Down Expand Up @@ -513,3 +513,21 @@ describe("unverified identifiers", () => {
expect(statuses()).toEqual([undefined, ["verified", "reserved"]]);
});
});

describe("telemetry", () => {
test("a source picked at the prompt is recorded", async () => {
const { payload } = await captureTelemetryPayload("migrate import", () =>
run({ input: "export.json", secretKey: "sk_test_x" }),
);
expect(mockSelect).toHaveBeenCalledTimes(1);
expect(payload).toMatchObject({ migrate_source: "clerk", stage: "done" });
});

test("declining at the confirm prompt is an abort at migrate_confirm", async () => {
confirmAnswer = false;
const { payload } = await captureTelemetryPayload("migrate import", () => run(importOptions), {
captureError: true,
});
expect(payload).toMatchObject({ outcome: "abort", stage: "migrate_confirm" });
});
});
Loading
Loading