diff --git a/src/quartermaster/logconfig.json b/src/quartermaster/logconfig.json new file mode 100644 index 0000000..90e6c6a --- /dev/null +++ b/src/quartermaster/logconfig.json @@ -0,0 +1,59 @@ +{ + "version": 1, + "disable_existing_loggers": false, + "formatters": { + "json": { + "()": "pythonjsonlogger.jsonlogger.JsonFormatter", + "fmt": "%(asctime)s %(levelname)s %(name)s %(message)s", + "rename_fields": { + "asctime": "timestamp", + "levelname": "level", + "name": "logger" + } + } + }, + "filters": { + "access": { + "()": "quartermaster.logging_config.AccessLogFilter" + } + }, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": "json" + }, + "access": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": "json", + "filters": ["access"] + } + }, + "loggers": { + "quartermaster": { + "level": "INFO", + "handlers": ["stdout"], + "propagate": false + }, + "uvicorn": { + "level": "INFO", + "handlers": ["stdout"], + "propagate": false + }, + "uvicorn.error": { + "level": "INFO", + "handlers": ["stdout"], + "propagate": false + }, + "uvicorn.access": { + "level": "INFO", + "handlers": ["access"], + "propagate": false + } + }, + "root": { + "level": "INFO", + "handlers": ["stdout"] + } +} diff --git a/src/quartermaster/logging_config.py b/src/quartermaster/logging_config.py new file mode 100644 index 0000000..d33519d --- /dev/null +++ b/src/quartermaster/logging_config.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path + +_CONFIG_PATH = Path(__file__).parent / "logconfig.json" +LOG_CONFIG = json.loads(_CONFIG_PATH.read_text()) + + +class AccessLogFilter(logging.Filter): + """Enrich uvicorn access records with structured fields for Loki. + + Uvicorn emits access records whose ``args`` tuple is + ``(client_addr, method, full_path, http_version, status_code)``. + """ + + def filter(self, record: logging.LogRecord) -> bool: + args = record.args + if isinstance(args, tuple) and len(args) >= 5: + record.event = "http_request" + record.client_ip = args[0] + record.method = args[1] + record.path = args[2] + record.status = args[4] + return True diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..f0401dd --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import io +import json +import logging + +from quartermaster.logging_config import LOG_CONFIG + + +def _build_formatter(): + """Instantiate the JSON formatter from LOG_CONFIG for direct testing.""" + from pydoc import locate + + cfg = dict(LOG_CONFIG["formatters"]["json"]) + factory_path = cfg.pop("()") + factory = locate(factory_path) + assert factory is not None, f"formatter factory not importable: {factory_path}" + return factory(**cfg) + + +def test_log_config_emits_json_with_required_fields(): + formatter = _build_formatter() + stream = io.StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(formatter) + + logger = logging.getLogger("tests.logging_smoke") + logger.handlers = [handler] + logger.propagate = False + logger.setLevel(logging.INFO) + + logger.info("smoke message", extra={"event": "smoke"}) + + line = stream.getvalue().strip() + assert line, "formatter produced no output" + payload = json.loads(line) + + assert payload["event"] == "smoke" + assert payload["level"] == "INFO" + assert payload["logger"] == "tests.logging_smoke" + assert payload["message"] == "smoke message" + assert "timestamp" in payload