Problem class: python-cli-structlog-stdout-pollution
Solution written to ~/chimera-stdout-pollution-fix.md.
Problem class: python-cli-structlog-stdout-pollution
Component: src/chimera/cli/main.py (config-loading funnel _load_cfg) + configure_logging
Reference: DF-CHIMERA-0906-2 — mirrors src/chimera/mcp/server.py
$ chimera providers list
2026-09-11 12:29:07 [info ] provider_cache_hit provider=alpha
2026-09-11 12:29:07 [info ] provider_fetch_ok provider=beta
2026-09-11 12:29:07 [info ] provider_discovery_done count=2
{"providers": ["alpha", "beta"]}
provider_cache_hit, provider_fetch_ok, provider_discovery_done leak onto stdout, breaking chimera ... | jq and any stdout wire protocol.
src/chimera/cli/main.py never calls configure_logging in _load_cfg.PrintLoggerFactory(file=None) resolves to sys.stdout.load_config(), before any Config/cfg.observability exists, so it hits the default stdout sink.configure_logging call ever redirects logging, so post-config logs stay on stdout too.It is not a YAML use_stdout=true problem — logging simply was never configured when discovery ran. mcp/server.py already avoids this.
force_stderr in configure_logging-def configure_logging(observability: Observability) -> None:
- stream = sys.stdout if observability.use_stdout else sys.stderr
+def configure_logging(
+ observability: Observability,
+ *,
+ force_stderr: bool = False,
+) -> None:
+ # force_stderr is structural: it wins over observability.use_stdout so the
+ # CLI's stdout stays a clean wire protocol even if YAML opts into stdout.
+ use_stdout = False if force_stderr else observability.use_stdout
+ stream = sys.stdout if use_stdout else sys.stderr
structlog.configure(
processors=[...],
wrapper_class=...,
logger_factory=structlog.PrintLoggerFactory(file=stream),
- cache_logger_on_first_use=True,
+ cache_logger_on_first_use=False,
)
_load_cfg# src/chimera/cli/main.py
from chimera.observability import Observability, configure_logging
def _load_cfg(*args, **kwargs) -> Config:
# Phase 1 — provider auto-discovery logs from inside load_config, before
# any Config exists; structlog's default sink is stdout, so pin stderr now.
configure_logging(Observability(use_stdout=False), force_stderr=True)
cfg = load_config(*args, **kwargs)
# Phase 2 — adopt user config, but keep stderr structurally pinned.
configure_logging(cfg.observability, force_stderr=True)
return cfg
Unified diff:
@@
+from chimera.observability import Observability, configure_logging
@@
def _load_cfg(*args, **kwargs) -> Config:
+ # Phase 1: catches pre-config discovery logs.
+ configure_logging(Observability(use_stdout=False), force_stderr=True)
+
cfg = load_config(*args, **kwargs)
+
+ # Phase 2: force_stderr overrides observability.use_stdout=true.
+ configure_logging(cfg.observability, force_stderr=True)
return cfg
If load_config raises, phase 1 still keeps tracebacks off stdout.
Do not use click.testing.CliRunner(mix_stderr=...) — removed in click 9.x, raises TypeError. Use a real subprocess so fd1/fd2 are split.
tests/cli/test_cli_stdout_purity.py:
import json
import os
import re
import subprocess
import sys
POLLUTION = re.compile(r"provider_(cache_hit|fetch_ok|discovery_done)")
def _run_cli(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-m", "chimera.cli.main", *args],
capture_output=True, text=True, env=dict(os.environ),
)
def test_provider_discovery_logs_go_to_stderr_not_stdout():
proc = _run_cli("providers", "list")
assert not POLLUTION.search(proc.stdout), proc.stdout
assert POLLUTION.search(proc.stderr), proc.stderr
def test_stdout_is_machine_parseable_json():
proc = _run_cli("providers", "list")
json.loads(proc.stdout.strip())
chimera providers list >/tmp/out 2>/tmp/err
! grep -qE 'provider_cache_hit|provider_fetch_ok|provider_discovery_done' /tmp/out
grep -qE 'provider_cache_hit|provider_fetch_ok|provider_discovery_done' /tmp/err
jq . /tmp/out >/dev/null && echo "stdout is clean JSON"
Rerun with observability.use_stdout: true in YAML — assertions must still hold.
pytest -q tests/cli/test_cli_stdout_purity.py
git log --oneline -- src/chimera/cli/main.py
FIX_COMMIT=<sha-of-fix>
git checkout "$FIX_COMMIT^" -- src/chimera/cli/main.py
pytest -q tests/cli/test_cli_stdout_purity.py # EXPECT: FAIL
git checkout "$FIX_COMMIT" -- src/chimera/cli/main.py
pytest -q tests/cli/test_cli_stdout_purity.py # EXPECT: PASS
I built a minimal app mirroring the architecture (Observability(use_stdout), configure_logging(obs, *, force_stderr=False), discovery inside load_config, click command) and ran split-fd probes.
Pre-fix (no configure_logging) — stdout polluted:
--- STDOUT ---
2026-09-11 [info] provider_cache_hit provider=alpha
2026-09-11 [info] provider_fetch_ok provider=beta
2026-09-11 [info] provider_discovery_done count=2
{"providers": ["alpha", "beta"]}
--- STDERR ---
(empty)
Post-fix, with adversarial cfg.observability.use_stdout=True:
--- STDOUT ---
{"providers": ["alpha", "beta"]}
--- STDERR ---
{"provider": "alpha", "event": "provider_cache_hit", "level": "info"}
{"provider": "beta", "event": "provider_fetch_ok", "level": "info"}
{"count": 2, "event": "provider_discovery_done", "level": "info"}
Pytest run against both versions:
pre-fix module -> 2 failed
fixed module -> 2 passed
This confirms (a) unconfigured structlog defaults to stdout, (b) discovery inside load_config is the pre-config leak, and (c) force_stderr overrides use_stdout=true.
configure_logging accepts keyword-only force_stderr._load_cfg pins force_stderr=True before load_config._load_cfg re-pins cfg.observability, force_stderr=True after load.subprocess, not CliRunner(mix_stderr=...).use_stdout: true cannot defeat stdout purity.# Evidence - Problem class: python-cli-structlog-stdout-pollution - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-11T17:29:42.627Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "chimera CLI leaked structlog provider-discovery lines (provider_cache_hit/provider_fetch_ok/provider_discovery_done) onto stdout because src/chimera/cli/main.py never called configure_logging \u2014 structlog unconfigured default writes to stdout, and provider auto-discovery runs INSIDE load_config before any config exists. Fix: two-phase pin in the config-loading funnel (_load_cfg): configure_logging(Observability(use_stdout=False), force_stderr=True) BEFORE load_config (catches pre-config logs), then configure_logging(cfg.observability, force_stderr=True) after a successful load. force_stderr is structural \u2014 overrides observability.use_stdout=true in yaml. Mirrors the mcp/server.py pattern (DF-CHIMERA-0906-2). Verify with subprocess split probes (chimera ... >/tmp/out 2>/tmp/err); click 9.x CliRunner(mix_stderr=...) raises TypeError so use subprocess. Mutation-check the regression tests: restore the pre-fix module (git checkout <commit>^ -- src/chimera/cli/main.py) and confirm they fail.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-cli-structlog-stdout-pollution", "provider": "openrouter", "solved_at": "2026-09-11T17:29:42.628Z", "version": ""}Solution written to ~/chimera-stdout-pollution-fix.md.
Problem class: python-cli-structlog-stdout-pollution
Component: src/chimera/cli/main.py (config-loading funnel _load_cfg) + configure_logging
Reference: DF-CHIMERA-0906-2 — mirrors src/chimera/mcp/server.py
$ chimera providers list
2026-09-11 12:29:07 [info ] provider_cache_hit provider=alpha
2026-09-11 12:29:07 [info ] provider_fetch_ok provider=beta
2026-09-11 12:29:07 [info ] provider_discovery_done count=2
{"providers": ["alpha", "beta"]}
provider_cache_hit, provider_fetch_ok, provider_discovery_done leak onto stdout, breaking chimera ... | jq and any stdout wire protocol.
src/chimera/cli/main.py never calls configure_logging in _load_cfg.PrintLoggerFactory(file=None) resolves to sys.stdout.load_config(), before any Config/cfg.observability exists, so it hits the default stdout sink.configure_logging call ever redirects logging, so post-config logs stay on stdout too.It is not a YAML use_stdout=true problem — logging simply was never configured when discovery ran. mcp/server.py already avoids this.
force_stderr in configure_logging-def configure_logging(observability: Observability) -> None:
- stream = sys.stdout if observability.use_stdout else sys.stderr
+def configure_logging(
+ observability: Observability,
+ *,
+ force_stderr: bool = False,
+) -> None:
+ # force_stderr is structural: it wins over observability.use_stdout so the
+ # CLI's stdout stays a clean wire protocol even if YAML opts into stdout.
+ use_stdout = False if force_stderr else observability.use_stdout
+ stream = sys.stdout if use_stdout else sys.stderr
structlog.configure(
processors=[...],
wrapper_class=...,
logger_factory=structlog.PrintLoggerFactory(file=stream),
- cache_logger_on_first_use=True,
+ cache_logger_on_first_use=False,
)
_load_cfg# src/chimera/cli/main.py
from chimera.observability import Observability, configure_logging
def _load_cfg(*args, **kwargs) -> Config:
# Phase 1 — provider auto-discovery logs from inside load_config, before
# any Config exists; structlog's default sink is stdout, so pin stderr now.
configure_logging(Observability(use_stdout=False), force_stderr=True)
cfg = load_config(*args, **kwargs)
# Phase 2 — adopt user config, but keep stderr structurally pinned.
configure_logging(cfg.observability, force_stderr=True)
return cfg
Unified diff:
@@
+from chimera.observability import Observability, configure_logging
@@
def _load_cfg(*args, **kwargs) -> Config:
+ # Phase 1: catches pre-config discovery logs.
+ configure_logging(Observability(use_stdout=False), force_stderr=True)
+
cfg = load_config(*args, **kwargs)
+
+ # Phase 2: force_stderr overrides observability.use_stdout=true.
+ configure_logging(cfg.observability, force_stderr=True)
return cfg
If load_config raises, phase 1 still keeps tracebacks off stdout.
Do not use click.testing.CliRunner(mix_stderr=...) — removed in click 9.x, raises TypeError. Use a real subprocess so fd1/fd2 are split.
tests/cli/test_cli_stdout_purity.py:
import json
import os
import re
import subprocess
import sys
POLLUTION = re.compile(r"provider_(cache_hit|fetch_ok|discovery_done)")
def _run_cli(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-m", "chimera.cli.main", *args],
capture_output=True, text=True, env=dict(os.environ),
)
def test_provider_discovery_logs_go_to_stderr_not_stdout():
proc = _run_cli("providers", "list")
assert not POLLUTION.search(proc.stdout), proc.stdout
assert POLLUTION.search(proc.stderr), proc.stderr
def test_stdout_is_machine_parseable_json():
proc = _run_cli("providers", "list")
json.loads(proc.stdout.strip())
chimera providers list >/tmp/out 2>/tmp/err
! grep -qE 'provider_cache_hit|provider_fetch_ok|provider_discovery_done' /tmp/out
grep -qE 'provider_cache_hit|provider_fetch_ok|provider_discovery_done' /tmp/err
jq . /tmp/out >/dev/null && echo "stdout is clean JSON"
Rerun with observability.use_stdout: true in YAML — assertions must still hold.
pytest -q tests/cli/test_cli_stdout_purity.py
git log --oneline -- src/chimera/cli/main.py
FIX_COMMIT=<sha-of-fix>
git checkout "$FIX_COMMIT^" -- src/chimera/cli/main.py
pytest -q tests/cli/test_cli_stdout_purity.py # EXPECT: FAIL
git checkout "$FIX_COMMIT" -- src/chimera/cli/main.py
pytest -q tests/cli/test_cli_stdout_purity.py # EXPECT: PASS
I built a minimal app mirroring the architecture (Observability(use_stdout), configure_logging(obs, *, force_stderr=False), discovery inside load_config, click command) and ran split-fd probes.
Pre-fix (no configure_logging) — stdout polluted:
--- STDOUT ---
2026-09-11 [info] provider_cache_hit provider=alpha
2026-09-11 [info] provider_fetch_ok provider=beta
2026-09-11 [info] provider_discovery_done count=2
{"providers": ["alpha", "beta"]}
--- STDERR ---
(empty)
Post-fix, with adversarial cfg.observability.use_stdout=True:
--- STDOUT ---
{"providers": ["alpha", "beta"]}
--- STDERR ---
{"provider": "alpha", "event": "provider_cache_hit", "level": "info"}
{"provider": "beta", "event": "provider_fetch_ok", "level": "info"}
{"count": 2, "event": "provider_discovery_done", "level": "info"}
Pytest run against both versions:
pre-fix module -> 2 failed
fixed module -> 2 passed
This confirms (a) unconfigured structlog defaults to stdout, (b) discovery inside load_config is the pre-config leak, and (c) force_stderr overrides use_stdout=true.
configure_logging accepts keyword-only force_stderr._load_cfg pins force_stderr=True before load_config._load_cfg re-pins cfg.observability, force_stderr=True after load.subprocess, not CliRunner(mix_stderr=...).use_stdout: true cannot defeat stdout purity.# Evidence - Problem class: python-cli-structlog-stdout-pollution - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-11T17:29:42.627Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "chimera CLI leaked structlog provider-discovery lines (provider_cache_hit/provider_fetch_ok/provider_discovery_done) onto stdout because src/chimera/cli/main.py never called configure_logging \u2014 structlog unconfigured default writes to stdout, and provider auto-discovery runs INSIDE load_config before any config exists. Fix: two-phase pin in the config-loading funnel (_load_cfg): configure_logging(Observability(use_stdout=False), force_stderr=True) BEFORE load_config (catches pre-config logs), then configure_logging(cfg.observability, force_stderr=True) after a successful load. force_stderr is structural \u2014 overrides observability.use_stdout=true in yaml. Mirrors the mcp/server.py pattern (DF-CHIMERA-0906-2). Verify with subprocess split probes (chimera ... >/tmp/out 2>/tmp/err); click 9.x CliRunner(mix_stderr=...) raises TypeError so use subprocess. Mutation-check the regression tests: restore the pre-fix module (git checkout <commit>^ -- src/chimera/cli/main.py) and confirm they fail.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-cli-structlog-stdout-pollution", "provider": "openrouter", "solved_at": "2026-09-11T17:29:42.628Z", "version": ""}