gabriel / musehub public
logging_config.py python
140 lines 5.4 KB
Raw
sha256:c2e5cf2d754ed2fe9a94e879d41ad12ff221d2ac4ac7b45247fcb981c76858bf docs: Section 8 database/migrations/backups — verified live… Sonnet 5 24 days ago
1 """Structured JSON logging for MuseHub.
2
3 Replaces plain-text ``basicConfig`` with a JSON formatter that emits one
4 compact JSON object per log line. Every record includes:
5
6 timestamp — ISO-8601 UTC
7 level — DEBUG / INFO / WARNING / ERROR / CRITICAL
8 logger — dotted module name
9 message — formatted message (PII scrubbed)
10 request_id — injected by AccessLogMiddleware (empty string outside requests)
11 user_id — injected by AccessLogMiddleware (empty string outside requests)
12
13 Per-request access records also carry:
14 method / path / status / duration_ms
15
16 Usage::
17
18 from musehub.logging_config import configure_logging
19 configure_logging(debug=settings.debug)
20 """
21
22 import json
23 import logging
24 import re
25 from contextvars import ContextVar
26 from datetime import datetime, timezone
27 from musehub.types.json_types import JSONObject
28
29 # ── Contextvars ───────────────────────────────────────────────────────────────
30 # Set by AccessLogMiddleware at the start of every HTTP request.
31 # Default to empty string so non-request log lines still produce valid JSON.
32
33 request_id_var: ContextVar[str] = ContextVar("request_id", default="")
34 user_id_var: ContextVar[str] = ContextVar("user_id", default="")
35
36 # ── PII / secret scrubbing filter ─────────────────────────────────────────────
37
38 # Pattern pairs: (compiled_regex, replacement_string)
39 # Applied in order to the fully-formatted message string.
40 _SCRUB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
41 # Authorization: Bearer <token> or bearer=<token>
42 (re.compile(r"(Bearer\s+)[A-Za-z0-9._\-/+=]{8,}", re.IGNORECASE), r"\1***"),
43 # token=<value> or token: <value> (query-string or log kv)
44 (re.compile(r"((?:api_)?token[=:\s]+)[^\s&,\"'<>]+", re.IGNORECASE), r"\1***"),
45 # password=<value> or password: <value>
46 (re.compile(r"(password[=:\s]+)[^\s&,\"'<>]+", re.IGNORECASE), r"\1***"),
47 # secret=<value>
48 (re.compile(r"(secret[=:\s]+)[^\s&,\"'<>]+", re.IGNORECASE), r"\1***"),
49 ]
50
51 class PiiFilter(logging.Filter):
52 """Scrub secrets and tokens from the formatted log message.
53
54 Operates on the fully-formatted message string so it catches values
55 interpolated from args as well as literal strings.
56 """
57
58 def filter(self, record: logging.LogRecord) -> bool:
59 # Format args into msg so we operate on the final string.
60 try:
61 msg = record.getMessage()
62 except Exception:
63 msg = str(record.msg)
64
65 for pattern, replacement in _SCRUB_PATTERNS:
66 msg = pattern.sub(replacement, msg)
67
68 record.msg = msg
69 record.args = () # already expanded — prevent double-formatting
70 return True
71
72 # ── JSON formatter ─────────────────────────────────────────────────────────────
73
74 # Standard LogRecord attributes — exclude from the "extra" pass-through so we
75 # don't double-emit them.
76 _STANDARD_ATTRS = frozenset(
77 logging.LogRecord("", 0, "", 0, "", (), None).__dict__.keys()
78 | {
79 "message",
80 "asctime",
81 "exc_text",
82 "stack_info",
83 "taskName",
84 }
85 )
86
87 # Per-request fields emitted by AccessLogMiddleware via `extra=`.
88 _ACCESS_FIELDS = ("method", "path", "status", "duration_ms")
89
90 class JsonFormatter(logging.Formatter):
91 """Emit one compact JSON object per log record.
92
93 The PiiFilter must be installed on the handler *before* this formatter
94 runs so that ``record.getMessage()`` returns a scrubbed string.
95 """
96
97 def format(self, record: logging.LogRecord) -> str:
98 # Let the base class populate record.exc_text from exc_info.
99 super().format(record)
100
101 doc: JSONObject = {
102 "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
103 "level": record.levelname,
104 "logger": record.name,
105 "message": record.getMessage(),
106 "request_id": request_id_var.get(),
107 "user_id": user_id_var.get(),
108 }
109
110 # Optional per-request fields (present on access log records).
111 for field in _ACCESS_FIELDS:
112 val = record.__dict__.get(field)
113 if val is not None:
114 doc[field] = val
115
116 if record.exc_text:
117 doc["exc_info"] = record.exc_text
118
119 return json.dumps(doc, ensure_ascii=False)
120
121 # ── Public API ─────────────────────────────────────────────────────────────────
122
123 def configure_logging(debug: bool = False) -> None:
124 """Install JSON formatter + PII filter on the root logger.
125
126 Safe to call multiple times — clears existing handlers first so there is
127 no duplication if an earlier ``basicConfig`` call ran before this one.
128 """
129 root = logging.getLogger()
130 root.setLevel(logging.DEBUG if debug else logging.INFO)
131
132 # Remove handlers installed by any earlier basicConfig / configure_logging.
133 for handler in list(root.handlers):
134 root.removeHandler(handler)
135 handler.close()
136
137 handler = logging.StreamHandler()
138 handler.setFormatter(JsonFormatter())
139 handler.addFilter(PiiFilter())
140 root.addHandler(handler)
File History 2 commits
sha256:c2e5cf2d754ed2fe9a94e879d41ad12ff221d2ac4ac7b45247fcb981c76858bf docs: Section 8 database/migrations/backups — verified live… Sonnet 5 24 days ago
sha256:80e1a60a39562f6e616aaafbb27487f03abbec7d8ffc6164302b7a0c0bfc63ee docs: check off Section 0 items verified in inventory doc, … Sonnet 5 24 days ago