feat: enhance logging configuration and add request logging context
This commit is contained in:
114
backend/tests/test_common_logging_policy.py
Normal file
114
backend/tests/test_common_logging_policy.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.logging import TRACE_LEVEL, AppLogger, get_logger
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
APP_ROOT = BACKEND_ROOT / "app"
|
||||
COMMON_LOGGER_FILE = APP_ROOT / "core" / "logging.py"
|
||||
|
||||
|
||||
def _iter_app_python_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for path in APP_ROOT.rglob("*.py"):
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def test_common_logger_supports_trace_to_critical_levels() -> None:
|
||||
AppLogger.configure(force=True)
|
||||
logger = get_logger("tests.common_logging_policy.levels")
|
||||
logger.setLevel(TRACE_LEVEL)
|
||||
|
||||
stream = io.StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setLevel(TRACE_LEVEL)
|
||||
handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
|
||||
try:
|
||||
logger.log(TRACE_LEVEL, "trace-level")
|
||||
logger.debug("debug-level")
|
||||
logger.info("info-level")
|
||||
logger.warning("warning-level")
|
||||
logger.error("error-level")
|
||||
logger.critical("critical-level")
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
lines = [line.strip() for line in stream.getvalue().splitlines() if line.strip()]
|
||||
assert lines == [
|
||||
"TRACE:trace-level",
|
||||
"DEBUG:debug-level",
|
||||
"INFO:info-level",
|
||||
"WARNING:warning-level",
|
||||
"ERROR:error-level",
|
||||
"CRITICAL:critical-level",
|
||||
]
|
||||
|
||||
|
||||
def test_backend_app_uses_common_logger() -> None:
|
||||
offenders: list[str] = []
|
||||
|
||||
for path in _iter_app_python_files():
|
||||
if path == COMMON_LOGGER_FILE:
|
||||
continue
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(BACKEND_ROOT).as_posix()
|
||||
|
||||
if re.search(r"^\s*import\s+logging\b", text, flags=re.MULTILINE):
|
||||
offenders.append(f"{rel}: imports logging directly")
|
||||
if "logging.getLogger(" in text:
|
||||
offenders.append(f"{rel}: calls logging.getLogger directly")
|
||||
|
||||
assert not offenders, "\n".join(offenders)
|
||||
|
||||
|
||||
def test_module_level_loggers_bind_via_get_logger() -> None:
|
||||
offenders: list[str] = []
|
||||
assignment_pattern = re.compile(r"^\s*logger\s*=\s*(.+)$", flags=re.MULTILINE)
|
||||
|
||||
for path in _iter_app_python_files():
|
||||
if path == COMMON_LOGGER_FILE:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(BACKEND_ROOT).as_posix()
|
||||
for expression in assignment_pattern.findall(text):
|
||||
normalized = expression.strip()
|
||||
if normalized.startswith("get_logger("):
|
||||
continue
|
||||
offenders.append(f"{rel}: logger assignment `{normalized}` is not get_logger(...)")
|
||||
|
||||
assert not offenders, "\n".join(offenders)
|
||||
|
||||
|
||||
def test_backend_app_has_all_log_levels_in_use() -> None:
|
||||
level_patterns: dict[str, re.Pattern[str]] = {
|
||||
"trace": re.compile(
|
||||
r"\b(?:self\.)?logger\.log\(\s*TRACE_LEVEL\b|\b(?:self\.)?logger\.trace\("
|
||||
),
|
||||
"debug": re.compile(r"\b(?:self\.)?logger\.debug\("),
|
||||
"info": re.compile(r"\b(?:self\.)?logger\.info\("),
|
||||
"warning": re.compile(r"\b(?:self\.)?logger\.warning\("),
|
||||
"error": re.compile(r"\b(?:self\.)?logger\.error\("),
|
||||
"critical": re.compile(r"\b(?:self\.)?logger\.critical\("),
|
||||
}
|
||||
|
||||
merged_source = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in _iter_app_python_files()
|
||||
if path != COMMON_LOGGER_FILE
|
||||
)
|
||||
|
||||
missing = [
|
||||
name for name, pattern in level_patterns.items() if not pattern.search(merged_source)
|
||||
]
|
||||
assert not missing, f"Missing log levels in backend app code: {', '.join(missing)}"
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import error_handling as error_handling_module
|
||||
from app.core.error_handling import REQUEST_ID_HEADER, RequestIdMiddleware
|
||||
from app.core.logging import TRACE_LEVEL, AppLogFilter, get_logger
|
||||
from app.core.version import APP_NAME, APP_VERSION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -89,3 +94,136 @@ async def test_request_id_middleware_does_not_duplicate_existing_header() -> Non
|
||||
v for k, v in start_headers if k.lower() == REQUEST_ID_HEADER.lower().encode("latin-1")
|
||||
]
|
||||
assert values == [b"already"]
|
||||
|
||||
|
||||
class _CaptureHandler(logging.Handler):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.records: list[logging.LogRecord] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_id_middleware_logs_trace_start_and_debug_completion() -> None:
|
||||
capture = _CaptureHandler()
|
||||
capture.setLevel(TRACE_LEVEL)
|
||||
logger = error_handling_module.logger
|
||||
logger.setLevel(TRACE_LEVEL)
|
||||
logger.addHandler(capture)
|
||||
|
||||
async def app(scope, receive, send): # type: ignore[no-untyped-def]
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
middleware = RequestIdMiddleware(app)
|
||||
request_scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/bootstrap",
|
||||
"client": ("127.0.0.1", 5454),
|
||||
"headers": [],
|
||||
}
|
||||
sent_messages: list[dict[str, object]] = []
|
||||
|
||||
async def send(message): # type: ignore[no-untyped-def]
|
||||
sent_messages.append(message)
|
||||
|
||||
try:
|
||||
await middleware(request_scope, lambda: None, send)
|
||||
finally:
|
||||
logger.removeHandler(capture)
|
||||
capture.close()
|
||||
|
||||
start = next(
|
||||
record for record in capture.records if record.getMessage() == "http.request.start"
|
||||
)
|
||||
complete = next(
|
||||
record for record in capture.records if record.getMessage() == "http.request.complete"
|
||||
)
|
||||
|
||||
assert start.levelname == "TRACE"
|
||||
assert getattr(start, "method", None) == "GET"
|
||||
assert getattr(start, "path", None) == "/api/v1/auth/bootstrap"
|
||||
|
||||
assert complete.levelname == "DEBUG"
|
||||
assert getattr(complete, "status_code", None) == 200
|
||||
assert isinstance(getattr(complete, "duration_ms", None), int)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_id_middleware_logs_error_for_5xx_completion() -> None:
|
||||
capture = _CaptureHandler()
|
||||
capture.setLevel(TRACE_LEVEL)
|
||||
logger = error_handling_module.logger
|
||||
logger.setLevel(TRACE_LEVEL)
|
||||
logger.addHandler(capture)
|
||||
|
||||
async def app(scope, receive, send): # type: ignore[no-untyped-def]
|
||||
await send({"type": "http.response.start", "status": 503, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"unavailable"})
|
||||
|
||||
middleware = RequestIdMiddleware(app)
|
||||
request_scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/tasks",
|
||||
"client": ("127.0.0.1", 5454),
|
||||
"headers": [],
|
||||
}
|
||||
sent_messages: list[dict[str, object]] = []
|
||||
|
||||
async def send(message): # type: ignore[no-untyped-def]
|
||||
sent_messages.append(message)
|
||||
|
||||
try:
|
||||
await middleware(request_scope, lambda: None, send)
|
||||
finally:
|
||||
logger.removeHandler(capture)
|
||||
capture.close()
|
||||
|
||||
complete = next(
|
||||
record for record in capture.records if record.getMessage() == "http.request.complete"
|
||||
)
|
||||
assert complete.levelname == "ERROR"
|
||||
assert getattr(complete, "status_code", None) == 503
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_id_middleware_enriches_in_request_logs_with_route_context() -> None:
|
||||
capture = _CaptureHandler()
|
||||
capture.setLevel(TRACE_LEVEL)
|
||||
capture.addFilter(AppLogFilter(APP_NAME, APP_VERSION))
|
||||
|
||||
app_logger = get_logger("tests.request_context.enrichment")
|
||||
app_logger.setLevel(TRACE_LEVEL)
|
||||
app_logger.addHandler(capture)
|
||||
|
||||
async def app(scope, receive, send): # type: ignore[no-untyped-def]
|
||||
app_logger.info("inside.request.handler")
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
middleware = RequestIdMiddleware(app)
|
||||
request_scope = {
|
||||
"type": "http",
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/boards/abc",
|
||||
"client": ("127.0.0.1", 5454),
|
||||
"headers": [],
|
||||
}
|
||||
|
||||
async def send(_message): # type: ignore[no-untyped-def]
|
||||
return None
|
||||
|
||||
try:
|
||||
await middleware(request_scope, lambda: None, send)
|
||||
finally:
|
||||
app_logger.removeHandler(capture)
|
||||
capture.close()
|
||||
|
||||
record = next(item for item in capture.records if item.getMessage() == "inside.request.handler")
|
||||
assert isinstance(getattr(record, "request_id", None), str) and getattr(record, "request_id")
|
||||
assert getattr(record, "method", None) == "PUT"
|
||||
assert getattr(record, "path", None) == "/api/v1/boards/abc"
|
||||
|
||||
Reference in New Issue
Block a user