Skip to content

Commit 7a93c6e

Browse files
authored
feat(dev): tee stdout/stderr to logs/dev-YYYY-MM-DD.log (#444)
Dev logs used to live only in the tsx-watch terminal scrollback — closing the window or scrolling past the buffer lost them, so debugging an intermittent upstream 422 had no evidence trail. installFileLogger opens a per-day append fd and patches process.stdout/stderr.write to also fan out to the file. Skipped in production (log shippers handle it), under Vitest, and via CODEX_PROXY_FILE_LOG=0. Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
1 parent 6ec3dc3 commit 7a93c6e

5 files changed

Lines changed: 231 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
### Added
1616

17+
- Dev 默认把 stdout/stderr tee 到 `logs/dev-YYYY-MM-DD.log``src/utils/log-file.ts` + `src/utils/install-dev-logger.ts``src/index.ts` 顶部 side-effect 引入):之前 dev 日志只活在 `tsx watch` terminal 的 scrollback 里,关窗或滚出去就找不到,排查上游偶发 422 之类的错误拿不到证据。新模块按天打开 append fd,patch `process.stdout.write` / `process.stderr.write` 同步写入文件 + 调原函数;prod (`NODE_ENV=production`) / 测试 (`VITEST` / `NODE_ENV=test`) / `CODEX_PROXY_FILE_LOG=0` 三档 opt-out,`logs/` 已被 `*.log` gitignore 覆盖。`tests/unit/utils/log-file.test.ts` 7 个用例覆盖 stdout/stderr tee、目录递归创建、uninstall 还原、append 不截断、默认文件名格式
1718
- Dashboard 用量页新增「时段命中率(Range Hit Rate)」卡片:基于当前选中时间窗口聚合 `cached_tokens / input_tokens`,与原本的全局累计「Cache Hit Rate」卡并列,方便对比近窗口与历史命中率(`web/src/pages/UsageStats.tsx``shared/i18n/translations.ts`
1819
- Dashboard 用量页新增独立的「Hit Rate Over Time」图:每个 bucket 渲染命中率折线 + 数据点 dot,hover 可见 `cached / input``input=0` 的 bucket 自动跳过(不渲染 0% 假命中),单数据点也用 dot 保证可见性(`web/src/components/UsageChart.tsx`
1920
- Usage history `five_min` granularity(5 分钟桶)+ Dashboard 新增「5 min」粒度选项与「Last 1h / 6h」时间窗:snapshot 默认 5 分钟一记,新粒度等同于一桶一快照,方便排查刚发生的请求;旧的 hourly/daily 不变,按 granularity 自动收敛兼容窗口(`src/auth/usage-stats.ts``src/routes/admin/usage-stats.ts``shared/hooks/use-usage-stats.ts``web/src/pages/UsageStats.tsx`

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import "./utils/install-dev-logger.js";
2+
13
import { Hono } from "hono";
24
import { serve } from "@hono/node-server";
35
import { loadConfig, loadFingerprint, getConfig, hasLocalOverride } from "./config.js";

src/utils/install-dev-logger.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Side-effect import: in dev, tee process.stdout/stderr to logs/dev-YYYY-MM-DD.log
3+
* so terminal scrollback isn't the only place we can debug from.
4+
*
5+
* Skipped in production (log shippers handle persistence) and under Vitest.
6+
*/
7+
8+
import { join } from "node:path";
9+
10+
import { installFileLogger } from "./log-file.js";
11+
12+
const isProduction = process.env.NODE_ENV === "production";
13+
const isTest = Boolean(process.env.VITEST) || process.env.NODE_ENV === "test";
14+
const disabled = process.env.CODEX_PROXY_FILE_LOG === "0";
15+
16+
if (!isProduction && !isTest && !disabled) {
17+
try {
18+
installFileLogger({ dir: join(process.cwd(), "logs") });
19+
} catch (err) {
20+
const msg = err instanceof Error ? err.message : String(err);
21+
process.stderr.write(`[dev-file-logger] failed to install: ${msg}\n`);
22+
}
23+
}

src/utils/log-file.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Tees process.stdout / process.stderr writes into a daily log file.
3+
*
4+
* Used in dev so terminal scrollback isn't the only place errors live.
5+
* Production stays pure stdout/stderr — log shippers handle persistence there.
6+
*/
7+
8+
import { closeSync, mkdirSync, openSync, writeSync } from "node:fs";
9+
import { join } from "node:path";
10+
11+
export interface InstallFileLoggerOptions {
12+
dir: string;
13+
filename?: string;
14+
}
15+
16+
export interface FileLoggerHandle {
17+
path: string;
18+
uninstall(): void;
19+
}
20+
21+
type WriteFn = typeof process.stdout.write;
22+
23+
export function installFileLogger(opts: InstallFileLoggerOptions): FileLoggerHandle {
24+
mkdirSync(opts.dir, { recursive: true });
25+
const filename = opts.filename ?? defaultFilename(new Date());
26+
const path = join(opts.dir, filename);
27+
const fd = openSync(path, "a");
28+
29+
const originalStdout = process.stdout.write;
30+
const originalStderr = process.stderr.write;
31+
32+
process.stdout.write = wrap(originalStdout, process.stdout, fd);
33+
process.stderr.write = wrap(originalStderr, process.stderr, fd);
34+
35+
let uninstalled = false;
36+
37+
return {
38+
path,
39+
uninstall(): void {
40+
if (uninstalled) return;
41+
uninstalled = true;
42+
process.stdout.write = originalStdout;
43+
process.stderr.write = originalStderr;
44+
try {
45+
closeSync(fd);
46+
} catch {
47+
// best-effort: closing twice or after process tear-down is harmless
48+
}
49+
},
50+
};
51+
}
52+
53+
function wrap(original: WriteFn, stream: NodeJS.WriteStream, fd: number): WriteFn {
54+
const wrapped = function (this: unknown, ...args: unknown[]): boolean {
55+
try {
56+
const chunk = args[0];
57+
const encoding =
58+
typeof args[1] === "string" ? (args[1] as BufferEncoding) : undefined;
59+
writeSync(fd, toBuffer(chunk, encoding));
60+
} catch {
61+
// never let the file sink break the caller — stdout/stderr must stay live
62+
}
63+
return (original as (...a: unknown[]) => boolean).apply(stream, args);
64+
};
65+
return wrapped as WriteFn;
66+
}
67+
68+
function toBuffer(chunk: unknown, encoding: BufferEncoding | undefined): Buffer {
69+
if (typeof chunk === "string") {
70+
return Buffer.from(chunk, encoding ?? "utf8");
71+
}
72+
if (chunk instanceof Uint8Array) {
73+
return Buffer.from(chunk);
74+
}
75+
return Buffer.from(String(chunk));
76+
}
77+
78+
function defaultFilename(date: Date): string {
79+
const yyyy = date.getFullYear();
80+
const mm = String(date.getMonth() + 1).padStart(2, "0");
81+
const dd = String(date.getDate()).padStart(2, "0");
82+
return `dev-${yyyy}-${mm}-${dd}.log`;
83+
}

tests/unit/utils/log-file.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Tests for installFileLogger — tees process.stdout/stderr writes into a
3+
* daily log file under a configurable directory.
4+
*/
5+
6+
import { describe, it, expect, afterEach } from "vitest";
7+
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
8+
import { tmpdir } from "node:os";
9+
import { join } from "node:path";
10+
11+
import { installFileLogger } from "@src/utils/log-file.js";
12+
13+
describe("installFileLogger", () => {
14+
const cleanups: Array<() => void> = [];
15+
16+
afterEach(() => {
17+
while (cleanups.length > 0) {
18+
const fn = cleanups.pop();
19+
try {
20+
fn?.();
21+
} catch {
22+
// swallow — cleanup is best-effort
23+
}
24+
}
25+
});
26+
27+
it("tees process.stdout writes into the target file", () => {
28+
const dir = mkdtempSync(join(tmpdir(), "codex-proxy-log-"));
29+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
30+
31+
const handle = installFileLogger({ dir, filename: "test.log" });
32+
cleanups.push(() => handle.uninstall());
33+
34+
process.stdout.write("hello stdout\n");
35+
36+
expect(handle.path).toBe(join(dir, "test.log"));
37+
expect(readFileSync(handle.path, "utf8")).toContain("hello stdout");
38+
});
39+
40+
it("tees process.stderr writes into the target file", () => {
41+
const dir = mkdtempSync(join(tmpdir(), "codex-proxy-log-"));
42+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
43+
44+
const handle = installFileLogger({ dir, filename: "test.log" });
45+
cleanups.push(() => handle.uninstall());
46+
47+
process.stderr.write("boom stderr\n");
48+
49+
expect(readFileSync(handle.path, "utf8")).toContain("boom stderr");
50+
});
51+
52+
it("creates nested target directory if it does not exist", () => {
53+
const base = mkdtempSync(join(tmpdir(), "codex-proxy-log-"));
54+
cleanups.push(() => rmSync(base, { recursive: true, force: true }));
55+
const dir = join(base, "nested", "logs");
56+
57+
const handle = installFileLogger({ dir, filename: "test.log" });
58+
cleanups.push(() => handle.uninstall());
59+
60+
process.stdout.write("nested\n");
61+
62+
expect(statSync(handle.path).isFile()).toBe(true);
63+
expect(readFileSync(handle.path, "utf8")).toContain("nested");
64+
});
65+
66+
it("uninstall restores original write functions and stops teeing", () => {
67+
const dir = mkdtempSync(join(tmpdir(), "codex-proxy-log-"));
68+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
69+
70+
const originalStdoutWrite = process.stdout.write;
71+
const handle = installFileLogger({ dir, filename: "test.log" });
72+
expect(process.stdout.write).not.toBe(originalStdoutWrite);
73+
74+
process.stdout.write("before\n");
75+
handle.uninstall();
76+
expect(process.stdout.write).toBe(originalStdoutWrite);
77+
78+
process.stdout.write("after\n");
79+
80+
const contents = readFileSync(handle.path, "utf8");
81+
expect(contents).toContain("before");
82+
expect(contents).not.toContain("after");
83+
});
84+
85+
it("defaults filename to dev-YYYY-MM-DD.log", () => {
86+
const dir = mkdtempSync(join(tmpdir(), "codex-proxy-log-"));
87+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
88+
89+
const handle = installFileLogger({ dir });
90+
cleanups.push(() => handle.uninstall());
91+
92+
expect(handle.path).toMatch(/dev-\d{4}-\d{2}-\d{2}\.log$/);
93+
});
94+
95+
it("preserves the boolean return value from the underlying write", () => {
96+
const dir = mkdtempSync(join(tmpdir(), "codex-proxy-log-"));
97+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
98+
99+
const handle = installFileLogger({ dir, filename: "test.log" });
100+
cleanups.push(() => handle.uninstall());
101+
102+
const result = process.stdout.write("payload\n");
103+
expect(typeof result).toBe("boolean");
104+
});
105+
106+
it("appends to an existing file instead of truncating", () => {
107+
const dir = mkdtempSync(join(tmpdir(), "codex-proxy-log-"));
108+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
109+
110+
const first = installFileLogger({ dir, filename: "test.log" });
111+
process.stdout.write("first run\n");
112+
first.uninstall();
113+
114+
const second = installFileLogger({ dir, filename: "test.log" });
115+
cleanups.push(() => second.uninstall());
116+
process.stdout.write("second run\n");
117+
118+
const contents = readFileSync(second.path, "utf8");
119+
expect(contents).toContain("first run");
120+
expect(contents).toContain("second run");
121+
});
122+
});

0 commit comments

Comments
 (0)