Skip to content

Commit ed90376

Browse files
authored
Merge pull request #127 from souloss/feat/proxy-stats
feat(proxy): per-day proxy retry statistics + ProxyStatsPage
2 parents 9d2b8b0 + 1af0f60 commit ed90376

17 files changed

Lines changed: 2004 additions & 25 deletions

history.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@
2828
- fix(proxy-retry): **RetryConfigModal save button had no feedback**`handleSave` called `onConfigChange` but neither closed the modal nor showed a success toast, so users thought the button was dead. Now closes the modal + shows `ui.retryConfig.saved` after POST. Added the missing `handleRetryConfigChange` handler in `AppBase` (optimistic setState + rollback on failure) and wired `retryConfig`/`retryDefaults`/`onRetryConfigChange` from `App.jsx``AppHeader` (the modal rendered but could not save because the prop chain was broken). 2 new i18n keys (`ui.retryConfig.saved`, `ui.retryConfig.saveFail`) × 18 locales.
2929
- fix(proxy-retry): **Hop-by-hop headers blocked LLM retries**`handleLlmApiRequest` forwarded the inbound `transfer-encoding: chunked` / `content-length` headers to undici, but the body had already been buffered to a full `Buffer`, so undici rejected every retry with `invalid transfer-encoding header` (status 0, 502 to the client). Now strips `transfer-encoding`/`connection`/`content-length` (case-insensitive) before handing options to the retry engine; covered by a new chunked-TE live-proxy test in `test/proxy-server.test.js`.
3030

