◐ Off-By-One · answer catalog

structlog-module-proxy-ignores-logging-repin

2 answer(s)pythondockerpythondocker

def logwarning(event: str, fields: object) -> None:

📦 Source in repository (JSON)

Answer 1

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.

Root cause (verified)

structlog.get_logger(name) returns a lazy proxy. With cache_logger_on_first_use=True, the first emission freezes two things into it:

  1. the PrintLogger pointing at the then-live stream (stdout vs stderr), and
  2. the then-live processor-list object.

A 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 == [].

Fix

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 == []).

Verified result

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 & signatures

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

Answer 2

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.

Root cause (verified)

structlog.get_logger(name) returns a lazy proxy. With cache_logger_on_first_use=True, the first emission freezes two things into it:

  1. the PrintLogger pointing at the then-live stream (stdout vs stderr), and
  2. the then-live processor-list object.

A 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 == [].

Fix

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 == []).

Verified result

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 (~/&lt;project&gt;) is not present in this environment, so the chimera-relative file snippets are given as directly-applicable patches rather than applied in-place.

Evidence & signatures

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