diff --git a/Dockerfile b/Dockerfile index d95641c..8a58c15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,4 +31,4 @@ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8000/health || exit 1 # Run the application -CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log", "--log-config", "app/uvicorn_log_config.json"] \ No newline at end of file diff --git a/app/__pycache__/logging_config.cpython-313.pyc b/app/__pycache__/logging_config.cpython-313.pyc index 45d1acb..e73c436 100644 Binary files a/app/__pycache__/logging_config.cpython-313.pyc and b/app/__pycache__/logging_config.cpython-313.pyc differ diff --git a/app/logging_config.py b/app/logging_config.py index bd1fa61..90c8cbe 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -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) + diff --git a/app/uvicorn_log_config.json b/app/uvicorn_log_config.json new file mode 100644 index 0000000..9afb3a0 --- /dev/null +++ b/app/uvicorn_log_config.json @@ -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 + } + } +} + +