chore: route uvicorn logs to structlog; disable default access logs

This commit is contained in:
HotSwapp
2025-10-06 23:00:25 -05:00
parent 0637fc2a63
commit 978a866813
4 changed files with 108 additions and 5 deletions

View File

@@ -9,6 +9,7 @@ included automatically in log records.
from __future__ import annotations
import logging
import logging.config
from typing import Any, Dict
import structlog
@@ -29,29 +30,93 @@ def _add_required_defaults(_: Any, __: str, event_dict: Dict[str, Any]) -> Dict[
return event_dict
def _build_foreign_pre_chain() -> list:
"""Common processor chain used for stdlib log bridging."""
return [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
_add_required_defaults,
structlog.processors.TimeStamper(fmt="iso", key="timestamp"),
structlog.processors.dict_tracebacks,
]
def _ensure_stdlib_processor_logging(log_level: int) -> None:
"""Route stdlib (and uvicorn) loggers through structlog's ProcessorFormatter.
This ensures uvicorn error logs and any stdlib logs are emitted as JSON,
matching the structlog output used by the application code.
"""
formatter = structlog.stdlib.ProcessorFormatter(
processor=structlog.processors.JSONRenderer(),
foreign_pre_chain=_build_foreign_pre_chain(),
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
# Root logger
root_logger = logging.getLogger()
root_logger.handlers = [handler]
root_logger.setLevel(log_level)
# Uvicorn loggers (error/access/parent)
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
uv_logger = logging.getLogger(name)
uv_logger.handlers = [handler]
uv_logger.propagate = False
uv_logger.setLevel(log_level)
def build_uvicorn_structlog_formatter() -> logging.Formatter:
"""
Factory used by logging.config.dictConfig to create a ProcessorFormatter
that renders JSON and merges contextvars. This is referenced by
app/uvicorn_log_config.json.
"""
return structlog.stdlib.ProcessorFormatter(
processor=structlog.processors.JSONRenderer(),
foreign_pre_chain=_build_foreign_pre_chain(),
)
def setup_logging(log_level: int = logging.INFO) -> None:
"""
Configure structlog for JSON logging with contextvars support.
Configure structlog for JSON logging with contextvars support and bridge
stdlib/uvicorn loggers to JSON output.
Args:
log_level: Minimum log level for application logs.
"""
# Configure stdlib logging basic config for third-party libs (uvicorn, etc.)
# Do not clobber handlers if already configured by a log-config (e.g., uvicorn --log-config)
# basicConfig is a no-op if handlers exist.
logging.basicConfig(level=log_level)
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
_add_required_defaults,
structlog.processors.TimeStamper(fmt="iso", key="timestamp"),
structlog.processors.dict_tracebacks,
structlog.processors.JSONRenderer(),
# Defer rendering to logging's ProcessorFormatter to avoid double JSON
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.make_filtering_bound_logger(log_level),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
# If uvicorn/root handlers are not already using ProcessorFormatter, ensure they do.
def _has_processor_formatter(logger: logging.Logger) -> bool:
for h in logger.handlers:
if isinstance(getattr(h, "formatter", None), structlog.stdlib.ProcessorFormatter):
return True
return False
if not (_has_processor_formatter(logging.getLogger()) or _has_processor_formatter(logging.getLogger("uvicorn"))):
_ensure_stdlib_processor_logging(log_level)