def logwarning(event: str, fields: object) -> None:
The solution is written to ~/structlog-module-proxy-fix.md. I reproduced the bug and verified both the RED and GREEN states against structlog==26.1.0 / Python 3.11, including a regression test that fails when the helper is reverted.
structlog.get_logger(name) returns a lazy proxy. With cache_logger_on_first_use=True, the first emission freezes two things into it:
PrintLogger pointing at the then-live stream (stdout vs stderr), andA later configure_logging(force_stderr=True) installs a fresh processor list and a new factory, but the proxy's self.bind has already been replaced by finalized_bind (structlog/_config.py) and never consults the globals again. Because capture_logs() mutates the current global list in place (to keep bound loggers working), the frozen proxy is attached to the old list and never reaches LogCapture → logs == [].
Resolve the logger per emission instead of holding a module proxy:
# src/chimera/config.py
_LOGGER_NAME = "chimera.config"
def _log_warning(event: str, **fields: object) -> None:
structlog.get_logger(_LOGGER_NAME).warning(event, **fields)
Replace log.warning(...) call sites, drop the module-level log = ..., and audit siblings with:
rg -n '^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*structlog\.(get_logger|getLogger)\(' \
src/chimera --glob '!**/observability.py'
reset_defaults() is explicitly rejected — it does not un-freeze a cached proxy, and inside capture_logs() it replaces the list being mutated (verified: prints to output, captured == []).
| mode | STDOUT | STDERR | captured |
|---|---|---|---|
proxy (pre-fix) |
category_scale_normalized |
(empty) | [] |
helper (fixed) |
(empty) | category_scale_normalized |
[category_scale_normalized] |
Regression test: 2 passed; reverting just the helper → 1 failed, 1 passed; restore → 2 passed.
The markdown is self-contained: it includes the exact patches, the audit commands, the pytest regression test, the revert-proof commands, and an appendix with the three-file repro harness (all verified). Note the real repo (~/<project>) is not present in this environment, so the chimera-relative file snippets are given as directly-applicable patches rather than applied in-place.
# Evidence - Problem class: structlog-module-proxy-ignores-logging-repin - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-18T11:17:20.044Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Problem: a structlog warning emitted from library code lands on the WRONG stream, and structlog.testing.capture_logs() returns an empty list, when the same process has already materialised a module-level logger BEFORE observability.configure_logging() re-pins the sink.\n\nSignature (measured on chimera, Python 3.11, structlog via observability.configure_logging):\n - A module-level `log = structlog.get_logger('chimera.config')` binds ONCE: the first emission captures the then-live PrintLoggerFactory stream and the then-live processor list. Every later `configure_logging(..., force_stderr=True)` sets cache_logger_on_first_use=True with a FRESH processor list, and the already-bound proxy ignores all of it.\n - Consequence A: in a CLI that pins logs to stderr before loading config (so `--quiet`/`--json` stdout is byte-exactly the answer), an in-process caller (API server, embedding app, earlier test) that touched the module first leaves the proxy writing to the OLD stream - the warning lands on machine-mode STDOUT, breaking the stdout wire protocol.\n - Consequence B: `with structlog.testing.capture_logs() as logs:` observes nothing (logs == []) because capture_logs swaps the live processor list, which the bound proxy no longer consults. Tests asserting captured warnings pass in isolation and fail only in a full-suite run.\nRED/GREEN control that proves it (same code, one helper differs):\n pre-fix (module proxy) STDOUT ['category_scale_normalized' x3] STDERR [] CAPTURED []\n fixed (per-emission) STDOUT ['category_scale_normalized' x1] STDERR [x1] CAPTURED [x1]\n\nFix recipe:\n 1. Do NOT hold the logger in a module-level proxy in code paths whose stream is pinned later. Emit through a tiny helper that resolves the logger per emission:\n def _log_warning(event: str, **fields) -> None:\n structlog.get_logger(_LOGGER_NAME).warning(event, **fields)\n A freshly resolved logger binds against the configuration in force at emission time, so it honours a later force_stderr pin and is visible to capture_logs().\n 2. Do NOT 'fix' it with structlog.reset_defaults() inside the test: that clears the shared processor list in place and breaks capture_logs() itself (makes the assertion weaker, not stronger).\n 3. Keep a regression test that REVERTS just the helper and shows the RED state (stdout emission + empty capture), then the GREEN state - otherwise the class silently returns.\n 4. Audit the sibling modules: any module-level structlog proxy in a process that re-pins sinks (gateway/cli/api) has the same latent defect; measured on chimera: `pytest tests/test_cli.py tests/test_gateway.py` -> 2 failed from this class, reversed order -> 77 passed.\n\nEnvironment: chimera (chimera-deliberation), src/chimera/observability.py (configure_logging with force_stderr + cache_logger_on_first_use=True), src/chimera/config.py, src/chimera/cli/main.py.\nStatus: verified locally post-debug on 2026-09-18 (tick <project>-2026-09-18-09-55-48); the production fix is committed, the sibling-module audit is still open.", "environment": "", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "structlog-module-proxy-ignores-logging-repin", "provider": "openrouter", "solved_at": "2026-09-18T11:17:20.044Z", "version": ""}The solution is written to ~/structlog-module-proxy-fix.md. I reproduced the bug and verified both the RED and GREEN states against structlog==26.1.0 / Python 3.11, including a regression test that fails when the helper is reverted.
structlog.get_logger(name) returns a lazy proxy. With cache_logger_on_first_use=True, the first emission freezes two things into it:
PrintLogger pointing at the then-live stream (stdout vs stderr), andA later configure_logging(force_stderr=True) installs a fresh processor list and a new factory, but the proxy's self.bind has already been replaced by finalized_bind (structlog/_config.py) and never consults the globals again. Because capture_logs() mutates the current global list in place (to keep bound loggers working), the frozen proxy is attached to the old list and never reaches LogCapture → logs == [].
Resolve the logger per emission instead of holding a module proxy:
# src/chimera/config.py
_LOGGER_NAME = "chimera.config"
def _log_warning(event: str, **fields: object) -> None:
structlog.get_logger(_LOGGER_NAME).warning(event, **fields)
Replace log.warning(...) call sites, drop the module-level log = ..., and audit siblings with:
rg -n '^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*structlog\.(get_logger|getLogger)\(' \
src/chimera --glob '!**/observability.py'
reset_defaults() is explicitly rejected — it does not un-freeze a cached proxy, and inside capture_logs() it replaces the list being mutated (verified: prints to output, captured == []).
| mode | STDOUT | STDERR | captured |
|---|---|---|---|
proxy (pre-fix) |
category_scale_normalized |
(empty) | [] |
helper (fixed) |
(empty) | category_scale_normalized |
[category_scale_normalized] |
Regression test: 2 passed; reverting just the helper → 1 failed, 1 passed; restore → 2 passed.
The markdown is self-contained: it includes the exact patches, the audit commands, the pytest regression test, the revert-proof commands, and an appendix with the three-file repro harness (all verified). Note the real repo (~/<project>) is not present in this environment, so the chimera-relative file snippets are given as directly-applicable patches rather than applied in-place.
# Evidence - Problem class: structlog-module-proxy-ignores-logging-repin - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-18T11:17:20.044Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Problem: a structlog warning emitted from library code lands on the WRONG stream, and structlog.testing.capture_logs() returns an empty list, when the same process has already materialised a module-level logger BEFORE observability.configure_logging() re-pins the sink.\n\nSignature (measured on chimera, Python 3.11, structlog via observability.configure_logging):\n - A module-level `log = structlog.get_logger('chimera.config')` binds ONCE: the first emission captures the then-live PrintLoggerFactory stream and the then-live processor list. Every later `configure_logging(..., force_stderr=True)` sets cache_logger_on_first_use=True with a FRESH processor list, and the already-bound proxy ignores all of it.\n - Consequence A: in a CLI that pins logs to stderr before loading config (so `--quiet`/`--json` stdout is byte-exactly the answer), an in-process caller (API server, embedding app, earlier test) that touched the module first leaves the proxy writing to the OLD stream - the warning lands on machine-mode STDOUT, breaking the stdout wire protocol.\n - Consequence B: `with structlog.testing.capture_logs() as logs:` observes nothing (logs == []) because capture_logs swaps the live processor list, which the bound proxy no longer consults. Tests asserting captured warnings pass in isolation and fail only in a full-suite run.\nRED/GREEN control that proves it (same code, one helper differs):\n pre-fix (module proxy) STDOUT ['category_scale_normalized' x3] STDERR [] CAPTURED []\n fixed (per-emission) STDOUT ['category_scale_normalized' x1] STDERR [x1] CAPTURED [x1]\n\nFix recipe:\n 1. Do NOT hold the logger in a module-level proxy in code paths whose stream is pinned later. Emit through a tiny helper that resolves the logger per emission:\n def _log_warning(event: str, **fields) -> None:\n structlog.get_logger(_LOGGER_NAME).warning(event, **fields)\n A freshly resolved logger binds against the configuration in force at emission time, so it honours a later force_stderr pin and is visible to capture_logs().\n 2. Do NOT 'fix' it with structlog.reset_defaults() inside the test: that clears the shared processor list in place and breaks capture_logs() itself (makes the assertion weaker, not stronger).\n 3. Keep a regression test that REVERTS just the helper and shows the RED state (stdout emission + empty capture), then the GREEN state - otherwise the class silently returns.\n 4. Audit the sibling modules: any module-level structlog proxy in a process that re-pins sinks (gateway/cli/api) has the same latent defect; measured on chimera: `pytest tests/test_cli.py tests/test_gateway.py` -> 2 failed from this class, reversed order -> 77 passed.\n\nEnvironment: chimera (chimera-deliberation), src/chimera/observability.py (configure_logging with force_stderr + cache_logger_on_first_use=True), src/chimera/config.py, src/chimera/cli/main.py.\nStatus: verified locally post-debug on 2026-09-18 (tick <project>-2026-09-18-09-55-48); the production fix is committed, the sibling-module audit is still open.", "environment": "", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "structlog-module-proxy-ignores-logging-repin", "provider": "openrouter", "solved_at": "2026-09-18T11:17:20.044Z", "version": ""}