ai-cc-router 0.2.4 → 0.2.5

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.
@@ -13,6 +13,8 @@ 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 { loadTelemetryState, writeTelemetryState } from "../config/telemetry.js";
17
+ import { trackEvent } from "../utils/telemetry.js";
16
18
  const execFileAsync = promisify(execFile);
17
19
  // ─── Public registration ──────────────────────────────────────────────────────
18
20
  export function registerSetup(program) {
@@ -212,9 +214,38 @@ async function runSetupWizard({ addMode }) {
212
214
  console.log(chalk.bold(`\n${"━".repeat(40)}\n Saving\n${"━".repeat(40)}\n`));
213
215
  saveAccounts(merged);
214
216
  console.log(chalk.green(` ✓ ${merged.length} account(s) saved to ~/.cc-router/accounts.json`));
217
+ showTelemetryDisclosureIfNeeded();
218
+ void trackEvent("setup_completed", { account_count: merged.length });
215
219
  // ─── Post-setup interactive flow ─────────────────────────────────────────
216
220
  await runPostSetupFlow(merged.length);
217
221
  }
222
+ // Anonymous telemetry disclosure, shown exactly once after a successful setup.
223
+ // Controlled by telemetry.disclosureShown in ~/.cc-router/telemetry.json.
224
+ function showTelemetryDisclosureIfNeeded() {
225
+ try {
226
+ const state = loadTelemetryState();
227
+ if (state.disclosureShown)
228
+ return;
229
+ console.log();
230
+ console.log(chalk.dim("─".repeat(60)));
231
+ console.log(chalk.bold(" Anonymous usage analytics"));
232
+ console.log();
233
+ console.log(" CC-Router sends anonymous lifecycle events (version, OS,");
234
+ console.log(" startup, heartbeat) to help us understand usage and prioritize");
235
+ console.log(" improvements. No IPs, no tokens, no prompts, no request content.");
236
+ console.log();
237
+ console.log(` Disable: ${chalk.cyan("cc-router telemetry off")}`);
238
+ console.log(` Or set: ${chalk.cyan("DO_NOT_TRACK=1")} | ${chalk.cyan("CC_ROUTER_TELEMETRY=0")}`);
239
+ console.log(` Source: ${chalk.dim("src/utils/telemetry.ts")}`);
240
+ console.log(chalk.dim("─".repeat(60)));
241
+ console.log();
242
+ state.disclosureShown = true;
243
+ writeTelemetryState(state);
244
+ }
245
+ catch {
246
+ // never block setup on telemetry errors
247
+ }
248
+ }
218
249
  // ─── Post-setup interactive flow ─────────────────────────────────────────────
219
250
  async function runPostSetupFlow(accountCount) {
220
251
  console.log(chalk.bold(`\n${"━".repeat(40)}\n Configure this machine\n${"━".repeat(40)}\n`));
@@ -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 } 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,22 @@ 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
+ try {
390
+ const telemetryState = loadTelemetryState();
391
+ // First-run detection: if the install is brand new, emit app_started too
392
+ const firstRunAge = Date.now() - new Date(telemetryState.firstRunAt).getTime();
393
+ if (firstRunAge < 5 * 60 * 1000) {
394
+ void trackEvent("app_started", { first_run: true });
395
+ }
396
+ void trackEvent("proxy_started", {
397
+ account_count: accounts.length,
398
+ mode,
399
+ });
400
+ startHeartbeat(accounts.length);
401
+ }
402
+ catch {
403
+ // never let telemetry break the proxy
404
+ }
386
405
  });
387
406
  }
@@ -0,0 +1,86 @@
1
+ import os from "os";
2
+ import { isTelemetryEnabled, loadTelemetryState } from "../config/telemetry.js";
3
+ import { detectPlatform } from "./platform.js";
4
+ import { getCurrentVersion } from "./self-update.js";
5
+ // ─── Aptabase configuration ──────────────────────────────────────────────────
6
+ // Aptabase is a privacy-first, open source analytics service.
7
+ // The full payload we send is documented below — search for "trackEvent" calls
8
+ // in the codebase to audit every event. Nothing here contains PII.
9
+ const APTABASE_APP_KEY = "A-EU-1060569594";
10
+ const APTABASE_ENDPOINT = "https://eu.aptabase.com/api/v0/event";
11
+ const TIMEOUT_MS = 3_000;
12
+ function getOsName() {
13
+ switch (detectPlatform()) {
14
+ case "macos": return "macOS";
15
+ case "linux": return "Linux";
16
+ case "windows": return "Windows";
17
+ }
18
+ }
19
+ function getLocale() {
20
+ try {
21
+ return Intl.DateTimeFormat().resolvedOptions().locale;
22
+ }
23
+ catch {
24
+ return process.env["LANG"]?.split(".")[0] ?? "unknown";
25
+ }
26
+ }
27
+ function getSystemProps() {
28
+ return {
29
+ isDebug: false,
30
+ locale: getLocale(),
31
+ osName: getOsName(),
32
+ osVersion: os.release(),
33
+ appVersion: getCurrentVersion(),
34
+ engineName: "node",
35
+ engineVersion: process.versions.node,
36
+ sdkVersion: `cc-router@${getCurrentVersion()}`,
37
+ };
38
+ }
39
+ // Session ID: <installId>-<epoch-hours>. This groups events that belong to the
40
+ // same "session" (proxy run) without leaking any timing precision finer than 1h.
41
+ function getSessionId(installId) {
42
+ const epochHours = Math.floor(Date.now() / 3_600_000);
43
+ return `${installId}-${epochHours}`;
44
+ }
45
+ // ─── Public API ──────────────────────────────────────────────────────────────
46
+ // Fire-and-forget: never throws, never blocks the caller. If telemetry is
47
+ // disabled (env var or opt-out) this is a synchronous no-op.
48
+ export async function trackEvent(eventName, props) {
49
+ try {
50
+ if (!isTelemetryEnabled())
51
+ return;
52
+ const state = loadTelemetryState();
53
+ const body = {
54
+ timestamp: new Date().toISOString(),
55
+ sessionId: getSessionId(state.installId),
56
+ eventName,
57
+ systemProps: getSystemProps(),
58
+ props: props ?? {},
59
+ };
60
+ await fetch(APTABASE_ENDPOINT, {
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ "App-Key": APTABASE_APP_KEY,
65
+ },
66
+ body: JSON.stringify(body),
67
+ signal: AbortSignal.timeout(TIMEOUT_MS),
68
+ });
69
+ }
70
+ catch {
71
+ // Silently swallow — telemetry must never disrupt the proxy
72
+ }
73
+ }
74
+ // Start a heartbeat that fires every 6 hours while the proxy is running.
75
+ // Uses .unref() so the timer does not prevent Node from exiting.
76
+ export function startHeartbeat(accountCount) {
77
+ const startTime = Date.now();
78
+ const timer = setInterval(() => {
79
+ const uptimeHours = Math.floor((Date.now() - startTime) / 3_600_000);
80
+ trackEvent("proxy_heartbeat", {
81
+ uptime_hours: uptimeHours,
82
+ account_count: accountCount,
83
+ });
84
+ }, 6 * 60 * 60 * 1000);
85
+ timer.unref();
86
+ }
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.5",
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": {