Files
chatbot_v3/tests/unit/infrastructure/observability/test_logging.py
Ali Zarinkolah 9e8987968c 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.
2026-08-20 19:20:27 +03:30

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"