gabriel / musehub public
logging_config.py python
157 lines 6.2 KB
Raw
sha256:7afc59b2ee9c70d18b748d269602c588385d6e936cc22b056fd775ab9f2fd499 fix: real CloudWatch alerting on both envs, add environment… Sonnet 5 minor ⚠ breaking 1 day 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 # Set once by configure_logging() — module-level rather than threaded through
37 # every log call, and deliberately not imported from musehub.config to avoid
38 # any import-order coupling between logging setup and settings loading.
39 _environment: str = ""
40 _release_version: str = ""
41
42 # ── PII / secret scrubbing filter ─────────────────────────────────────────────
43
44 # Pattern pairs: (compiled_regex, replacement_string)
45 # Applied in order to the fully-formatted message string.
46 _SCRUB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
47 # Authorization: Bearer <token> or bearer=<token>
48 (re.compile(r"(Bearer\s+)[A-Za-z0-9._\-/+=]{8,}", re.IGNORECASE), r"\1***"),
49 # token=<value> or token: <value> (query-string or log kv)
50 (re.compile(r"((?:api_)?token[=:\s]+)[^\s&,\"'<>]+", re.IGNORECASE), r"\1***"),
51 # password=<value> or password: <value>
52 (re.compile(r"(password[=:\s]+)[^\s&,\"'<>]+", re.IGNORECASE), r"\1***"),
53 # secret=<value>
54 (re.compile(r"(secret[=:\s]+)[^\s&,\"'<>]+", re.IGNORECASE), r"\1***"),
55 ]
56
57 class PiiFilter(logging.Filter):
58 """Scrub secrets and tokens from the formatted log message.
59
60 Operates on the fully-formatted message string so it catches values
61 interpolated from args as well as literal strings.
62 """
63
64 def filter(self, record: logging.LogRecord) -> bool:
65 # Format args into msg so we operate on the final string.
66 try:
67 msg = record.getMessage()
68 except Exception:
69 msg = str(record.msg)
70
71 for pattern, replacement in _SCRUB_PATTERNS:
72 msg = pattern.sub(replacement, msg)
73
74 record.msg = msg
75 record.args = () # already expanded — prevent double-formatting
76 return True
77
78 # ── JSON formatter ─────────────────────────────────────────────────────────────
79
80 # Standard LogRecord attributes — exclude from the "extra" pass-through so we
81 # don't double-emit them.
82 _STANDARD_ATTRS = frozenset(
83 logging.LogRecord("", 0, "", 0, "", (), None).__dict__.keys()
84 | {
85 "message",
86 "asctime",
87 "exc_text",
88 "stack_info",
89 "taskName",
90 }
91 )
92
93 # Per-request fields emitted by AccessLogMiddleware via `extra=`.
94 _ACCESS_FIELDS = ("method", "path", "status", "duration_ms")
95
96 class JsonFormatter(logging.Formatter):
97 """Emit one compact JSON object per log record.
98
99 The PiiFilter must be installed on the handler *before* this formatter
100 runs so that ``record.getMessage()`` returns a scrubbed string.
101 """
102
103 def format(self, record: logging.LogRecord) -> str:
104 # Let the base class populate record.exc_text from exc_info.
105 super().format(record)
106
107 doc: JSONObject = {
108 "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
109 "level": record.levelname,
110 "logger": record.name,
111 "message": record.getMessage(),
112 "request_id": request_id_var.get(),
113 "user_id": user_id_var.get(),
114 "environment": _environment,
115 "release_version": _release_version,
116 }
117
118 # Optional per-request fields (present on access log records).
119 for field in _ACCESS_FIELDS:
120 val = record.__dict__.get(field)
121 if val is not None:
122 doc[field] = val
123
124 if record.exc_text:
125 doc["exc_info"] = record.exc_text
126
127 return json.dumps(doc, ensure_ascii=False)
128
129 # ── Public API ─────────────────────────────────────────────────────────────────
130
131 def configure_logging(debug: bool = False, environment: str = "", release_version: str = "") -> None:
132 """Install JSON formatter + PII filter on the root logger.
133
134 Safe to call multiple times — clears existing handlers first so there is
135 no duplication if an earlier ``basicConfig`` call ran before this one.
136
137 ``environment`` (e.g. "staging"/"production") and ``release_version``
138 (the deployed image tag) are stamped onto every log line so a single
139 CloudWatch dashboard/query can distinguish records from either
140 environment or correlate errors with a specific deploy.
141 """
142 global _environment, _release_version
143 _environment = environment
144 _release_version = release_version
145
146 root = logging.getLogger()
147 root.setLevel(logging.DEBUG if debug else logging.INFO)
148
149 # Remove handlers installed by any earlier basicConfig / configure_logging.
150 for handler in list(root.handlers):
151 root.removeHandler(handler)
152 handler.close()
153
154 handler = logging.StreamHandler()
155 handler.setFormatter(JsonFormatter())
156 handler.addFilter(PiiFilter())
157 root.addHandler(handler)
File History 1 commit
sha256:7afc59b2ee9c70d18b748d269602c588385d6e936cc22b056fd775ab9f2fd499 fix: real CloudWatch alerting on both envs, add environment… Sonnet 5 minor 1 day ago