diff --git a/README.md b/README.md index 2bfcd35..3ea67c2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_zh.md b/README_zh.md index e1b72d3..393424d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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`),不要轮询。 diff --git a/proxy.mjs b/proxy.mjs index 8ec2def..fc34b34 100644 --- a/proxy.mjs +++ b/proxy.mjs @@ -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 {} diff --git a/test/log-format.test.mjs b/test/log-format.test.mjs new file mode 100644 index 0000000..a41acdf --- /dev/null +++ b/test/log-format.test.mjs @@ -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(); } +});