Excessive SQLite WAL writes during streaming due to TRACE logs ignoring RUST_LOG

Open 💬 34 comments Opened Apr 10, 2026 by piotrkacala

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?

  1. Open a workspace in VSCodium/VS Code with the Codex extension.
  2. Confirm RUST_LOG=warn is set:
cat /proc/$(pgrep -f 'codex.*app-server')/environ | tr '\0' '\n' | grep RUST_LOG
  1. Run strace -f -e trace=pwrite64 -p $(pgrep -f 'codex.*app-server') in a separate terminal.
  2. Ask any question in the Codex chat panel and observe streaming.
  3. Observe continuous pwrite64 calls to logs_2.sqlite-wal at ~5 MiB/s despite RUST_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.

View original on GitHub ↗

34 Comments

christopherball · 4 months ago

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.

clemisch · 3 months ago

I also see this behavior. Do you still have this problem? Could you fix it by symlinking to a tmpfs? Just linking logs_2.sqlite to 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 😬

piotrkacala · 3 months ago
Could you fix it by symlinking to a tmpfs?

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.

clemisch · 3 months ago

Thanks. I ended up just symlinking the whole ~/.codex folder (and copying it back before shutdown, hopefully...). /tmp is indeed a tmpfs for me.

niklassheth · 3 months ago

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.

synap5e · 3 months ago
sqlite3 ~/.codex/logs_2.sqlite "CREATE TRIGGER IF NOT EXISTS block_log_inserts BEFORE INSERT ON logs BEGIN SELECT RAISE(IGNORE); END;"
niklassheth · 3 months ago
`` sqlite3 ~/.codex/logs_2.sqlite "CREATE TRIGGER IF NOT EXISTS block_log_inserts BEFORE INSERT ON logs BEGIN SELECT RAISE(IGNORE); END;" ``

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

clemisch · 3 months ago
but there's some more CPU savings from disabling the logging fully.

how did you do that?

niklassheth · 3 months ago

I patched the codex source to remove the log db's initialization.

<details>
<summary>Patch: disable log DB initialization</summary>

diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs
--- a/codex-rs/app-server/src/lib.rs
+++ b/codex-rs/app-server/src/lib.rs
@@ -600,7 +600,7 @@ pub async fn run_main_with_transport_options(
 
     let feedback_layer = feedback.logger_layer();
     let feedback_metadata_layer = feedback.metadata_layer();
-    let log_db = state_db.clone().map(log_db::start);
+    let log_db = None;
     let log_db_layer = log_db
         .clone()
         .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE)));
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -1047,7 +1047,7 @@ pub async fn run_main(
 
     let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer());
 
-    let log_db = state_db.clone().map(log_db::start);
+    let log_db = None;
     let log_db_layer = log_db
         .clone()
         .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE)));

</details>

emaadmanzoor · 2 months ago

Useful workarounds here when using Codex on a Raspberry Pi with SD card storage, thanks!

insilications · 2 months ago

This kind of program behavior is disrespectful in general, especially toward users with SSDs.

Gerry9000 · 2 months ago

There's already a shortage of SSDs, plz fix.

adamgwinn · 2 months ago

I can reproduce this on Windows Desktop.

Environment:

  • Codex Desktop: OpenAI.Codex 26.616.6631.0
  • CLI: codex-cli 0.128.0
  • OS: Microsoft Windows NT 10.0.26200.0
  • User env: RUST_LOG=warn, LOG_LEVEL=warn
  • config.toml: [analytics] enabled = false
  • Log DB had been freshly rotated before the second measurement.

Before workaround:

  • Previous logs_2.sqlite had grown to about 2.0 GB, with about 1.47 GB of freelist bloat.
  • After rotation/restart, a fresh DB still logged heavily while active.
  • One 60-second sample had 1,734 rows:
  • 950 TRACE log
  • 210 INFO codex_otel.log_only
  • 207 INFO codex_otel.trace_safe
  • 202 TRACE codex_api::endpoint::responses_websocket
  • 65 TRACE codex_api::sse::responses
  • smaller TRACE/DEBUG targets as well
  • No active git.exe storm was present during this sample.

