ai-cc-router 0.2.4 → 0.2.6

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.
package/README.md CHANGED
@@ -513,12 +513,47 @@ Press `q` to quit. Run with `--json` for non-interactive output.
513
513
  - The file is excluded by `.gitignore`
514
514
  - Writes are atomic (write to `.tmp`, then rename) — no corruption on crash
515
515
  - Keychain reads use `execFile` with a fixed argument array — no shell injection
516
- - No telemetry, no external logging
516
+ - Anonymous opt-out telemetry via [Aptabase](https://aptabase.com) (see [Telemetry](#telemetry) below)
517
517
 
518
518
  See [docs/security.md](docs/security.md) for details.
519
519
 
520
520
  ---
521
521
 
522
+ ## Telemetry
523
+
524
+ CC-Router sends a handful of anonymous lifecycle events to [Aptabase](https://aptabase.com) (privacy-first, open source, EU-hosted). The goal is simple: know how many people use the project, which versions are live, and roughly how many instances are running — so we can prioritize fixes and features.
525
+
526
+ **What we send** — the entire payload lives in [`src/utils/telemetry.ts`](src/utils/telemetry.ts), audit it yourself:
527
+
528
+ | Event | When | Custom props |
529
+ | -------------------- | ------------------------------------------------ | ---------------------------------------- |
530
+ | `app_started` | First proxy start after install | `first_run: true` |
531
+ | `setup_completed` | Setup wizard finishes successfully | `account_count` |
532
+ | `proxy_started` | Each `cc-router start` | `account_count`, `mode` |
533
+ | `proxy_heartbeat` | Every 6h while the proxy is running | `uptime_hours`, `account_count` |
534
+ | `telemetry_disabled` | When you run `cc-router telemetry off` | — |
535
+
536
+ Plus anonymous system props with every event: `appVersion`, `osName` (macOS/Linux/Windows), `osVersion`, `locale`, `engineVersion` (Node), and an anonymous `installId` (random UUID generated on first run, stored in `~/.cc-router/telemetry.json`).
537
+
538
+ **What we never send**: IPs, OAuth tokens, account names, request content, prompts, responses, URLs, hostnames, usernames, file paths — nothing that could identify you or your usage patterns.
539
+
540
+ **Disable it** — three ways, any one works:
541
+
542
+ ```bash
543
+ # 1. Persistent opt-out (recommended)
544
+ cc-router telemetry off
545
+
546
+ # 2. Respect the de-facto standard (honored by many OSS tools)
547
+ export DO_NOT_TRACK=1
548
+
549
+ # 3. Project-specific override
550
+ export CC_ROUTER_TELEMETRY=0
551
+ ```
552
+
553
+ Check status anytime: `cc-router telemetry status`.
554
+
555
+ ---
556
+
522
557
  ## Disclaimer
523
558
 
524
559
  > CC-Router uses the OAuth tokens of your own Claude Max subscriptions.
@@ -4,6 +4,7 @@ import { fileURLToPath } from "url";
4
4
  import { join, dirname } from "path";
5
5
  import chalk from "chalk";
6
6
  import { detectPlatform } from "../utils/platform.js";
7
+ import { showTelemetryDisclosureIfNeeded } from "../utils/telemetry.js";
7
8
  const execFileAsync = promisify(execFile);
8
9
  // Resolve the path to the compiled CLI entry point
9
10
  const __filename = fileURLToPath(import.meta.url);
@@ -18,6 +19,9 @@ export function registerService(program) {
18
19
  .description("Register cc-router to start automatically on system boot")
19
20
  .action(async () => {
20
21
  console.log(chalk.cyan("\nInstalling cc-router as a system service...\n"));
22
+ // Show telemetry disclosure once before the service takes over — after
23
+ // this point output goes to PM2 logs, not the user's terminal.
24
+ showTelemetryDisclosureIfNeeded();
21
25
  // 1. Verify PM2 is installed
22
26
  const pm2Version = await getPm2Version();
23
27
  if (!pm2Version) {
@@ -13,6 +13,7 @@ import { DEFAULT_RATE_LIMITS } from "../proxy/types.js";
13
13
  import { existsSync } from "fs";
14
14
  import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, getNetworkExtensionStatus, openNetworkExtensionSettings, } from "../interceptor/mitmproxy-manager.js";
15
15
  import { printDesktopSupportExplainer, printNetworkExtensionInstructions } from "./cmd-client.js";
16
+ import { trackEvent, showTelemetryDisclosureIfNeeded } from "../utils/telemetry.js";
16
17
  const execFileAsync = promisify(execFile);
17
18
  // ─── Public registration ──────────────────────────────────────────────────────
18
19
  export function registerSetup(program) {
@@ -212,6 +213,8 @@ async function runSetupWizard({ addMode }) {
212
213
  console.log(chalk.bold(`\n${"━".repeat(40)}\n Saving\n${"━".repeat(40)}\n`));
213
214
  saveAccounts(merged);
214
215
  console.log(chalk.green(` ✓ ${merged.length} account(s) saved to ~/.cc-router/accounts.json`));
216
+ showTelemetryDisclosureIfNeeded();
217
+ void trackEvent("setup_completed", { account_count: merged.length });
215
218
  // ─── Post-setup interactive flow ─────────────────────────────────────────
216
219
  await runPostSetupFlow(merged.length);
217
220
  }
@@ -2,6 +2,7 @@ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import chalk from "chalk";
4
4
  import { PROXY_PORT, LITELLM_PORT, ACCOUNTS_PATH } from "../config/paths.js";
5
+ import { showTelemetryDisclosureIfNeeded } from "../utils/telemetry.js";
5
6
  const execFileAsync = promisify(execFile);
6
7
  export function registerStart(program) {
7
8
  program
@@ -13,6 +14,9 @@ export function registerStart(program) {
13
14
  .option("--accounts <path>", "Path to accounts.json", ACCOUNTS_PATH)
14
15
  .action(async (opts) => {
15
16
  if (opts.daemon) {
17
+ // Show telemetry disclosure in the user's terminal before handing off
18
+ // to PM2 — once the daemon starts, its stdout goes to PM2 logs.
19
+ showTelemetryDisclosureIfNeeded();
16
20
  await startDaemon();
17
21
  return;
18
22
  }
@@ -0,0 +1,60 @@
1
+ import chalk from "chalk";
2
+ import { loadTelemetryState, writeTelemetryState, isTelemetryEnabled } from "../config/telemetry.js";
3
+ import { trackEvent } from "../utils/telemetry.js";
4
+ export function registerTelemetry(program) {
5
+ program
6
+ .command("telemetry [action]")
7
+ .description("Manage anonymous usage analytics: on, off, status (default: status)")
8
+ .action(async (action) => {
9
+ const resolved = action ?? "status";
10
+ if (resolved === "status") {
11
+ showStatus();
12
+ return;
13
+ }
14
+ if (resolved === "on") {
15
+ const state = loadTelemetryState();
16
+ state.enabled = true;
17
+ writeTelemetryState(state);
18
+ console.log(chalk.green("Telemetry enabled."));
19
+ console.log(chalk.dim(`Install ID: ${state.installId}`));
20
+ return;
21
+ }
22
+ if (resolved === "off") {
23
+ // Send one last event so we know about opt-out rates
24
+ await trackEvent("telemetry_disabled");
25
+ const state = loadTelemetryState();
26
+ state.enabled = false;
27
+ writeTelemetryState(state);
28
+ console.log(chalk.yellow("Telemetry disabled. No data will be sent."));
29
+ console.log(chalk.dim("Re-enable anytime with: cc-router telemetry on"));
30
+ return;
31
+ }
32
+ console.error(chalk.red(`Unknown action "${resolved}". Use: on, off, status`));
33
+ process.exitCode = 1;
34
+ });
35
+ }
36
+ function showStatus() {
37
+ const state = loadTelemetryState();
38
+ const envDisabled = process.env["DO_NOT_TRACK"] === "1" || process.env["CC_ROUTER_TELEMETRY"] === "0";
39
+ console.log(chalk.bold("Telemetry"));
40
+ console.log();
41
+ if (envDisabled) {
42
+ console.log(` Status: ${chalk.yellow("disabled")} (by environment variable)`);
43
+ }
44
+ else if (state.enabled) {
45
+ console.log(` Status: ${chalk.green("enabled")}`);
46
+ }
47
+ else {
48
+ console.log(` Status: ${chalk.yellow("disabled")}`);
49
+ }
50
+ console.log(` Active: ${isTelemetryEnabled() ? chalk.green("yes") : chalk.yellow("no")}`);
51
+ console.log(` Install ID: ${chalk.dim(state.installId)}`);
52
+ console.log(` Since: ${chalk.dim(state.firstRunAt)}`);
53
+ console.log();
54
+ console.log(chalk.dim(" What we send: version, OS, locale, lifecycle events (start, heartbeat)"));
55
+ console.log(chalk.dim(" What we DON'T: IPs, tokens, prompts, request content, account names"));
56
+ console.log(chalk.dim(" Source code: src/utils/telemetry.ts"));
57
+ console.log();
58
+ console.log(chalk.dim(" Disable: cc-router telemetry off"));
59
+ console.log(chalk.dim(" Or set: DO_NOT_TRACK=1 | CC_ROUTER_TELEMETRY=0"));
60
+ }
package/dist/cli/index.js CHANGED
@@ -10,6 +10,7 @@ import { registerConfigure } from "./cmd-configure.js";
10
10
  import { registerDocker } from "./cmd-docker.js";
11
11
  import { registerUpdate } from "./cmd-update.js";
12
12
  import { registerClient } from "./cmd-client.js";
13
+ import { registerTelemetry } from "./cmd-telemetry.js";
13
14
  import { getCurrentVersion, checkForUpdate, printUpdateBanner } from "../utils/self-update.js";
14
15
  const program = new Command();
15
16
  program
@@ -42,6 +43,7 @@ registerConfigure(program);
42
43
  registerDocker(program);
43
44
  registerUpdate(program);
44
45
  registerClient(program);
46
+ registerTelemetry(program);
45
47
  // Background update check — fires on every CLI invocation, uses 6h disk cache
46
48
  // so it's essentially free after the first check. Notify on process exit.
47
49
  if (!process.env["NO_UPDATE_NOTIFIER"] && !process.env["CI"]) {
@@ -12,3 +12,6 @@ export const LITELLM_URL = process.env["LITELLM_URL"];
12
12
  // Proxy-level config (password, future settings) — separate from accounts.json
13
13
  export const CONFIG_PATH = process.env["CONFIG_PATH"] ??
14
14
  path.join(CONFIG_DIR, "config.json");
15
+ // Anonymous telemetry state — install id + opt-in flag
16
+ export const TELEMETRY_PATH = process.env["TELEMETRY_PATH"] ??
17
+ path.join(CONFIG_DIR, "telemetry.json");
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
2
+ import { randomUUID } from "crypto";
3
+ import { TELEMETRY_PATH } from "./paths.js";
4
+ import { ensureConfigDir } from "./manager.js";
5
+ function defaultState() {
6
+ return {
7
+ enabled: true,
8
+ installId: randomUUID(),
9
+ firstRunAt: new Date().toISOString(),
10
+ disclosureShown: false,
11
+ };
12
+ }
13
+ // Read the telemetry state, creating and persisting a fresh one on first run.
14
+ // Malformed files are treated as missing so a corrupted file can't crash the CLI.
15
+ export function loadTelemetryState() {
16
+ if (!existsSync(TELEMETRY_PATH)) {
17
+ const state = defaultState();
18
+ writeTelemetryState(state);
19
+ return state;
20
+ }
21
+ try {
22
+ const raw = JSON.parse(readFileSync(TELEMETRY_PATH, "utf-8"));
23
+ // Fill any missing fields to keep the file forward-compatible
24
+ const state = {
25
+ enabled: raw.enabled ?? true,
26
+ installId: raw.installId ?? randomUUID(),
27
+ firstRunAt: raw.firstRunAt ?? new Date().toISOString(),
28
+ disclosureShown: raw.disclosureShown ?? false,
29
+ };
30
+ if (!raw.installId || raw.disclosureShown === undefined) {
31
+ writeTelemetryState(state);
32
+ }
33
+ return state;
34
+ }
35
+ catch {
36
+ const state = defaultState();
37
+ writeTelemetryState(state);
38
+ return state;
39
+ }
40
+ }
41
+ // Atomic write: .tmp + rename, same pattern as writeAccountsAtomic
42
+ export function writeTelemetryState(state) {
43
+ ensureConfigDir();
44
+ const tmp = TELEMETRY_PATH + ".tmp";
45
+ writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
46
+ renameSync(tmp, TELEMETRY_PATH);
47
+ }
48
+ // Returns true only if the user has not opted out through any mechanism:
49
+ // - DO_NOT_TRACK=1 (de-facto standard)
50
+ // - CC_ROUTER_TELEMETRY=0 (project-specific override)
51
+ // - `cc-router telemetry off` (persisted enabled: false)
52
+ export function isTelemetryEnabled() {
53
+ if (process.env["DO_NOT_TRACK"] === "1")
54
+ return false;
55
+ if (process.env["CC_ROUTER_TELEMETRY"] === "0")
56
+ return false;
57
+ try {
58
+ return loadTelemetryState().enabled;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
@@ -6,6 +6,8 @@ import { TokenPool } from "./token-pool.js";
6
6
  import { needsRefresh, refreshAccountToken, saveAccounts, startRefreshLoop } from "./token-refresher.js";
7
7
  import { loadAccounts, accountsFileExists, readAccountsFromPath, readConfig } from "../config/manager.js";
8
8
  import { checkForUpdate, performUpdate, restartSelf } from "../utils/self-update.js";
9
+ import { trackEvent, startHeartbeat, showTelemetryDisclosureIfNeeded } from "../utils/telemetry.js";
10
+ import { loadTelemetryState } from "../config/telemetry.js";
9
11
  import { logRoute, logError, logStartup } from "./logger.js";
10
12
  import { stats } from "./stats.js";
11
13
  import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
@@ -383,5 +385,25 @@ export async function startServer(opts = {}) {
383
385
  logStartup(port, host, mode, target, accounts.length);
384
386
  if (autoUpdate)
385
387
  console.log(chalk.gray(" Auto-update: enabled (patch/minor)"));
388
+ // Anonymous telemetry — fire-and-forget, never blocks proxy startup.
389
+ // Show the disclosure on the very first start (covers existing users
390
+ // upgrading from versions before telemetry existed) before sending anything.
391
+ try {
392
+ showTelemetryDisclosureIfNeeded();
393
+ const telemetryState = loadTelemetryState();
394
+ // First-run detection: if the install is brand new, emit app_started too
395
+ const firstRunAge = Date.now() - new Date(telemetryState.firstRunAt).getTime();
396
+ if (firstRunAge < 5 * 60 * 1000) {
397
+ void trackEvent("app_started", { first_run: true });
398
+ }
399
+ void trackEvent("proxy_started", {
400
+ account_count: accounts.length,
401
+ mode,
402
+ });
403
+ startHeartbeat(accounts.length);
404
+ }
405
+ catch {
406
+ // never let telemetry break the proxy
407
+ }
386
408
  });
387
409
  }
@@ -0,0 +1,117 @@
1
+ import os from "os";
2
+ import chalk from "chalk";
3
+ import { isTelemetryEnabled, loadTelemetryState, writeTelemetryState } from "../config/telemetry.js";
4
+ import { detectPlatform } from "./platform.js";
5
+ import { getCurrentVersion } from "./self-update.js";
6
+ // ─── Aptabase configuration ──────────────────────────────────────────────────
7
+ // Aptabase is a privacy-first, open source analytics service.
8
+ // The full payload we send is documented below — search for "trackEvent" calls
9
+ // in the codebase to audit every event. Nothing here contains PII.
10
+ const APTABASE_APP_KEY = "A-EU-1060569594";
11
+ const APTABASE_ENDPOINT = "https://eu.aptabase.com/api/v0/event";
12
+ const TIMEOUT_MS = 3_000;
13
+ function getOsName() {
14
+ switch (detectPlatform()) {
15
+ case "macos": return "macOS";
16
+ case "linux": return "Linux";
17
+ case "windows": return "Windows";
18
+ }
19
+ }
20
+ function getLocale() {
21
+ try {
22
+ return Intl.DateTimeFormat().resolvedOptions().locale;
23
+ }
24
+ catch {
25
+ return process.env["LANG"]?.split(".")[0] ?? "unknown";
26
+ }
27
+ }
28
+ function getSystemProps() {
29
+ return {
30
+ isDebug: false,
31
+ locale: getLocale(),
32
+ osName: getOsName(),
33
+ osVersion: os.release(),
34
+ appVersion: getCurrentVersion(),
35
+ engineName: "node",
36
+ engineVersion: process.versions.node,
37
+ sdkVersion: `cc-router@${getCurrentVersion()}`,
38
+ };
39
+ }
40
+ // Session ID: <installId>-<epoch-hours>. This groups events that belong to the
41
+ // same "session" (proxy run) without leaking any timing precision finer than 1h.
42
+ function getSessionId(installId) {
43
+ const epochHours = Math.floor(Date.now() / 3_600_000);
44
+ return `${installId}-${epochHours}`;
45
+ }
46
+ // ─── Public API ──────────────────────────────────────────────────────────────
47
+ // Fire-and-forget: never throws, never blocks the caller. If telemetry is
48
+ // disabled (env var or opt-out) this is a synchronous no-op.
49
+ export async function trackEvent(eventName, props) {
50
+ try {
51
+ if (!isTelemetryEnabled())
52
+ return;
53
+ const state = loadTelemetryState();
54
+ const body = {
55
+ timestamp: new Date().toISOString(),
56
+ sessionId: getSessionId(state.installId),
57
+ eventName,
58
+ systemProps: getSystemProps(),
59
+ props: props ?? {},
60
+ };
61
+ await fetch(APTABASE_ENDPOINT, {
62
+ method: "POST",
63
+ headers: {
64
+ "Content-Type": "application/json",
65
+ "App-Key": APTABASE_APP_KEY,
66
+ },
67
+ body: JSON.stringify(body),
68
+ signal: AbortSignal.timeout(TIMEOUT_MS),
69
+ });
70
+ }
71
+ catch {
72
+ // Silently swallow — telemetry must never disrupt the proxy
73
+ }
74
+ }
75
+ // Start a heartbeat that fires every 6 hours while the proxy is running.
76
+ // Uses .unref() so the timer does not prevent Node from exiting.
77
+ export function startHeartbeat(accountCount) {
78
+ const startTime = Date.now();
79
+ const timer = setInterval(() => {
80
+ const uptimeHours = Math.floor((Date.now() - startTime) / 3_600_000);
81
+ trackEvent("proxy_heartbeat", {
82
+ uptime_hours: uptimeHours,
83
+ account_count: accountCount,
84
+ });
85
+ }, 6 * 60 * 60 * 1000);
86
+ timer.unref();
87
+ }
88
+ // One-time disclosure shown the very first time CC-Router runs after install
89
+ // or upgrade. Idempotent — gated by telemetry.disclosureShown so it's safe to
90
+ // call from multiple entry points (setup wizard, foreground start, daemon
91
+ // start, service install). Returns true if the disclosure was just shown.
92
+ export function showTelemetryDisclosureIfNeeded() {
93
+ try {
94
+ const state = loadTelemetryState();
95
+ if (state.disclosureShown)
96
+ return false;
97
+ console.log();
98
+ console.log(chalk.dim("─".repeat(60)));
99
+ console.log(chalk.bold(" Anonymous usage analytics"));
100
+ console.log();
101
+ console.log(" CC-Router sends anonymous lifecycle events (version, OS,");
102
+ console.log(" startup, heartbeat) to help us understand usage and prioritize");
103
+ console.log(" improvements. No IPs, no tokens, no prompts, no request content.");
104
+ console.log();
105
+ console.log(` Disable: ${chalk.cyan("cc-router telemetry off")}`);
106
+ console.log(` Or set: ${chalk.cyan("DO_NOT_TRACK=1")} | ${chalk.cyan("CC_ROUTER_TELEMETRY=0")}`);
107
+ console.log(` Source: ${chalk.dim("src/utils/telemetry.ts")}`);
108
+ console.log(chalk.dim("─".repeat(60)));
109
+ console.log();
110
+ state.disclosureShown = true;
111
+ writeTelemetryState(state);
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Round-robin proxy for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {