feat(observability): add dual local logging sinks and static environment context
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.
This commit is contained in:
106
tests/unit/infrastructure/observability/test_logging.py
Normal file
106
tests/unit/infrastructure/observability/test_logging.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user