Why:
- Wanted human-readable console output while developing locally, without
losing a machine-parseable log for later grepping/parsing. A single
renderer chosen by a flag can't do both at once.
- ADR-0011 had no way to correlate an issue with a specific deployment
(build/region/instance) independent of any one request.
Changes:
- configure_logging() now builds two independent handlers: console (always
on, colored unless LOG_JSON_FORMAT=true) and an optional rotating JSON file
(LOG_FILE_PATH, unset by default) -- the same structlog event fans out to
both, so call sites are unaffected.
- A static structlog processor binds env/service_version onto every event.
Deliberately not a contextvar: RequestIdMiddleware's clear_contextvars()
would wipe a value bound there before the first request.
- New settings: APP_SERVICE_VERSION, LOG_FILE_PATH/LOG_FILE_MAX_BYTES/
LOG_FILE_BACKUP_COUNT.
- ADR-0011 amended with both decisions ("console and file are independent
sinks locally"; "bind process-level environment context once at startup").
Impact:
- configure_logging() signature changed to (logging_settings, app_settings);
both call sites (lifespan, qdrant_bootstrap CLI) updated.
107 lines
3.8 KiB
Python
107 lines
3.8 KiB
Python
"""Dual-sink logging config and the static environment processor (ADR-0011).
|
|
|
|
`configure_logging` mutates global logging state (`logging.config.dictConfig`,
|
|
`structlog.configure`), so these tests assert on the *handler configuration it
|
|
builds*, plus one end-to-end capture per sink, rather than trying to isolate
|
|
process-global state across tests.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
|
|
import pytest
|
|
import structlog
|
|
|
|
from src.config import AppLimitSettings, LoggingSettings
|
|
from src.infrastructure.observability.logging import configure_logging
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
# Cross-test pollution from configure_logging() (cache_logger_on_first_use=True
|
|
# etc.) is reset by the autouse fixture in tests/conftest.py after every test,
|
|
# not just this module's -- these tests call the real configure_logging()
|
|
# directly and need the same cleanup any other test does.
|
|
|
|
|
|
def test_configure_logging_without_file_path_registers_only_console(tmp_path) -> None:
|
|
configure_logging(LoggingSettings(file_path=None), AppLimitSettings())
|
|
|
|
root = logging.getLogger()
|
|
handler_names = {type(h).__name__ for h in root.handlers}
|
|
assert handler_names == {"StreamHandler"}
|
|
|
|
|
|
def test_configure_logging_with_file_path_adds_a_rotating_file_handler(tmp_path) -> None:
|
|
log_file = tmp_path / "app.log"
|
|
|
|
configure_logging(
|
|
LoggingSettings(file_path=str(log_file), file_max_bytes=1024, file_backup_count=2),
|
|
AppLimitSettings(),
|
|
)
|
|
|
|
root = logging.getLogger()
|
|
handler_names = {type(h).__name__ for h in root.handlers}
|
|
assert handler_names == {"StreamHandler", "RotatingFileHandler"}
|
|
|
|
|
|
def test_file_sink_is_json_even_when_console_is_not(tmp_path, capsys) -> None:
|
|
"""The two sinks render independently: console stays human-readable while
|
|
the file stays JSON, regardless of LOG_JSON_FORMAT.
|
|
"""
|
|
log_file = tmp_path / "app.log"
|
|
|
|
configure_logging(
|
|
LoggingSettings(json_format=False, file_path=str(log_file)),
|
|
AppLimitSettings(),
|
|
)
|
|
structlog.get_logger("test").info("logging.dual_sink.test", widget_id="abc123")
|
|
|
|
console_output = capsys.readouterr().out
|
|
file_output = log_file.read_text().strip()
|
|
|
|
# Console: human-readable, not parseable JSON.
|
|
with pytest.raises(json.JSONDecodeError):
|
|
json.loads(console_output)
|
|
assert "logging.dual_sink.test" in console_output
|
|
|
|
# File: valid JSON with the same event.
|
|
file_event = json.loads(file_output)
|
|
assert file_event["event"] == "logging.dual_sink.test"
|
|
assert file_event["widget_id"] == "abc123"
|
|
|
|
|
|
def test_every_event_carries_env_and_service_version(tmp_path, capsys) -> None:
|
|
"""Static environment context, not a per-request contextvar -- it must
|
|
show up on an event with no request in flight.
|
|
"""
|
|
configure_logging(
|
|
LoggingSettings(json_format=True, file_path=None),
|
|
AppLimitSettings(env="staging", service_version="abc1234"),
|
|
)
|
|
|
|
structlog.get_logger("test").info("logging.env_context.test")
|
|
|
|
event = json.loads(capsys.readouterr().out.strip())
|
|
assert event["env"] == "staging"
|
|
assert event["service_version"] == "abc1234"
|
|
|
|
|
|
def test_env_context_survives_request_contextvar_clearing(tmp_path, capsys) -> None:
|
|
"""The bug this design avoids: if env/service_version were bound via
|
|
contextvars before a request, RequestIdMiddleware's clear_contextvars()
|
|
would wipe them. They must still appear after a clear.
|
|
"""
|
|
configure_logging(
|
|
LoggingSettings(json_format=True, file_path=None),
|
|
AppLimitSettings(env="prod", service_version="v42"),
|
|
)
|
|
|
|
structlog.contextvars.clear_contextvars()
|
|
structlog.contextvars.bind_contextvars(request_id="req-1")
|
|
structlog.get_logger("test").info("logging.post_clear.test")
|
|
|
|
event = json.loads(capsys.readouterr().out.strip())
|
|
assert event["env"] == "prod"
|
|
assert event["service_version"] == "v42"
|
|
assert event["request_id"] == "req-1"
|