feat(logging): add JSON log config and formatter (#27)

This commit is contained in:
Jeff Smith 2026-04-19 11:29:53 -06:00
parent 07d33bff99
commit 5e0d5d5ec1
3 changed files with 127 additions and 0 deletions

View file

@ -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"]
}
}

View file

@ -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

42
tests/test_logging.py Normal file
View file

@ -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