ai-cc-router 0.2.3 → 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 +51 -1
- package/dist/cli/cmd-client.js +5 -1
- package/dist/cli/cmd-setup.js +31 -0
- package/dist/cli/cmd-telemetry.js +60 -0
- package/dist/cli/index.js +2 -0
- package/dist/config/paths.js +3 -0
- package/dist/config/telemetry.js +63 -0
- package/dist/proxy/server.js +25 -0
- package/dist/ui/Dashboard.js +19 -2
- package/dist/utils/telemetry.js +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,21 @@ Distribute Claude Code requests across N subscriptions to multiply your throughp
|
|
|
7
7
|
[](https://www.npmjs.com/package/ai-cc-router)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
- **Round-robin token rotation** — distribute requests across 2-20 Claude Max accounts automatically
|
|
13
|
+
- **Transparent proxy** — Claude Code works normally; streaming, thinking, tool use, prompt caching all pass through
|
|
14
|
+
- **Automatic token refresh** — OAuth tokens are refreshed before they expire, saved atomically to disk
|
|
15
|
+
- **Rate limit awareness** — detects 429/529 responses and coolsdown accounts; picks the least-loaded one
|
|
16
|
+
- **Client mode** — connect to a remote CC-Router from any machine with one command (`cc-router client connect <url>`)
|
|
17
|
+
- **Claude Desktop support** — route Cowork / Agent-mode traffic through CC-Router via mitmproxy interception (macOS, Windows, Linux)
|
|
18
|
+
- **Guided setup wizard** — interactive `cc-router setup` extracts tokens from Keychain or credentials file, configures everything
|
|
19
|
+
- **Live dashboard** — real-time terminal UI showing account health, request counts, token usage, recent activity
|
|
20
|
+
- **Proxy authentication** — optional Bearer / x-api-key secret for internet-exposed deployments
|
|
21
|
+
- **Auto-update** — patch/minor releases install automatically (opt-out available)
|
|
22
|
+
- **Multiple deployment modes** — foreground, PM2 daemon, system service, Docker Compose (with LiteLLM)
|
|
23
|
+
- **Cross-platform** — macOS, Linux, Windows; Node.js 20+
|
|
24
|
+
|
|
10
25
|
---
|
|
11
26
|
|
|
12
27
|
> **Warning**
|
|
@@ -498,12 +513,47 @@ Press `q` to quit. Run with `--json` for non-interactive output.
|
|
|
498
513
|
- The file is excluded by `.gitignore`
|
|
499
514
|
- Writes are atomic (write to `.tmp`, then rename) — no corruption on crash
|
|
500
515
|
- Keychain reads use `execFile` with a fixed argument array — no shell injection
|
|
501
|
-
-
|
|
516
|
+
- Anonymous opt-out telemetry via [Aptabase](https://aptabase.com) (see [Telemetry](#telemetry) below)
|
|
502
517
|
|
|
503
518
|
See [docs/security.md](docs/security.md) for details.
|
|
504
519
|
|
|
505
520
|
---
|
|
506
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
|
+
|
|
507
557
|
## Disclaimer
|
|
508
558
|
|
|
509
559
|
> CC-Router uses the OAuth tokens of your own Claude Max subscriptions.
|
package/dist/cli/cmd-client.js
CHANGED
|
@@ -204,7 +204,11 @@ export function registerClient(program) {
|
|
|
204
204
|
const status = log.statusCode ?? 0;
|
|
205
205
|
const statusColor = status >= 500 || status === 0 ? chalk.red : status >= 400 ? chalk.yellow : chalk.green;
|
|
206
206
|
const duration = log.durationMs ? ` ${chalk.gray(log.durationMs + "ms")}` : "";
|
|
207
|
-
|
|
207
|
+
const src = log.source === "cli" ? chalk.blue("cli")
|
|
208
|
+
: log.source === "desktop" ? chalk.magenta("dsk")
|
|
209
|
+
: log.source === "api" ? chalk.gray("api")
|
|
210
|
+
: chalk.gray(" ");
|
|
211
|
+
console.log(` ${chalk.gray(formatTime(log.ts))} ${src} ${log.accountId.padEnd(18)} ` +
|
|
208
212
|
`${(log.method ?? "?").padEnd(5)} ${(log.path ?? "?").padEnd(22)} ` +
|
|
209
213
|
`${statusColor(String(status))}${duration}`);
|
|
210
214
|
}
|
package/dist/cli/cmd-setup.js
CHANGED
|
@@ -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"]) {
|
package/dist/config/paths.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/proxy/server.js
CHANGED
|
@@ -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";
|
|
@@ -317,6 +319,11 @@ export async function startServer(opts = {}) {
|
|
|
317
319
|
}
|
|
318
320
|
req._ccAccount = account;
|
|
319
321
|
req._startTime = Date.now();
|
|
322
|
+
const source = req.headers["x-claude-code-session-id"]
|
|
323
|
+
? "cli"
|
|
324
|
+
: req.headers["x-api-key"]
|
|
325
|
+
? "desktop"
|
|
326
|
+
: "api";
|
|
320
327
|
req._pendingLog = {
|
|
321
328
|
ts: Date.now(),
|
|
322
329
|
accountId: account.id,
|
|
@@ -324,6 +331,7 @@ export async function startServer(opts = {}) {
|
|
|
324
331
|
type: "route",
|
|
325
332
|
method: req.method,
|
|
326
333
|
path: req.path,
|
|
334
|
+
source,
|
|
327
335
|
};
|
|
328
336
|
stats.totalRequests++;
|
|
329
337
|
logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - Date.now()) / 60_000));
|
|
@@ -377,5 +385,22 @@ export async function startServer(opts = {}) {
|
|
|
377
385
|
logStartup(port, host, mode, target, accounts.length);
|
|
378
386
|
if (autoUpdate)
|
|
379
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
|
+
}
|
|
380
405
|
});
|
|
381
406
|
}
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -146,6 +146,13 @@ function LogRow({ log, selected }) {
|
|
|
146
146
|
: "gray";
|
|
147
147
|
const bg = selected ? "white" : undefined;
|
|
148
148
|
const fg = (c) => selected ? "black" : c;
|
|
149
|
+
const sourceLabel = log.source === "cli" ? "cli"
|
|
150
|
+
: log.source === "desktop" ? "dsk"
|
|
151
|
+
: log.source === "api" ? "api"
|
|
152
|
+
: " ";
|
|
153
|
+
const sourceColor = log.source === "cli" ? "blue"
|
|
154
|
+
: log.source === "desktop" ? "magenta"
|
|
155
|
+
: "gray";
|
|
149
156
|
// Per-request token stats
|
|
150
157
|
const inputTok = (log.cacheReadTokens ?? 0) + (log.cacheCreationTokens ?? 0) + (log.inputTokens ?? 0);
|
|
151
158
|
const outputTok = log.outputTokens ?? 0;
|
|
@@ -154,7 +161,7 @@ function LogRow({ log, selected }) {
|
|
|
154
161
|
: cacheHitPct >= 70 ? "green"
|
|
155
162
|
: cacheHitPct >= 30 ? "yellow"
|
|
156
163
|
: "red";
|
|
157
|
-
return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
|
|
164
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(sourceColor), children: [sourceLabel, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
|
|
158
165
|
? _jsxs(Text, { backgroundColor: bg, color: fg("white"), children: [" ", log.method, " ", log.path.padEnd(14)] })
|
|
159
166
|
: _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [" ", log.type.padEnd(9)] }), log.statusCode !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg(statusColor), children: [" ", log.statusCode] })), log.durationMs !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.durationMs, "ms"] })), cacheHitPct !== null && (_jsxs(Text, { backgroundColor: bg, color: fg(cacheColor), children: [" \u2191", cacheHitPct, "%"] })), (inputTok > 0 || outputTok > 0) && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", fmtTok(inputTok), "\u2191 ", fmtTok(outputTok), "\u2193"] })), log.details && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.details] }))] }));
|
|
160
167
|
}
|
|
@@ -174,7 +181,7 @@ function DetailPanel({ log }) {
|
|
|
174
181
|
: log.statusCode >= 500 ? "red"
|
|
175
182
|
: log.statusCode >= 400 ? "yellow"
|
|
176
183
|
: "green";
|
|
177
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) })), log.cacheReadTokens !== undefined && (_jsx(Box, { gap: 2, children: _jsx(CacheBreakdown, { read: log.cacheReadTokens, created: log.cacheCreationTokens ?? 0, input: log.inputTokens ?? 0, output: log.outputTokens ?? 0 }) }))] })] }));
|
|
184
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type }), _jsx(Field, { label: "Source", value: sourceFullLabel(log.source) })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) })), log.cacheReadTokens !== undefined && (_jsx(Box, { gap: 2, children: _jsx(CacheBreakdown, { read: log.cacheReadTokens, created: log.cacheCreationTokens ?? 0, input: log.inputTokens ?? 0, output: log.outputTokens ?? 0 }) }))] })] }));
|
|
178
185
|
}
|
|
179
186
|
function Field({ label, value }) {
|
|
180
187
|
return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
|
|
@@ -215,6 +222,16 @@ function fmtTok(n) {
|
|
|
215
222
|
return `${(n / 1_000).toFixed(1)}k`;
|
|
216
223
|
return String(n);
|
|
217
224
|
}
|
|
225
|
+
// ─── Source label ─────────────────────────────────────────────────────────────
|
|
226
|
+
function sourceFullLabel(source) {
|
|
227
|
+
if (source === "cli")
|
|
228
|
+
return "Claude Code";
|
|
229
|
+
if (source === "desktop")
|
|
230
|
+
return "Claude Desktop";
|
|
231
|
+
if (source === "api")
|
|
232
|
+
return "API";
|
|
233
|
+
return "—";
|
|
234
|
+
}
|
|
218
235
|
// ─── HTTP status text ─────────────────────────────────────────────────────────
|
|
219
236
|
function httpStatusText(code) {
|
|
220
237
|
const map = {
|
|
@@ -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
|
+
}
|