Workaround tested:

CREATE TRIGGER codex_drop_noisy_logs
BEFORE INSERT ON logs
WHEN NEW.level IN ('TRACE', 'DEBUG')
  OR NEW.target IN ('codex_otel.log_only', 'codex_otel.trace_safe')
BEGIN
  SELECT RAISE(IGNORE);
END;

Result:

  • After waiting more than 60 seconds, those noisy targets stopped being persisted as logs rows.
  • A later 60-second sample contained only low-volume preserved INFO/WARN rows, roughly 30-40 rows/min in an active session.
  • This is only a local DB workaround. Codex still appears to emit/attempt these events; the real fix probably needs to apply the effective log-level filter before the SQLite sink, or expose a supported setting to disable/rate-limit the local SQLite log sink.
Necmttn · 2 months ago

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._

zhuhaow · 2 months ago

I made a small unofficial tool for running community-found Codex workarounds:

https://codexfixes.com

Run it with:

npx codex-fixes@latest

So we can stop checking issue threads every day for months and just patch the annoying stuff when the community finds a workaround.

Master0fFate · 2 months ago

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.

Chenyu2046 · 1 month ago

Windows Desktop confirmation, same failure mode on Windows.

  • Codex Desktop: OpenAI.Codex_26.707.9564.0_x64
  • RUST_LOG=warn was configured in both .codex/.env and the user environment.
  • Before workaround: 15-second samples showed id_delta=229 with count_delta=0, and another sample grew the WAL by 26,199,080 bytes (~25.0 MiB).
  • TRACE sources included rmcp::service, codex_api::sse::responses, and hyper_util.

I then applied the community block_log_inserts BEFORE INSERT trigger. With Codex still running, a fresh 15-second read-only sample showed id_delta=0, count_delta=0, and wal_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_LOG or avoid persisting high-volume SSE/WebSocket TRACE events by default.

Bob-Student · 1 month ago

macOS confirmation with local quantitative evidence.

Environment:

  • macOS on Apple Silicon
  • Codex CLI reported codex-cli 0.145.0-alpha.18
  • Desktop/app-server uses ~/.codex/logs_2.sqlite

Historical snapshot before the insert-blocking workaround took effect:

  • logs_2.sqlite: 223,064,064 bytes (~212.7 MiB)
  • WAL: 4,148,872 bytes
  • Retained rows: 81,041
  • Total estimated_bytes: 147,795,856
  • TRACE: 38,733 rows / 100,151,645 estimated bytes
  • INFO: 32,598 rows / 42,269,296 estimated bytes
  • DEBUG: 8,488 rows / 4,580,166 estimated bytes
  • Current sqlite_sequence for logs: 11,634,061

The 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_inserts trigger is currently installed. During active Desktop streaming, read-only samples showed:

  • no change in row count, MAX(id), or sqlite_sequence
  • no sustained WAL growth
  • the log table is now empty
  • 99.86% of the 54,459 database pages are on the freelist

This 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:

  1. does not persist TRACE/DEBUG SSE, WebSocket, and networking events by default;
  2. makes the SQLite sink honor the effective log filter;
  3. exposes a supported setting to disable or cap persistent diagnostic logging;
  4. bounds retention and WAL/checkpoint write amplification.

All measurements above were collected read-only; no VACUUM, checkpoint, deletion, or database rebuild was performed during verification.

bbingz · 1 month ago

Analysis (community)

Thanks for tracking this — attempting a residual-focused analysis aligned with docs/contributing.md (invitation-only: analysis in-thread, no unsolicited PR).

  • Issue under review: #17320 — Excessive SQLite WAL writes during streaming due to TRACE logs ignoring RUST_LOG
  • Theme / radar cluster: TRACE logs ignore level / SQLite sink residual (logs_2.sqlite SSD)
  • Reporter posture: static + local evidence where available; not claiming a maintainer invite.

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 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 wr

Local 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.sqlite size=1778548736 mtime=Fri Jul 24 23:09:38 2026
  • /Users/bing/.codex/logs_2.sqlite-wal size=30405632 mtime=Fri Jul 24 23:10:04 2026
  • codex version: codex-cli 0.146.0-alpha.6

