"""Structured JSON logging for MuseHub. Replaces plain-text ``basicConfig`` with a JSON formatter that emits one compact JSON object per log line. Every record includes: timestamp — ISO-8601 UTC level — DEBUG / INFO / WARNING / ERROR / CRITICAL logger — dotted module name message — formatted message (PII scrubbed) request_id — injected by AccessLogMiddleware (empty string outside requests) user_id — injected by AccessLogMiddleware (empty string outside requests) Per-request access records also carry: method / path / status / duration_ms Usage:: from musehub.logging_config import configure_logging configure_logging(debug=settings.debug) """ import json import logging import re from contextvars import ContextVar from datetime import datetime, timezone from musehub.types.json_types import JSONObject # ── Contextvars ─────────────────────────────────────────────────────────────── # Set by AccessLogMiddleware at the start of every HTTP request. # Default to empty string so non-request log lines still produce valid JSON. request_id_var: ContextVar[str] = ContextVar("request_id", default="") user_id_var: ContextVar[str] = ContextVar("user_id", default="") # Set once by configure_logging() — module-level rather than threaded through # every log call, and deliberately not imported from musehub.config to avoid # any import-order coupling between logging setup and settings loading. _environment: str = "" _release_version: str = "" # ── PII / secret scrubbing filter ───────────────────────────────────────────── # Pattern pairs: (compiled_regex, replacement_string) # Applied in order to the fully-formatted message string. _SCRUB_PATTERNS: list[tuple[re.Pattern[str], str]] = [ # Authorization: Bearer or bearer= (re.compile(r"(Bearer\s+)[A-Za-z0-9._\-/+=]{8,}", re.IGNORECASE), r"\1***"), # token= or token: (query-string or log kv) (re.compile(r"((?:api_)?token[=:\s]+)[^\s&,\"'<>]+", re.IGNORECASE), r"\1***"), # password= or password: (re.compile(r"(password[=:\s]+)[^\s&,\"'<>]+", re.IGNORECASE), r"\1***"), # secret= (re.compile(r"(secret[=:\s]+)[^\s&,\"'<>]+", re.IGNORECASE), r"\1***"), ] class PiiFilter(logging.Filter): """Scrub secrets and tokens from the formatted log message. Operates on the fully-formatted message string so it catches values interpolated from args as well as literal strings. """ def filter(self, record: logging.LogRecord) -> bool: # Format args into msg so we operate on the final string. try: msg = record.getMessage() except Exception: msg = str(record.msg) for pattern, replacement in _SCRUB_PATTERNS: msg = pattern.sub(replacement, msg) record.msg = msg record.args = () # already expanded — prevent double-formatting return True # ── JSON formatter ───────────────────────────────────────────────────────────── # Standard LogRecord attributes — exclude from the "extra" pass-through so we # don't double-emit them. _STANDARD_ATTRS = frozenset( logging.LogRecord("", 0, "", 0, "", (), None).__dict__.keys() | { "message", "asctime", "exc_text", "stack_info", "taskName", } ) # Per-request fields emitted by AccessLogMiddleware via `extra=`. _ACCESS_FIELDS = ("method", "path", "status", "duration_ms") class JsonFormatter(logging.Formatter): """Emit one compact JSON object per log record. The PiiFilter must be installed on the handler *before* this formatter runs so that ``record.getMessage()`` returns a scrubbed string. """ def format(self, record: logging.LogRecord) -> str: # Let the base class populate record.exc_text from exc_info. super().format(record) doc: JSONObject = { "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), "level": record.levelname, "logger": record.name, "message": record.getMessage(), "request_id": request_id_var.get(), "user_id": user_id_var.get(), "environment": _environment, "release_version": _release_version, } # Optional per-request fields (present on access log records). for field in _ACCESS_FIELDS: val = record.__dict__.get(field) if val is not None: doc[field] = val if record.exc_text: doc["exc_info"] = record.exc_text return json.dumps(doc, ensure_ascii=False) # ── Public API ───────────────────────────────────────────────────────────────── def configure_logging(debug: bool = False, environment: str = "", release_version: str = "") -> None: """Install JSON formatter + PII filter on the root logger. Safe to call multiple times — clears existing handlers first so there is no duplication if an earlier ``basicConfig`` call ran before this one. ``environment`` (e.g. "staging"/"production") and ``release_version`` (the deployed image tag) are stamped onto every log line so a single CloudWatch dashboard/query can distinguish records from either environment or correlate errors with a specific deploy. """ global _environment, _release_version _environment = environment _release_version = release_version root = logging.getLogger() root.setLevel(logging.DEBUG if debug else logging.INFO) # Remove handlers installed by any earlier basicConfig / configure_logging. for handler in list(root.handlers): root.removeHandler(handler) handler.close() handler = logging.StreamHandler() handler.setFormatter(JsonFormatter()) handler.addFilter(PiiFilter()) root.addHandler(handler)