feat(logging): add JSON log config and formatter (#27)
This commit is contained in:
parent
07d33bff99
commit
5e0d5d5ec1
3 changed files with 127 additions and 0 deletions
59
src/quartermaster/logconfig.json
Normal file
59
src/quartermaster/logconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/quartermaster/logging_config.py
Normal file
26
src/quartermaster/logging_config.py
Normal 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
42
tests/test_logging.py
Normal 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
|
||||||
Loading…
Reference in a new issue