|
1 | | -"""Logging service for the application.""" |
2 | | - |
3 | | -# Author: Alexander Hambley |
4 | | -# License: MIT |
5 | | -# Copyright (c) 2025 eScience Lab, The University of Manchester |
| 1 | +"""Structured JSON logging with request IDs and secret redaction.""" |
6 | 2 |
|
| 3 | +import json |
7 | 4 | import logging |
| 5 | +import uuid |
| 6 | + |
| 7 | +from contextvars import ContextVar |
| 8 | +from typing import Iterable, Optional |
| 9 | + |
| 10 | +# correlation ID readable from any logging call in the same context. per request basis. |
| 11 | +# (request handler or Celery task). Defaults to "-" when unset. |
| 12 | +_request_id: ContextVar[str] = ContextVar("request_id", default="-") |
| 13 | + |
| 14 | + |
| 15 | +def new_request_id() -> str: |
| 16 | + """Return a fresh, unique request ID.""" |
| 17 | + return str(uuid.uuid4()) |
| 18 | + |
| 19 | + |
| 20 | +def set_request_id(request_id: Optional[str]) -> None: |
| 21 | + """Set the current request ID (``None`` resets to the default).""" |
| 22 | + _request_id.set(request_id or "-") |
| 23 | + |
| 24 | + |
| 25 | +def get_request_id() -> str: |
| 26 | + """Return the current request ID, or ``"-"`` if unset.""" |
| 27 | + return _request_id.get() |
| 28 | + |
| 29 | + |
| 30 | +class RequestIdFilter(logging.Filter): |
| 31 | + """Attaches the current request ID to every log record.""" |
8 | 32 |
|
| 33 | + def filter(self, record: logging.LogRecord) -> bool: |
| 34 | + record.request_id = get_request_id() |
| 35 | + return True |
9 | 36 |
|
10 | | -def setup_logging(level: int = logging.INFO) -> None: |
| 37 | + |
| 38 | +class RedactionFilter(logging.Filter): |
| 39 | + """Masks known secret values wherever they appear in a log message.""" |
| 40 | + |
| 41 | + def __init__(self, secrets: Iterable[Optional[str]]): |
| 42 | + super().__init__() |
| 43 | + self._secrets = [s for s in secrets if s] |
| 44 | + |
| 45 | + def filter(self, record: logging.LogRecord) -> bool: |
| 46 | + if self._secrets: |
| 47 | + message = record.getMessage() |
| 48 | + for secret in self._secrets: |
| 49 | + message = message.replace(secret, "***") |
| 50 | + record.msg = message |
| 51 | + record.args = None |
| 52 | + return True |
| 53 | + |
| 54 | + |
| 55 | +class JsonFormatter(logging.Formatter): |
| 56 | + """Formats log records as single-line JSON.""" |
| 57 | + |
| 58 | + def format(self, record: logging.LogRecord) -> str: |
| 59 | + payload = { |
| 60 | + "timestamp": self.formatTime(record), |
| 61 | + "level": record.levelname, |
| 62 | + "logger": record.name, |
| 63 | + "message": record.getMessage(), |
| 64 | + "request_id": getattr(record, "request_id", "-"), |
| 65 | + } |
| 66 | + if record.exc_info: |
| 67 | + payload["exc_info"] = self.formatException(record.exc_info) |
| 68 | + return json.dumps(payload) |
| 69 | + |
| 70 | + |
| 71 | +def setup_logging(settings=None, level: int = logging.INFO) -> None: |
11 | 72 | """ |
12 | | - Configure the logging for the application. |
| 73 | + Configure root logging: JSON output, request IDs, and secret redaction. |
13 | 74 |
|
14 | | - :param level: The logging level to set. Defaults to INFO. |
| 75 | + :param settings: Optional Settings; its credentials are redacted from logs. |
| 76 | + :param level: The logging level to set. |
15 | 77 | """ |
16 | | - logging.basicConfig( |
17 | | - level=level, |
18 | | - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", |
19 | | - ) |
| 78 | + secrets = [] |
| 79 | + if settings is not None: |
| 80 | + secrets = [settings.s3_secret_key, settings.s3_access_key] |
| 81 | + |
| 82 | + handler = logging.StreamHandler() |
| 83 | + handler.setFormatter(JsonFormatter()) |
| 84 | + handler.addFilter(RequestIdFilter()) |
| 85 | + handler.addFilter(RedactionFilter(secrets)) |
| 86 | + |
| 87 | + root = logging.getLogger() |
| 88 | + root.handlers.clear() |
| 89 | + root.addHandler(handler) |
| 90 | + root.setLevel(level) |
0 commit comments