formatters.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. # -*- coding: utf-8 -*-
  2. """
  3. Log-Formatierer für verschiedene Output-Formate.
  4. Bietet JSON, Text und Compact Formatierung für strukturierte Logs.
  5. """
  6. from __future__ import annotations
  7. import json
  8. import logging
  9. import traceback
  10. from abc import ABC, abstractmethod
  11. from datetime import datetime
  12. from typing import Any
  13. from trixy_core.utils.logging.context import get_context, get_correlation_id
  14. class BaseFormatter(logging.Formatter, ABC):
  15. """
  16. Abstrakte Basisklasse für Log-Formatierer.
  17. Definiert die gemeinsame Schnittstelle für alle Formatierer
  18. und bietet Hilfsmethoden für die Verarbeitung von Log-Records.
  19. """
  20. # Standard-Zeitformat
  21. DEFAULT_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S"
  22. DEFAULT_MSEC_FORMAT = "%s.%03d"
  23. def __init__(
  24. self,
  25. time_format: str | None = None,
  26. include_context: bool = True,
  27. include_correlation_id: bool = True,
  28. ) -> None:
  29. """
  30. Initialisiert den Formatierer.
  31. Args:
  32. time_format: Optionales Zeitformat (strftime-kompatibel).
  33. include_context: Ob der Log-Kontext eingefügt werden soll.
  34. include_correlation_id: Ob die Korrelations-ID eingefügt werden soll.
  35. """
  36. super().__init__()
  37. self.time_format = time_format or self.DEFAULT_TIME_FORMAT
  38. self.include_context = include_context
  39. self.include_correlation_id = include_correlation_id
  40. def format_timestamp(self, record: logging.LogRecord) -> str:
  41. """
  42. Formatiert den Zeitstempel eines Log-Records.
  43. Args:
  44. record: Der Log-Record.
  45. Returns:
  46. Formatierter Zeitstempel als ISO-8601-String.
  47. """
  48. ct = datetime.fromtimestamp(record.created)
  49. return ct.strftime(self.time_format) + f".{int(record.msecs):03d}"
  50. def format_exception(self, record: logging.LogRecord) -> str | None:
  51. """
  52. Formatiert Exception-Informationen.
  53. Args:
  54. record: Der Log-Record.
  55. Returns:
  56. Formatierter Exception-Traceback oder None.
  57. """
  58. if record.exc_info:
  59. return "".join(traceback.format_exception(*record.exc_info))
  60. return None
  61. def get_extra_fields(self, record: logging.LogRecord) -> dict[str, Any]:
  62. """
  63. Extrahiert zusätzliche Felder aus dem Log-Record.
  64. Args:
  65. record: Der Log-Record.
  66. Returns:
  67. Dictionary mit Extra-Feldern.
  68. """
  69. # Standard-Attribute, die nicht als Extra gelten
  70. standard_attrs = {
  71. "name", "msg", "args", "created", "filename", "funcName",
  72. "levelname", "levelno", "lineno", "module", "msecs",
  73. "pathname", "process", "processName", "relativeCreated",
  74. "stack_info", "exc_info", "exc_text", "thread", "threadName",
  75. "message", "asctime",
  76. }
  77. extra = {}
  78. for key, value in record.__dict__.items():
  79. if key not in standard_attrs and not key.startswith("_"):
  80. extra[key] = value
  81. return extra
  82. @abstractmethod
  83. def format(self, record: logging.LogRecord) -> str:
  84. """
  85. Formatiert einen Log-Record.
  86. Args:
  87. record: Der zu formatierende Log-Record.
  88. Returns:
  89. Formatierter Log-String.
  90. """
  91. pass
  92. class JSONFormatter(BaseFormatter):
  93. """
  94. JSON-Formatierer für maschinenlesbare Log-Ausgabe.
  95. Ideal für Log-Aggregation in Systemen wie ELK, Splunk oder CloudWatch.
  96. """
  97. def __init__(
  98. self,
  99. time_format: str | None = None,
  100. include_context: bool = True,
  101. include_correlation_id: bool = True,
  102. indent: int | None = None,
  103. ensure_ascii: bool = False,
  104. include_stacktrace: bool = True,
  105. ) -> None:
  106. """
  107. Initialisiert den JSON-Formatierer.
  108. Args:
  109. time_format: Optionales Zeitformat.
  110. include_context: Ob der Log-Kontext eingefügt werden soll.
  111. include_correlation_id: Ob die Korrelations-ID eingefügt werden soll.
  112. indent: Einrückung für Pretty-Print (None für kompakt).
  113. ensure_ascii: Ob Nicht-ASCII-Zeichen escaped werden sollen.
  114. include_stacktrace: Ob Stacktraces inkludiert werden sollen.
  115. """
  116. super().__init__(time_format, include_context, include_correlation_id)
  117. self.indent = indent
  118. self.ensure_ascii = ensure_ascii
  119. self.include_stacktrace = include_stacktrace
  120. def format(self, record: logging.LogRecord) -> str:
  121. """
  122. Formatiert einen Log-Record als JSON.
  123. Args:
  124. record: Der zu formatierende Log-Record.
  125. Returns:
  126. JSON-String mit Log-Daten.
  127. """
  128. log_data: dict[str, Any] = {
  129. "timestamp": self.format_timestamp(record),
  130. "level": record.levelname,
  131. "logger": record.name,
  132. "message": record.getMessage(),
  133. }
  134. # Korrelations-ID hinzufügen
  135. if self.include_correlation_id:
  136. log_data["correlation_id"] = get_correlation_id()
  137. # Quell-Informationen
  138. log_data["source"] = {
  139. "file": record.filename,
  140. "line": record.lineno,
  141. "function": record.funcName,
  142. "module": record.module,
  143. }
  144. # Process/Thread-Informationen
  145. log_data["process"] = {
  146. "id": record.process,
  147. "name": record.processName,
  148. }
  149. log_data["thread"] = {
  150. "id": record.thread,
  151. "name": record.threadName,
  152. }
  153. # Kontext hinzufügen
  154. if self.include_context:
  155. context = get_context()
  156. if context:
  157. log_data["context"] = context
  158. # Extra-Felder hinzufügen
  159. extra = self.get_extra_fields(record)
  160. if extra:
  161. log_data["extra"] = extra
  162. # Exception-Informationen
  163. if self.include_stacktrace:
  164. exception = self.format_exception(record)
  165. if exception:
  166. log_data["exception"] = exception
  167. return json.dumps(
  168. log_data,
  169. indent=self.indent,
  170. ensure_ascii=self.ensure_ascii,
  171. default=str,
  172. )
  173. class TextFormatter(BaseFormatter):
  174. """
  175. Klassischer Text-Formatierer für menschenlesbare Log-Ausgabe.
  176. Enthält optional farbige Ausgabe für Terminal-Nutzung.
  177. """
  178. # ANSI-Farbcodes
  179. COLORS = {
  180. "DEBUG": "\033[36m", # Cyan
  181. "INFO": "\033[32m", # Grün
  182. "WARNING": "\033[33m", # Gelb
  183. "ERROR": "\033[31m", # Rot
  184. "CRITICAL": "\033[35m", # Magenta
  185. }
  186. RESET = "\033[0m"
  187. BOLD = "\033[1m"
  188. DIM = "\033[2m"
  189. def __init__(
  190. self,
  191. time_format: str | None = None,
  192. include_context: bool = True,
  193. include_correlation_id: bool = True,
  194. use_colors: bool = True,
  195. show_source: bool = True,
  196. format_string: str | None = None,
  197. ) -> None:
  198. """
  199. Initialisiert den Text-Formatierer.
  200. Args:
  201. time_format: Optionales Zeitformat.
  202. include_context: Ob der Log-Kontext eingefügt werden soll.
  203. include_correlation_id: Ob die Korrelations-ID eingefügt werden soll.
  204. use_colors: Ob ANSI-Farben verwendet werden sollen.
  205. show_source: Ob Datei/Zeile angezeigt werden soll.
  206. format_string: Optionales eigenes Format-Template.
  207. """
  208. super().__init__(time_format, include_context, include_correlation_id)
  209. self.use_colors = use_colors
  210. self.show_source = show_source
  211. self.format_string = format_string
  212. def _colorize(self, text: str, level: str) -> str:
  213. """Fügt Farbcodes basierend auf dem Log-Level hinzu."""
  214. if not self.use_colors:
  215. return text
  216. color = self.COLORS.get(level, "")
  217. return f"{color}{text}{self.RESET}"
  218. def format(self, record: logging.LogRecord) -> str:
  219. """
  220. Formatiert einen Log-Record als lesbaren Text.
  221. Args:
  222. record: Der zu formatierende Log-Record.
  223. Returns:
  224. Formatierter Text-String.
  225. """
  226. timestamp = self.format_timestamp(record)
  227. level = record.levelname.ljust(8)
  228. message = record.getMessage()
  229. # Basis-Format
  230. parts = [
  231. f"{self.DIM if self.use_colors else ''}{timestamp}{self.RESET if self.use_colors else ''}",
  232. self._colorize(level, record.levelname),
  233. ]
  234. # Korrelations-ID
  235. if self.include_correlation_id:
  236. cid = get_correlation_id()[:8] # Verkürzt für Lesbarkeit
  237. if self.use_colors:
  238. parts.append(f"{self.DIM}[{cid}]{self.RESET}")
  239. else:
  240. parts.append(f"[{cid}]")
  241. # Logger-Name
  242. parts.append(f"[{record.name}]")
  243. # Nachricht
  244. parts.append(message)
  245. # Quell-Information
  246. if self.show_source:
  247. source = f"({record.filename}:{record.lineno})"
  248. if self.use_colors:
  249. parts.append(f"{self.DIM}{source}{self.RESET}")
  250. else:
  251. parts.append(source)
  252. output = " ".join(parts)
  253. # Kontext hinzufügen
  254. if self.include_context:
  255. context = get_context()
  256. if context:
  257. ctx_str = " ".join(f"{k}={v}" for k, v in context.items())
  258. if self.use_colors:
  259. output += f"\n {self.DIM}Context: {ctx_str}{self.RESET}"
  260. else:
  261. output += f"\n Context: {ctx_str}"
  262. # Extra-Felder
  263. extra = self.get_extra_fields(record)
  264. if extra:
  265. extra_str = " ".join(f"{k}={v}" for k, v in extra.items())
  266. if self.use_colors:
  267. output += f"\n {self.DIM}Extra: {extra_str}{self.RESET}"
  268. else:
  269. output += f"\n Extra: {extra_str}"
  270. # Exception
  271. exception = self.format_exception(record)
  272. if exception:
  273. if self.use_colors:
  274. output += f"\n{self.COLORS['ERROR']}{exception}{self.RESET}"
  275. else:
  276. output += f"\n{exception}"
  277. return output
  278. class CompactFormatter(BaseFormatter):
  279. """
  280. Kompakter Formatierer für minimale Log-Ausgabe.
  281. Ideal für High-Volume-Logging wo Speicherplatz kritisch ist.
  282. """
  283. # Einbuchstabige Level-Codes
  284. LEVEL_CODES = {
  285. "DEBUG": "D",
  286. "INFO": "I",
  287. "WARNING": "W",
  288. "ERROR": "E",
  289. "CRITICAL": "C",
  290. }
  291. def __init__(
  292. self,
  293. time_format: str | None = None,
  294. include_context: bool = False,
  295. include_correlation_id: bool = True,
  296. separator: str = "|",
  297. ) -> None:
  298. """
  299. Initialisiert den Compact-Formatierer.
  300. Args:
  301. time_format: Optionales Zeitformat.
  302. include_context: Ob der Log-Kontext eingefügt werden soll.
  303. include_correlation_id: Ob die Korrelations-ID eingefügt werden soll.
  304. separator: Trennzeichen zwischen Feldern.
  305. """
  306. super().__init__(time_format, include_context, include_correlation_id)
  307. self.separator = separator
  308. def format(self, record: logging.LogRecord) -> str:
  309. """
  310. Formatiert einen Log-Record im Kompakt-Format.
  311. Args:
  312. record: Der zu formatierende Log-Record.
  313. Returns:
  314. Kompakter Log-String.
  315. """
  316. parts = [
  317. self.format_timestamp(record),
  318. self.LEVEL_CODES.get(record.levelname, "?"),
  319. ]
  320. if self.include_correlation_id:
  321. parts.append(get_correlation_id()[:8])
  322. parts.append(record.name)
  323. parts.append(record.getMessage())
  324. output = self.separator.join(parts)
  325. # Exception als zusätzliche Zeile
  326. exception = self.format_exception(record)
  327. if exception:
  328. # Nur erste Zeile des Tracebacks
  329. first_line = exception.strip().split("\n")[-1]
  330. output += f"{self.separator}EXC:{first_line}"
  331. return output
  332. class KeyValueFormatter(BaseFormatter):
  333. """
  334. Key-Value-Formatierer für einfache Parsing-Kompatibilität.
  335. Ausgabe im Format: key1=value1 key2=value2
  336. """
  337. def __init__(
  338. self,
  339. time_format: str | None = None,
  340. include_context: bool = True,
  341. include_correlation_id: bool = True,
  342. quote_strings: bool = True,
  343. ) -> None:
  344. """
  345. Initialisiert den Key-Value-Formatierer.
  346. Args:
  347. time_format: Optionales Zeitformat.
  348. include_context: Ob der Log-Kontext eingefügt werden soll.
  349. include_correlation_id: Ob die Korrelations-ID eingefügt werden soll.
  350. quote_strings: Ob String-Werte in Anführungszeichen gesetzt werden.
  351. """
  352. super().__init__(time_format, include_context, include_correlation_id)
  353. self.quote_strings = quote_strings
  354. def _format_value(self, value: Any) -> str:
  355. """Formatiert einen Wert für die Ausgabe."""
  356. if isinstance(value, str):
  357. # Escape Anführungszeichen und füge Quotes hinzu
  358. escaped = value.replace('"', '\\"')
  359. return f'"{escaped}"' if self.quote_strings else escaped
  360. return str(value)
  361. def format(self, record: logging.LogRecord) -> str:
  362. """
  363. Formatiert einen Log-Record als Key-Value-Paare.
  364. Args:
  365. record: Der zu formatierende Log-Record.
  366. Returns:
  367. Key-Value-formatierter String.
  368. """
  369. pairs = [
  370. f"timestamp={self._format_value(self.format_timestamp(record))}",
  371. f"level={record.levelname}",
  372. f"logger={record.name}",
  373. f"message={self._format_value(record.getMessage())}",
  374. ]
  375. if self.include_correlation_id:
  376. pairs.append(f"correlation_id={get_correlation_id()}")
  377. pairs.extend([
  378. f"file={record.filename}",
  379. f"line={record.lineno}",
  380. f"function={record.funcName}",
  381. ])
  382. # Kontext hinzufügen
  383. if self.include_context:
  384. context = get_context()
  385. for key, value in context.items():
  386. pairs.append(f"ctx_{key}={self._format_value(value)}")
  387. # Extra-Felder
  388. extra = self.get_extra_fields(record)
  389. for key, value in extra.items():
  390. pairs.append(f"{key}={self._format_value(value)}")
  391. # Exception
  392. exception = self.format_exception(record)
  393. if exception:
  394. # Nur letzte Zeile (die Exception selbst)
  395. last_line = exception.strip().split("\n")[-1]
  396. pairs.append(f"exception={self._format_value(last_line)}")
  397. return " ".join(pairs)