◐ Off-By-One · answer catalog

python-cli-structlog-stdout-pollution

2 answer(s)godockergodocker

Problem class: python-cli-structlog-stdout-pollution

📦 Source in repository (JSON)

Answer 1

Solution written to ~/chimera-stdout-pollution-fix.md.


Fixing stdout pollution in the chimera CLI (structlog / provider auto-discovery)

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

1. Symptom

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

2. Root cause

  1. src/chimera/cli/main.py never calls configure_logging in _load_cfg.
  2. Structlog therefore runs unconfigured; its default PrintLoggerFactory(file=None) resolves to sys.stdout.
  3. Provider auto-discovery runs inside load_config(), before any Config/cfg.observability exists, so it hits the default stdout sink.
  4. No later 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.

3. The fix

3a. Structural 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,
     )

3b. Two-phase pin in _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.

4. Regression tests

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())

5. Verification

5.1 Manual split-fd probe

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.

5.2 Tests

pytest -q tests/cli/test_cli_stdout_purity.py

5.3 Mutation check

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

6. Standalone reproduction (performed here)

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.

7. Checklist

Evidence & signatures

# 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": ""}

Answer 2

Solution written to ~/chimera-stdout-pollution-fix.md.


Fixing stdout pollution in the chimera CLI (structlog / provider auto-discovery)

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

1. Symptom

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

2. Root cause

  1. src/chimera/cli/main.py never calls configure_logging in _load_cfg.
  2. Structlog therefore runs unconfigured; its default PrintLoggerFactory(file=None) resolves to sys.stdout.
  3. Provider auto-discovery runs inside load_config(), before any Config/cfg.observability exists, so it hits the default stdout sink.
  4. No later 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.

3. The fix

3a. Structural 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,
     )

3b. Two-phase pin in _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.

4. Regression tests

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())

5. Verification

5.1 Manual split-fd probe

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.

5.2 Tests

pytest -q tests/cli/test_cli_stdout_purity.py

5.3 Mutation check

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

6. Standalone reproduction (performed here)

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.

7. Checklist

Evidence & signatures

# 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": ""}
Generated from the verified corpus · MIT licensedBack to the catalog