Skip to content

Commit 4fb635d

Browse files
committed
feat(telemetry): add anonymous usage analytics via Aptabase
Privacy-first, opt-out telemetry using Aptabase (EU-hosted, open source). Sends only lifecycle events (startup, heartbeat) with version, OS, and an anonymous install UUID. No IPs, tokens, prompts, or request content. - New command: cc-router telemetry on/off/status - Honors DO_NOT_TRACK=1 and CC_ROUTER_TELEMETRY=0 - First-run disclosure shown once after setup wizard - 6h heartbeat while proxy runs (.unref() — won't block shutdown) - 13 unit tests covering state, env vars, HTTP client, and silent failure
1 parent d944ee2 commit 4fb635d

10 files changed

Lines changed: 538 additions & 2 deletions

File tree

README.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,12 +513,47 @@ Press `q` to quit. Run with `--json` for non-interactive output.
513513
- The file is excluded by `.gitignore`
514514
- Writes are atomic (write to `.tmp`, then rename) — no corruption on crash
515515
- 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)
517517

518518
See [docs/security.md](docs/security.md) for details.
519519

520520
---
521521

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+
522557
## Disclaimer
523558

524559
> CC-Router uses the OAuth tokens of your own Claude Max subscriptions.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ai-cc-router",
3-
"version": "0.2.4",
3+
"version": "0.2.5",
44
"description": "Round-robin proxy for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
55
"type": "module",
66
"bin": {

src/__tests__/telemetry.test.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import * as fs from "fs";
3+
4+
// ─── Isolated temp directory for every run ───────────────────────────────────
5+
const MOCK_DIR = vi.hoisted(() => {
6+
const tmp = process.env["TMPDIR"] ?? process.env["TEMP"] ?? "/tmp";
7+
return `${tmp}/cc-router-telemetry-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
8+
});
9+
10+
vi.mock("../config/paths.js", () => ({
11+
CONFIG_DIR: MOCK_DIR,
12+
TELEMETRY_PATH: `${MOCK_DIR}/telemetry.json`,
13+
ACCOUNTS_PATH: `${MOCK_DIR}/accounts.json`,
14+
CLAUDE_SETTINGS_PATH: `${MOCK_DIR}/settings.json`,
15+
CONFIG_PATH: `${MOCK_DIR}/config.json`,
16+
PROXY_PORT: 3456,
17+
LITELLM_PORT: 4000,
18+
LITELLM_URL: undefined,
19+
}));
20+
21+
import {
22+
loadTelemetryState,
23+
writeTelemetryState,
24+
isTelemetryEnabled,
25+
type TelemetryState,
26+
} from "../config/telemetry.js";
27+
28+
beforeEach(() => {
29+
fs.mkdirSync(MOCK_DIR, { recursive: true });
30+
// Reset env vars
31+
delete process.env["DO_NOT_TRACK"];
32+
delete process.env["CC_ROUTER_TELEMETRY"];
33+
});
34+
35+
afterEach(() => {
36+
fs.rmSync(MOCK_DIR, { recursive: true, force: true });
37+
delete process.env["DO_NOT_TRACK"];
38+
delete process.env["CC_ROUTER_TELEMETRY"];
39+
});
40+
41+
// ─── TelemetryState persistence ──────────────────────────────────────────────
42+
43+
describe("loadTelemetryState", () => {
44+
it("creates fresh state with UUID and persists on first call", () => {
45+
const state = loadTelemetryState();
46+
expect(state.enabled).toBe(true);
47+
expect(state.installId).toMatch(
48+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
49+
);
50+
expect(state.disclosureShown).toBe(false);
51+
expect(new Date(state.firstRunAt).getTime()).toBeGreaterThan(0);
52+
53+
// Was persisted to disk
54+
const onDisk = JSON.parse(
55+
fs.readFileSync(`${MOCK_DIR}/telemetry.json`, "utf-8"),
56+
) as TelemetryState;
57+
expect(onDisk.installId).toBe(state.installId);
58+
});
59+
60+
it("returns the same installId on subsequent calls", () => {
61+
const first = loadTelemetryState();
62+
const second = loadTelemetryState();
63+
expect(second.installId).toBe(first.installId);
64+
});
65+
66+
it("recovers from corrupted JSON", () => {
67+
fs.writeFileSync(`${MOCK_DIR}/telemetry.json`, "NOT_JSON{}", "utf-8");
68+
const state = loadTelemetryState();
69+
expect(state.enabled).toBe(true);
70+
expect(state.installId).toBeDefined();
71+
});
72+
});
73+
74+
describe("writeTelemetryState", () => {
75+
it("atomically writes state", () => {
76+
const state: TelemetryState = {
77+
enabled: false,
78+
installId: "test-uuid",
79+
firstRunAt: "2026-01-01T00:00:00.000Z",
80+
disclosureShown: true,
81+
};
82+
writeTelemetryState(state);
83+
const raw = JSON.parse(
84+
fs.readFileSync(`${MOCK_DIR}/telemetry.json`, "utf-8"),
85+
) as TelemetryState;
86+
expect(raw).toEqual(state);
87+
// .tmp was cleaned up (rename replaces)
88+
expect(fs.existsSync(`${MOCK_DIR}/telemetry.json.tmp`)).toBe(false);
89+
});
90+
});
91+
92+
// ─── isTelemetryEnabled ──────────────────────────────────────────────────────
93+
94+
describe("isTelemetryEnabled", () => {
95+
it("returns true by default", () => {
96+
expect(isTelemetryEnabled()).toBe(true);
97+
});
98+
99+
it("returns false when DO_NOT_TRACK=1", () => {
100+
process.env["DO_NOT_TRACK"] = "1";
101+
expect(isTelemetryEnabled()).toBe(false);
102+
});
103+
104+
it("returns false when CC_ROUTER_TELEMETRY=0", () => {
105+
process.env["CC_ROUTER_TELEMETRY"] = "0";
106+
expect(isTelemetryEnabled()).toBe(false);
107+
});
108+
109+
it("returns false when state.enabled is false", () => {
110+
const state = loadTelemetryState();
111+
state.enabled = false;
112+
writeTelemetryState(state);
113+
expect(isTelemetryEnabled()).toBe(false);
114+
});
115+
116+
it("env var takes precedence even when state.enabled is true", () => {
117+
const state = loadTelemetryState();
118+
state.enabled = true;
119+
writeTelemetryState(state);
120+
process.env["DO_NOT_TRACK"] = "1";
121+
expect(isTelemetryEnabled()).toBe(false);
122+
});
123+
});
124+
125+
// ─── trackEvent (HTTP client) ────────────────────────────────────────────────
126+
127+
describe("trackEvent", () => {
128+
const fetchSpy = vi.spyOn(globalThis, "fetch");
129+
130+
beforeEach(() => {
131+
fetchSpy.mockReset();
132+
fetchSpy.mockResolvedValue(new Response("ok", { status: 200 }));
133+
});
134+
135+
afterEach(() => {
136+
fetchSpy.mockRestore();
137+
});
138+
139+
// Import after mocks are in place
140+
let trackEvent: typeof import("../utils/telemetry.js").trackEvent;
141+
142+
beforeEach(async () => {
143+
// Dynamic import so the module picks up our mocked paths
144+
const mod = await import("../utils/telemetry.js");
145+
trackEvent = mod.trackEvent;
146+
});
147+
148+
it("sends event to Aptabase EU endpoint", async () => {
149+
// Ensure telemetry state exists (enabled by default)
150+
loadTelemetryState();
151+
152+
await trackEvent("test_event", { key: "value" });
153+
154+
expect(fetchSpy).toHaveBeenCalledOnce();
155+
const [url, opts] = fetchSpy.mock.calls[0];
156+
expect(url).toBe("https://eu.aptabase.com/api/v0/event");
157+
expect((opts as RequestInit).method).toBe("POST");
158+
expect((opts as RequestInit).headers).toEqual(
159+
expect.objectContaining({
160+
"Content-Type": "application/json",
161+
"App-Key": "A-EU-1060569594",
162+
}),
163+
);
164+
165+
const body = JSON.parse((opts as RequestInit).body as string);
166+
expect(body.eventName).toBe("test_event");
167+
expect(body.props.key).toBe("value");
168+
expect(body.systemProps.osName).toMatch(/^(macOS|Linux|Windows)$/);
169+
expect(body.systemProps.engineName).toBe("node");
170+
expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
171+
expect(body.sessionId).toContain(loadTelemetryState().installId);
172+
});
173+
174+
it("does not call fetch when telemetry is disabled", async () => {
175+
process.env["DO_NOT_TRACK"] = "1";
176+
await trackEvent("should_not_send");
177+
expect(fetchSpy).not.toHaveBeenCalled();
178+
});
179+
180+
it("never throws, even if fetch rejects", async () => {
181+
loadTelemetryState();
182+
fetchSpy.mockRejectedValue(new Error("network down"));
183+
await expect(trackEvent("crash_test")).resolves.toBeUndefined();
184+
});
185+
186+
it("never throws if fetch times out", async () => {
187+
loadTelemetryState();
188+
fetchSpy.mockImplementation(
189+
() => new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 10)),
190+
);
191+
await expect(trackEvent("timeout_test")).resolves.toBeUndefined();
192+
});
193+
});

src/cli/cmd-setup.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
openNetworkExtensionSettings,
2929
} from "../interceptor/mitmproxy-manager.js";
3030
import { printDesktopSupportExplainer, printNetworkExtensionInstructions } from "./cmd-client.js";
31+
import { loadTelemetryState, writeTelemetryState } from "../config/telemetry.js";
32+
import { trackEvent } from "../utils/telemetry.js";
3133

3234
const execFileAsync = promisify(execFile);
3335

@@ -252,10 +254,39 @@ async function runSetupWizard({ addMode }: { addMode: boolean }): Promise<void>
252254
saveAccounts(merged);
253255
console.log(chalk.green(` ✓ ${merged.length} account(s) saved to ~/.cc-router/accounts.json`));
254256

257+
showTelemetryDisclosureIfNeeded();
258+
void trackEvent("setup_completed", { account_count: merged.length });
259+
255260
// ─── Post-setup interactive flow ─────────────────────────────────────────
256261
await runPostSetupFlow(merged.length);
257262
}
258263

264+
// Anonymous telemetry disclosure, shown exactly once after a successful setup.
265+
// Controlled by telemetry.disclosureShown in ~/.cc-router/telemetry.json.
266+
function showTelemetryDisclosureIfNeeded(): void {
267+
try {
268+
const state = loadTelemetryState();
269+
if (state.disclosureShown) return;
270+
console.log();
271+
console.log(chalk.dim("─".repeat(60)));
272+
console.log(chalk.bold(" Anonymous usage analytics"));
273+
console.log();
274+
console.log(" CC-Router sends anonymous lifecycle events (version, OS,");
275+
console.log(" startup, heartbeat) to help us understand usage and prioritize");
276+
console.log(" improvements. No IPs, no tokens, no prompts, no request content.");
277+
console.log();
278+
console.log(` Disable: ${chalk.cyan("cc-router telemetry off")}`);
279+
console.log(` Or set: ${chalk.cyan("DO_NOT_TRACK=1")} | ${chalk.cyan("CC_ROUTER_TELEMETRY=0")}`);
280+
console.log(` Source: ${chalk.dim("src/utils/telemetry.ts")}`);
281+
console.log(chalk.dim("─".repeat(60)));
282+
console.log();
283+
state.disclosureShown = true;
284+
writeTelemetryState(state);
285+
} catch {
286+
// never block setup on telemetry errors
287+
}
288+
}
289+
259290
// ─── Post-setup interactive flow ─────────────────────────────────────────────
260291

261292
async function runPostSetupFlow(accountCount: number): Promise<void> {

src/cli/cmd-telemetry.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Command } from "commander";
2+
import chalk from "chalk";
3+
import { loadTelemetryState, writeTelemetryState, isTelemetryEnabled } from "../config/telemetry.js";
4+
import { trackEvent } from "../utils/telemetry.js";
5+
6+
export function registerTelemetry(program: Command): void {
7+
program
8+
.command("telemetry [action]")
9+
.description("Manage anonymous usage analytics: on, off, status (default: status)")
10+
.action(async (action?: string) => {
11+
const resolved = action ?? "status";
12+
13+
if (resolved === "status") {
14+
showStatus();
15+
return;
16+
}
17+
18+
if (resolved === "on") {
19+
const state = loadTelemetryState();
20+
state.enabled = true;
21+
writeTelemetryState(state);
22+
console.log(chalk.green("Telemetry enabled."));
23+
console.log(chalk.dim(`Install ID: ${state.installId}`));
24+
return;
25+
}
26+
27+
if (resolved === "off") {
28+
// Send one last event so we know about opt-out rates
29+
await trackEvent("telemetry_disabled");
30+
const state = loadTelemetryState();
31+
state.enabled = false;
32+
writeTelemetryState(state);
33+
console.log(chalk.yellow("Telemetry disabled. No data will be sent."));
34+
console.log(chalk.dim("Re-enable anytime with: cc-router telemetry on"));
35+
return;
36+
}
37+
38+
console.error(chalk.red(`Unknown action "${resolved}". Use: on, off, status`));
39+
process.exitCode = 1;
40+
});
41+
}
42+
43+
function showStatus(): void {
44+
const state = loadTelemetryState();
45+
const envDisabled =
46+
process.env["DO_NOT_TRACK"] === "1" || process.env["CC_ROUTER_TELEMETRY"] === "0";
47+
48+
console.log(chalk.bold("Telemetry"));
49+
console.log();
50+
51+
if (envDisabled) {
52+
console.log(` Status: ${chalk.yellow("disabled")} (by environment variable)`);
53+
} else if (state.enabled) {
54+
console.log(` Status: ${chalk.green("enabled")}`);
55+
} else {
56+
console.log(` Status: ${chalk.yellow("disabled")}`);
57+
}
58+
59+
console.log(` Active: ${isTelemetryEnabled() ? chalk.green("yes") : chalk.yellow("no")}`);
60+
console.log(` Install ID: ${chalk.dim(state.installId)}`);
61+
console.log(` Since: ${chalk.dim(state.firstRunAt)}`);
62+
console.log();
63+
console.log(chalk.dim(" What we send: version, OS, locale, lifecycle events (start, heartbeat)"));
64+
console.log(chalk.dim(" What we DON'T: IPs, tokens, prompts, request content, account names"));
65+
console.log(chalk.dim(" Source code: src/utils/telemetry.ts"));
66+
console.log();
67+
console.log(chalk.dim(" Disable: cc-router telemetry off"));
68+
console.log(chalk.dim(" Or set: DO_NOT_TRACK=1 | CC_ROUTER_TELEMETRY=0"));
69+
}

src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { registerConfigure } from "./cmd-configure.js";
1010
import { registerDocker } from "./cmd-docker.js";
1111
import { registerUpdate } from "./cmd-update.js";
1212
import { registerClient } from "./cmd-client.js";
13+
import { registerTelemetry } from "./cmd-telemetry.js";
1314
import { getCurrentVersion, checkForUpdate, printUpdateBanner } from "../utils/self-update.js";
1415

1516
const program = new Command();
@@ -47,6 +48,7 @@ registerConfigure(program);
4748
registerDocker(program);
4849
registerUpdate(program);
4950
registerClient(program);
51+
registerTelemetry(program);
5052

5153
// Background update check — fires on every CLI invocation, uses 6h disk cache
5254
// so it's essentially free after the first check. Notify on process exit.

src/config/paths.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,8 @@ export const LITELLM_URL = process.env["LITELLM_URL"];
2020
export const CONFIG_PATH =
2121
process.env["CONFIG_PATH"] ??
2222
path.join(CONFIG_DIR, "config.json");
23+
24+
// Anonymous telemetry state — install id + opt-in flag
25+
export const TELEMETRY_PATH =
26+
process.env["TELEMETRY_PATH"] ??
27+
path.join(CONFIG_DIR, "telemetry.json");

0 commit comments

Comments
 (0)