Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,7 @@ A more robust cap still belongs at the reverse proxy (`limit_conn`), since only
### Other notes

- **`logFile` uses `appendFileSync`** — synchronous writes on the event loop. Under public load they serialize the loop; prefer leaving it empty and collecting stdout.
- **Log timestamps are host-local**: lines read `[2026-10-30 16:19:43.726] [info] ...` in the machine's timezone, with no offset marker. If you ship stdout/`logFile` to a central collector that expects RFC 3339 (promtail/Loki, fluent-bit), set the source timezone on the collector side — the line itself does not carry it.
- **systemd guard rails**: set `MemoryMax=` and `NODE_OPTIONS=--max-old-space-size=` so an overshoot kills the proxy, not `sshd`/`nginx`.
- **Content-derived session id**: without a client-supplied session header the id hashes `apiKey + model + system + user text up to the first non-user message`, so anything that changes inside that prefix — a system prompt carrying today's date, a model switch — gives a different session id mid-conversation, which forces a cache rebuild. Send `x-session-id` or `prompt_cache_key` from the client when you need exact control.
- **Multi-account + multiple instances**: the only per-process state left is `keyStateStore` (a deterministic fingerprint plus an init throttle per key). The same API key served by two instances therefore reports the **same** device fingerprint and the **same** session id — provided `CC_FINGERPRINT_SALT` and `CC_DEVICE_PROJECT_DIR` are identical on both, or the fingerprints diverge. Only the fingerprint/lifecycle pre-request timing is per-instance, so two instances may each send one init round inside the same window. When scaling out, use consistent hashing on the API key (`hash $cc_key consistent`) rather than round-robin.
Expand Down
1 change: 1 addition & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,7 @@ CC_CLIENT_DRAIN_TIMEOUT_MS=60000 npm start
### 其它注意事项

- **`logFile` 是同步写**(`appendFileSync`),公网负载下会阻塞事件循环 —— 建议保持留空,从 stdout 收集。
- **日志时间戳为宿主机本地时间**:形状 `[2026-10-30 16:19:43.726] [info] ...`,不带时区偏移标记。若把 stdout / `logFile` 收集到集中式日志系统(promtail/Loki、fluent-bit),需在采集侧指定来源时区 —— 行内不携带偏移。
- **systemd 兜底**:配 `MemoryMax=` 与 `NODE_OPTIONS=--max-old-space-size=`,让超限杀掉 proxy 而不是 `sshd`/`nginx`。
- **按内容派生的 session id**:客户端不带 session header 时,id 是 `apiKey + model + system + 首个非 user 消息之前的连续 user 文本` 的哈希 —— 这段前缀里任何变化(system 里带当天日期、中途换 model)都会让同一场对话中途换 id,进而导致缓存重建。需要精确控制时请由客户端下发 `x-session-id` 或 `prompt_cache_key`。
- **多账号 + 多实例**:唯一的进程内状态只剩 `keyStateStore`(每 key 一份确定性指纹 + 初始化节流)。同一个 API key 打到两个实例,报告的设备指纹与 session id 都是**同一个** —— 前提是各实例的 `CC_FINGERPRINT_SALT` 与 `CC_DEVICE_PROJECT_DIR` 保持一致,否则指纹会分叉。只有 fingerprint/lifecycle 预请求的节流是各实例独立的,所以两个实例可能落在同一时间窗内各发一轮初始化。横向扩展请按 API key 做一致性哈希(`hash $cc_key consistent`),不要轮询。
Expand Down
6 changes: 5 additions & 1 deletion proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,11 @@ const TIMEOUT_REDUCE_CONTEXT_THRESHOLD = 3;

// ── 日志 ─────────────────────────────────────────────
function log(level, msg, data) {
const line = `[${new Date().toISOString()}] [${level}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}`;
const d = new Date();
const pad = (n, w = 2) => String(n).padStart(w, '0');
const ts = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
` ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
const line = `[${ts}] [${level}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}`;
console.log(line);
if (CFG.logFile) {
try { appendFileSync(CFG.logFile, line + '\n', 'utf-8'); } catch {}
Expand Down
33 changes: 33 additions & 0 deletions test/log-format.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 日志时间戳契约:本地时间 [YYYY-MM-DD HH:MM:SS.mmm],不带 ISO 8601 的 T 分隔符与 Z(UTC) 后缀。
// 子进程强制 TZ=Asia/Kathmandu(+05:45,无夏令时),因此即使 CI 主机是 UTC,
// "UTC 冒充本地" 的实现(toISOString().replace('T',' ').replace('Z',''))也会被时间差抓住。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as sleep } from 'node:timers/promises';
import { setup } from './helpers.mjs';

const TZ = 'Asia/Kathmandu';
const TZ_OFFSET_MS = (5 * 60 + 45) * 60 * 1000; // +05:45,无 DST

test('日志时间戳为本地时间 [YYYY-MM-DD HH:MM:SS.mmm],无 T/Z 标记', async () => {
const s = await setup({ env: { TZ } });
try {
// 启动日志在 /health 可用前就已写出,但 stdout 事件可能晚到;轮询到完整形状出现,
// 避免把被 chunk 切断的半行当成日志(只匹配整行形状,不匹配前缀)。
const SHAPE = /^\[(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})\] \[(info|warn|error)\] /;
let line;
let m;
for (let i = 0; i < 40 && !m; i++) {
line = s.proxy.logs().split('\n').find(l => SHAPE.test(l));
if (line) m = line.match(SHAPE);
else await sleep(50);
}
assert.ok(line, '启动后应至少有一条日志');
assert.ok(m, '时间戳形状应为 [YYYY-MM-DD HH:MM:SS.mmm],实际: ' + String(line).slice(0, 48));

// 语义校验:时间戳按 +05:45 换算后应落在当前时刻附近;若实现是 UTC,差值恒为 5h45m。
const stampUtc = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6], +m[7]) - TZ_OFFSET_MS;
assert.ok(Math.abs(Date.now() - stampUtc) < 5 * 60 * 1000,
'时间戳应按子进程本地时区(Asia/Kathmandu)渲染(UTC 形态会偏差 5h45m)');
} finally { await s.close(); }
});