@timo972/cc-router 0.12.1 → 0.12.2-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/Dockerfile +1 -0
  3. package/README.md +1 -1
  4. package/dist/cli/cmd-accounts.js +116 -65
  5. package/dist/cli/cmd-setup.js +100 -30
  6. package/dist/cli/cmd-status.js +14 -2
  7. package/dist/cli/cmd-telemetry.js +43 -32
  8. package/dist/cli/index.js +15 -1
  9. package/dist/config/directory.js +14 -0
  10. package/dist/config/telemetry.js +192 -41
  11. package/dist/providers/anthropic/usage-refresher.js +35 -1
  12. package/dist/providers/model-discovery.js +17 -11
  13. package/dist/providers/openai/device-oauth.js +88 -31
  14. package/dist/providers/openai/token-refresher.js +12 -2
  15. package/dist/providers/openai/usage-fetch.js +19 -2
  16. package/dist/proxy/anthropic-messages-route.js +144 -5
  17. package/dist/proxy/anthropic-proxy.js +10 -0
  18. package/dist/proxy/anthropic-response-capture.js +6 -19
  19. package/dist/proxy/openai-ingress.js +98 -1
  20. package/dist/proxy/server.js +35 -22
  21. package/dist/proxy/token-refresher.js +12 -2
  22. package/dist/proxy/usage-capture.js +41 -4
  23. package/dist/telemetry/contracts.js +129 -0
  24. package/dist/telemetry/facade.js +654 -0
  25. package/dist/telemetry/otel-exporters.js +289 -0
  26. package/dist/telemetry/posthog-client.js +398 -0
  27. package/dist/telemetry/privacy.js +567 -0
  28. package/dist/telemetry/runtime.js +306 -0
  29. package/dist/telemetry/setup-diagnostics.js +239 -0
  30. package/dist/utils/token-extractor.js +79 -11
  31. package/dist/utils/token-validator.js +25 -9
  32. package/docs/README.md +34 -0
  33. package/docs/telemetry.md +167 -0
  34. package/package.json +12 -2
  35. package/dist/utils/telemetry.js +0 -88
package/CHANGELOG.md CHANGED
@@ -8,6 +8,24 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - Privacy-bounded OpenTelemetry and PostHog EU telemetry: 10%-sampled proxy
14
+ traces, closed-schema setup and runtime diagnostics, lifecycle events, and
15
+ sanitized exceptions with diagnostic IDs. Every outbound record is rebuilt
16
+ from an allowlist immediately before export; prompts, bodies, headers,
17
+ URLs, account identifiers, tokens, and raw error messages are never sent.
18
+ Fresh installations default on; `cc-router telemetry off`, `DO_NOT_TRACK=1`,
19
+ and `CC_ROUTER_TELEMETRY=0` disable every signal, and a running daemon stops
20
+ exporting as soon as it observes an explicit opt-out. See
21
+ [docs/telemetry.md](docs/telemetry.md) for the complete inventory.
22
+
23
+ ### Changed
24
+
25
+ - Aptabase telemetry has been removed. An existing persisted opt-out remains
26
+ off after upgrade; no PostHog Person profiles are created and GeoIP
27
+ enrichment is disabled.
28
+
11
29
  ---
12
30
 
13
31
  ## [0.12.1] — 2026-09-14
package/Dockerfile CHANGED
@@ -8,6 +8,7 @@ COPY package*.json ./
8
8
  RUN npm ci
9
9
 
10
10
  COPY tsconfig.json ./
11
+ COPY scripts/ ./scripts/
11
12
  COPY src/ ./src/
12
13
  RUN npm run build
13
14
 
package/README.md CHANGED
@@ -125,7 +125,7 @@ Per-platform token extraction, Codex CLI, Docker and everything else lives in
125
125
  | [LiteLLM](docs/litellm-setup.md) | Optional logging and rate-limiting layer |
126
126
  | [OAuth tokens](docs/oauth-tokens.md) | How subscription tokens and refresh rotation work |
127
127
  | [Security](docs/security.md) | Token storage, proxy auth, threat model |
128
- | [Telemetry](docs/telemetry.md) | Opt-in analytics: what's sent if you enable it |
128
+ | [Telemetry](docs/telemetry.md) | Privacy-bounded telemetry, on by default: exactly what is sent and how to turn it off |
129
129
  | [Troubleshooting](docs/troubleshooting.md) | When something doesn't connect |
