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