# -*- coding: utf-8 -*- """ Log-Formatierer für verschiedene Output-Formate. Bietet JSON, Text und Compact Formatierung für strukturierte Logs. """ from __future__ import annotations import json import logging import traceback from abc import ABC, abstractmethod from datetime import datetime from typing import Any from trixy_core.utils.logging.context import get_context, get_correlation_id class BaseFormatter(logging.Formatter, ABC): """ Abstrakte Basisklasse für Log-Formatierer. Definiert die gemeinsame Schnittstelle für alle Formatierer und bietet Hilfsmethoden für die Verarbeitung von Log-Records. """ # Standard-Zeitformat DEFAULT_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S" DEFAULT_MSEC_FORMAT = "%s.%03d" def __init__( self, time_format: str | None = None, include_context: bool = True, include_correlation_id: bool = True, ) -> None: """ Initialisiert den Formatierer. Args: time_format: Optionales Zeitformat (strftime-kompatibel). include_context: Ob der Log-Kontext eingefügt werden soll. include_correlation_id: Ob die Korrelations-ID eingefügt werden soll. """ super().__init__() self.time_format = time_format or self.DEFAULT_TIME_FORMAT self.include_context = include_context self.include_correlation_id = include_correlation_id def format_timestamp(self, record: logging.LogRecord) -> str: """ Formatiert den Zeitstempel eines Log-Records. Args: record: Der Log-Record. Returns: Formatierter Zeitstempel als ISO-8601-String. """ ct = datetime.fromtimestamp(record.created) return ct.strftime(self.time_format) + f".{int(record.msecs):03d}" def format_exception(self, record: logging.LogRecord) -> str | None: """ Formatiert Exception-Informationen. Args: record: Der Log-Record. Returns: Formatierter Exception-Traceback oder None. """ if record.exc_info: return "".join(traceback.format_exception(*record.exc_info)) return None def get_extra_fields(self, record: logging.LogRecord) -> dict[str, Any]: """ Extrahiert zusätzliche Felder aus dem Log-Record. Args: record: Der Log-Record. Returns: Dictionary mit Extra-Feldern. """ # Standard-Attribute, die nicht als Extra gelten standard_attrs = { "name", "msg", "args", "created", "filename", "funcName", "levelname", "levelno", "lineno", "module", "msecs", "pathname", "process", "processName", "relativeCreated", "stack_info", "exc_info", "exc_text", "thread", "threadName", "message", "asctime", } extra = {} for key, value in record.__dict__.items(): if key not in standard_attrs and not key.startswith("_"): extra[key] = value return extra @abstractmethod def format(self, record: logging.LogRecord) -> str: """ Formatiert einen Log-Record. Args: record: Der zu formatierende Log-Record. Returns: Formatierter Log-String. """ pass class JSONFormatter(BaseFormatter): """ JSON-Formatierer für maschinenlesbare Log-Ausgabe. Ideal für Log-Aggregation in Systemen wie ELK, Splunk oder CloudWatch. """ def __init__( self, time_format: str | None = None, include_context: bool = True, include_correlation_id: bool = True, indent: int | None = None, ensure_ascii: bool = False, include_stacktrace: bool = True, ) -> None: """ Initialisiert den JSON-Formatierer. Args: time_format: Optionales Zeitformat. include_context: Ob der Log-Kontext eingefügt werden soll. include_correlation_id: Ob die Korrelations-ID eingefügt werden soll. indent: Einrückung für Pretty-Print (None für kompakt). ensure_ascii: Ob Nicht-ASCII-Zeichen escaped werden sollen. include_stacktrace: Ob Stacktraces inkludiert werden sollen. """ super().__init__(time_format, include_context, include_correlation_id) self.indent = indent self.ensure_ascii = ensure_ascii self.include_stacktrace = include_stacktrace def format(self, record: logging.LogRecord) -> str: """ Formatiert einen Log-Record als JSON. Args: record: Der zu formatierende Log-Record. Returns: JSON-String mit Log-Daten. """ log_data: dict[str, Any] = { "timestamp": self.format_timestamp(record), "level": record.levelname, "logger": record.name, "message": record.getMessage(), } # Korrelations-ID hinzufügen if self.include_correlation_id: log_data["correlation_id"] = get_correlation_id() # Quell-Informationen log_data["source"] = { "file": record.filename, "line": record.lineno, "function": record.funcName, "module": record.module, } # Process/Thread-Informationen log_data["process"] = { "id": record.process, "name": record.processName, } log_data["thread"] = { "id": record.thread, "name": record.threadName, } # Kontext hinzufügen if self.include_context: context = get_context() if context: log_data["context"] = context # Extra-Felder hinzufügen extra = self.get_extra_fields(record) if extra: log_data["extra"] = extra # Exception-Informationen if self.include_stacktrace: exception = self.format_exception(record) if exception: log_data["exception"] = exception return json.dumps( log_data, indent=self.indent, ensure_ascii=self.ensure_ascii, default=str, ) class TextFormatter(BaseFormatter): """ Klassischer Text-Formatierer für menschenlesbare Log-Ausgabe. Enthält optional farbige Ausgabe für Terminal-Nutzung. """ # ANSI-Farbcodes COLORS = { "DEBUG": "\033[36m", # Cyan "INFO": "\033[32m", # Grün "WARNING": "\033[33m", # Gelb "ERROR": "\033[31m", # Rot "CRITICAL": "\033[35m", # Magenta } RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" def __init__( self, time_format: str | None = None, include_context: bool = True, include_correlation_id: bool = True, use_colors: bool = True, show_source: bool = True, format_string: str | None = None, ) -> None: """ Initialisiert den Text-Formatierer. Args: time_format: Optionales Zeitformat. include_context: Ob der Log-Kontext eingefügt werden soll. include_correlation_id: Ob die Korrelations-ID eingefügt werden soll. use_colors: Ob ANSI-Farben verwendet werden sollen. show_source: Ob Datei/Zeile angezeigt werden soll. format_string: Optionales eigenes Format-Template. """ super().__init__(time_format, include_context, include_correlation_id) self.use_colors = use_colors self.show_source = show_source self.format_string = format_string def _colorize(self, text: str, level: str) -> str: """Fügt Farbcodes basierend auf dem Log-Level hinzu.""" if not self.use_colors: return text color = self.COLORS.get(level, "") return f"{color}{text}{self.RESET}" def format(self, record: logging.LogRecord) -> str: """ Formatiert einen Log-Record als lesbaren Text. Args: record: Der zu formatierende Log-Record. Returns: Formatierter Text-String. """ timestamp = self.format_timestamp(record) level = record.levelname.ljust(8) message = record.getMessage() # Basis-Format parts = [ f"{self.DIM if self.use_colors else ''}{timestamp}{self.RESET if self.use_colors else ''}", self._colorize(level, record.levelname), ] # Korrelations-ID if self.include_correlation_id: cid = get_correlation_id()[:8] # Verkürzt für Lesbarkeit if self.use_colors: parts.append(f"{self.DIM}[{cid}]{self.RESET}") else: parts.append(f"[{cid}]") # Logger-Name parts.append(f"[{record.name}]") # Nachricht parts.append(message) # Quell-Information if self.show_source: source = f"({record.filename}:{record.lineno})" if self.use_colors: parts.append(f"{self.DIM}{source}{self.RESET}") else: parts.append(source) output = " ".join(parts) # Kontext hinzufügen if self.include_context: context = get_context() if context: ctx_str = " ".join(f"{k}={v}" for k, v in context.items()) if self.use_colors: output += f"\n {self.DIM}Context: {ctx_str}{self.RESET}" else: output += f"\n Context: {ctx_str}" # Extra-Felder extra = self.get_extra_fields(record) if extra: extra_str = " ".join(f"{k}={v}" for k, v in extra.items()) if self.use_colors: output += f"\n {self.DIM}Extra: {extra_str}{self.RESET}" else: output += f"\n Extra: {extra_str}" # Exception exception = self.format_exception(record) if exception: if self.use_colors: output += f"\n{self.COLORS['ERROR']}{exception}{self.RESET}" else: output += f"\n{exception}" return output class CompactFormatter(BaseFormatter): """ Kompakter Formatierer für minimale Log-Ausgabe. Ideal für High-Volume-Logging wo Speicherplatz kritisch ist. """ # Einbuchstabige Level-Codes LEVEL_CODES = { "DEBUG": "D", "INFO": "I", "WARNING": "W", "ERROR": "E", "CRITICAL": "C", } def __init__( self, time_format: str | None = None, include_context: bool = False, include_correlation_id: bool = True, separator: str = "|", ) -> None: """ Initialisiert den Compact-Formatierer. Args: time_format: Optionales Zeitformat. include_context: Ob der Log-Kontext eingefügt werden soll. include_correlation_id: Ob die Korrelations-ID eingefügt werden soll. separator: Trennzeichen zwischen Feldern. """ super().__init__(time_format, include_context, include_correlation_id) self.separator = separator def format(self, record: logging.LogRecord) -> str: """ Formatiert einen Log-Record im Kompakt-Format. Args: record: Der zu formatierende Log-Record. Returns: Kompakter Log-String. """ parts = [ self.format_timestamp(record), self.LEVEL_CODES.get(record.levelname, "?"), ] if self.include_correlation_id: parts.append(get_correlation_id()[:8]) parts.append(record.name) parts.append(record.getMessage()) output = self.separator.join(parts) # Exception als zusätzliche Zeile exception = self.format_exception(record) if exception: # Nur erste Zeile des Tracebacks first_line = exception.strip().split("\n")[-1] output += f"{self.separator}EXC:{first_line}" return output class KeyValueFormatter(BaseFormatter): """ Key-Value-Formatierer für einfache Parsing-Kompatibilität. Ausgabe im Format: key1=value1 key2=value2 """ def __init__( self, time_format: str | None = None, include_context: bool = True, include_correlation_id: bool = True, quote_strings: bool = True, ) -> None: """ Initialisiert den Key-Value-Formatierer. Args: time_format: Optionales Zeitformat. include_context: Ob der Log-Kontext eingefügt werden soll. include_correlation_id: Ob die Korrelations-ID eingefügt werden soll. quote_strings: Ob String-Werte in Anführungszeichen gesetzt werden. """ super().__init__(time_format, include_context, include_correlation_id) self.quote_strings = quote_strings def _format_value(self, value: Any) -> str: """Formatiert einen Wert für die Ausgabe.""" if isinstance(value, str): # Escape Anführungszeichen und füge Quotes hinzu escaped = value.replace('"', '\\"') return f'"{escaped}"' if self.quote_strings else escaped return str(value) def format(self, record: logging.LogRecord) -> str: """ Formatiert einen Log-Record als Key-Value-Paare. Args: record: Der zu formatierende Log-Record. Returns: Key-Value-formatierter String. """ pairs = [ f"timestamp={self._format_value(self.format_timestamp(record))}", f"level={record.levelname}", f"logger={record.name}", f"message={self._format_value(record.getMessage())}", ] if self.include_correlation_id: pairs.append(f"correlation_id={get_correlation_id()}") pairs.extend([ f"file={record.filename}", f"line={record.lineno}", f"function={record.funcName}", ]) # Kontext hinzufügen if self.include_context: context = get_context() for key, value in context.items(): pairs.append(f"ctx_{key}={self._format_value(value)}") # Extra-Felder extra = self.get_extra_fields(record) for key, value in extra.items(): pairs.append(f"{key}={self._format_value(value)}") # Exception exception = self.format_exception(record) if exception: # Nur letzte Zeile (die Exception selbst) last_line = exception.strip().split("\n")[-1] pairs.append(f"exception={self._format_value(last_line)}") return " ".join(pairs)