130
130
 
131
131
  ## Disclaimer
@@ -8,6 +8,7 @@ import { loginOpenAIWithDeviceCode } from "../providers/openai/device-oauth.js";
8
8
  import { importGrokCliAuth } from "../providers/xai/import-auth.js";
9
9
  import { loginXaiWithDeviceCode } from "../providers/xai/device-oauth.js";
10
10
  import { isValidAccountId } from "../proxy/account-rename.js";
11
+ import { createSetupAttempt, failAttemptFromError, withSetupTelemetryFlush, } from "../telemetry/setup-diagnostics.js";
11
12
  export function registerAccounts(program) {
12
13
  const accounts = program
13
14
  .command("accounts")
@@ -118,9 +119,9 @@ export function registerAccounts(program) {
118
119
  .command("add")
119
120
  .description("Add a new Claude Max account interactively")
120
121
  .action(async () => {
121
- const { setupSingleAccount } = await import("./cmd-setup.js");
122
+ const { setupSingleAccountWithAttempt } = await import("./cmd-setup.js");
122
123
  const existing = accountsFileExists() ? loadAccounts() : [];
123
- const account = await setupSingleAccount(existing.length + 1);
124
+ const { account, attempt } = await setupSingleAccountWithAttempt(existing.length + 1);
124
125
  if (!account) {
125
126
  console.log(chalk.yellow("\nNo account added.\n"));
126
127
  return;
@@ -130,10 +131,19 @@ export function registerAccounts(program) {
130
131
  ...existing.filter(a => a.id !== account.id),
131
132
  account,
132
133
  ];
133
- const { mode } = await addAccountRuntimeAware(serialize([account])[0], {
134
- tryAddLive: tryAddAccountToRunningProxy,
135
- addStored: () => saveAccounts(merged),
136
- });
134
+ let mode;
135
+ try {
136
+ ({ mode } = await addAccountRuntimeAware(serialize([account])[0], {
137
+ tryAddLive: tryAddAccountToRunningProxy,
138
+ addStored: () => saveAccounts(merged),
139
+ }));
140
+ }
141
+ catch (error) {
142
+ endFailedAttempt(attempt, error, "persistence");
143
+ throw error;
144
+ }
145
+ attempt.stageCompleted("persistence");
146
+ attempt.succeeded();
137
147
  console.log(chalk.green(`\n✓ Account "${account.id}" added (${merged.length} total).\n`));
138
148
  printAddOutcome(mode);
139
149
  });
@@ -141,71 +151,101 @@ export function registerAccounts(program) {
141
151
  accounts
142
152
  .command("add-openai")
143
153
  .description("Add an OpenAI ChatGPT/Codex subscription account manually")
144
- .action(async () => {
154
+ .action(async () => withSetupTelemetryFlush(async () => {
145
155
  const { input, password } = await import("@inquirer/prompts");
146
- const id = await input({
147
- message: "OpenAI account ID:",
148
- default: `openai-account-${loadOpenAIAccounts().length + 1}`,
149
- validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
150
- });
151
- const accessToken = await password({
152
- message: "OpenAI access token:",
153
- mask: "*",
154
- validate: (v) => v.trim().length > 0 || "Access token is required",
155
- });
156
- const refreshToken = await password({
157
- message: "OpenAI refresh token:",
158
- mask: "*",
159
- validate: (v) => v.trim().length > 0 || "Refresh token is required",
160
- });
161
- const expiresAt = await input({
162
- message: "Access token expiry (Unix ms):",
163
- default: String(Date.now() + 60 * 60 * 1000),
164
- validate: (v) => Number.isFinite(Number(v)) && Number(v) > 0 || "Enter a positive Unix timestamp in milliseconds",
165
- });
166
- const scopes = await input({
167
- message: "Scopes:",
168
- default: "openid profile email offline_access",
169
- });
170
- const record = createOpenAIAccountRecord({
171
- id,
172
- accessToken,
173
- refreshToken,
174
- expiresAt,
175
- scopes,
176
- });
177
- const { mode } = await addAccountRuntimeAware(record);
178
- console.log(chalk.green(`\n✓ OpenAI account "${record.id}" saved.\n`));
179
- printAddOutcome(mode);
180
- console.log(chalk.yellow(" Treat this as experimental until the OAuth login wizard lands.\n"));
181
- });
156
+ const attempt = createSetupAttempt({ provider: "openai", method: "manual_token" });
157
+ attempt.stageCompleted("credential_source_selection");
158
+ let reached = "credential_read";
159
+ try {
160
+ const id = await input({
161
+ message: "OpenAI account ID:",
162
+ default: `openai-account-${loadOpenAIAccounts().length + 1}`,
163
+ validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
164
+ });
165
+ const accessToken = await password({
166
+ message: "OpenAI access token:",
167
+ mask: "*",
168
+ validate: (v) => v.trim().length > 0 || "Access token is required",
169
+ });
170
+ const refreshToken = await password({
171
+ message: "OpenAI refresh token:",
172
+ mask: "*",
173
+ validate: (v) => v.trim().length > 0 || "Refresh token is required",
174
+ });
175
+ const expiresAt = await input({
176
+ message: "Access token expiry (Unix ms):",
177
+ default: String(Date.now() + 60 * 60 * 1000),
178
+ validate: (v) => Number.isFinite(Number(v)) && Number(v) > 0 || "Enter a positive Unix timestamp in milliseconds",
179
+ });
180
+ const scopes = await input({
181
+ message: "Scopes:",
182
+ default: "openid profile email offline_access",
183
+ });
184
+ attempt.stageCompleted("credential_read");
185
+ reached = "credential_parse";
186
+ const record = createOpenAIAccountRecord({
187
+ id,
188
+ accessToken,
189
+ refreshToken,
190
+ expiresAt,
191
+ scopes,
192
+ });
193
+ attempt.stageCompleted("credential_parse");
194
+ reached = "persistence";
195
+ const { mode } = await addAccountRuntimeAware(record);
196
+ attempt.stageCompleted("persistence");
197
+ attempt.succeeded();
198
+ console.log(chalk.green(`\n✓ OpenAI account "${record.id}" saved.\n`));
199
+ printAddOutcome(mode);
200
+ console.log(chalk.yellow(" Treat this as experimental until the OAuth login wizard lands.\n"));
201
+ }
202
+ catch (error) {
203
+ endFailedAttempt(attempt, error, reached);
204
+ throw error;
205
+ }
206
+ }));
182
207
  // ── accounts login-openai ────────────────────────────────────────────────
183
208
  accounts
184
209
  .command("login-openai")
185
210
  .description("Sign in to an OpenAI ChatGPT/Codex subscription account with device code")
186
- .action(async () => {
211
+ .action(async () => withSetupTelemetryFlush(async () => {
187
212
  const { input } = await import("@inquirer/prompts");
188
- const accountId = await input({
189
- message: "OpenAI account ID:",
190
- default: `openai-account-${loadOpenAIAccounts().length + 1}`,
191
- validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
192
- });
193
- console.log(chalk.cyan("\nOpenAI Codex device login"));
194
- console.log(chalk.gray("This will open no local callback server. You will approve the login in your browser.\n"));
195
- const record = await loginOpenAIWithDeviceCode({
196
- accountId,
197
- onDeviceCode: (code) => {
198
- console.log(chalk.bold("1. Open this URL:"));
199
- console.log(` ${chalk.cyan(code.verificationUrl)}`);
200
- console.log(chalk.bold("2. Enter this code:"));
201
- console.log(` ${chalk.cyan(code.userCode)}\n`);
202
- console.log(chalk.gray("Waiting for authorization..."));
203
- },
204
- });
205
- const { mode } = await addAccountRuntimeAware(record);
206
- console.log(chalk.green(`\n✓ OpenAI account "${record.id}" saved via device login.\n`));
207
- printAddOutcome(mode);
208
- });
213
+ const attempt = createSetupAttempt({ provider: "openai", method: "device_oauth" });
214
+ let reached = "device_code_request";
215
+ try {
216
+ const accountId = await input({
217
+ message: "OpenAI account ID:",
218
+ default: `openai-account-${loadOpenAIAccounts().length + 1}`,
219
+ validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
220
+ });
221
+ console.log(chalk.cyan("\nOpenAI Codex device login"));
222
+ console.log(chalk.gray("This will open no local callback server. You will approve the login in your browser.\n"));
223
+ const record = await loginOpenAIWithDeviceCode({
224
+ accountId,
225
+ onDeviceCode: (code) => {
226
+ console.log(chalk.bold("1. Open this URL:"));
227
+ console.log(` ${chalk.cyan(code.verificationUrl)}`);
228
+ console.log(chalk.bold("2. Enter this code:"));
229
+ console.log(` ${chalk.cyan(code.userCode)}\n`);
230
+ console.log(chalk.gray("Waiting for authorization..."));
231
+ },
232
+ onStageCompleted: (stage) => {
233
+ attempt.stageCompleted(stage);
234
+ reached = stage;
235
+ },
236
+ });
237
+ reached = "persistence";
238
+ const { mode } = await addAccountRuntimeAware(record);
239
+ attempt.stageCompleted("persistence");
240
+ attempt.succeeded();
241
+ console.log(chalk.green(`\n✓ OpenAI account "${record.id}" saved via device login.\n`));
242
+ printAddOutcome(mode);
243
+ }
244
+ catch (error) {
245
+ endFailedAttempt(attempt, error, reached);
246
+ throw error;
247
+ }
248
+ }));
209
249
  // ── accounts add-grok ────────────────────────────────────────────────────
210
250
  accounts
211
251
  .command("add-grok")
@@ -345,6 +385,17 @@ export function registerAccounts(program) {
345
385
  });
346
386
  }
347
387
  // ─── Helpers ──────────────────────────────────────────────────────────────────
388
+ /**
389
+ * Close a setup attempt that ended in a thrown error. A cancelled prompt is a
390
+ * user decision, not a failure, and only an unexpected failure gets a
391
+ * diagnostic ID worth quoting in a bug report.
392
+ */
393
+ function endFailedAttempt(attempt, error, fallbackStage) {
394
+ const outcome = failAttemptFromError(attempt, error, fallbackStage);
395
+ if (outcome?.unexpected) {
396
+ console.log(chalk.gray(` Diagnostic ID: ${outcome.diagnosticId}`));
397
+ }
398
+ }
348
399
  /** Tell the user whether the new account is already live or needs a restart. */
349
400
  function printAddOutcome(mode) {
350
401
  console.log(mode === "live"
@@ -1,7 +1,7 @@
1
1
  import { select, input, confirm, password } from "@inquirer/prompts";
2
2
  import chalk from "chalk";
3
3
  import { detectPlatform, isMacos } from "../utils/platform.js";
4
- import { extractFromKeychain, extractFromCredentialsFile, formatExpiry, redactToken, } from "../utils/token-extractor.js";
4
+ import { extractFromKeychainDetailed, extractFromCredentialsFileDetailed, formatExpiry, redactToken, } from "../utils/token-extractor.js";
5
5
  import { validateToken } from "../utils/token-validator.js";
6
6
  import { writeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
7
7
  import { saveAccounts } from "../proxy/token-refresher.js";
@@ -11,7 +11,7 @@ import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS } from "../proxy/types.js";
11
11
  import { existsSync } from "fs";
12
12
  import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, getNetworkExtensionStatus, openNetworkExtensionSettings, } from "../interceptor/mitmproxy-manager.js";
13
13
  import { printDesktopSupportExplainer, printNetworkExtensionInstructions } from "./cmd-client.js";
14
- import { trackEvent } from "../utils/telemetry.js";
14
+ import { createSetupAttempt, failAttemptFromError, withSetupTelemetryFlush, } from "../telemetry/setup-diagnostics.js";
15
15
  // ─── Public registration ──────────────────────────────────────────────────────
16
16
  export function registerSetup(program) {
17
17
  program
@@ -19,11 +19,21 @@ export function registerSetup(program) {
19
19
  .description("Interactive wizard: extract tokens and configure Claude Code automatically")
20
20
  .option("--add", "Add a new account to an existing configuration (skip intro questions)")
21
21
  .action(async (opts) => {
22
- await runSetupWizard({ addMode: opts.add ?? false });
22
+ await withSetupTelemetryFlush(() => runSetupWizard({ addMode: opts.add ?? false }));
23
23
  });
24
24
  }
25
+ /** Only an unexpected failure gets a diagnostic ID worth quoting in a bug report. */
26
+ function printDiagnosticId(outcome) {
27
+ if (!outcome.unexpected)
28
+ return;
29
+ console.log(chalk.gray(` Diagnostic ID: ${outcome.diagnosticId}`));
30
+ }
25
31
  // ─── Shared single-account setup (also used by `accounts add`) ───────────────
26
- export async function setupSingleAccount(index) {
32
+ /**
33
+ * Same step, but also hands back the setup attempt so the caller can mark the
34
+ * `persistence` stage and the final outcome once the account is written.
35
+ */
36
+ export async function setupSingleAccountWithAttempt(index) {
27
37
  const choices = [];
28
38
  if (isMacos()) {
29
39
  choices.push({ name: "Extract automatically from macOS Keychain (recommended)", value: "keychain" });
@@ -34,11 +44,35 @@ export async function setupSingleAccount(index) {
34
44
  message: "How do you want to add the tokens?",
35
45
  choices,
36
46
  });
47
+ const attempt = createSetupAttempt({
48
+ provider: "anthropic",
49
+ method: method === "keychain"
50
+ ? "macos_keychain"
51
+ : method === "credentials" ? "claude_credentials_file" : "manual_token",
52
+ });
53
+ attempt.stageCompleted("credential_source_selection");
54
+ // Shared by reference: the file-extraction fallback swaps in a manual-token attempt.
55
+ const current = { attempt };
56
+ let reached = "credential_read";
57
+ try {
58
+ return await collectAnthropicAccount(index, method, current, stage => { reached = stage; });
59
+ }
60
+ catch (error) {
61
+ // A thrown prompt or extraction error must still close the funnel record.
62
+ const outcome = failAttemptFromError(current.attempt, error, reached);
63
+ if (outcome)
64
+ printDiagnosticId(outcome);
65
+ throw error;
66
+ }
67
+ }
68
+ async function collectAnthropicAccount(index, method, current, reached) {
69
+ let attempt = current.attempt;
37
70
  let tokens = null;
38
71
  if (method === "keychain") {
39
72
  process.stdout.write(chalk.gray(" Extracting from Keychain... "));
40
- tokens = await extractFromKeychain();
41
- if (tokens) {
73
+ const extraction = await extractFromKeychainDetailed();
74
+ if (extraction.ok) {
75
+ tokens = extraction.tokens;
42
76
  console.log(chalk.green("✓"));
43
77
  console.log(chalk.gray(` Token: ${redactToken(tokens.accessToken)}`));
44
78
  console.log(chalk.gray(` Expiry: ${formatExpiry(tokens.expiresAt)}`));
@@ -47,15 +81,18 @@ export async function setupSingleAccount(index) {
47
81
  console.log(chalk.red("✗"));
48
82
  console.log(chalk.yellow(" Could not find credentials in Keychain."));
49
83
  console.log(chalk.gray(" Make sure Claude Code is logged in: run `claude login` first."));
84
+ printDiagnosticId(attempt.stageFailed(extraction.error, "credential_read"));
50
85
  const retry = await confirm({ message: "Try another extraction method?", default: true });
86
+ attempt.cancelled();
51
87
  if (!retry)
52
- return null;
53
- return setupSingleAccount(index);
88
+ return { account: null, attempt };
89
+ return setupSingleAccountWithAttempt(index);
54
90
  }
55
91
  }
56
92
  if (method === "credentials") {
57
- tokens = extractFromCredentialsFile();
58
- if (tokens) {
93
+ const extraction = extractFromCredentialsFileDetailed();
94
+ if (extraction.ok) {
95
+ tokens = extraction.tokens;
59
96
  console.log(chalk.green(` ✓ Found credentials in ~/.claude/.credentials.json`));
60
97
  console.log(chalk.gray(` Token: ${redactToken(tokens.accessToken)}`));
61
98
  console.log(chalk.gray(` Expiry: ${formatExpiry(tokens.expiresAt)}`));
@@ -64,16 +101,29 @@ export async function setupSingleAccount(index) {
64
101
  console.log(chalk.red(" ✗ ~/.claude/.credentials.json not found or unreadable."));
65
102
  console.log(chalk.gray(" Make sure Claude Code is installed and you've run `claude login`."));
66
103
  const retry = await confirm({ message: "Paste tokens manually instead?", default: true });
67
- if (!retry)
68
- return null;
104
+ if (!retry) {
105
+ printDiagnosticId(attempt.stageFailed(extraction.error, "credential_read"));
106
+ attempt.cancelled();
107
+ return { account: null, attempt };
108
+ }
109
+ // The file-based attempt failed; the pasted tokens are a manual-token setup.
110
+ printDiagnosticId(attempt.failed(extraction.error, "credential_read"));
111
+ attempt = createSetupAttempt({ provider: "anthropic", method: "manual_token" });
112
+ current.attempt = attempt;
113
+ attempt.stageCompleted("credential_source_selection");
69
114
  tokens = await promptManualTokens();
70
115
  }
71
116
  }
72
117
  if (method === "manual") {
73
118
  tokens = await promptManualTokens();
74
119
  }
75
- if (!tokens)
76
- return null;
120
+ if (!tokens) {
121
+ attempt.cancelled();
122
+ return { account: null, attempt };
123
+ }
124
+ attempt.stageCompleted("credential_read");
125
+ attempt.stageCompleted("credential_parse");
126
+ reached("token_validation");
77
127
  const defaultId = `max-account-${index}`;
78
128
  const accountId = await input({
79
129
  message: "Account ID (press Enter to accept default):",
@@ -84,27 +134,34 @@ export async function setupSingleAccount(index) {
84
134
  const validation = await validateToken(tokens.accessToken);
85
135
  if (validation.valid) {
86
136
  console.log(chalk.green("✓ Valid"));
137
+ attempt.stageCompleted("token_validation");
87
138
  }
88
139
  else {
89
140
  console.log(chalk.red("✗ Invalid"));
90
141
  console.log(chalk.yellow(` Reason: ${validation.reason}`));
142
+ printDiagnosticId(attempt.stageFailed(validation.diagnostic, "token_validation"));
91
143
  console.log(chalk.gray(" The token will be saved but may not work until refreshed."));
92
144
  const keepAnyway = await confirm({ message: "Save this account anyway?", default: false });
93
- if (!keepAnyway)
94
- return null;
145
+ if (!keepAnyway) {
146
+ attempt.cancelled();
147
+ return { account: null, attempt };
148
+ }
95
149
  }
96
150
  return {
97
- id: accountId,
98
- tokens,
99
- healthy: validation.valid,
100
- busy: false,
101
- requestCount: 0,
102
- errorCount: 0,
103
- lastUsed: 0,
104
- lastRefresh: 0,
105
- consecutiveErrors: 0,
106
- rateLimits: { ...DEFAULT_RATE_LIMITS },
107
- ...ACCOUNT_USER_DEFAULTS,
151
+ account: {
152
+ id: accountId,
153
+ tokens,
154
+ healthy: validation.valid,
155
+ busy: false,
156
+ requestCount: 0,
157
+ errorCount: 0,
158
+ lastUsed: 0,
159
+ lastRefresh: 0,
160
+ consecutiveErrors: 0,
161
+ rateLimits: { ...DEFAULT_RATE_LIMITS },
162
+ ...ACCOUNT_USER_DEFAULTS,
163
+ },
164
+ attempt,
108
165
  };
109
166
  }
110
167
  // ─── Full wizard ──────────────────────────────────────────────────────────────
@@ -179,6 +236,7 @@ export async function runSetupWizard({ addMode }) {
179
236
  }) ?? 1;
180
237
  }
181
238
  const newAccounts = [];
239
+ const savedAttempts = [];
182
240
  for (let i = 0; i < numAccounts; i++) {
183
241
  const label = numAccounts > 1 ? `${i + 1}/${numAccounts}` : "";
184
242
  console.log(chalk.bold(`\n${"━".repeat(40)}\n Account ${label}\n${"━".repeat(40)}\n`));
@@ -189,9 +247,10 @@ export async function runSetupWizard({ addMode }) {
189
247
  await confirm({ message: "Ready?", default: true });
190
248
  }
191
249
  const existingCount = hasExisting ? loadAccounts().length : 0;
192
- const account = await setupSingleAccount(i + 1 + existingCount);
250
+ const { account, attempt } = await setupSingleAccountWithAttempt(i + 1 + existingCount);
193
251
  if (account) {
194
252
  newAccounts.push(account);
253
+ savedAttempts.push(attempt);
195
254
  console.log(chalk.green(`\n ✓ Account "${account.id}" ready.\n`));
196
255
  }
197
256
  else {
@@ -209,9 +268,20 @@ export async function runSetupWizard({ addMode }) {
209
268
  ...newAccounts,
210
269
  ];
211
270
  console.log(chalk.bold(`\n${"━".repeat(40)}\n Saving\n${"━".repeat(40)}\n`));
212
- saveAccounts(merged);
271
+ try {
272
+ saveAccounts(merged);
273
+ }
274
+ catch (error) {
275
+ const outcomes = savedAttempts.map(attempt => attempt.failed(error, "persistence"));
276
+ if (outcomes[0])
277
+ printDiagnosticId(outcomes[0]);
278
+ throw error;
279
+ }
213
280
  console.log(chalk.green(` ✓ ${merged.length} account(s) saved to ~/.cc-router/accounts.json`));
214
- void trackEvent("setup_completed", { account_count: merged.length });
281
+ for (const attempt of savedAttempts) {
282
+ attempt.stageCompleted("persistence");
283
+ attempt.succeeded();
284
+ }
215
285
  // ─── Post-setup interactive flow ─────────────────────────────────────────
216
286
  await runPostSetupFlow(merged.length);
217
287
  }
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { PROXY_PORT } from "../config/paths.js";
3
3
  import { readConfig } from "../config/manager.js";
4
+ import { classifyHttpSetupFailure, failAttemptFromError, } from "../telemetry/setup-diagnostics.js";
4
5
  export function resolveStatusTarget(port) {
5
6
  const cfg = readConfig();
6
7
  if (cfg.client) {
@@ -143,10 +144,13 @@ async function dashboardLoop(port) {
143
144
  * success, or null if the user aborted / an error occurred.
144
145
  */
145
146
  async function runAddAccountFlow(target) {
147
+ let attempt;
146
148
  try {
147
- const { setupSingleAccount } = await import("./cmd-setup.js");
149
+ const { setupSingleAccountWithAttempt } = await import("./cmd-setup.js");
148
150
  // The index shown in the flow is just for display, pick something neutral.
149
- const account = await setupSingleAccount(1);
151
+ const setup = await setupSingleAccountWithAttempt(1);
152
+ attempt = setup.attempt;
153
+ const account = setup.account;
150
154
  if (!account)
151
155
  return null;
152
156
  const res = await fetch(`${target.baseUrl}/cc-router/accounts`, {
@@ -169,12 +173,20 @@ async function runAddAccountFlow(target) {
169
173
  console.error(chalk.red(`\n✗ Server rejected account: HTTP ${res.status}`));
170
174
  if (text)
171
175
  console.error(chalk.gray(` ${text}`));
176
+ attempt.failed(classifyHttpSetupFailure("persistence", res.status, "dashboard account add rejected"), "persistence");
172
177
  return null;
173
178
  }
179
+ attempt.stageCompleted("persistence");
180
+ attempt.succeeded();
174
181
  return account.id;
175
182
  }
176
183
  catch (err) {
177
184
  console.error(chalk.red(`\n✗ Failed to add account: ${err.message}`));
185
+ if (attempt) {
186
+ const outcome = failAttemptFromError(attempt, err, "persistence");
187
+ if (outcome?.unexpected)
188
+ console.error(chalk.gray(` Diagnostic ID: ${outcome.diagnosticId}`));
189
+ }
178
190
  return null;
179
191
  }
180
192
  }
@@ -1,57 +1,68 @@
1
1
  import chalk from "chalk";
2
- import { loadTelemetryState, writeTelemetryState, isTelemetryEnabled } from "../config/telemetry.js";
2
+ import { getTelemetrySnapshot, updateTelemetryConsent } from "../config/telemetry.js";
3
3
  export function registerTelemetry(program) {
4
4
  program
5
5
  .command("telemetry [action]")
6
- .description("Manage anonymous usage analytics: on, off, status (default: status)")
6
+ .description("Manage privacy-safe telemetry: on, off, status (default: status; fresh installs: on)")
7
7
  .action(async (action) => {
8
8
  const resolved = action ?? "status";
9
- if (resolved === "status") {
10
- showStatus();
11
- return;
9
+ try {
10
+ runTelemetryAction(resolved);
12
11
  }
13
- if (resolved === "on") {
14
- const state = loadTelemetryState();
15
- state.enabled = true;
16
- writeTelemetryState(state);
17
- console.log(chalk.green("Telemetry enabled."));
18
- console.log(chalk.dim(`Install ID: ${state.installId}`));
19
- return;
12
+ catch (error) {
13
+ // An unreadable state file keeps telemetry off; never replace it blindly.
14
+ console.error(chalk.red(error instanceof Error ? error.message : "Telemetry state could not be read."));
15
+ console.error(chalk.dim("Telemetry stays disabled until the file is readable again."));
16
+ process.exitCode = 1;
20
17
  }
21
- if (resolved === "off") {
22
- // Do not beacon on opt-out: an explicit "turn it off" must not send data.
23
- const state = loadTelemetryState();
24
- state.enabled = false;
25
- writeTelemetryState(state);
26
- console.log(chalk.yellow("Telemetry disabled. No data will be sent."));
27
- console.log(chalk.dim("Re-enable anytime with: cc-router telemetry on"));
28
- return;
29
- }
30
- console.error(chalk.red(`Unknown action "${resolved}". Use: on, off, status`));
31
- process.exitCode = 1;
32
18
  });
33
19
  }
20
+ function runTelemetryAction(resolved) {
21
+ if (resolved === "status") {
22
+ showStatus();
23
+ return;
24
+ }
25
+ if (resolved === "on") {
26
+ const state = updateTelemetryConsent(true);
27
+ console.log(chalk.green("Telemetry enabled for future daemon starts."));
28
+ console.log(chalk.dim("Restart a daemon that started with telemetry disabled to begin sending telemetry."));
29
+ console.log(chalk.dim(`Install ID: ${state.installId}`));
30
+ return;
31
+ }
32
+ if (resolved === "off") {
33
+ // Do not beacon on opt-out: an explicit "turn it off" must not send data.
34
+ updateTelemetryConsent(false);
35
+ console.log(chalk.yellow("Telemetry disabled. New outbound telemetry stops immediately."));
36
+ console.log(chalk.dim("Re-enable anytime with: cc-router telemetry on"));
37
+ return;
38
+ }
39
+ console.error(chalk.red(`Unknown action "${resolved}". Use: on, off, status`));
40
+ process.exitCode = 1;
41
+ }
34
42
  function showStatus() {
35
- const state = loadTelemetryState();
36
- const envDisabled = process.env["DO_NOT_TRACK"] === "1" || process.env["CC_ROUTER_TELEMETRY"] === "0";
43
+ const { state, environmentDisabled, enabled } = getTelemetrySnapshot();
37
44
  console.log(chalk.bold("Telemetry"));
38
45
  console.log();
39
- if (envDisabled) {
46
+ if (environmentDisabled) {
40
47
  console.log(` Status: ${chalk.yellow("disabled")} (by environment variable)`);
41
48
  }
42
49
  else if (state.enabled) {
43
- console.log(` Status: ${chalk.green("enabled")}`);
50
+ console.log(` Status: ${chalk.green("enabled")} (persisted)`);
44
51
  }
45
52
  else {
46
- console.log(` Status: ${chalk.yellow("disabled")}`);
53
+ console.log(` Status: ${chalk.yellow("disabled")} (persisted)`);
47
54
  }
48
- console.log(` Active: ${isTelemetryEnabled() ? chalk.green("yes") : chalk.yellow("no")}`);
55
+ console.log(` Active: ${enabled ? chalk.green("yes") : chalk.yellow("no")}`);
49
56
  console.log(` Install ID: ${chalk.dim(state.installId)}`);
50
57
  console.log(` Since: ${chalk.dim(state.firstRunAt)}`);
51
58
  console.log();
52
- console.log(chalk.dim(" What we send: version, OS, locale, lifecycle events (start, heartbeat)"));
53
- console.log(chalk.dim(" What we DON'T: IPs, tokens, prompts, request content, account names"));
54
- console.log(chalk.dim(" Source code: src/utils/telemetry.ts"));
59
+ console.log(chalk.dim(" What we send: sampled traces, safe diagnostics, lifecycle events, sanitized exceptions"));
60
+ console.log(chalk.dim(" What we DON'T: tokens, prompts/content, account/session IDs, raw errors, URLs, headers"));
61
+ console.log(chalk.dim(" Network note: PostHog EU sees the HTTPS source IP; it is not added to the payload"));
62
+ console.log(chalk.dim(" Identity: random install pseudonym; no Person profile or GeoIP enrichment"));
63
+ console.log(chalk.dim(" Source code: src/telemetry/"));
64
+ console.log(chalk.dim(" Inventory: docs/telemetry.md"));
65
+ console.log(chalk.dim(" Default: on for new installs"));
55
66
  console.log();
56
67
  console.log(chalk.dim(" Disable: cc-router telemetry off"));
57
68
  console.log(chalk.dim(" Or set: DO_NOT_TRACK=1 | CC_ROUTER_TELEMETRY=0"));