level counts (ro)

TRACE|94794
DEBUG|21528
INFO|19618
WARN|796
ERROR|370

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)

  1. Feedback/log pipeline still admits TRACE (or default level too verbose) into logs_2.sqlite.
  2. WAL/checkpoint policy does not cap growth under idle or streaming load.
  3. Partial cut of TRACE paths may leave high-volume targets writing.

High-level fix outline (not a PR)

  • Default sink level: never store TRACE in SQLite unless explicitly enabled.
  • Honor RUST_LOG / app log level end-to-end into the SQLite writer.
  • Hard caps: max DB size / WAL size / rotation with user-visible warning.
  • Fail-first tests: write-rate budget under idle + streaming fixtures.

Tests

  • Unit: writer rejects TRACE when configured level is INFO.
  • Integration: after N seconds idle with default config, row growth rate < bound.

Questions for maintainers

  1. Is the intended long-term policy documented for this area (especially sandbox gitdir / log TRACE defaults)?
  2. Preferred residual anchor issue if several open threads describe the same mechanism?
  3. If the high-level outline above is roughly aligned, would an invited PR with failing-first tests be welcome later — or is an internal fix preferred?

Happy to refine with more measurements or code pointers. No unsolicited PR from me.

bbingz · 1 month ago

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).
  • Same file LogDbLayer::on_event still constructs LogEntry and try_sends every accepted event into the DB inserter; only special-cases target=="log" and low-level opentelemetry_sdk TRACE/DEBUG drops.
  • DB filename constant: codex-rs/state/src/sqlite.rs LOGS_DB_FILENAME = "logs_2.sqlite".
  • Insert path: codex-rs/state/src/runtime/logs.rs (INSERT INTO logs ... feedback_log_body ...).
  • Unit test documents TRACE retention for non-filtered targets: codex-rs/state/src/log_db_filter_tests.rs keeps ("TRACE", "codex_state", Some("retained-trace")).

Runtime on this host (2026-07-24):

  • codex-cli 0.146.0-alpha.6
  • ~/.codex/logs_2.sqlite size 1778548736 bytes; WAL 30405632 bytes
  • Level histogram:
err:Command '['sqlite3', 'file:/Users/bing/.codex/logs_2.sqlite?mode=ro', "SELECT target||'|'||COUNT(*) FROM logs WHERE level='TRACE' GROUP BY target ORDER BY 2 DESC LIMIT 8;"]' returned non-zero exit status 1.
  • Top TRACE targets:

Notably codex_api::sse::responses and 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)

  1. Change default_filter() default from TRACE → INFO (or DEBUG) for SQLite sink only.
  2. Keep explicit opt-in for TRACE via env/config.
  3. Hard-cap WAL / rotate logs_2.sqlite.
  4. Fail-first: extend log_db_filter_tests so default filter does not retain generic TRACE targets.

Questions for maintainers

  1. Is TRACE-in-SQLite still intentional for feedback packaging?
  2. Preferred residual anchor among #17320 / #28997 / #29532?
  3. If outline aligns, would an invited PR with failing-first tests be welcome?

Happy to refine. No unsolicited PR from me.

Nicolas0315 · 1 month ago

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, default sqlite_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 GetProcessIoCounters against 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() is Targets::new().with_default(LevelFilter::TRACE) plus a per-target denylist, and it is the only filter on the sink. RUST_LOG reaches just the stderr layer (EnvFilter::from_default_env() in app-server/src/lib.rs), which is why setting RUST_LOG=warn has 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, then codex_api::responses_websocket_timing). #34291 and #35308 suggest that is not converging, and codex_api::sse::responses — the largest producer in my sample — is still unfiltered on main.

Outline of a fix

A single opt-in environment variable, CODEX_SQLITE_LOG, parsed with the existing Targets directive syntax and defaulting to today's behaviour when unset. CODEX_SQLITE_LOG=warn removes the churn while keeping WARN/ERROR available for /feedback.

