logging_setup.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. """Safe structured logging shared by HTTP, database and CLI entrypoints."""
  2. import hashlib
  3. import json
  4. import logging
  5. import os
  6. import re
  7. import sys
  8. import threading
  9. import time
  10. import traceback
  11. from contextvars import ContextVar
  12. from datetime import datetime, timedelta, timezone
  13. from pathlib import Path
  14. from uuid import uuid4
  15. from zoneinfo import ZoneInfo
  16. from concurrent_log_handler import ConcurrentTimedRotatingFileHandler
  17. request_context = ContextVar("request_logging_context", default=None)
  18. db_operation = ContextVar("database_logging_operation", default=None)
  19. db_query = ContextVar("database_logging_query", default=None)
  20. BERLIN = ZoneInfo("Europe/Berlin")
  21. PROJECT_ROOT = Path(__file__).resolve().parent.parent
  22. _lock = threading.RLock()
  23. _configuration = None
  24. _SAFE_FIELDS = (
  25. "event",
  26. "operation",
  27. "query_name",
  28. "query_fingerprint",
  29. "sql",
  30. "sql_truncated",
  31. "sqlstate",
  32. "db_code",
  33. "db_message",
  34. "db_diagnostics",
  35. "status",
  36. "duration_ms",
  37. "outcome",
  38. "size_bytes",
  39. "count",
  40. "reason",
  41. "error_type",
  42. )
  43. def clean(value):
  44. return str(value).replace("\r", "\\r").replace("\n", "\\n").replace("\x00", "\\0")[:8192]
  45. def sanitize_sql(statement):
  46. """Lex T-SQL literals/comments without modifying quoted identifiers or bind names."""
  47. source, output, i = str(statement), [], 0
  48. while i < len(source):
  49. char = source[i]
  50. if source.startswith("--", i):
  51. end = source.find("\n", i + 2)
  52. i = len(source) if end < 0 else end
  53. output.append(" ")
  54. elif source.startswith("/*", i):
  55. depth = 1
  56. i += 2
  57. while i < len(source) and depth:
  58. if source.startswith("/*", i):
  59. depth += 1
  60. i += 2
  61. elif source.startswith("*/", i):
  62. depth -= 1
  63. i += 2
  64. else:
  65. i += 1
  66. output.append(" ")
  67. elif char == "'" or (
  68. char in "nN" and source[i : i + 2].endswith("'") and (i == 0 or not source[i - 1].isalnum())
  69. ):
  70. if char != "'":
  71. i += 1
  72. i += 1
  73. while i < len(source):
  74. if source[i : i + 2] == "''":
  75. i += 2
  76. elif source[i] == "'":
  77. i += 1
  78. break
  79. else:
  80. i += 1
  81. output.append("?")
  82. elif char in '["':
  83. end_char = "]" if char == "[" else '"'
  84. start = i
  85. i += 1
  86. while i < len(source):
  87. if source[i : i + 2] == end_char * 2:
  88. i += 2
  89. elif source[i] == end_char:
  90. i += 1
  91. break
  92. else:
  93. i += 1
  94. output.append(source[start:i] if char == "[" else "?")
  95. elif (char.isdecimal() or char == "." and i + 1 < len(source) and source[i + 1].isdecimal()) and (
  96. i == 0 or not (source[i - 1].isalnum() or source[i - 1] in "_:@$")
  97. ):
  98. match = re.match(r"(?:0[xX][0-9a-fA-F]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)", source[i:])
  99. output.append("?")
  100. i += len(match[0])
  101. else:
  102. output.append(char)
  103. i += 1
  104. return " ".join("".join(output).split())
  105. def exception_roots(error):
  106. seen = set()
  107. def visit(exc):
  108. if id(exc) in seen:
  109. return []
  110. seen.add(id(exc))
  111. if isinstance(exc, BaseExceptionGroup):
  112. return [root for child in exc.exceptions for root in visit(child)]
  113. original = getattr(exc, "orig", None) or exc.__cause__
  114. return visit(original) if isinstance(original, BaseException) else [exc]
  115. return visit(error)
  116. def frames(tb=None):
  117. extracted = traceback.extract_tb(tb) if tb else traceback.extract_stack()[:-1]
  118. return [dict(file=Path(item.filename).name, line=item.lineno, function=item.name) for item in extracted[-45:]]
  119. class ContextFilter(logging.Filter):
  120. def filter(self, record):
  121. if record.name.startswith("uvicorn.") and record.levelno < logging.INFO:
  122. return False
  123. if (
  124. not record.name.startswith(("app.", "uvicorn.", "__main__", "filter_export"))
  125. and record.levelno < logging.WARNING
  126. ):
  127. return False
  128. if not hasattr(record, "event_id"):
  129. record.event_id = uuid4().hex
  130. context = request_context.get()
  131. request_id = context["request_id"] if context else None
  132. if record.exc_info and record.exc_info[1]:
  133. roots = exception_roots(record.exc_info[1])
  134. fresh = [
  135. exc
  136. for exc in roots
  137. if getattr(exc, "_gc_log_event", None) in (None, record.event_id)
  138. or request_id is not None
  139. and getattr(exc, "_gc_log_request", None) != request_id
  140. ]
  141. if not fresh:
  142. return False
  143. for exc in fresh:
  144. exc._gc_log_event = record.event_id
  145. exc._gc_log_request = request_id
  146. record.safe_exceptions = [
  147. dict(
  148. type=type(exc).__module__ + "." + type(exc).__name__,
  149. frames=getattr(exc, "_gc_log_frames", None) or frames(exc.__traceback__),
  150. )
  151. for exc in fresh
  152. ]
  153. record.request_fields = {}
  154. if context:
  155. scope = context["scope"]
  156. principal = scope.get("state", {}).get("principal")
  157. record.request_fields = dict(
  158. request_id=context["request_id"],
  159. user=getattr(principal, "username", None),
  160. method=scope["method"],
  161. path=scope.get("path", ""),
  162. )
  163. return True
  164. def record_data(record):
  165. data = dict(
  166. timestamp=datetime.fromtimestamp(record.created, BERLIN).isoformat(timespec="milliseconds"),
  167. level=record.levelname,
  168. logger=record.name,
  169. event_id=record.event_id,
  170. event=getattr(record, "event", "application"),
  171. message=(
  172. clean(record.getMessage())
  173. if record.name.startswith(("app.", "uvicorn.", "__main__", "filter_export"))
  174. else "External logger event"
  175. ),
  176. )
  177. data.update(
  178. {key: clean(value) if isinstance(value, str) else value for key, value in record.request_fields.items()}
  179. )
  180. data.update({key: getattr(record, key) for key in _SAFE_FIELDS if hasattr(record, key)})
  181. if hasattr(record, "safe_exceptions"):
  182. data["exceptions"] = record.safe_exceptions
  183. return data
  184. class JsonFormatter(logging.Formatter):
  185. def format(self, record):
  186. return json.dumps(
  187. record_data(record), ensure_ascii=True, separators=(",", ":"), default=lambda _: "[unsupported]"
  188. )
  189. class ConsoleFormatter(logging.Formatter):
  190. def format(self, record):
  191. data = record_data(record)
  192. suffix = {key: value for key, value in data.items() if key not in ("timestamp", "level", "logger", "message")}
  193. suffix_text = json.dumps(suffix, ensure_ascii=True, separators=(",", ":")) if suffix else ""
  194. return f"{data['timestamp']} {data['level']} {data['logger']}: {data['message']} {suffix_text}".rstrip()
  195. class SafeConsoleHandler(logging.StreamHandler):
  196. def emit(self, record):
  197. super().emit(record)
  198. record._gc_console_emitted = True
  199. def handleError(self, record):
  200. pass # A broken console must not change application behavior or expose raw LogRecords.
  201. class ApplicationFileHandler(ConcurrentTimedRotatingFileHandler):
  202. def __init__(self, filename, retention_days=30):
  203. self.retention_days = retention_days
  204. self.last_fallback = float("-inf")
  205. super().__init__(
  206. filename=filename,
  207. when="midnight",
  208. interval=1,
  209. utc=True,
  210. backupCount=retention_days,
  211. encoding="utf-8",
  212. maxBytes=0,
  213. )
  214. self.cleanup_archives()
  215. def cleanup_archives(self):
  216. cutoff = datetime.now(timezone.utc).date() - timedelta(days=self.retention_days)
  217. path = Path(self.baseFilename)
  218. for candidate in path.parent.glob(path.name + ".*"):
  219. suffix = candidate.name[len(path.name) + 1 :]
  220. if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", suffix):
  221. continue
  222. try:
  223. if datetime.strptime(suffix, "%Y-%m-%d").date() < cutoff:
  224. candidate.unlink(missing_ok=True)
  225. except (OSError, ValueError):
  226. self.fallback(None, "log_cleanup_failed")
  227. def doRollover(self):
  228. try:
  229. super().doRollover()
  230. except Exception:
  231. self.fallback(None, "log_rotation_failed")
  232. raise
  233. self.cleanup_archives()
  234. def fallback(self, record, event="log_file_unavailable"):
  235. handler = SafeConsoleHandler(sys.stderr)
  236. handler.setFormatter(ConsoleFormatter())
  237. if time.monotonic() - self.last_fallback >= 60:
  238. self.last_fallback = time.monotonic()
  239. notice = logging.LogRecord(
  240. "app.logging", logging.ERROR, __file__, 0, "Logdatei nicht verfügbar; Konsolenfallback aktiv", (), None
  241. )
  242. notice.event = event
  243. ContextFilter().filter(notice)
  244. handler.handle(notice)
  245. if record is not None and not getattr(record, "_gc_console_emitted", False):
  246. handler.handle(record)
  247. def handleError(self, record):
  248. self.fallback(record)
  249. def configure_logging(config=None, *, console_stream=None):
  250. """Replace only our own handlers; repeated startup in one process is a no-op."""
  251. global _configuration
  252. if config is None:
  253. from .config import settings
  254. config = settings
  255. directory = Path(config.LOG_DIR)
  256. if not directory.is_absolute():
  257. directory = PROJECT_ROOT / directory
  258. signature = (
  259. os.getpid(),
  260. str(directory),
  261. config.LOG_LEVEL,
  262. config.LOG_TO_FILE,
  263. config.LOG_TO_CONSOLE,
  264. config.LOG_RETENTION_DAYS,
  265. config.LOG_SQL_STATEMENT,
  266. id(console_stream),
  267. )
  268. with _lock:
  269. root = logging.getLogger()
  270. if _configuration == signature and any(getattr(handler, "_gc_handler", False) for handler in root.handlers):
  271. return
  272. for handler in list(root.handlers):
  273. if getattr(handler, "_gc_handler", False):
  274. root.removeHandler(handler)
  275. handler.close()
  276. handlers, file_failure = [], False
  277. if config.LOG_TO_CONSOLE:
  278. console = SafeConsoleHandler(console_stream or sys.stderr)
  279. console.setFormatter(ConsoleFormatter())
  280. handlers.append(console)
  281. if config.LOG_TO_FILE:
  282. try:
  283. directory.mkdir(parents=True, exist_ok=True)
  284. file_handler = ApplicationFileHandler(str(directory / "application.log"), config.LOG_RETENTION_DAYS)
  285. file_handler.setFormatter(JsonFormatter())
  286. handlers.append(file_handler)
  287. except Exception:
  288. file_failure = True
  289. if not handlers:
  290. console = SafeConsoleHandler(console_stream or sys.stderr)
  291. console.setFormatter(ConsoleFormatter())
  292. handlers.append(console)
  293. if not handlers:
  294. handlers.append(logging.NullHandler())
  295. for handler in handlers:
  296. handler._gc_handler = True
  297. handler.addFilter(ContextFilter())
  298. root.addHandler(handler)
  299. root.setLevel(config.LOG_LEVEL)
  300. for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
  301. logger = logging.getLogger(name)
  302. logger.handlers.clear()
  303. logger.propagate = True
  304. logger.setLevel(logging.INFO)
  305. logging.getLogger("uvicorn.access").disabled = True
  306. for name in ("sqlalchemy", "urllib3", "httpx", "httpcore", "ldap3", "docuware"):
  307. logging.getLogger(name).setLevel(logging.WARNING)
  308. _configuration = signature
  309. if file_failure:
  310. logging.getLogger("app.logging").error(
  311. "Logverzeichnis nicht verfügbar; Konsolenfallback aktiv", extra={"event": "log_file_unavailable"}
  312. )
  313. def database_diagnostics(error, statement=None, parameters=None):
  314. """Read ODBC diagnostics from the driver, never from SQLAlchemy's formatted exception."""
  315. if type(error).__module__ != "pyodbc":
  316. return []
  317. state = (
  318. error.args[0]
  319. if error.args and isinstance(error.args[0], str) and re.fullmatch(r"[A-Z0-9]{5}", error.args[0])
  320. else None
  321. )
  322. secrets = set()
  323. def collect(value):
  324. if isinstance(value, str) and value:
  325. secrets.add(value)
  326. elif isinstance(value, bytes):
  327. collect(value.decode("utf-8", errors="replace"))
  328. elif isinstance(value, dict):
  329. for child in value.values():
  330. collect(child)
  331. elif isinstance(value, (tuple, list)):
  332. for child in value:
  333. collect(child)
  334. collect(parameters)
  335. if statement is not None:
  336. for match in re.finditer(r"N?'((?:''|[^'])*)'", str(statement), re.IGNORECASE):
  337. collect(match[1].replace("''", "'"))
  338. diagnostics = []
  339. for raw in error.args[1:] if state else error.args:
  340. if not isinstance(raw, str):
  341. continue
  342. # pyodbc concatenates SQLGetDiagRec records, appending native codes and the ODBC function.
  343. parts = list(re.finditer(r"(.*?)\s+\((-?\d+)\)(?:\s+\(SQL\w+\))?(?=;\s*|$)", raw, re.DOTALL))
  344. records = [(part[1].lstrip("; \t\r\n"), int(part[2])) for part in parts] or [(raw.strip(), None)]
  345. for description, code in records:
  346. own_state = re.match(r"\[([A-Z0-9]{5})\]", description)
  347. description = re.sub(r"^(?:\[[^\]\r\n]*\]\s*)+", "", description)
  348. # Error 207 identifies schema metadata, not a parameter value. Only exempt
  349. # the canonical English/German message, never arbitrary text with code 207.
  350. column_message = (
  351. description
  352. if code == 207
  353. and re.fullmatch(
  354. r"""(?:Invalid column name|Ungültiger Spaltenname)\s+(?:'(?:''|[^'])*'|"(?:""|[^"])*"|„[^“]*“|“[^”]*”|‚[^‘]*‘|‘[^’]*’|«[^»]*»)\s*\.?""",
  355. description.strip(),
  356. re.IGNORECASE,
  357. )
  358. else None
  359. )
  360. # Values may also be echoed without quotes (notably duplicate key tuples).
  361. for secret in sorted(secrets, key=len, reverse=True):
  362. description = description.replace(secret, "?").replace(secret.replace("'", "''"), "?")
  363. description = re.sub(r"\([^)]*(?:\)|$)", "(?)", description)
  364. description = re.sub(r"(?i)\b(password|pwd|token|secret)\s*[:=]\s*[^;\s]+", r"\1=?", description)
  365. description = clean(column_message if column_message is not None else sanitize_sql(description))[:4096]
  366. diagnostics.append(dict(sqlstate=own_state[1] if own_state else state, code=code, message=description))
  367. return diagnostics[:20]
  368. def log_query_error(error, statement=None, query_name=None, operation=None, parameters=None):
  369. from .config import settings
  370. try:
  371. sql = sanitize_sql(statement) if statement is not None else ""
  372. except Exception:
  373. sql = "[statement unavailable]"
  374. metadata = dict(
  375. event="database_error",
  376. operation=operation or db_operation.get() or "execute",
  377. query_name=query_name or query_origin(),
  378. query_fingerprint=hashlib.sha256(sql.encode()).hexdigest(),
  379. )
  380. if settings.LOG_SQL_STATEMENT and sql:
  381. metadata.update(sql=sql[:8192], sql_truncated=len(sql) > 8192)
  382. roots = exception_roots(error)
  383. diagnostics = []
  384. for root in roots:
  385. if not getattr(root, "_gc_log_frames", None):
  386. root._gc_log_frames = frames()
  387. first = root.args[0] if root.args else None
  388. if isinstance(first, str) and re.fullmatch(r"[A-Z0-9]{5}", first):
  389. metadata["sqlstate"] = first
  390. code = getattr(root, "sqlite_errorcode", None)
  391. if isinstance(code, int):
  392. metadata["db_code"] = code
  393. try:
  394. diagnostics.extend(
  395. database_diagnostics(
  396. root, statement, parameters if parameters is not None else getattr(error, "params", None)
  397. )
  398. )
  399. except Exception:
  400. pass # A malformed driver diagnostic must not replace the original database error.
  401. if diagnostics:
  402. metadata["db_diagnostics"] = diagnostics[:20]
  403. metadata["db_message"] = diagnostics[0]["message"]
  404. if diagnostics[0]["code"] is not None:
  405. metadata["db_code"] = diagnostics[0]["code"]
  406. logging.getLogger("app.db").error(
  407. "Datenbankoperation fehlgeschlagen", exc_info=(type(error), error, error.__traceback__), extra=metadata
  408. )
  409. def query_origin():
  410. for item in reversed(traceback.extract_stack()[:-1]):
  411. path = Path(item.filename)
  412. if PROJECT_ROOT / "app" in path.parents and path.name not in ("logging_setup.py", "db.py"):
  413. return f"{path.name}:{item.name}"
  414. return "database"
  415. def run_cli(callback):
  416. configure_logging()
  417. try:
  418. callback()
  419. except Exception:
  420. logging.getLogger("app.cli").exception("Hilfsprogramm fehlgeschlagen", extra={"event": "cli_failed"})
  421. raise SystemExit(1) from None