Excessive SQLite WAL writes during streaming due to TRACE logs ignoring RUST_LOG
What version of the IDE extension are you using?
openai.chatgpt-26.406.31014-linux-x64 (VSCodium on Linux Mint)
What subscription do you have?
ChatGPT Plus
Which IDE are you using?
VSCodium (Code - OSS fork)
What platform is your computer?
Linux x64 (Linux Mint)
What issue are you seeing?
During model streaming responses, the Codex app-server binary writes ~5 MiB/s (observed up to ~16 MiB/s in iotop) to ~/.codex/logs_2.sqlite-wal. This results in sustained disk write rates that can significantly impact SSD longevity and system performance.
This issue is independent of the thread-stream-state-changed warning spam and the open-in-targets retry loop, which were investigated separately.
Root cause
The extension correctly sets RUST_LOG=warn when spawning the app-server process — confirmed via /proc/<pid>/environ:
RUST_LOG=warn
However, the SQLite logging sink appears to bypass or not respect the RUST_LOG filter, since TRACE-level entries are still persisted to logs_2.sqlite despite RUST_LOG=warn. The SQLite logging path appears to operate outside of the standard RUST_LOG filtering mechanism, or uses a separate filter configuration.
This was confirmed via strace during streaming:
pwrite64(20</home/k/.codex/logs_2.sqlite-wal>, ..., 4096, ...) = 4096
pwrite64(20</home/k/.codex/logs_2.sqlite-wal>, ..., 4096, ...) = 4096
...
And via SQLite query — MAX(id) jumped by ~5000 rows for a single short (~50 token) response, while total row count remained stable, indicating that rows are being rapidly inserted and pruned/rotated during streaming:
-- Before response:
SELECT MAX(id), COUNT(*) FROM logs; --> 3088667 | 30141
-- After response:
SELECT MAX(id), COUNT(*) FROM logs; --> 3093673 | 30141
Log level breakdown in logs_2.sqlite:
TRACE 20485 (68%)
INFO 8286
DEBUG 1326
WARN 44
Sample TRACE entries written during streaming:
target=hyper_util::client::legacy::pool feedback_log_body="idle interval checking for expired"
target=log feedback_log_body="WouldBlock"
target=log feedback_log_body="/home/runner/work/codex/codex/.cargo-home/git/checkouts/tokio-tungstenite-..."
These are low-level internal network events from tokio-tungstenite and hyper_util with no diagnostic value for end users. They should not be persisted to disk in a production binary — and critically, they are not suppressed by RUST_LOG=warn as one would expect.
What steps can reproduce the bug?
- Open a workspace in VSCodium/VS Code with the Codex extension.
- Confirm
RUST_LOG=warnis set:
cat /proc/$(pgrep -f 'codex.*app-server')/environ | tr '\0' '\n' | grep RUST_LOG
- Run
strace -f -e trace=pwrite64 -p $(pgrep -f 'codex.*app-server')in a separate terminal. - Ask any question in the Codex chat panel and observe streaming.
- Observe continuous
pwrite64calls tologs_2.sqlite-walat ~5 MiB/s despiteRUST_LOG=warn.
Alternatively, check log level distribution after a session:
sqlite3 ~/.codex/logs_2.sqlite "SELECT level, COUNT(*) FROM logs GROUP BY level ORDER BY COUNT(*) DESC"
What is the expected behavior?
RUST_LOG=warn should suppress TRACE and DEBUG output everywhere, including the SQLite logging sink. The current behavior — where RUST_LOG=warn suppresses stderr output but does not affect SQLite persistence — is inconsistent and undocumented.
TRACE-level events from internal networking libraries (tokio-tungstenite, hyper_util) should never be written to the persistent SQLite log database during normal operation.
Additional information
Suggested fix
The SQLite logging sink in app-server should apply the same log-level filter as RUST_LOG (or the internal tracing subscriber filter), rather than maintaining a separate or hardcoded verbosity level. The fix should ensure that setting RUST_LOG=warn — as the extension already does — is sufficient to suppress TRACE/DEBUG writes to logs_2.sqlite.
Workaround
Users can redirect logs_2.sqlite to tmpfs to protect their SSD while waiting for a fix:
# stop VSCodium first
mv ~/.codex/logs_2.sqlite ~/.codex/logs_2.sqlite.bak
ln -s /tmp/logs_2.sqlite ~/.codex/logs_2.sqlite
Note: logs_2.sqlite contains only internal diagnostic logs — no conversation history — so data loss on reboot is acceptable.
After symlinking logs_2.sqlite to tmpfs, disk I/O from the Codex extension dropped to zero during streaming, confirming that the SQLite WAL is the sole source of the observed write load.
Workaround 2: block inserts into the SQLite log table
Another workaround was suggested by @synap5e in this comment:
https://github.com/openai/codex/issues/17320#issuecomment-4514992998
Instead of redirecting logs_2.sqlite to tmpfs, users can prevent new rows from being inserted into the logs table by adding a SQLite trigger:
sqlite3 ~/.codex/logs_2.sqlite "CREATE TRIGGER IF NOT EXISTS block_log_inserts BEFORE INSERT ON logs BEGIN SELECT RAISE(IGNORE); END;"
This causes attempted inserts into the logs table to be ignored without returning an error to the application, which should prevent logs_2.sqlite-wal from growing due to persistent TRACE/DEBUG log writes.
To remove the workaround:
sqlite3 ~/.codex/logs_2.sqlite "DROP TRIGGER IF EXISTS block_log_inserts;"
As with the tmpfs workaround, this is not a proper fix. It disables local persistent logging rather than fixing the underlying issue: the SQLite logging sink appears to ignore RUST_LOG=warn and continues persisting TRACE/DEBUG entries during normal streaming.
34 Comments
I haven't dug into this issue with the same depth as you have, but I started noticing HUGE swings in my disk use over the past month (like 60GB less by end of a single day just doing light-weight work with codex). I eventually realized the "Code Helper" under "disk" within the Activity Monitor on MacOS was showing insane amounts of data being written to disk. This was happening even when I wasn't actively using VSCode (just remained open from past completed Codex work). In my particular case, even after closing VSCode and restarting, the disk space wasn't reclaimed.
It took some extra time to realize this bug was perfectly paired with the default setting of Time Machine holding onto local hourly snapshots on disk so I went ahead and did a backup, manually deleted all local snapshots, and set Time Machine to manual so as to avoid compounding this particular Codex bug.
Given its current state, I am literally going to stop using Codex for now, as it aggressively writes to disk all the time and there's nothing I can do about it. Apologies if what I've described isn't an exact match, but given the overlap of the extension writing to disk at aggressive rates, it felt related.
I also see this behavior. Do you still have this problem? Could you fix it by symlinking to a tmpfs? Just linking
logs_2.sqliteto tmpfs didn't solve the apparent disk writes for me (unless Linux reports writes to tmpfs equally??)I searched for similar issues and there are mutliple, but no reaction at all from the devs yet. These issues are a month old already 😬
It's a workaround but yes, it works. Just make sure that your /tmp is using RAM. Some distros have it set to HDD/SDD.
Thanks. I ended up just symlinking the whole
~/.codexfolder (and copying it back before shutdown, hopefully...). /tmp is indeed a tmpfs for me.I also found this while investigating Codex's CPU usage, writing these trace level logs burns a lot of CPU. A way to reduce the logging level should be added to speed up Codex on older devices.
I tested this and it helps a lot, but there's some more CPU savings from disabling the logging fully.
Each Codex instance was prompted to edit a json file.
Mode | Log rows | Log DB size | time CPU | perf user task-clock | SQLite worker
-- | -- | -- | -- | -- | --
Full inserts | 1888 | ~3.8 MB | 3.64s | 2.394s | 0.657s
Insert ignored | 0 | ~40 KB | 2.42s | 1.576s | 0.061s
Fully disabled | 0 | ~40 KB | 2.01s | 1.061s | 0 samples
how did you do that?
I patched the codex source to remove the log db's initialization.
<details>
<summary>Patch: disable log DB initialization</summary>
</details>
Useful workarounds here when using Codex on a Raspberry Pi with SD card storage, thanks!
This kind of program behavior is disrespectful in general, especially toward users with SSDs.
There's already a shortage of SSDs, plz fix.
I can reproduce this on Windows Desktop.
Environment:
OpenAI.Codex26.616.6631.0codex-cli 0.128.0Microsoft Windows NT 10.0.26200.0RUST_LOG=warn,LOG_LEVEL=warnconfig.toml:[analytics] enabled = falseBefore workaround:
logs_2.sqlitehad grown to about 2.0 GB, with about 1.47 GB of freelist bloat.TRACElogINFOcodex_otel.log_onlyINFOcodex_otel.trace_safeTRACEcodex_api::endpoint::responses_websocketTRACEcodex_api::sse::responsesTRACE/DEBUGtargets as wellgit.exestorm was present during this sample.Workaround tested:
Result:
logsrows.INFO/WARNrows, roughly 30-40 rows/min in an active session.The SQLite sink needs to respect an effective persisted-log filter, not only the process RUST_LOG. I would log one startup row showing requested filter, effective DB filter, targets enabled, and whether transport payload logging is on. Streaming diagnostics should be durable as counters and error events, while raw TRACE frames stay off unless explicitly enabled with a byte cap.
---
_Generated with ax._
I made a small unofficial tool for running community-found Codex workarounds:
https://codexfixes.com
Run it with:
So we can stop checking issue threads every day for months and just patch the annoying stuff when the community finds a workaround.
Reproduced on Windows Codex Desktop on 2026-06-29 00:40:10 CEDT +0200.
RUST_LOG=warn was set.
~/.codex/logs_2.sqlite was 689,213,440 bytes.
logs_2.sqlite-wal was 5,500,232 bytes.
logs table had ~207k rows, mostly TRACE.
After blocking inserts + VACUUM + WAL truncate, DB dropped to 40,960 bytes and WAL to 0.
Windows Desktop confirmation, same failure mode on Windows.
RUST_LOG=warnwas configured in both.codex/.envand the user environment.id_delta=229withcount_delta=0, and another sample grew the WAL by 26,199,080 bytes (~25.0 MiB).rmcp::service,codex_api::sse::responses, andhyper_util.I then applied the community
block_log_insertsBEFORE INSERT trigger. With Codex still running, a fresh 15-second read-only sample showedid_delta=0,count_delta=0, andwal_delta_bytes=0.This is an effective local stopgap but disables persistent local diagnostics. The upstream fix should still make the SQLite sink honor
RUST_LOGor avoid persisting high-volume SSE/WebSocket TRACE events by default.macOS confirmation with local quantitative evidence.
Environment:
codex-cli 0.145.0-alpha.18~/.codex/logs_2.sqliteHistorical snapshot before the insert-blocking workaround took effect:
logs_2.sqlite: 223,064,064 bytes (~212.7 MiB)estimated_bytes: 147,795,856sqlite_sequenceforlogs: 11,634,061The large sequence value relative to retained rows is consistent with substantial insert-and-prune churn. The database file remaining around 213 MiB therefore does not reflect cumulative physical writes.
The community
block_log_insertstrigger is currently installed. During active Desktop streaming, read-only samples showed:sqlite_sequenceThis supports the trigger being an effective stopgap on macOS as well, but it disables persistent diagnostics and is not an acceptable long-term product fix.
Please prioritize an upstream fix that:
All measurements above were collected read-only; no VACUUM, checkpoint, deletion, or database rebuild was performed during verification.
Analysis (community)
Thanks for tracking this — attempting a residual-focused analysis aligned with docs/contributing.md (invitation-only: analysis in-thread, no unsolicited PR).
logs_2.sqlite SSD)Observed (from issue + public residual signal)
What version of the IDE extension are you using?
openai.chatgpt-26.406.31014-linux-x64(VSCodium on Linux Mint) ### What subscription do you have? ChatGPT Plus ### Which IDE are you using? VSCodium (Code - OSS fork) ### What platform is your computer? Linux x64 (Linux Mint) ### What issue are you seeing? During model streaming responses, the Codexapp-serverbinary writes ~5 MiB/s (observed up to ~16 MiB/s iniotop) to~/.codex/logs_2.sqlite-wal. This results in sustained disk wrLocal residual measurement (2026-07-24, this reporter)
On a host running codex-cli 0.146.0-alpha.6 (newer than 0.142–0.145 residual reports):
Signal-14 INTERIM evidence (from qwen_s14 mid-flight)
captured: 2026-07-24T15:10:05Z
/Users/bing/.codex/logs_2.sqlitesize=1778548736 mtime=Fri Jul 24 23:09:38 2026/Users/bing/.codex/logs_2.sqlite-walsize=30405632 mtime=Fri Jul 24 23:10:04 2026codex-cli 0.146.0-alpha.6level counts (ro)
rows/max_ts: 137106|2026-07-24 23:10:05
This is not a claim that every install still burns TB/year — it is a version-pinned observation that TRACE still lands in the SQLite sink after the #28224-era mitigations on a current alpha, consistent with residual issues still open.
Root-cause hypothesis (to be confirmed on
main)logs_2.sqlite.High-level fix outline (not a PR)
RUST_LOG/ app log level end-to-end into the SQLite writer.Tests
Questions for maintainers
Happy to refine with more measurements or code pointers. No unsolicited PR from me.
Analysis (community)
Residual analysis for #17320 (TRACE into SQLite sink) under docs/contributing.md — invitation-only, no unsolicited PR.
Deterministic evidence (code + runtime)
Code on current main (
5dd992acd3):codex-rs/state/src/log_db.rs—default_filter()sets.with_default(LevelFilter::TRACE)(line ~55).LogDbLayer::on_eventstill constructsLogEntryandtry_sends every accepted event into the DB inserter; only special-casestarget=="log"and low-levelopentelemetry_sdkTRACE/DEBUG drops.codex-rs/state/src/sqlite.rsLOGS_DB_FILENAME = "logs_2.sqlite".codex-rs/state/src/runtime/logs.rs(INSERT INTO logs ... feedback_log_body ...).codex-rs/state/src/log_db_filter_tests.rskeeps("TRACE", "codex_state", Some("retained-trace")).Runtime on this host (2026-07-24):
codex-cli 0.146.0-alpha.6~/.codex/logs_2.sqlitesize 1778548736 bytes; WAL 30405632 bytesNotably
codex_api::sse::responsesand app-server/tui markdown stream dominate TRACE volume — not only “sdk noise” already filtered.Root-cause hypothesis
#28224-era mitigations dropped some high-fanout targets, but default_filter still admits TRACE globally into
logs_2.sqlite. Residual issues remain open because default sink level was not raised (or not capped).High-level fix outline (not a PR)
default_filter()default from TRACE → INFO (or DEBUG) for SQLite sink only.logs_2.sqlite.log_db_filter_testsso default filter does not retain generic TRACE targets.Questions for maintainers
Happy to refine. No unsolicited PR from me.
Measured root cause, and an outline of a fix
Sharing measurements and analysis per the contributing guidelines — not requesting a PR.
Environment: Windows 11,
codex-cli 0.145.0, defaultsqlite_home.Measurements on
~/.codex/logs_2.sqlite| | |
|---|---|
| Cumulative inserts (
sqlite_sequence) | 2,671,625 over 47.5 days = 56,288/day || Rows retained | 15,369 (≈31h window) |
| TRACE share of retained rows | 70.4% |
| Largest single target |
codex_api::sse::responses= 51.4% || Real disk write per attempted insert | 3,787 bytes |
The per-insert figure was measured with
GetProcessIoCountersagainst a throwaway DB carrying the same schema, the same five indexes, and the PRAGMAs read off the live file (journal_mode=WAL,synchronous=FULL,page_size=4096,auto_vacuum=INCREMENTAL,wal_autocheckpoint=1000), inserting in batches of 128 with the retention prune in the same transaction.One methodology note for anyone else measuring this: WAL file size is a poor proxy, because the WAL is reused in place — sampling file sizes can show zero growth while writes are happening.
Filtering TRACE out at the sink brought this to 1,133 B per attempted insert, a 70.1% reduction, matching the TRACE share almost exactly.
For calibration on the endurance framing in #28224: SMART on this machine reports
Percentage Used: 2%after 5,965 power-on hours and 42.0 TB of host writes, of which this sink accounts for roughly 0.12%. The churn is real and worth fixing, but I was not able to reproduce anything close to a 640 TB/year rate from the observable insert rates.Root cause
codex-rs/state/src/log_db.rs::default_filter()isTargets::new().with_default(LevelFilter::TRACE)plus a per-target denylist, and it is the only filter on the sink.RUST_LOGreaches just the stderr layer (EnvFilter::from_default_env()inapp-server/src/lib.rs), which is why settingRUST_LOG=warnhas no effect here.With no environment variable and no config key for the sink, the only available lever is adding denylist entries release by release (#29432, #29457, #29599, then
rmcp::service, thencodex_api::responses_websocket_timing). #34291 and #35308 suggest that is not converging, andcodex_api::sse::responses— the largest producer in my sample — is still unfiltered onmain.Outline of a fix
A single opt-in environment variable,
CODEX_SQLITE_LOG, parsed with the existingTargetsdirective syntax and defaulting to today's behaviour when unset.CODEX_SQLITE_LOG=warnremoves the churn while keeping WARN/ERROR available for/feedback.Two details that are easy to get wrong:
RUST_LOG.Targetsderives no default level from a value that has no bare level, soRUST_LOG=codex_core=debug— andEnvFilter-valid span syntax such as[my_span]=debug, whichTargetsreinterprets as a target name — would silence the sink for every target the user did not name. Separately, a value someone set for stderr verbosity should not silently degrade persisted diagnostics.rmcp::serviceto INFO even when the user asked forwarn. Takingmin(requested, ceiling)avoids that; noteLevelFilterorders by verbosity (OFF < ERROR < WARN < INFO < DEBUG < TRACE), sominyields the more restrictive level.A reference implementation is on my fork if it is useful:
Nicolas0315/codex@feat/configurable-sqlite-log-verbosity. Two files, +202/−10.cargo test -p codex-state --lib log_dbpasses with the environment clean, withRUST_LOG=warnset, and withCODEX_SQLITE_LOG=warnset;cargo fmt --checkandcargo clippy -p codex-state --all-targetsare clean. I will not open a PR unless invited.A warning about a workaround circulating for this
Applying a SQLite trigger to
logs_2.sqliteto drop inserts is not safe.codex-rs/state/logs_migrations/0002_logs_feedback_log_body.sqlrebuilds the table (ALTER TABLE logs RENAME TO logs_old→CREATE TABLE logs→ copy →DROP TABLE logs_old), which silently drops any user-defined trigger. The mitigation disappears at the next logs migration with no indication to the user, who will believe they are still protected.Follow-up: the same measurement across five machines, and version-over-version evidence
I ran the same read-only probe on five machines (three Windows 11, two macOS 15) to check whether the numbers in my previous comment generalise.
Method corrections first, since they affect anyone reproducing this
sqlite_sequence.seq ÷ database age.On one machine that overstated the rate by 14× against live sampling. Use the retained rows instead:MAX(id) - MIN(id)overMAX(ts) - MIN(ts).idis contiguous, so that yields the actual number of inserts during the retained window.os.path.getctimeis the inode change time, not birth time, so it always equals mtime. Usestat -f %B.PRAGMA synchronousis per-connection and is not persisted in the file, so reading it from your own connection tells you nothing about what Codex uses. Codex setsSqliteSynchronous::Normalincodex-rs/state/src/sqlite.rs. I re-ran the write-amplification benchmark under NORMAL and got an identical 3,787 bytes per attempted insert —synchronouschanges fsync frequency, not byte volume — so the figure in my earlier comment holds.Results
| codex | inserts/day | rows retained/day | survival | TRACE % | db / wal |
|---|---|---|---|---|---|
| 0.144.6 | 910,649 | 18,017 | 2.0% | 65.0% | 270 / 25 MB |
| 0.145.0 | 73,357 | 10,497 | 14.3% | 67.0% | 25 / 1.4 MB |
| 0.144.6 | 72,153 | 32,656 | 45.3% | 64.3% | 191 / 4.2 MB |
| 0.144.6 | 24,924 | 2,916 | 11.7% | 62.7% | 46 / 3.0 MB |
| 0.144.6 | 12,087 | 1,534 | 12.7% | 43.5% | 21 / 2.2 MB |
TRACE is 62–67% of retained rows everywhere except the least-used machine. All five report
journal_mode=wal,page_size=4096, four indexes onlogs, and no user-defined triggers.The per-target denylist is not converging, and the fleet shows it directly
v0.145.0 added
hyper_utiltodefault_filter(). Comparing the four machines still on 0.144.6 against the one already on 0.145.0:| codex |
hyper_util::*(three targets combined) |codex_api::sse::responses||---|---|---|
| 0.144.6 | 80.2% | not in top 5 |
| 0.144.6 | 43.1% | 22.0% |
| 0.144.6 | 37.7% | not in top 5 |
| 0.144.6 | 29.1% | 32.4% |
| 0.145.0 | gone from top 5 | 48.2% |
The
hyper_utilentry did exactly what it was meant to do.codex_api::sse::responsesthen immediately became the largest single producer at 48.2%, and that target is still unfiltered onmain. This is the pattern described in #34291 and #35308, measured across versions rather than inferred.The tail matters more than the median
The busiest machine — a macOS host running roughly 35 concurrent
codexprocesses — writes 910,649 inserts/day and keeps 2.0% of them. At 3,787 bytes per attempted insert that projects to about 3.4 GB/day, or 1.26 TB/year, against roughly 0.1 TB/year on a typical single-session machine.To be explicit about the limits of that number: it is a projection, not a measurement. Neither macOS host exposes SMART wear data through standard tooling, so I cannot report actual host writes for them the way I could for the Windows machine earlier (
Percentage Used: 2%after 42.0 TB lifetime writes, this sink accounting for ~0.12%). The ratio between machines is measured, though, and it suggests heavy multi-session users see roughly an order of magnitude more of this than my earlier single-machine figures implied.Separately, both macOS databases had grown to 191 MB and 270 MB, which seems relevant to #27741.
Why this reinforces the earlier suggestion
An opt-in level knob for the sink helps the tail immediately and without another release cycle: the busiest machine here is on 0.144.6 and would drop about 65% of its insert volume with a single environment variable, without waiting for
codex_api::sse::responses— or whatever succeeds it — to be added to the denylist.Correction: measured in vivo, my 70.1% figure was too low and described a narrower change
I built a patched app-server and measured it against the production backend rather than modelling it. Two of my earlier numbers need correcting, and one of my conclusions was backwards.
Setup.
codex-app-serverbuilt from a patched tree, driven over JSON-RPC/stdio through real model turns, 6 turns per arm, each arm with its own isolatedCODEX_SQLITE_HOME. Write volume measured per turn by polling the WAL-index header in each arm's ownlogs_2.sqlite-shm, soframes × 4120 Bis exactly what was appended to that arm's WAL with no contamination fromstate_5/goals_1/rollout writes. Numbers are normalised per clean turn (zero error notifications).| arm | attempted inserts / turn | WAL bytes / turn | reduction |
|---|---|---|---|
| unset (today's default) | 1,603 | 4,236,733 | — |
| discard TRACE only | 80 | 438,093 | 86–90% |
| WARN and above | 1.8 | 35,020 | 99.2% |
| off | 0 | 0 | 100% |
What I got wrong. The 70.1% above was modelled from content-byte shares. The input was right — TRACE really is 76.4% of rows and 67.7% of
SUM(estimated_bytes)in an unpruned real session — but WAL volume does not scale with content bytes. It scales with pages dirtied across thelogstable's four indexes, with the number of 2-second flush commits (19.5 → 5.3 per turn), and with the row-cap pruning the unfiltered sink performs continuously: filtering removes the inserts and the ~8,600 DELETEs they trigger. Note also that 70.1% described "discard TRACE only", which is a narrower intervention than "WARN and above".The counter-effect I predicted earlier — that filtering thins the batches and raises per-insert cost — is real but an order of magnitude too small to flip the sign. Bytes per surviving insert rose 2,642 → 5,476 → 20,011 across the three arms, costing roughly 5 percentage points against a 95% row reduction.
The unfiltered baseline came out at 2,642 B per attempted insert here, against 3,787 from the synthetic harness and 3,891 from an instrumented stock 0.145.0 run. Treat that figure as 2,600–3,900 depending on workload shape rather than a constant.
What I had backwards. I said turning the sink down reduces what
/feedbackand crash reports can include. That is true of per-event detail and false of history.LOG_PARTITION_ROW_LIMITis 1,000 per partition, so in the unfiltered arm 9,638 rows were inserted and 1,159 retained, with the threadless partition holding ids 8631–9638 exclusively — the first 89.6% pruned away inside a single 160-second, six-turn session, leaving roughly the last 30 seconds of process history. That arm retained zeroERRORrows. TheWARN-and-above arm retained all of its rows, including 6ERROR. So the unfiltered sink evicts the levels most likely to matter, and turning it down widens the useful diagnostic window rather than narrowing it. That seems worth weighing against the "we need TRACE for/feedback" argument for the current default.Two reproduction notes, in case they help anyone else measuring this:
400 Bad Requestonwss://chatgpt.com/backend-api/codex/responsesand every turn aborted.Feature::ResponsesWebsocketsisStage::Removed, so there is no flag left to disable it; I had to add a[model_providers.*]entry cloning the ChatGPT provider withsupports_websockets = falseto force the HTTP SSE path — which is also the path that emitscodex_api::sse::responses. Possibly specific to my account or build, but it made the default path unmeasurable.Exceeded maximum HTTP header buffer size of 100KB. That string does not appear anywhere in the tree, so it is server-side. It killed 2–4 of 6 turns per arm, which is why everything above is normalised per clean turn.Caveats on the new numbers. Windows, one machine, debug build, 3–6 clean turns per arm, and the unset baseline itself varied between 771 and 1,028 WAL frames per turn across two runs. Treat 86% as the floor for "discard TRACE only" rather than a precise figure.
One methodology caveat on my fleet table. The inserts/day column was
(MAX(id) − MIN(id)) / (MAX(ts) − MIN(ts))over retained rows. The numerator is sound, but the denominator is set by retention, not by activity — the oldest retained row is whatever survived the row cap in a quiet partition. It is a rate over a retention-determined span, not a sustained daily average, and the bias direction differs per machine. Only the 0.145.0 cell has been recomputed since (77,626/day against the 73,357 reported, the difference being load I drove in while measuring).The reference branch has been updated with the parse-normalisation and documentation fixes that came out of this:
Nicolas0315/codex@feat/configurable-sqlite-log-verbosity. Still no PR unless invited.Correction: my ERROR-row comparison did not support the conclusion I drew from it
In the comment above I wrote that the unfiltered arm retained zero
ERRORrows while theWARN-and-above arm retained all of its rows including 6ERROR, and offered that as evidence that the unfiltered sink evicts its own errors. Both counts are real, but they are not comparable and do not support that inference. The two arms are different processes with different error populations — the unfiltered arm logged no turn errors at all, so it had noERRORrows to lose — and a second session under the default filter did retain 4ERRORrows. A reviewer of my own working notes caught this.The eviction mechanism itself does not depend on that pair.
LOG_PARTITION_ROW_LIMITis 1,000 per partition, and in a 160-second six-turn session the threadless partition held ids 8631–9638 out of 9,638 inserted — the first 8,630 rows, 89.6%, pruned inside that session, leaving roughly the last 30 seconds of process history. That is the measured claim, and it stands: on a busy session the sink can evict any level, including warnings and errors, within seconds. What I cannot claim from my data is a specific ERROR-row count difference between filtered and unfiltered runs.So the weaker version is what I should have written: the default's TRACE volume shortens the retained window for every level, and a lower setting lengthens it. Whether that outweighs the loss of per-event TRACE detail for
/feedbackis a judgement call for the team, not something my numbers settle.Nothing else in the measurements above changes — the WAL-frame accounting, the 86–90% and 99.2% reductions, and the per-insert byte range are unaffected. The reference branch has the corresponding documentation fix.
Release-build confirmation, and corrections to how I reported bytes and rates
Two independent reviews of my own working notes, a release-profile re-run, and a re-measurement of the five machines. The reduction figures hold; several of the things I wrapped around them do not.
The reduction holds on a release build
Repeated on
cargo build --release -p codex-app-serveragainst real model turns, 8 turns per arm: discarding TRACE only cuts WAL write per clean turn by 88.4% prompt-matched, and WARN-and-above by 99.21%, against 89.7% and 99.17% on the debug build. Matched on prompt index the two baselines agree within 0.9% on WAL frames and 0.07% on attempted inserts, and rows per flush transaction is 82.6 release against 82.2 debug — turn duration is network-bound either way, andLOG_BATCH_SIZE = 128forces most of an unfiltered turn's commits by row count rather than by the 2-second ticker, which is profile-independent by construction. So the debug-build caveat can be retired.One correction to my own hedge. I wrote that the unset baseline "varied 771–1,028 WAL frames per turn across two runs, so treat 86% as the floor." That rationale was wrong: attempted inserts are a deterministic function of the prompt and reproduce across three runs and both profiles. The spread came from backend errors killing the four longest prompts in one run, leaving the two shortest. The band is 88–90%.
Bytes per insert: two accountings, not scatter
Reporting "2,600–3,900 B" implied measurement noise. It is a bracket:
logs_2.sqlitegives 2,530–2,663 B per attempted insert across four sessions on both profiles. That is a lower bound — it counts each page once as it is appended to the WAL, and not again when the checkpoint writes it into the main database.state_5.sqlite,memories_1.sqlite,goals_1.sqliteand the session files. In one runstate_5.sqlite's WAL alone grew 2.4 MB.Read it as roughly 2,600 B of WAL append and roughly 3,800 B of total device write per persisted row. Relatedly, the reduction figures above are WAL-append reductions; checkpoint volume tracks WAL volume so the direction and order hold, but I have not measured device writes per arm.
Also: I said that harness carried "the same five indexes". The production table has four. Five is the count in
logs_migrations/0001;0002rebuilds the table with four, droppingidx_logs_process_uuid. The harness created four, so the measurement is unaffected.The five-machine table: right numbers, wrong label, and one estimator I nearly published that is wrong
The
inserts/daycolumn was(MAX(id) − MIN(id)) / (MAX(ts) − MIN(ts))over retained rows. Every cell reproduces within 0.9%, but the denominator is not an activity window — the oldest retained row is whatever survivedLOG_PARTITION_ROW_LIMITin the quietest partition.I set out to replace it with
sqlite_sequence.seq / file age, which looks sound because0001creates the table empty and0002copies ids through unchanged. Do not use it. On the busiest machine it gives 13.2 M/day, and a maintenance VACUUM had reset that file's creation time. The check that catches this is cheap: several of these machines keep dated copies oflogs_2.sqlite, which turn into a snapshot series. DifferencingMAX(id)between consecutive snapshots gives a rate with an unambiguous denominator:| host | measured between snapshots | published |
|---|---|---|
| macOS A | 892,469 → 1,209,113 → 661,082 /day | 910,649 |
| Windows A | 25,680 /day over 8.9 d | 24,924 |
| Windows B | 11,381 /day over 6.0 d | 12,087 |
So the published column was closer than its derivation deserved — within 0.3% and 6% on the two Windows machines, and inside the measured band on the Mac — while the estimator I was about to substitute overstates by an order of magnitude. On one Mac the current file's
MAX(id)is below its own earlier snapshot's, which is how a lineage reset looks; treat any per-file lifetime average as suspect unless the oldest row is stillid = 1.My
survivalcolumn (2.0 / 14.3 / 45.3 / 11.7 / 12.7%) should be disregarded. It is close to a partition count: threadless rows retained in the last 24 hours are exactly 3,000, 3,000, 4,000 and 4,000 on four of these machines, and 47 of 919 partitions on the busiest sit exactly at 1,000.The version labels on both tables were wrong in kind
Each
logs_2.sqliteis written concurrently by several codex binaries. Joininglogs.thread_idtothreads.cli_versioninstate_5.sqliteshows 0.144.2 through 0.146.0-alpha.3.1 writing into single files, and on all five machines the process actually writing was a desktop-app-bundled binary rather than whatevercodexresolves to onPATH. A table row cannot carry one version.The
hyper_utilfinding survives as a mechanism rather than a per-machine fact:("hyper_util", LevelFilter::WARN)is absent fromdefault_filter()atrust-v0.144.6and present atrust-v0.145.0, and the machines with a large share are the ones where a pre-0.145.0 writer ran inside the retained window. Two of the four need a time label — the 43.1% machine last wrote such a row 48 hours before sampling and is at 0.0% over its last 24 hours; the 80.2% machine is at 16.0%. Only the 29.1% and 37.7% machines are current, at 53.0% and 35.5% of their last 24 hours. Likewise the 32.4%codex_api::sse::responsesshare I reported for one machine came from a single 71-minute burst 211 hours before sampling and is 0.0% of its last 24 hours; the 48.2% machine now reads 39.6%.One consequence for the proposal itself, which I had not considered:
CODEX_SQLITE_LOGcan only govern inserts from processes running a build that has it. One of these files is still receivingtarget='log'rows that the unconditionalon_eventguard has excluded since #29599, so a co-resident writer predates or bypasses it.Unchanged: the
synchronousfinding, and the migration-0002 trigger behaviour, which I have since read line by line rather than inferred.Jesus please turn off your agent, what you're doing is useless.
Closing three loose ends: device writes, why my two reduction figures disagreed, and migration 0002 executed
The reduction is the same in device writes as in WAL appends
I caveated the 88–90% and 99.2% figures as WAL-append reductions, with checkpoint volume unmeasured. Measured now, on the same runs, with both instruments — WAL frames from the WAL-index header, and process I/O counters, which include the checkpoint write-back:
| TRACE share of workload | rows removed | WAL-append reduction | device-write reduction |
|---|---|---|---|
| 70.4% | 70.5% | 71.3% | 71.3% |
| 85.0% | 85.3% | 86.1% | 86.0% |
| 95.0% | 95.2% | 96.1% | 96.1% |
| 98.0% | 98.1% | 98.8% | 98.5% |
The two accountings agree within 0.3 points at every point, so that caveat can be dropped: cutting WAL appends cuts device writes by the same proportion, because checkpoint volume tracks WAL volume.
Why 70.1% and 88–90% both held, and what supersedes both
I earlier described the 70.1% as an underestimate that the in-vivo 88–90% replaced. That framing was wrong, and the table above shows why: the reduction tracks the share of the workload you are cutting, almost one-for-one. 70.1% came from a synthetic workload whose TRACE share was 70.4%. The 88–90% came from six back-to-back streaming turns, where TRACE was about 95% of attempted inserts. Both are correct measurements of different workloads; neither is "the" number.
So the honest statement is a model rather than a percentage: byte reduction ≈ the share of inserts you remove, within a few points. In the real streaming arms it landed slightly below the row share — 89.7% of bytes against 95.0% of rows — because the rows that survive TRACE filtering are individually larger than the ones removed. In a uniform-row-size synthetic workload it lands slightly above, because removing rows also removes commits and index churn.
The practical consequence for this issue is unchanged and, if anything, easier to reason about: on TRACE-dominated traffic, which is what streaming turns produce,
warnremoves ~99% and dropping TRACE alone removes ~86–90%. On mixed or mostly-idle traffic the same settings remove proportionally less, roughly whatever fraction of your rows is TRACE. Anyone reproducing this should report their TRACE share alongside the percentage, which I should have done.Migration 0002 executed rather than read
I claimed from the SQL that a user trigger on
logsis silently dropped. Executed now against a throwaway database carrying three kinds of user object, with0001and0002fetched verbatim frommain:CREATE TRIGGER ... BEFORE INSERT ON logs— gone, and the migration raised nothing. A post-migration TRACE insert is no longer suppressed, so a user relying on this loses protection with no signal.AFTER INSERT ON logswriting to another table) — also gone.logs— survives insqlite_masterbut is permanently broken:SELECTagainst it returnsno such table: main.logs_old.ALTER TABLE logs RENAME TO logs_oldrewrote the view's body, andDROP TABLE logs_oldleft it pointing at nothing.The migration succeeding silently is the worse outcome of the two possibilities, since a user who applied a trigger workaround has no way to notice it stopped working. Incidentally this also confirms the index count I corrected earlier:
0002dropsidx_logs_process_uuid, taking the table from five indexes to four.Coverage that still does not exist
The TUI path is verified only statically:
codex-rs/tui/src/lib.rs:1288-1291andcodex-rs/app-server/src/lib.rs:665-668construct the layer and its filter with identical code, and none of the other layers in either stack writes tologs_2.sqlite, so the filter behaviour is shared by construction. But every end-to-end byte measurement I have posted drovecodex-app-serveron Windows 11. No TUI run, and nothing on macOS or Linux, where page cache and fsync behaviour differ. The percentages should hold since they are ratios within a single platform, but I have not shown that.Fresh Windows reproduction on codex-cli 0.147.0
Adding a current Windows data point.
This is not an unbounded-WAL reproduction like #28997: the WAL remained bounded at about 6 MiB. However, the default SQLite log sink is still persisting a large number of TRACE-level streaming events, causing frequent WAL writes/checkpoints and leaving substantial unreclaimed free space in
logs_2.sqlite.Environment
codex-cli 0.147.0, installed via npm%USERPROFILE%\.codex\logs_2.sqliteTRACE,RUST_LOG,log_dir,sqlite_home, or similar logging override was present inconfig.tomlor the relevant environment variablesVACUUM, checkpoint commands, or modify the databaseCurrent database state
logs_2.sqlite: 803,803,136 bytes (766.6 MiB)logs_2.sqlite-wal: approximately 6.0 MiBThis suggests retention/pruning removes rows but does not reclaim the database file automatically.
Recent log distribution
In one representative 5-minute window:
| Level | Rows | Estimated stored bytes |
|---|---:|---:|
| TRACE | 1,732 | 1,317,718 |
| DEBUG | 233 | 206,060 |
| INFO | 217 | 147,007 |
| ERROR | 6 | 6,209 |
| WARN | 4 | 1,171 |
TRACE represented approximately 79% of inserted rows and 78.5% of the estimated stored payload.
TRACE activity occurred during 88 of the 300 seconds, with a maximum of 124 TRACE rows in one second.
The highest-volume TRACE targets were:
| Target | Rows |
|---|---:|
|
codex_app_server::outgoing_message| 872 ||
codex_tui::markdown_stream| 480 ||
codex_api::sse::responses| 232 |The preceding 24-hour window contained 20,007 rows in total, including 11,172 TRACE rows in the earlier snapshot.
WAL-frame measurements
I measured SQLite WAL frame-count changes through the read-only
-shmWAL-index headers.During an active 45.0-second sample:
After allowing activity to cool for 10 seconds, a second 30.0-second sample showed:
The “WAL plus checkpoint” number assumes at most one 4 KiB database-page write per WAL frame, so it is deliberately an upper bound. It is not a measurement of SSD NAND writes or write amplification.
Extrapolating these short active samples continuously would produce very large daily figures, but that would overstate my real workload. Scaling against the actual observed 24-hour row count gives a rough workload-adjusted estimate of approximately 0.5–0.7 GiB/day of SQLite host writes. This is far below the extreme historical reports, but it still appears unnecessary for diagnostic logging under the default configuration.
Requested behavior
It would be helpful to:
codex_app_server::outgoing_messagecodex_tui::markdown_streamcodex_api::sse::responsesThis appears closely related to #31142 and the call-site analysis in #31542.
I have not attached the database or raw log bodies because they may contain prompts, responses, paths, or other sensitive session data, but I can provide additional aggregate queries or read-only measurements if useful.
Thanks — clean residual datapoint on
codex-cli 0.147.0.Matches the earlier residual analysis: default SQLite sink still admits high-fanout TRACE (
codex_app_server::outgoing_message,codex_tui::markdown_stream,codex_api::sse::responses), separate from unbounded-WAL #28997; freelist after prune is an additional reclaim gap.Root cause: SQLite sink ignores
RUST_LOGConfirmed this still matches current
main(ee7815dad2) from the logging wiring (not claiming a fresh disk-churn measurement in this comment).RUST_LOGis respected by the stderr/file logging layers, but not by the SQLite log sink:EnvFilter::from_default_env(), soRUST_LOGapplies there:codex-rs/app-server/src/lib.rs:648-662EnvFilter::try_from_default_env():codex-rs/tui/src/lib.rs:1189-1200codex-rs/app-server/src/lib.rs:667-670codex-rs/tui/src/lib.rs:1229-1232log_db::default_filter(), whose default is stillLevelFilter::TRACE:codex-rs/state/src/log_db.rs:53-68So
RUST_LOG=warncan suppress stderr/file output while SQLite still persists TRACE rows allowed bylog_db::default_filter(). The existing denylist/demotions are useful (hyper_util,log,codex_otel.*,codex_api::sse, etc.), but they do not make the SQLite sink honor the process log level.This also explains residual TRACE/SQLite churn reports in #29532 after partial denylist fixes: default remains TRACE, so first-party high-volume TRACE can still persist.
Minimal fix proposal: apply the effective
RUST_LOG/EnvFilterto the SQLite layer as well, while preserving the existinglog_db::default_filter()denylist. SQLite persistence should be gated by both:RUST_LOG, with the same fallback semantics as the surrounding binary), andlog_db::default_filter().Test plan:
codex-statecoverage showinglog_db::default_filter()still drops/demotes the existing noisy targets.RUST_LOG=warn, TRACE/DEBUG events should not reachlogs_2.sqlite; WARN/ERROR should.RUST_LOG=tracecan still retain TRACE except for targets blocked by the SQLite denylist.just fmt,just fix -p codex-state, and focused tests for changed crates (just test -p codex-state, plus app-server/TUI if wiring changes there).Happy to implement if maintainers want this approach.
Ran into the same TRACE/SQLite churn and independently traced it to the same root cause @Aryan4443 described above:
log_db::default_filter()hardcodesLevelFilter::TRACE(codex-rs/state/src/log_db.rs), so the SQLite sink never honorsRUST_LOG, even though the stderr (app-server) / file (TUI) layers do.I implemented and verified the fix @Aryan4443 proposed — combine the existing
log_db::default_filter()denylist with anEnvFilterderived fromRUST_LOGviaFilterExt::and, falling back totracewhenRUST_LOGis unset so/feedbackkeeps its historical, denylist-limited default:Verified:
cargo check -p codex-app-server -p codex-tuijust fmtjust test -p codex-app-server -p codex-tui(green aside from pre-existing, unrelated failures caused by a missingcodex-code-mode-hostbinary / mock-server flakiness in my sandbox)just fix -p codex-app-server/just fix -p codex-tuiFull branch: https://github.com/Excelius-Wang/codex/compare/main...Excelius-Wang:codex:fix/log-db-respect-rust-log
I don't have collaborator access on this repo, so I can't open a PR directly (
pull_request_creation_policyiscollaborators_only). Posting here so @Aryan4443 or a maintainer can cherry-pick/open the PR — happy to add the suggestedcodex-state/app-server/TUI filter-composition tests too if that's useful.Additional reproduction on Linux Desktop Preview with
codex-cli 0.147.0I can still reproduce the SQLite/WAL write churn described here in the Codex Linux Desktop Preview, after the fixes referenced by #28224. This is not a VSCodium/VS Code extension report or a standalone CLI-only report.
Environment
codex --version:codex-cli 0.147.0CODEX_HOME: default user Codex directory (~/.codex)~/.codex/logs_2.sqlitewallog_dirorsqlite_homeoverride was configuredReproduction evidence
Before applying the workaround, a 15-second read-only sample showed:
So the log sequence advanced by 178 IDs in 15 seconds (~11.9 inserts/sec), while the retained row count increased by only 36. This is consistent with continuous insert/prune or page-reuse churn rather than simple file growth.
At the time of intervention, the retained level distribution was:
The main database was about 52.7 MiB and
logs_2.sqlite-walwas about 4.3 MiB. The dominant targets included:Workaround validation
While the Linux Desktop Preview session was still running, I created this temporary SQLite trigger:
No restart was required. In the following 15-second sample:
The
logs_2.sqlite-walsize and modification time also remained unchanged during that sample. Codex Desktop Preview stayed usable and the active session was not interrupted.This confirms that the persistent low-level rows are the direct source of the observed SQLite/WAL churn. The trigger is only a local, reversible workaround; it is not a product fix.
Expected behavior
The SQLite feedback-log sink should honor the effective process log filter (for example,
RUST_LOG=warn) or provide a documented setting to disable, lower, rate-limit, or bound persistent feedback logging. Normal Linux Desktop Preview use should not continuously persist high-volume TRACE/DEBUG app-server, SSE, markdown-stream, or dependency-internal events tologs_2.sqlite.Could this be re-checked against the current Linux Desktop Preview app-server/TUI logging path? In particular, the SQLite sink appears to have a filter independent from the normal process log filter. A regression test covering
RUST_LOG=warnand the SQLite sink would help prevent this from recurring.No database files, raw log rows, configuration files, or user data are attached.
OK BRO they got it holy shi you dont have to paste your AI bug-tracing anymore.
The multi-MB/s writes to
logs_2.sqlite-walduring streaming come from the SQLite log sink, which uses its own TRACE default and does not honorRUST_LOG.Method:
~/.codex/logs_2.sqlite,logs_2.sqlite-wal, andlogs_2.sqlite-shm(same three names underCODEX_HOMEif you relocated it).RUST_LOG=warnonly reduces the non-SQLite log path.Evidence: still reproduced on 0.147.0.
log_db::default_filter()keeps TRACE, and a current Windows Desktop install shows a ~840MBlogs_2.sqliteplus a live WAL while the app is running.Independent community workaround; not an official OpenAI fix.