Two details that are easy to get wrong:

  1. Do not reuse RUST_LOG. Targets derives no default level from a value that has no bare level, so RUST_LOG=codex_core=debug — and EnvFilter-valid span syntax such as [my_span]=debug, which Targets reinterprets 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.
  2. Apply the noisy-target entries as ceilings, not overrides. Folding them on unconditionally re-raises rmcp::service to INFO even when the user asked for warn. Taking min(requested, ceiling) avoids that; note LevelFilter orders by verbosity (OFF < ERROR < WARN < INFO < DEBUG < TRACE), so min yields 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_db passes with the environment clean, with RUST_LOG=warn set, and with CODEX_SQLITE_LOG=warn set; cargo fmt --check and cargo clippy -p codex-state --all-targets are clean. I will not open a PR unless invited.

A warning about a workaround circulating for this

Applying a SQLite trigger to logs_2.sqlite to drop inserts is not safe. codex-rs/state/logs_migrations/0002_logs_feedback_log_body.sql rebuilds 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.

Nicolas0315 · 1 month ago

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
  • Do not derive a rate from 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) over MAX(ts) - MIN(ts). id is contiguous, so that yields the actual number of inserts during the retained window.
  • On macOS, os.path.getctime is the inode change time, not birth time, so it always equals mtime. Use stat -f %B.
  • PRAGMA synchronous is per-connection and is not persisted in the file, so reading it from your own connection tells you nothing about what Codex uses. Codex sets SqliteSynchronous::Normal in codex-rs/state/src/sqlite.rs. I re-ran the write-amplification benchmark under NORMAL and got an identical 3,787 bytes per attempted insert — synchronous changes 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 on logs, and no user-defined triggers.

The per-target denylist is not converging, and the fleet shows it directly

v0.145.0 added hyper_util to default_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_util entry did exactly what it was meant to do. codex_api::sse::responses then immediately became the largest single producer at 48.2%, and that target is still unfiltered on main. 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 codex processes — 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.

Nicolas0315 · 1 month ago

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-server built from a patched tree, driven over JSON-RPC/stdio through real model turns, 6 turns per arm, each arm with its own isolated CODEX_SQLITE_HOME. Write volume measured per turn by polling the WAL-index header in each arm's own logs_2.sqlite-shm, so frames × 4120 B is exactly what was appended to that arm's WAL with no contamination from state_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 the logs table'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 /feedback and crash reports can include. That is true of per-event detail and false of history. LOG_PARTITION_ROW_LIMIT is 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 zero ERROR rows. The WARN-and-above arm retained all of its rows, including 6 ERROR. 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:

  • On this build the default Responses-over-WebSocket transport was rejected by the backend with 400 Bad Request on wss://chatgpt.com/backend-api/codex/responses and every turn aborted. Feature::ResponsesWebsockets is Stage::Removed, so there is no flag left to disable it; I had to add a [model_providers.*] entry cloning the ChatGPT provider with supports_websockets = false to force the HTTP SSE path — which is also the path that emits codex_api::sse::responses. Possibly specific to my account or build, but it made the default path unmeasurable.
  • Turns intermittently failed with 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.

Nicolas0315 · 1 month ago

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 ERROR rows while the WARN-and-above arm retained all of its rows including 6 ERROR, 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 no ERROR rows to lose — and a second session under the default filter did retain 4 ERROR rows. A reviewer of my own working notes caught this.

The eviction mechanism itself does not depend on that pair. LOG_PARTITION_ROW_LIMIT is 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 /feedback is 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.

Nicolas0315 · 1 month ago

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-server against 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, and LOG_BATCH_SIZE = 128 forces 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:

  • WAL-frame accounting on an isolated logs_2.sqlite gives 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.
  • Whole-process I/O counters over those same sessions give 3,804 and 4,325 B. That is an upper bound — the counter also carries state_5.sqlite, memories_1.sqlite, goals_1.sqlite and the session files. In one run state_5.sqlite's WAL alone grew 2.4 MB.
  • The 3,787 B synthetic figure was an I/O-counter measurement against a database holding nothing but the logs table, so it is the cleanest of the three upper bounds.

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; 0002 rebuilds the table with four, dropping idx_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/day column 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 survived LOG_PARTITION_ROW_LIMIT in the quietest partition.

