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

@@ -31,4 +31,4 @@ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1 CMD curl -f http://localhost:8000/health || exit 1
# Run the application # Run the application
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log", "--log-config", "app/uvicorn_log_config.json"]

View File

@@ -9,6 +9,7 @@ included automatically in log records.
from __future__ import annotations from __future__ import annotations
import logging import logging
import logging.config
from typing import Any, Dict from typing import Any, Dict
import structlog import structlog
@@ -29,29 +30,93 @@ def _add_required_defaults(_: Any, __: str, event_dict: Dict[str, Any]) -> Dict[
return event_dict return event_dict
def setup_logging(log_level: int = logging.INFO) -> None: def _build_foreign_pre_chain() -> list:
""" """Common processor chain used for stdlib log bridging."""
Configure structlog for JSON logging with contextvars support. return [
Args:
log_level: Minimum log level for application logs.
"""
# Configure stdlib logging basic config for third-party libs (uvicorn, etc.)
logging.basicConfig(level=log_level)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars, structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level, structlog.stdlib.add_log_level,
_add_required_defaults, _add_required_defaults,
structlog.processors.TimeStamper(fmt="iso", key="timestamp"), structlog.processors.TimeStamper(fmt="iso", key="timestamp"),
structlog.processors.dict_tracebacks, structlog.processors.dict_tracebacks,
structlog.processors.JSONRenderer(), ]
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(), def _ensure_stdlib_processor_logging(log_level: int) -> None:
wrapper_class=structlog.make_filtering_bound_logger(log_level), """Route stdlib (and uvicorn) loggers through structlog's ProcessorFormatter.
cache_logger_on_first_use=True,
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 and bridge
stdlib/uvicorn loggers to JSON output.
Args:
log_level: Minimum log level for application logs.
"""
# 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,
# 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.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)

View File

@@ -0,0 +1,38 @@
{
"version": 1,
"disable_existing_loggers": false,
"formatters": {
"structlog": {
"()": "app.logging_config.build_uvicorn_structlog_formatter"
}
},
"handlers": {
"default": {
"class": "logging.StreamHandler",
"formatter": "structlog"
}
},
"loggers": {
"": {
"handlers": ["default"],
"level": "INFO"
},
"uvicorn": {
"handlers": ["default"],
"level": "INFO",
"propagate": false
},
"uvicorn.error": {
"handlers": ["default"],
"level": "INFO",
"propagate": false
},
"uvicorn.access": {
"handlers": ["default"],
"level": "INFO",
"propagate": false
}
}
}