gabriel / musehub public
logging_config.py python
140 lines 5.4 KB
Raw
sha256:4bbb33ceef5bd17de36b0feeca88784bbb3f72f79a882386496dec2f96993a54 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 10 hours 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 17 commits
sha256:4bbb33ceef5bd17de36b0feeca88784bbb3f72f79a882386496dec2f96993a54 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 10 hours ago
sha256:8a1389ae62d0a688d5e027763249bd8faedfc39ab00857d65da6620d1ab8a689 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 10 hours ago
sha256:bee12c5cbde2334f98421c6c209d768fa6b8004d6705c9ea798ce6c1651bc11f Merge 'infra/database-phase3-4-cleanup' into 'dev' — propos… Human 1 day ago
sha256:9c64dbfd65ef4e8a85f500e5909c06c2e6c65255a69e84188ee73b63f111cecd Merge 'docs/status-banners-closed-tickets' into 'dev' — pro… Human 1 day ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 64 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 67 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 70 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 72 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 85 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 89 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 90 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 91 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 91 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 91 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 91 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 103 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 105 days ago