I set out to replace it with sqlite_sequence.seq / file age, which looks sound because 0001 creates the table empty and 0002 copies 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 of logs_2.sqlite, which turn into a snapshot series. Differencing MAX(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 still id = 1.

My survival column (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.sqlite is written concurrently by several codex binaries. Joining logs.thread_id to threads.cli_version in state_5.sqlite shows 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 whatever codex resolves to on PATH. A table row cannot carry one version.

The hyper_util finding survives as a mechanism rather than a per-machine fact: ("hyper_util", LevelFilter::WARN) is absent from default_filter() at rust-v0.144.6 and present at rust-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::responses share 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_LOG can only govern inserts from processes running a build that has it. One of these files is still receiving target='log' rows that the unconditional on_event guard has excluded since #29599, so a co-resident writer predates or bypasses it.

Unchanged: the synchronous finding, and the migration-0002 trigger behaviour, which I have since read line by line rather than inferred.

btglr · 1 month ago

Jesus please turn off your agent, what you're doing is useless.

Nicolas0315 · 1 month ago

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, warn removes ~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 logs is silently dropped. Executed now against a throwaway database carrying three kinds of user object, with 0001 and 0002 fetched verbatim from main:

  • 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.
  • A cross-table trigger (AFTER INSERT ON logs writing to another table) — also gone.
  • A view over logs — survives in sqlite_master but is permanently broken: SELECT against it returns no such table: main.logs_old. ALTER TABLE logs RENAME TO logs_old rewrote the view's body, and DROP TABLE logs_old left 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: 0002 drops idx_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-1291 and codex-rs/app-server/src/lib.rs:665-668 construct the layer and its filter with identical code, and none of the other layers in either stack writes to logs_2.sqlite, so the filter behaviour is shared by construction. But every end-to-end byte measurement I have posted drove codex-app-server on 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.

rullerzhou-afk · 18 days ago

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
  • Windows x64
  • codex-cli 0.147.0, installed via npm
  • Database: %USERPROFILE%\.codex\logs_2.sqlite
  • No explicit TRACE, RUST_LOG, log_dir, sqlite_home, or similar logging override was present in config.toml or the relevant environment variables
  • All measurements below were read-only; I did not run VACUUM, checkpoint commands, or modify the database
Current database state
  • logs_2.sqlite: 803,803,136 bytes (766.6 MiB)
  • logs_2.sqlite-wal: approximately 6.0 MiB
  • SQLite page size: 4,096 bytes
  • Total pages: 196,241
  • Freelist pages: 153,304 — approximately 78.1% of the database
  • Estimated live/non-free portion: approximately 167.7 MiB
  • Oldest retained log record: 2026-07-30

This 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 -shm WAL-index headers.

During an active 45.0-second sample:

  • 325 new log rows
  • 229 TRACE rows
  • 1,122 WAL frames
  • 25 commits
  • 1 WAL reset
  • Approximate WAL bytes appended: 4.62 MB
  • Upper-bound WAL plus checkpoint writes: 9.22 MB

After allowing activity to cool for 10 seconds, a second 30.0-second sample showed:

  • 99 new log rows
  • 404 WAL frames
  • 10 commits
  • Approximate WAL bytes appended: 1.66 MB
  • Upper-bound WAL plus checkpoint writes: 3.32 MB

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:

  1. Stop persisting routine high-frequency TRACE events to SQLite by default.
  2. Demote or summarize high-fanout first-party targets such as:
  • codex_app_server::outgoing_message
  • codex_tui::markdown_stream
  • codex_api::sse::responses
  1. Provide a documented setting to disable the SQLite log sink or independently configure its minimum level.
  2. Reclaim or compact free pages after retention pruning, so the database does not remain mostly freelist space.

This 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.

bbingz · 18 days ago

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.

Aryan4443 · 17 days ago

Root cause: SQLite sink ignores RUST_LOG

Confirmed this still matches current main (ee7815dad2) from the logging wiring (not claiming a fresh disk-churn measurement in this comment).

RUST_LOG is respected by the stderr/file logging layers, but not by the SQLite log sink:

  • app-server stderr logging uses EnvFilter::from_default_env(), so RUST_LOG applies there: codex-rs/app-server/src/lib.rs:648-662
  • TUI file logging also uses EnvFilter::try_from_default_env(): codex-rs/tui/src/lib.rs:1189-1200
  • The SQLite sink is wired separately in app-server and TUI:
  • codex-rs/app-server/src/lib.rs:667-670
  • codex-rs/tui/src/lib.rs:1229-1232
  • That SQLite layer uses log_db::default_filter(), whose default is still LevelFilter::TRACE: codex-rs/state/src/log_db.rs:53-68

So RUST_LOG=warn can suppress stderr/file output while SQLite still persists TRACE rows allowed by log_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 / EnvFilter to the SQLite layer as well, while preserving the existing log_db::default_filter() denylist. SQLite persistence should be gated by both:

  1. user/process verbosity (RUST_LOG, with the same fallback semantics as the surrounding binary), and
  2. the SQLite-specific denylist/demotions in log_db::default_filter().

Test plan:

  • Add/extend codex-state coverage showing log_db::default_filter() still drops/demotes the existing noisy targets.
  • Add coverage for the app-server/TUI SQLite layer filter composition: with RUST_LOG=warn, TRACE/DEBUG events should not reach logs_2.sqlite; WARN/ERROR should.
  • Add a regression case proving explicit RUST_LOG=trace can still retain TRACE except for targets blocked by the SQLite denylist.
  • Run 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.

Excelius-Wang · 16 days ago

Ran into the same TRACE/SQLite churn and independently traced it to the same root cause @Aryan4443 described above: log_db::default_filter() hardcodes LevelFilter::TRACE (codex-rs/state/src/log_db.rs), so the SQLite sink never honors RUST_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 an EnvFilter derived from RUST_LOG via FilterExt::and, falling back to trace when RUST_LOG is unset so /feedback keeps its historical, denylist-limited default:

diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs
index cdaf1ba240..c30f7d13f3 100644
--- a/codex-rs/app-server/src/lib.rs
+++ b/codex-rs/app-server/src/lib.rs
@@ -76,6 +76,7 @@ use tracing::info;
 use tracing::warn;
 use tracing_subscriber::EnvFilter;
 use tracing_subscriber::Layer;
+use tracing_subscriber::filter::FilterExt;
 use tracing_subscriber::layer::SubscriberExt;
 use tracing_subscriber::registry::Registry;
 use tracing_subscriber::util::SubscriberInitExt;
@@ -665,9 +666,17 @@ pub async fn run_main_with_transport_options(
     let feedback_layer = feedback.logger_layer();
     let feedback_metadata_layer = feedback.metadata_layer();
     let log_db = state_db.clone().map(log_db::start);
-    let log_db_layer = log_db
-        .clone()
-        .map(|layer| layer.with_filter(log_db::default_filter()));
+    // `RUST_LOG` already governs the stderr layer above; apply it to the SQLite
+    // log sink too so lowering verbosity (e.g. `RUST_LOG=warn`) also stops
+    // high-volume TRACE/DEBUG rows from being persisted to `logs_2.sqlite`. When
+    // `RUST_LOG` is unset, fall back to `trace` so `/feedback` keeps its
+    // historical, denylist-limited default.
+    let log_db_layer = log_db.clone().map(|layer| {
+        layer.with_filter(
+            log_db::default_filter()
+                .and(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("trace"))),
+        )
+    });
     let (otel_layers, otel_logger_reload_handle) = otel_reloader::layers(otel.as_ref());
     let _ = tracing_subscriber::registry()
         .with(stderr_fmt)
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index c79e6add72..509f74be0a 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -82,6 +82,7 @@ use tracing::error;
 use tracing::warn;
 use tracing_appender::non_blocking;
 use tracing_subscriber::EnvFilter;
+use tracing_subscriber::filter::FilterExt;
 use tracing_subscriber::prelude::*;
 use url::Url;
 use uuid::Uuid;
@@ -1227,9 +1228,17 @@ pub async fn run_main(
     let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer());
 
     let log_db = state_db.clone().map(log_db::start);
-    let log_db_layer = log_db
-        .clone()
-        .map(|layer| layer.with_filter(log_db::default_filter()));
+    // `RUST_LOG` already governs the file layer above; apply it to the SQLite
+    // log sink too so lowering verbosity (e.g. `RUST_LOG=warn`) also stops
+    // high-volume TRACE/DEBUG rows from being persisted to `logs_2.sqlite`. When
+    // `RUST_LOG` is unset, fall back to `trace` so `/feedback` keeps its
+    // historical, denylist-limited default.
+    let log_db_layer = log_db.clone().map(|layer| {
+        layer.with_filter(
+            log_db::default_filter()
+                .and(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("trace"))),
+        )
+    });
 
     let _ = tracing_subscriber::registry()
         .with(tui_file_layer)

Verified:

  • cargo check -p codex-app-server -p codex-tui
  • just fmt
  • just test -p codex-app-server -p codex-tui (green aside from pre-existing, unrelated failures caused by a missing codex-code-mode-host binary / mock-server flakiness in my sandbox)
  • just fix -p codex-app-server / just fix -p codex-tui

Full 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_policy is collaborators_only). Posting here so @Aryan4443 or a maintainer can cherry-pick/open the PR — happy to add the suggested codex-state/app-server/TUI filter-composition tests too if that's useful.

