feat(06-02): add structlog dependency + create services/logging.py
- Append structlog>=25.5.0 to requirements.txt under Phase 6 D-01 comment - Create backend/services/logging.py with setup_logging(json_logs, log_level) - shared_processors: merge_contextvars first, then add_log_level, add_logger_name, PositionalArgumentsFormatter, ExtraAdder, TimeStamper(iso), StackInfoRenderer - JSON branch appends format_exc_info to shared_processors - JSONRenderer when json_logs=True, ConsoleRenderer when False - ProcessorFormatter bridges uvicorn/stdlib loggers through same chain - Idempotent: clears root handlers before installing new one - uvicorn.access propagate=False — middleware owns request logging
This commit is contained in:
@@ -32,3 +32,6 @@ google-api-python-client>=2.196.0
|
||||
msal>=1.36.0
|
||||
webdavclient3>=3.14.7
|
||||
cachetools>=5.3.0
|
||||
|
||||
# Observability (Phase 6 — D-01)
|
||||
structlog>=25.5.0
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Structured logging service — D-01.
|
||||
|
||||
Single entry-point for structlog + stdlib bridge.
|
||||
|
||||
Bridges stdlib loggers (uvicorn, sqlalchemy, celery) through the same
|
||||
structured JSON processor chain.
|
||||
|
||||
Security invariants:
|
||||
- No function raises HTTPException — pure Python, no FastAPI coupling.
|
||||
- merge_contextvars is FIRST in the processor chain — correlation_id and
|
||||
other contextvar-bound fields appear in every log line.
|
||||
- Calling setup_logging twice is idempotent — existing root handlers are
|
||||
cleared before adding the new handler.
|
||||
- uvicorn.access propagate is False — CorrelationIDMiddleware in main.py
|
||||
owns request-level logging; access log lines are not doubled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def setup_logging(json_logs: bool = False, log_level: str = "INFO") -> None:
|
||||
"""Configure structlog + stdlib logging bridge.
|
||||
|
||||
Args:
|
||||
json_logs: If True, render logs as JSON (production). If False, use
|
||||
ConsoleRenderer with colours (development default).
|
||||
log_level: Root logger level string, e.g. "INFO", "DEBUG", "WARNING".
|
||||
Case-insensitive.
|
||||
|
||||
Idempotent: clears existing root handlers before installing the new one so
|
||||
calling this function multiple times in tests does not duplicate output.
|
||||
"""
|
||||
timestamper = structlog.processors.TimeStamper(fmt="iso")
|
||||
|
||||
# shared_processors: applied by BOTH structlog native loggers and stdlib
|
||||
# loggers bridged through ProcessorFormatter (foreign_pre_chain).
|
||||
# merge_contextvars MUST be first — ensures correlation_id, user_id, etc.
|
||||
# bound via structlog.contextvars.bind_contextvars() appear in every line.
|
||||
shared_processors: list = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.stdlib.ExtraAdder(),
|
||||
timestamper,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
]
|
||||
if json_logs:
|
||||
# format_exc_info renders exception tracebacks as a JSON string field
|
||||
# rather than a multi-line text block.
|
||||
shared_processors.append(structlog.processors.format_exc_info)
|
||||
|
||||
# Configure structlog itself — processors for native structlog log calls.
|
||||
structlog.configure(
|
||||
processors=shared_processors + [
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
# Pick renderer based on mode.
|
||||
log_renderer = (
|
||||
structlog.processors.JSONRenderer()
|
||||
if json_logs
|
||||
else structlog.dev.ConsoleRenderer()
|
||||
)
|
||||
|
||||
# ProcessorFormatter bridges stdlib loggers (uvicorn, sqlalchemy, celery)
|
||||
# through the same processor chain. foreign_pre_chain handles records
|
||||
# that arrive from stdlib; processors runs after that.
|
||||
formatter = structlog.stdlib.ProcessorFormatter(
|
||||
foreign_pre_chain=shared_processors,
|
||||
processors=[
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
log_renderer,
|
||||
],
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
# Idempotency: clear existing handlers before adding ours.
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel(log_level.upper())
|
||||
|
||||
# Route uvicorn logs through structlog.
|
||||
# uvicorn and uvicorn.error: propagate to root so they pass through
|
||||
# the ProcessorFormatter and emit in the same JSON format.
|
||||
for name in ("uvicorn", "uvicorn.error"):
|
||||
_log = logging.getLogger(name)
|
||||
_log.handlers.clear()
|
||||
_log.propagate = True
|
||||
|
||||
# uvicorn.access: suppress — CorrelationIDMiddleware in main.py owns
|
||||
# request-level logging, so the default uvicorn access lines must NOT
|
||||
# be duplicated.
|
||||
_access = logging.getLogger("uvicorn.access")
|
||||
_access.handlers.clear()
|
||||
_access.propagate = False
|
||||
Reference in New Issue
Block a user