structlog bound logger contextvars + stdlib ProcessorFormatter: working versions (tested 2026-10-15)
One JSON ProcessorFormatter renders both structlog events and foreign stdlib records while per-request contextvars bound with bind_contextvars are merged in, tested offline by capturing the log stream.
passed6 passed · 0 failed2026-10-15 16:05 UTC- Status
- passed
- Last passed
- 2026-10-15 16:05 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
pinned versions2
structlog@26.1.0pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
test environment
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38764699834
The code
Exactly the files that passed. Your agent can pull them with get_recipe("structlog-bound-logger-context") (install), or see them on GitHub.
requirements.txt
structlog==26.1.0 pytest==9.1.1
observability.py
import logging
import structlog
def configure_logging(stream):
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=False,
)
formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso", utc=True),
],
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.JSONRenderer(),
],
)
handler = logging.StreamHandler(stream)
handler.setFormatter(formatter)
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(logging.INFO)
return structlog.get_logger()
def reset_logging():
structlog.reset_defaults()
logging.getLogger().handlers.clear()
structlog.contextvars.clear_contextvars()
tests/test_observability.py
import io
import json
import logging
import pytest
import structlog
from observability import configure_logging, reset_logging
@pytest.fixture(autouse=True)
def clean_logging():
reset_logging()
yield
reset_logging()
def last_record(stream):
return json.loads(stream.getvalue().splitlines()[-1])
def test_merge_contextvars_into_structlog():
stream = io.StringIO()
log = configure_logging(stream)
structlog.contextvars.bind_contextvars(request_id="abc")
log.info("hello", user=1)
rec = last_record(stream)
assert rec["event"] == "hello"
assert rec["request_id"] == "abc"
assert rec["user"] == 1
def test_bound_contextvars_is_scoped():
stream = io.StringIO()
log = configure_logging(stream)
with structlog.contextvars.bound_contextvars(task="import"):
log.info("in")
log.info("out")
records = [json.loads(line) for line in stream.getvalue().splitlines()]
assert records[0]["task"] == "import"
assert "task" not in records[1]
def test_explicit_event_key_wins_over_contextvar():
stream = io.StringIO()
log = configure_logging(stream)
structlog.contextvars.bind_contextvars(user="ctx")
log.info("e", user="explicit")
assert last_record(stream)["user"] == "explicit"
def test_foreign_stdlib_record_uses_json_and_context():
stream = io.StringIO()
configure_logging(stream)
structlog.contextvars.bind_contextvars(request_id="r1")
logging.getLogger("third.party").warning("issued a warning")
rec = last_record(stream)
assert rec["event"] == "issued a warning"
assert rec["level"] == "warning"
assert rec["logger"] == "third.party"
assert rec["request_id"] == "r1"
def test_processors_meta_is_stripped():
stream = io.StringIO()
log = configure_logging(stream)
log.info("clean")
rec = last_record(stream)
assert "_record" not in rec and "_from_structlog" not in rec
def test_capture_logs_skips_contextvars():
log = configure_logging(io.StringIO())
structlog.contextvars.bind_contextvars(request_id="abc")
with structlog.testing.capture_logs() as captured:
log.info("captured")
assert "request_id" not in captured[0]
pytest.ini
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.