ang-XWBWZ · 16 days ago

Additional reproduction on Linux Desktop Preview with codex-cli 0.147.0

I 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

  • Product: Codex Desktop Preview for Linux
  • Bundled/local codex --version: codex-cli 0.147.0
  • CODEX_HOME: default user Codex directory (~/.codex)
  • Database: ~/.codex/logs_2.sqlite
  • SQLite journal mode: wal
  • No log_dir or sqlite_home override was configured

Reproduction evidence

Before applying the workaround, a 15-second read-only sample showed:

                    t0       t15
retained rows       43603    43639
max(id)             796162   796340
sqlite_sequence     796162   796340

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:

TRACE  30162
DEBUG   7781
INFO    5768
WARN     201
ERROR     55

The main database was about 52.7 MiB and logs_2.sqlite-wal was about 4.3 MiB. The dominant targets included:

codex_app_server::outgoing_message
codex_tui::markdown_stream
codex_api::sse::responses
codex_core::stream_events_utils

Workaround validation

While the Linux Desktop Preview session was still running, I created this temporary SQLite trigger:

CREATE TRIGGER block_non_error_log_inserts
BEFORE INSERT ON logs
WHEN coalesce(upper(NEW.level), '') <> 'ERROR'
BEGIN
  SELECT RAISE(IGNORE);
