概要:
前几次排查已经确认:Codex 会持续向 ~/.codex/logs_2.sqlite 写入诊断日志,即使数据库保留的行数没有增加,反复执行的插入、淘汰、WAL 写入和 checkpoint 仍然会产生实际磁盘 I/O。
这次使用自制监控器查看新增日志来源时,发现:
1 | codex_app_server::outgoing_message |
第一反应是用 Rust 常见的 RUST_LOG 定向关闭这个 target。实际测试后却发现,RUST_LOG=info 并不能阻止这些 TRACE/DEBUG 写入 logs_2.sqlite。
继续查源码才发现,Codex 的普通日志和 SQLite 持久化日志使用了两套过滤器。绕了一圈,最终又回到了最早使用过的 SQLite Trigger。
- 概要:
- 监控发现 outgoing_message 占比最高
- 尝试使用 RUST_LOG
- 实测 RUST_LOG 对 logs_2.sqlite 无效
- 为什么会有两套日志过滤
- 官方之前是怎么过滤日志的
- 清除全局 RUST_LOG
- 最终重新启用 Trigger
- 结论
监控发现 outgoing_message 占比最高
第一轮监控持续约 469 秒,结果如下:
| 指标 | 结果 |
|---|---|
| 日志 ID 增量 | 514 |
| Procmon 捕获的数据库写入 | 6.763 MiB |
| 折算写入速度 | 51.91 MiB/h |
| WriteFile 次数 | 3,092 |
新增日志 Top 5:
| target | 新增行 | 占比 | level |
|---|---|---|---|
codex_app_server::outgoing_message |
355 | 69.07% | TRACE 355 |
codex_api::sse::responses |
35 | 6.81% | TRACE 35 |
codex_core::stream_events_utils |
30 | 5.84% | DEBUG 26 / INFO 4 |
feedback_tags |
17 | 3.31% | INFO 17 |
opentelemetry-otlp |
10 | 1.95% | DEBUG 10 |
outgoing_message 不仅条数最多,而且全部是 TRACE,看上去非常适合优先过滤。
尝试使用 RUST_LOG
Codex 官方文档说明,CLI 和 app-server 支持通过 RUST_LOG 控制 Rust 日志级别,也支持按 target 设置过滤规则。例如:
1 | codex_core=debug,codex_tui=debug |
参考:Codex 环境变量文档。
最初考虑过同时设置全局级别和 target 级别,例如让大部分日志保留 INFO,同时为部分模块保留 DEBUG/TRACE,再单独关闭 outgoing_message。
但这种写法有一个容易忽略的问题:
1 | codex_app_server=trace |
并不是“保留当前已有的 TRACE”,而是允许整个 codex_app_server 命名空间产生 TRACE。它可能打开原本没有启用的其他低级别日志。把 RUST_LOG 设置为 Windows 用户级环境变量,还可能影响其他主动读取该变量的 Rust 程序。
于是改为更简单的测试:
1 | setx RUST_LOG "info" |
彻底退出 Codex 后重新打开,再运行一轮监控。按通常的 Rust 日志过滤语义,info 应当保留 INFO、WARN、ERROR,并关闭 TRACE、DEBUG。
实测 RUST_LOG 对 logs_2.sqlite 无效
第二轮监控持续约 626 秒,logs_2.sqlite 中仍然出现:
| target | 新增行 | level |
|---|---|---|
codex_app_server::message_processor |
58 | TRACE 56 / INFO 2 |
codex_config::loader::layer_io |
56 | DEBUG 56 |
codex_app_server::outgoing_message |
31 | TRACE 31 |
只要数据库中仍然有 TRACE 和 DEBUG,就足以说明:这条 SQLite 写入链路没有被 RUST_LOG=info 关闭。
两轮数据对比如下:
| 指标 | 设置前 | 设置后 | 变化 |
|---|---|---|---|
| 新增日志/分钟 | 65.76 | 33.82 | -48.6% |
| outgoing_message/分钟 | 45.42 | 2.97 | -93.5% |
| 数据库写入 | 51.91 MiB/h | 26.08 MiB/h | -49.8% |
第二轮写入确实更低,但不能据此认定是 RUST_LOG 生效:
- 两轮执行的工作内容不同;
outgoing_message数量会随界面通知、工具调用和输出过程变化;- 测试期间 Codex 还可能发生版本更新;
- 最关键的是,第二轮数据库里依然明确存在 TRACE 和 DEBUG。
OpenAI 仓库中已有同类问题:即使 app-server 进程带有 RUST_LOG=warn,logs_2.sqlite 仍会保存 TRACE。参考:Issue #17320。
为什么会有两套日志过滤
一条 Rust tracing 事件可以同时交给多个接收层:
1 | tracing::trace!(...) |
这里的“持久化层”,指的是把日志真正保存到磁盘、使它在进程退出后仍然存在的接收层。logs_2.sqlite 就是本地持久化日志数据库。
当前官方源码中的 SQLite 默认过滤器大致如下:
1 | pub fn default_filter() -> Targets { |
也就是说:
- SQLite 层默认接收全部 target 的 TRACE;
- 只有源码里明确列出的 target 会被关闭;
- 这个过滤器目前没有公开的
config.toml或环境变量入口; RUST_LOG控制另一层,不能直接改写这份Targets规则。
参考:PR #29457 的源码改动。
如果官方要在 SQLite 层关闭本次发现的日志,只需要增加类似规则:
1 | .with_target( |
但普通用户无法直接修改桌面版内置的 codex.exe。自己编译虽然可行,却还要解决桌面版如何使用自编译 app-server、版本升级后如何维护等问题,不适合作为日常处理方案。
官方之前是怎么过滤日志的
上一篇提到的官方修复,本质上也是修改 Rust 源码,而不是让用户设置 `RUST_LOG`:- PR #29432 直接移除了成功 Responses WebSocket 事件的逐条 TRACE 和重复 OpenTelemetry 事件,只保留计数、耗时和错误等信息。
- PR #29457 修改 SQLite 持久化层的
default_filter(),排除target=log、codex_otel.log_only和codex_otel.trace_safe。 - 后续修复又在桥接日志完成转换后补了一次拦截,避免
target=log绕过前面的判断。相关汇总见 Issue #28224。
因此,设置 RUST_LOG=info 不会把官方已经排除的两个 INFO target 重新写进 SQLite;它们是在 SQLite 自己的过滤器中按 target 关闭的。
清除全局 RUST_LOG
由于使用的是 setx,RUST_LOG=info 被保存为 Windows 用户级环境变量。它不仅会被之后启动的 Codex 继承,也可能影响其他主动读取 RUST_LOG 的 Rust 程序。
这里的影响不一定都是“减少日志”。如果某个程序原本只记录 WARN/ERROR,全局设置成 INFO 反而可能让它输出更多内容。
删除用户级变量:
1 | [Environment]::SetEnvironmentVariable('RUST_LOG', $null, 'User') |
清除当前 PowerShell 会话中可能残留的值:
1 | Remove-Item Env:RUST_LOG -ErrorAction SilentlyContinue |
验证用户级变量已经删除:
1 | [Environment]::GetEnvironmentVariable('RUST_LOG', 'User') |
最后一条没有输出即表示删除成功。已经运行的程序不会动态更新环境变量,需要彻底退出 Codex 后重新打开;如果新进程仍然继承旧值,可以注销并重新登录 Windows。
最终重新启用 Trigger
绕了一圈之后,最终又回到了第一次排查时使用的 SQLite Trigger。
逐个 target 拦截最精确,但需要持续追踪新出现的日志来源。既然本地正常使用并不依赖 TRACE/DEBUG,直接阻止这两个级别入库更简单:
执行前必须彻底退出 Codex,并确认没有其他 codex.exe 正在使用 logs_2.sqlite。
1 | CREATE TRIGGER IF NOT EXISTS block_trace_debug |
这样会:
- 保留 INFO、WARN、ERROR;
- 阻止 TRACE、DEBUG 真正写入表和索引;
- 减少后续日志淘汰、WAL 写入和 checkpoint 压力;
- 不影响其他 Rust 应用;
- 不需要维护不断变化的 target 列表。
它仍然不是源码级关闭:事件已经产生,也已经到达 SQLite 写入器,Trigger 只是在 INSERT 的最后一步返回 RAISE(IGNORE)。因此还会保留少量事件格式化、队列和 Trigger 判断开销,但能避免这些日志真正成为数据库行。
官方问题中的测试也显示,忽略 INSERT 可以消除大部分数据库与 CPU 开销,但完全关闭 SQLite 日志层仍会更轻。参考:Issue #17320 的 Trigger 与完全禁用对比。
需要恢复完整日志时:
1 | DROP TRIGGER IF EXISTS block_trace_debug; |
检查 Trigger 是否仍然存在:
1 | SELECT name, sql |
Codex 更新、数据库迁移或重建后,Trigger 可能消失,需要重新检查。启用 RAISE(IGNORE) 后,sqlite_sequence 的 ID 高水位也不再适合作为绝对精确的成功插入数;分析效果时应优先查看 Procmon 捕获到的目标文件实际写入量。
结论
这次排查最后回到了原点,但中间确认了几个重要边界:
outgoing_message是当前最主要的低级别日志来源之一;RUST_LOG能控制 Codex 的普通 Rust 日志,但当前管不到 SQLite 持久化层;- 官方之前的降噪也是修改 Rust 源码中的事件产生点和 SQLite
default_filter(); - 当前桌面版没有开放 SQLite 日志级别配置;
- 不自编译 Codex 的前提下,Trigger 仍然是最直接、可撤销、能够明显减少磁盘写入的本地方案。
真正理想的修复,是官方为 SQLite 日志层增加可配置级别,或者直接把 codex_app_server::outgoing_message 加入默认排除列表。在此之前,重新启用只拦截 TRACE/DEBUG 的 Trigger,至少比全局设置 RUST_LOG 更精确,也不会影响其他程序。
评论