Files
turnstone/tests/test_logging.py
T
Patrick Buckley 0fd0ad3b2d Add structured logging with structlog and context propagation
Replace ad-hoc logging.basicConfig() calls across all 6 entry points with
a centralized configure_logging() function backed by structlog. JSON output
when stderr is not a TTY (production/Docker), colored console output otherwise.

- New turnstone/core/log.py: configure_logging(), get_logger(), contextvars
  for node_id/ws_id/user_id/request_id auto-injected into every log event
- All entry points (server, bridge, console, sim, cli, migrate) call
  configure_logging() with --log-level and --log-format CLI flags
- Server operational print() calls replaced with structured log.info()
- LogContextMiddleware sets request_id + ws_id per HTTP request with
  token-based reset to prevent context leaking across requests
- Bridge _run_in_context() helper propagates ctx_node_id to child threads
- Env var overrides: TURNSTONE_LOG_LEVEL, TURNSTONE_LOG_FORMAT
- 18 new tests (959 total passing)
2026-03-04 06:47:30 -08:00

215 lines
7.5 KiB
Python

"""Tests for turnstone.core.log — structured logging configuration."""
from __future__ import annotations
import json
import logging
import structlog
from turnstone.core.log import (
configure_logging,
ctx_node_id,
ctx_request_id,
ctx_user_id,
ctx_ws_id,
get_logger,
)
class TestConfigureLogging:
"""Test configure_logging() sets up handlers and formatters."""
def setup_method(self):
# Reset structlog and stdlib between tests
structlog.reset_defaults()
root = logging.getLogger()
root.handlers.clear()
root.setLevel(logging.WARNING)
# Reset context vars
for var in (ctx_node_id, ctx_ws_id, ctx_user_id, ctx_request_id):
var.set("")
def test_sets_root_handler(self):
configure_logging(level="INFO", json_output=False, service="test")
root = logging.getLogger()
assert len(root.handlers) == 1
assert root.level == logging.INFO
def test_level_debug(self):
configure_logging(level="DEBUG", json_output=False)
root = logging.getLogger()
assert root.level == logging.DEBUG
def test_level_warning(self):
configure_logging(level="WARNING", json_output=False)
root = logging.getLogger()
assert root.level == logging.WARNING
def test_json_output(self, capsys):
configure_logging(level="INFO", json_output=True, service="test-svc")
log = logging.getLogger("test.json_output")
log.info("hello world")
captured = capsys.readouterr()
# JSON goes to stderr
line = captured.err.strip()
data = json.loads(line)
assert data["event"] == "hello world"
assert data["level"] == "info"
assert data["service"] == "test-svc"
assert "timestamp" in data
def test_console_output(self, capsys):
configure_logging(level="INFO", json_output=False)
log = logging.getLogger("test.console_output")
log.info("console hello")
captured = capsys.readouterr()
assert "console hello" in captured.err
def test_quiet_third_party(self):
configure_logging(level="DEBUG", json_output=False)
for name in ("httpx", "httpcore", "openai", "anthropic", "uvicorn.access"):
assert logging.getLogger(name).level == logging.WARNING
def test_replaces_existing_handlers(self):
root = logging.getLogger()
# Count existing handlers (pytest may add its own)
before = len(root.handlers)
root.addHandler(logging.StreamHandler())
root.addHandler(logging.StreamHandler())
assert len(root.handlers) == before + 2
configure_logging(level="INFO", json_output=False)
# configure_logging clears all and adds exactly 1
assert len(root.handlers) == 1
def test_env_var_level_override(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_LOG_LEVEL", "ERROR")
configure_logging(level="DEBUG", json_output=False)
root = logging.getLogger()
assert root.level == logging.ERROR
def test_env_var_format_json(self, monkeypatch, capsys):
monkeypatch.setenv("TURNSTONE_LOG_FORMAT", "json")
configure_logging(level="INFO", service="test")
log = logging.getLogger("test.env_json")
log.info("env json test")
captured = capsys.readouterr()
data = json.loads(captured.err.strip())
assert data["event"] == "env json test"
def test_env_var_format_text(self, monkeypatch, capsys):
monkeypatch.setenv("TURNSTONE_LOG_FORMAT", "text")
configure_logging(level="INFO", json_output=True) # json_output overridden by env
log = logging.getLogger("test.env_text")
log.info("env text test")
captured = capsys.readouterr()
# Should NOT be JSON
line = captured.err.strip()
assert "env text test" in line
# Verify it's not JSON
try:
json.loads(line)
is_json = True
except json.JSONDecodeError:
is_json = False
assert not is_json
class TestContextInjection:
"""Test that context variables appear in log output."""
def setup_method(self):
structlog.reset_defaults()
root = logging.getLogger()
root.handlers.clear()
root.setLevel(logging.WARNING)
for var in (ctx_node_id, ctx_ws_id, ctx_user_id, ctx_request_id):
var.set("")
def test_node_id_in_output(self, capsys):
configure_logging(level="INFO", json_output=True)
ctx_node_id.set("worker-01_a3f2")
log = logging.getLogger("test.ctx")
log.info("ctx test")
data = json.loads(capsys.readouterr().err.strip())
assert data["node_id"] == "worker-01_a3f2"
def test_ws_id_in_output(self, capsys):
configure_logging(level="INFO", json_output=True)
ctx_ws_id.set("abc123")
log = logging.getLogger("test.ctx")
log.info("ws test")
data = json.loads(capsys.readouterr().err.strip())
assert data["ws_id"] == "abc123"
def test_empty_context_omitted(self, capsys):
configure_logging(level="INFO", json_output=True)
# All context vars are empty string (default)
log = logging.getLogger("test.ctx")
log.info("empty ctx")
data = json.loads(capsys.readouterr().err.strip())
assert "node_id" not in data
assert "ws_id" not in data
assert "user_id" not in data
assert "request_id" not in data
def test_multiple_context_vars(self, capsys):
configure_logging(level="INFO", json_output=True)
ctx_node_id.set("node-1")
ctx_ws_id.set("ws-2")
ctx_request_id.set("req-3")
log = logging.getLogger("test.ctx")
log.info("multi ctx")
data = json.loads(capsys.readouterr().err.strip())
assert data["node_id"] == "node-1"
assert data["ws_id"] == "ws-2"
assert data["request_id"] == "req-3"
assert "user_id" not in data
class TestGetLogger:
"""Test get_logger() returns a usable bound logger."""
def setup_method(self):
structlog.reset_defaults()
root = logging.getLogger()
root.handlers.clear()
root.setLevel(logging.WARNING)
def test_get_logger_returns_bound_logger(self):
configure_logging(level="INFO", json_output=False)
log = get_logger("test.bound")
assert log is not None
def test_get_logger_outputs(self, capsys):
configure_logging(level="INFO", json_output=True)
log = get_logger("test.bound")
log.info("bound logger test", extra_key="extra_val")
data = json.loads(capsys.readouterr().err.strip())
assert data["event"] == "bound logger test"
assert data["extra_key"] == "extra_val"
class TestServiceField:
"""Test that service name is injected when configured."""
def setup_method(self):
structlog.reset_defaults()
root = logging.getLogger()
root.handlers.clear()
root.setLevel(logging.WARNING)
def test_service_present(self, capsys):
configure_logging(level="INFO", json_output=True, service="myservice")
log = logging.getLogger("test.svc")
log.info("svc test")
data = json.loads(capsys.readouterr().err.strip())
assert data["service"] == "myservice"
def test_no_service_when_empty(self, capsys):
configure_logging(level="INFO", json_output=True)
log = logging.getLogger("test.svc")
log.info("no svc")
data = json.loads(capsys.readouterr().err.strip())
assert "service" not in data