END;

No restart was required. In the following 15-second sample:

retained rows       43967 -> 43967
max(id)             812183 -> 812183
sqlite_sequence     812183 -> 812183
TRACE               30162 -> 30162
DEBUG                7781 -> 7781
INFO                 5768 -> 5768
WARN                  201 -> 201
ERROR                  55 -> 55

The logs_2.sqlite-wal size 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 to logs_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=warn and the SQLite sink would help prevent this from recurring.

No database files, raw log rows, configuration files, or user data are attached.

Master0fFate · 12 days ago

OK BRO they got it holy shi you dont have to paste your AI bug-tracing anymore.

naipi11 · 10 days ago

The multi-MB/s writes to logs_2.sqlite-wal during streaming come from the SQLite log sink, which uses its own TRACE default and does not honor RUST_LOG.

Method:

  1. Fully quit Codex Desktop / the app-server so the DB files are not held open.
  2. Move aside ~/.codex/logs_2.sqlite, logs_2.sqlite-wal, and logs_2.sqlite-shm (same three names under CODEX_HOME if you relocated it).
  3. Restart. The files are recreated much smaller.
  4. Treat this as periodic cleanup, not a permanent fix. RUST_LOG=warn only 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 ~840MB logs_2.sqlite plus a live WAL while the app is running.

Independent community workaround; not an official OpenAI fix.