31+
- feat(proxy): **Per-day proxy retry statistics** — every proxied LLM request records a detail line (`attempts`/`retries`/`upstream_status`/`final_status`/`duration_ms`/`retry_codes`/`succeeded`/`profile_id`/`profile_name`) to a per-day `proxy_YYYY-MM-DD.jsonl` shard under `LOG_DIR/<project>/`. Disable with `CCV_PROXY_STATS=off`.
32+
- **Stats integration**: the existing `stats-worker` (Worker thread) scans `proxy_*.jsonl` alongside the session JSONL and merges an aggregate into `<project>/<project>.json` under a new top-level `proxyStats` field (`STATS_VERSION` bumped to 12 forces a rebuild). Aggregate covers upstream-vs-downstream availability (dual-caliber: first-attempt success rate vs final success rate), P50/P95/P99/max/avg duration, current streak + worst failure streak, retry-count distribution, upstream error-code distribution, by-model/by-path/by-profile breakdowns, slowest/fastest request, and recent records — all as pure functions in `server/lib/proxy-stats.js`.
33+
- **HTTP API + UI**: `GET /api/proxy-stats` and `POST /api/refresh-proxy-stats` (new `server/routes/proxy-stats.js`). A new `?view=proxy-stats` query-param route renders a dedicated `<ProxyStatsPage>` (lazy-loaded, Ant Design only — no ECharts) with overview cards, availability analysis, retry distribution, duration analysis, by-model/by-path/by-profile tables, and a recent-records table (15s auto-refresh). Entry point: a "Proxy Retry Stats" toggle button in the AppHeader.
34+
- 37 new `ui.proxyStats.*` i18n keys × 18 locales. Tests: `test/proxy-stats.test.js` (buildRecord/percentile/computeStreak/aggregateRecords dual-availability/streak/byModel/byPath/byProfile/retryDistribution + incremental cache).
35+
36+
- perf(proxy-stats): **aggregateProxyStats full-rescanned every proxy detail file per request** — ignored the `existing` param. Added file-level incremental caching (`mergeProxyFileCache` pure helper in `proxy-stats.js`): unchanged `proxy_YYYY-MM-DD.jsonl` shards (matched by size+mtime) reuse cached records, only changed/new files re-parse. The `proxyStats` field rides the `STATS_VERSION` 12 rebuild. 6 new cache tests.
37+
- perf(proxy-stats): **two P1 findings from the 6-role review adopted.** (1) `notifyProxyStats` had no debounce — every proxied LLM request posted a worker message that re-aggregated the whole project (the session-log path has the log-watcher debounce, the proxy path had none; O(N²) over a busy day). Now a 2s trailing coalesce: the first notify schedules a flush, further notifies within the window only update the pending file; the timer is `unref()`ed so shutdown is never held open. (2) The per-file records cache was persisted as `proxyStatsFiles` inside `<project>.json` — every raw record duplicated on disk (unbounded growth), every stats update rewrote it and every `GET /api/proxy-stats` re-parsed it. The cache now lives in worker memory only (`_proxyCacheByDir`); the JSON carries just the aggregated `proxyStats`, and a worker restart simply re-parses the shards once.
38+
- fix(ui): **ProxyStatsPage poll failure wiped the whole panel** (review P1) — the fetch `.catch` did `setData(null)`, so one transient network blip / 5xx during the 15s auto-refresh collapsed already-rendered stats to the empty state, and the error was silently swallowed (CLAUDE.md `reportSwallowed` rule). Failures now keep the last rendered data and report via `reportSwallowed('proxyStats.fetch', err)`; non-OK responses throw instead of masquerading as "no data".
39+
- fix(proxy-stats): **seven P2 findings from the 6-role review adopted.** (1) `upstream_status` was hard-wired to `final_status` in `handleLlmApiRequest` — the `upstreamStatus` that `executeRequest` already returns was never destructured, so the schema's dual-status distinction (real last upstream code vs code returned to the client on race/stagger fallbacks) was dead on arrival; now wired through with a `?? finalStatus` fallback. (2) `appendRecord` did a synchronous `mkdirSync` + `appendFileSync` on the proxy hot path before the first response byte; it now writes through the shared `AsyncWriteQueue` (ordered, sync-fallback on process exit via `flushRecords`) and caches directory creation per process. (3) Dependency inversion: `proxy.js` no longer dynamically `import('./server.js')` to reach the statsWorker (a future proxy-only process would have booted a second viewer via module side effects) — `server.js` registers its debounced notifier through the new `setProxyStatsListener`/`emitProxyStatsUpdate` registry in `proxy-stats.js`; no listener → no-op. (4) `POST /api/refresh-proxy-stats` coalesces concurrent calls: a module-level latch shares ONE scan + ONE worker listener among all in-flight requests (was: one 30s listener per request piling up, any `scan-all-done` cross-resolving other requests' waits, and N floods = N full scans). (5) Retention: opt-in `CCV_PROXY_STATS_RETAIN_DAYS=N` prunes `proxy_YYYY-MM-DD.jsonl` shards dated older than N days during worker scans (strict filename parse — unparsable names never deleted; OFF by default so nothing is ever removed without the user choosing it). (6) `ProxyStatsPage.module.css` wrote a bare `.ant-table-wrapper` inside a CSS Module — the class got hashed and the rule never matched (uneven table widths); now `:global(.ant-table-wrapper)`. (7) The ProxyStats header toggle is now gated on `_isProxyMode()` (non-built-in profile active, or Default pointing at a non-official endpoint — same test as ProxyModal's Max warning) for both the web button and the Electron tab-bar model, and its icon switched `ApiOutlined`→`LineChartOutlined` (was colliding with plugin-management). Tests: +1 route case (concurrent-refresh coalescing: one scan/one listener/all waiters resolved/latch resets), +2 worker cases (retention prunes beyond-window shard & keeps within-window; no env → nothing deleted), +2 lib cases (appendRecord async write + auto-mkdir + flush ordering; notifier no-op/forward/error-containment).
40+
- test(proxy-stats): **wiring-layer coverage** (review P1 — the pure aggregation layer was well-tested, the integration layer had zero): new `test/api-proxy-stats.test.js` (9 cases: GET 404-no-project / null-no-file / 200-with-field / null-pre-v12 / 500-corrupt-JSON; POST starts-worker+scan-all / 200-on-done+listener-removed / ignores-unrelated-messages / 500-no-worker) and `test/stats-worker-proxy.test.js` (5 worker-thread cases: cross-day shard merge + no-persisted-cache invariant, corrupt-line skip, append+re-aggregate through the memory cache in one worker, proxy-only project still writes stats, session-only project gets empty proxyStats).
41+
- perf(proxy): **per-request `import('./server.js')`** cached into a module-level promise (`_notifyProxyStats`); failures now log under `CCV_DEBUG` instead of being silently swallowed (stats ingest is a diagnostic side effect).
42+
- fix(ui): **ProxyStatsPage column titles were hardcoded English** — 'Path'/'Model'/'Method'/'Time'/'Status' bypassed `t()`. Replaced with 5 new `ui.proxyStats.col*` i18n keys × 18 locales (non-en users now see translated headers).
43+
- fix(ui): **ProxyStatsPage `goBack` did a full page reload** — ignored the `onBack` prop already passed from `App.jsx`. Now calls `onBack` (in-app state toggle, no reload) when present, falling back to `window.location.search=''` only for the standalone `?view=proxy-stats` entry.
44+
- feat(proxy-stats): **Per-profile stats**`buildRecord` now carries `profile_id`/`profile_name` (from `interceptor._activeProfile`, fallback `default`/`Default`), `aggregateRecords` groups a new `byProfile` array (requests/retries/upstream-vs-downstream availability/P95 + carried `profile_name`), and `ProxyStatsPage` renders a `byProfile` table after the byPath table. End-to-end verified: a profile pointing at a mock upstream records the profile id/name on the detail line, retries fire (503→200, `x-forward-attempts: 2`), and the aggregate surfaces the profile in `byProfile`. 1 new i18n key (`ui.proxyStats.byProfile`) × 18 locales; 2 new `proxy-stats.test.js` cases (buildRecord profile fields + byProfile aggregation).
45+
3146
- fix(proxy-retry): **five P1 findings from a 7-role review adopted.** (1) `raceMode` no longer gates a round on `Promise.all`: the round resolves the moment ANY attempt succeeds and the still-pending losers are aborted right then — previously the "winner" waited for the slowest header arrival and the loser `abort()` calls fired only after every attempt had already settled (no-ops), so all N hedged requests ran to completion and billed every round. (2) `singleFetch` composes the external abort signal with the timeout via `AbortSignal.any` for the WHOLE response lifetime — the old listener-bridge was torn down in `finally` the instant fetch resolved (headers in), which made a loser's already-streaming body un-cancellable whenever `connectTimeoutMs > 0` (the default). (3) Client disconnects now propagate: `handleLlmApiRequest` arms a `res 'close'` AbortController threaded through `executeRequest` into every mode's loop conditions, in-flight attempts, and retry sleeps (`sleep` is abortable and waits are clamped to the total-duration deadline, so a huge upstream `Retry-After` can no longer pin an abandoned request) — previously an abandoned request kept hammering (and billing) the upstream for up to `maxRetryDurationMs` (4h default). (4) mode `off` is truly backward-compatible again: the legacy pass-through had no time-to-headers bound, but the engine imposed the 10s `connectTimeoutMs` on every LLM request even with retry disabled, aborting non-streaming completions that hold headers past 10s into a brand-new 502 — the timeout now applies only when a retry mode is active. (5) `POST /api/retry-config` is `isLocal`-gated (403, mirroring `ccswitchImportPost`): retry config lands in a cross-process hot-reloaded file, so a LAN client could flip the host into `race`×high-concurrency and multiply the HOST's paid upstream traffic machine-wide. Also folded in from the same review: a single-winner latch in `staggerMode` (two same-tick 200s could overwrite `resolved` with a body the first winner's cleanup had just aborted — truncated 200 to the client), failed-attempt bodies are drained via `discardBody` in race/stagger (undrained bodies pinned upstream sockets exactly when retries are most active; serial already did this, now before the wait instead of after), and race-round waits derive `Retry-After` from the last REAL failure instead of `results[0]` (which could be a headerless network error). Coverage: new `test/proxy-retry-cancel.test.js` (9 cases) with a deferred, signal-aware fetch mock — the original suite's instant/signal-blind mock made every one of these properties unobservable (deleting all `abort()` calls kept it green) — pinning winner-before-stragglers + loser aborts, double-win body discard, client-disconnect teardown across all three modes, off-mode signal-less single-attempt, and failure-body drain; new `test/api-retry-config.test.js` (403 gate writes nothing, 400 shape, validated 0o600 write + live-binding refresh + SSE `retry_config` broadcast); and an end-to-end live-proxy case (529×2 → 200 with `X-Forward-Attempts: 3`) in `test/proxy-server.test.js`.
3247

3348
- feat(proxy): **Import providers from cc-switch** — reads AI provider credentials (baseURL/authToken/model mappings) from the local [cc-switch](https://github.com/farion1231/cc-switch) Tauri app's SQLite database and auto-generates cc-viewer proxy profiles. Cross-platform path detection probes `~/.cc-switch/cc-switch.db` **first on every platform** (cc-switch hardcodes this path via `get_app_config_dir()` in its `config.rs` on mac/linux/windows; the Tauri identifier does not affect the DB path), with platform-specific Tauri app-data paths (`~/Library/Application Support/cc-switch/`, `%APPDATA%\cc-switch\`, `~/.local/share/cc-switch/`) kept only as low-priority legacy fallbacks so a stale leftover there can never shadow the real DB. Opens `cc-switch.db` in **read-only** mode (no SQLITE_BUSY lock when cc-switch is running), queries the `providers` table for `app_type='claude'` rows, and maps `settings_config.env` → cc-viewer profile fields (`ANTHROPIC_BASE_URL`→`baseURL`, `ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY`→`apiKey`, `ANTHROPIC_MODEL` + the three family-model fields, ignoring the `_NAME` suffixed variants; `CLAUDE_CODE_EFFORT_LEVEL`→`effort` so a user's effort toggle is not silently dropped on import). Codex providers are skipped (incompatible auth format). Imported profiles get a `ccs_` id prefix and `source: 'cc-switch'` marker; a `mergeImportedProfiles` pure function updates existing `ccs_` entries (credential refresh) and appends new ones while **leaving user-created `proxy_` profiles untouched** — deleted-from-cc-switch entries are pruned. New `server/lib/ccswitch-import.js` (pure functions, fully unit-tested against the real db); `GET /api/ccswitch-providers` (preview, masked off-host) + `POST /api/ccswitch-import` (local-only merge + SSE `proxy_profile` broadcast); a "从 cc-switch 导入" button in ProxyModal. 4 new `ui.proxy.ccswitch*` i18n keys × 18 locales; `test/ccswitch-import.test.js` (25 cases incl. live-db integration + cross-platform path-priority injection).

0 commit comments

Comments
 (0)