handlers.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. # -*- coding: utf-8 -*-
  2. """
  3. Erweiterte Log-Handler für verschiedene Output-Ziele.
  4. Bietet Rotating, Syslog und Async Handler für produktionsreifes Logging.
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. import gzip
  9. import logging
  10. import os
  11. import queue
  12. import socket
  13. import threading
  14. from datetime import datetime
  15. from pathlib import Path
  16. from typing import Any, Callable
  17. class RotatingFileHandler(logging.Handler):
  18. """
  19. Handler für rotierende Log-Dateien mit Größen- und Zeitlimits.
  20. Unterstützt automatische Kompression alter Logs und Aufräumung
  21. basierend auf Anzahl oder Alter der Backup-Dateien.
  22. """
  23. def __init__(
  24. self,
  25. filename: str | Path,
  26. max_bytes: int = 10 * 1024 * 1024, # 10 MB
  27. backup_count: int = 5,
  28. rotate_on_time: bool = False,
  29. rotation_interval: str = "midnight", # midnight, hourly, daily
  30. compress: bool = True,
  31. encoding: str = "utf-8",
  32. ) -> None:
  33. """
  34. Initialisiert den Rotating-Handler.
  35. Args:
  36. filename: Pfad zur Log-Datei.
  37. max_bytes: Maximale Dateigröße vor Rotation.
  38. backup_count: Anzahl der zu behaltenden Backup-Dateien.
  39. rotate_on_time: Ob zusätzlich zeitbasiert rotiert werden soll.
  40. rotation_interval: Rotationsintervall (midnight, hourly, daily).
  41. compress: Ob alte Dateien komprimiert werden sollen.
  42. encoding: Datei-Encoding.
  43. """
  44. super().__init__()
  45. self.filename = Path(filename)
  46. self.max_bytes = max_bytes
  47. self.backup_count = backup_count
  48. self.rotate_on_time = rotate_on_time
  49. self.rotation_interval = rotation_interval
  50. self.compress = compress
  51. self.encoding = encoding
  52. self._stream: Any = None
  53. self._lock = threading.RLock()
  54. self._current_size = 0
  55. self._last_rotation: datetime | None = None
  56. self._rotation_at: datetime | None = None
  57. # Verzeichnis erstellen falls nötig
  58. self.filename.parent.mkdir(parents=True, exist_ok=True)
  59. # Initiale Datei öffnen
  60. self._open_stream()
  61. # Nächste zeitbasierte Rotation berechnen
  62. if self.rotate_on_time:
  63. self._calculate_next_rotation()
  64. def _open_stream(self) -> None:
  65. """Öffnet den Datei-Stream."""
  66. with self._lock:
  67. if self._stream:
  68. self._stream.close()
  69. self._stream = open(
  70. self.filename,
  71. "a",
  72. encoding=self.encoding,
  73. )
  74. # Aktuelle Dateigröße ermitteln
  75. try:
  76. self._current_size = self.filename.stat().st_size
  77. except OSError:
  78. self._current_size = 0
  79. def _calculate_next_rotation(self) -> None:
  80. """Berechnet den nächsten Rotationszeitpunkt."""
  81. now = datetime.now()
  82. if self.rotation_interval == "midnight":
  83. # Nächster Mitternacht
  84. next_day = now.replace(
  85. hour=0, minute=0, second=0, microsecond=0
  86. )
  87. if next_day <= now:
  88. from datetime import timedelta
  89. next_day += timedelta(days=1)
  90. self._rotation_at = next_day
  91. elif self.rotation_interval == "hourly":
  92. # Nächste volle Stunde
  93. next_hour = now.replace(minute=0, second=0, microsecond=0)
  94. if next_hour <= now:
  95. from datetime import timedelta
  96. next_hour += timedelta(hours=1)
  97. self._rotation_at = next_hour
  98. elif self.rotation_interval == "daily":
  99. # Gleiche Zeit morgen
  100. from datetime import timedelta
  101. self._rotation_at = now + timedelta(days=1)
  102. def _should_rotate(self) -> bool:
  103. """Prüft, ob rotiert werden sollte."""
  104. # Größenbasierte Rotation
  105. if self._current_size >= self.max_bytes:
  106. return True
  107. # Zeitbasierte Rotation
  108. if self.rotate_on_time and self._rotation_at:
  109. if datetime.now() >= self._rotation_at:
  110. return True
  111. return False
  112. def _do_rotation(self) -> None:
  113. """Führt die Dateirotation durch."""
  114. with self._lock:
  115. if self._stream:
  116. self._stream.close()
  117. self._stream = None
  118. # Backup-Dateien rotieren
  119. for i in range(self.backup_count - 1, 0, -1):
  120. src = self._get_backup_name(i)
  121. dst = self._get_backup_name(i + 1)
  122. if src.exists():
  123. if dst.exists():
  124. dst.unlink()
  125. src.rename(dst)
  126. # Aktuelle Datei als erstes Backup
  127. if self.filename.exists():
  128. backup = self._get_backup_name(1)
  129. if backup.exists():
  130. backup.unlink()
  131. self.filename.rename(backup)
  132. # Optional komprimieren
  133. if self.compress:
  134. self._compress_file(backup)
  135. # Überzählige Backups löschen
  136. self._cleanup_old_backups()
  137. # Neue Datei öffnen
  138. self._open_stream()
  139. self._last_rotation = datetime.now()
  140. if self.rotate_on_time:
  141. self._calculate_next_rotation()
  142. def _get_backup_name(self, index: int) -> Path:
  143. """Generiert den Namen für eine Backup-Datei."""
  144. suffix = f".{index}"
  145. if self.compress and index > 0:
  146. suffix += ".gz"
  147. return self.filename.with_suffix(self.filename.suffix + suffix)
  148. def _compress_file(self, filepath: Path) -> None:
  149. """Komprimiert eine Datei mit gzip."""
  150. try:
  151. compressed = filepath.with_suffix(filepath.suffix + ".gz")
  152. with open(filepath, "rb") as f_in:
  153. with gzip.open(compressed, "wb") as f_out:
  154. f_out.write(f_in.read())
  155. filepath.unlink()
  156. except Exception:
  157. # Bei Fehlern unkomprimierte Datei behalten
  158. pass
  159. def _cleanup_old_backups(self) -> None:
  160. """Entfernt überzählige Backup-Dateien."""
  161. for i in range(self.backup_count + 1, self.backup_count + 100):
  162. backup = self._get_backup_name(i)
  163. if backup.exists():
  164. backup.unlink()
  165. else:
  166. break
  167. def emit(self, record: logging.LogRecord) -> None:
  168. """
  169. Schreibt einen Log-Record in die Datei.
  170. Args:
  171. record: Der zu schreibende Log-Record.
  172. """
  173. try:
  174. with self._lock:
  175. if self._should_rotate():
  176. self._do_rotation()
  177. msg = self.format(record) + "\n"
  178. self._stream.write(msg)
  179. self._stream.flush()
  180. self._current_size += len(msg.encode(self.encoding))
  181. except Exception:
  182. self.handleError(record)
  183. def close(self) -> None:
  184. """Schließt den Handler und den Datei-Stream."""
  185. with self._lock:
  186. if self._stream:
  187. self._stream.close()
  188. self._stream = None
  189. super().close()
  190. class SyslogHandler(logging.Handler):
  191. """
  192. Handler für Syslog-Ausgabe (RFC 5424).
  193. Unterstützt UDP und TCP Verbindungen zu lokalen oder Remote-Syslog-Servern.
  194. """
  195. # Syslog Facility Codes
  196. FACILITIES = {
  197. "kern": 0, "user": 1, "mail": 2, "daemon": 3,
  198. "auth": 4, "syslog": 5, "lpr": 6, "news": 7,
  199. "uucp": 8, "cron": 9, "authpriv": 10, "ftp": 11,
  200. "local0": 16, "local1": 17, "local2": 18, "local3": 19,
  201. "local4": 20, "local5": 21, "local6": 22, "local7": 23,
  202. }
  203. # Syslog Severity (entspricht ungefähr Python Log-Levels)
  204. SEVERITY = {
  205. logging.CRITICAL: 2, # CRITICAL
  206. logging.ERROR: 3, # ERROR
  207. logging.WARNING: 4, # WARNING
  208. logging.INFO: 6, # INFO
  209. logging.DEBUG: 7, # DEBUG
  210. }
  211. def __init__(
  212. self,
  213. address: tuple[str, int] = ("localhost", 514),
  214. facility: str = "user",
  215. use_tcp: bool = False,
  216. app_name: str = "trixy",
  217. ) -> None:
  218. """
  219. Initialisiert den Syslog-Handler.
  220. Args:
  221. address: (Host, Port) Tupel für den Syslog-Server.
  222. facility: Syslog Facility (local0-local7, daemon, etc.).
  223. use_tcp: Ob TCP statt UDP verwendet werden soll.
  224. app_name: Anwendungsname für die Syslog-Nachricht.
  225. """
  226. super().__init__()
  227. self.address = address
  228. self.facility = self.FACILITIES.get(facility, 1)
  229. self.use_tcp = use_tcp
  230. self.app_name = app_name
  231. self._socket: socket.socket | None = None
  232. self._lock = threading.Lock()
  233. self._connect()
  234. def _connect(self) -> None:
  235. """Stellt die Verbindung zum Syslog-Server her."""
  236. with self._lock:
  237. if self._socket:
  238. self._socket.close()
  239. if self.use_tcp:
  240. self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  241. self._socket.connect(self.address)
  242. else:
  243. self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  244. def _format_message(self, record: logging.LogRecord) -> bytes:
  245. """Formatiert eine Syslog-Nachricht nach RFC 5424."""
  246. severity = self.SEVERITY.get(record.levelno, 6)
  247. priority = (self.facility << 3) | severity
  248. timestamp = datetime.fromtimestamp(record.created).isoformat()
  249. hostname = socket.gethostname()
  250. pid = os.getpid()
  251. msg = self.format(record)
  252. # RFC 5424 Format
  253. syslog_msg = (
  254. f"<{priority}>1 {timestamp} {hostname} "
  255. f"{self.app_name} {pid} - - {msg}"
  256. )
  257. return syslog_msg.encode("utf-8")
  258. def emit(self, record: logging.LogRecord) -> None:
  259. """
  260. Sendet einen Log-Record an den Syslog-Server.
  261. Args:
  262. record: Der zu sendende Log-Record.
  263. """
  264. try:
  265. msg = self._format_message(record)
  266. with self._lock:
  267. if self._socket is None:
  268. self._connect()
  269. if self.use_tcp:
  270. self._socket.send(msg + b"\n")
  271. else:
  272. self._socket.sendto(msg, self.address)
  273. except Exception:
  274. self.handleError(record)
  275. # Verbindung neu aufbauen bei Fehlern
  276. self._socket = None
  277. def close(self) -> None:
  278. """Schließt den Handler und die Socket-Verbindung."""
  279. with self._lock:
  280. if self._socket:
  281. self._socket.close()
  282. self._socket = None
  283. super().close()
  284. class AsyncHandler(logging.Handler):
  285. """
  286. Asynchroner Handler für nicht-blockierendes Logging.
  287. Logs werden in eine Queue geschrieben und von einem
  288. Hintergrund-Thread verarbeitet.
  289. """
  290. def __init__(
  291. self,
  292. handler: logging.Handler,
  293. queue_size: int = 10000,
  294. flush_on_close: bool = True,
  295. overflow_policy: str = "drop", # drop, block, replace_oldest
  296. ) -> None:
  297. """
  298. Initialisiert den Async-Handler.
  299. Args:
  300. handler: Der eigentliche Handler für die Verarbeitung.
  301. queue_size: Maximale Größe der Queue.
  302. flush_on_close: Ob die Queue beim Schließen geleert werden soll.
  303. overflow_policy: Verhalten bei voller Queue.
  304. """
  305. super().__init__()
  306. self._handler = handler
  307. self._queue: queue.Queue[logging.LogRecord | None] = queue.Queue(
  308. maxsize=queue_size
  309. )
  310. self._flush_on_close = flush_on_close
  311. self._overflow_policy = overflow_policy
  312. self._running = True
  313. self._dropped = 0
  314. # Hintergrund-Thread starten
  315. self._thread = threading.Thread(
  316. target=self._process_queue,
  317. daemon=True,
  318. name="AsyncLogHandler",
  319. )
  320. self._thread.start()
  321. def _process_queue(self) -> None:
  322. """Verarbeitet Log-Records aus der Queue."""
  323. while self._running or not self._queue.empty():
  324. try:
  325. record = self._queue.get(timeout=0.1)
  326. if record is None:
  327. break
  328. self._handler.handle(record)
  329. except queue.Empty:
  330. continue
  331. except Exception:
  332. # Fehler in der Verarbeitung ignorieren
  333. pass
  334. def emit(self, record: logging.LogRecord) -> None:
  335. """
  336. Fügt einen Log-Record zur Queue hinzu.
  337. Args:
  338. record: Der zu logende Record.
  339. """
  340. if not self._running:
  341. return
  342. try:
  343. if self._overflow_policy == "block":
  344. self._queue.put(record)
  345. elif self._overflow_policy == "replace_oldest":
  346. try:
  347. self._queue.put_nowait(record)
  348. except queue.Full:
  349. # Ältestes Element entfernen
  350. try:
  351. self._queue.get_nowait()
  352. self._dropped += 1
  353. except queue.Empty:
  354. pass
  355. self._queue.put_nowait(record)
  356. else: # drop
  357. try:
  358. self._queue.put_nowait(record)
  359. except queue.Full:
  360. self._dropped += 1
  361. except Exception:
  362. self.handleError(record)
  363. @property
  364. def dropped_count(self) -> int:
  365. """Gibt die Anzahl der verworfenen Records zurück."""
  366. return self._dropped
  367. @property
  368. def queue_size(self) -> int:
  369. """Gibt die aktuelle Queue-Größe zurück."""
  370. return self._queue.qsize()
  371. def flush(self) -> None:
  372. """Wartet bis die Queue leer ist."""
  373. while not self._queue.empty():
  374. pass
  375. self._handler.flush()
  376. def close(self) -> None:
  377. """Schließt den Handler und stoppt den Thread."""
  378. self._running = False
  379. if self._flush_on_close:
  380. self.flush()
  381. # Sentinel-Wert zum Beenden des Threads
  382. try:
  383. self._queue.put_nowait(None)
  384. except queue.Full:
  385. pass
  386. self._thread.join(timeout=5.0)
  387. self._handler.close()
  388. super().close()
  389. class BufferedHandler(logging.Handler):
  390. """
  391. Handler mit Pufferung für Batch-Verarbeitung.
  392. Sammelt Log-Records und schreibt sie in Batches.
  393. """
  394. def __init__(
  395. self,
  396. handler: logging.Handler,
  397. capacity: int = 100,
  398. flush_interval: float = 5.0,
  399. flush_level: int | None = logging.ERROR,
  400. ) -> None:
  401. """
  402. Initialisiert den Buffered-Handler.
  403. Args:
  404. handler: Der eigentliche Handler für die Ausgabe.
  405. capacity: Maximale Puffer-Größe vor automatischem Flush.
  406. flush_interval: Zeitintervall für automatisches Flushing (Sekunden).
  407. flush_level: Log-Level bei dem sofort geflusht wird.
  408. """
  409. super().__init__()
  410. self._handler = handler
  411. self._capacity = capacity
  412. self._flush_interval = flush_interval
  413. self._flush_level = flush_level
  414. self._buffer: list[logging.LogRecord] = []
  415. self._lock = threading.Lock()
  416. self._last_flush = datetime.now()
  417. # Hintergrund-Thread für zeitbasiertes Flushing
  418. self._running = True
  419. self._timer_thread = threading.Thread(
  420. target=self._timer_loop,
  421. daemon=True,
  422. name="BufferedLogTimer",
  423. )
  424. self._timer_thread.start()
  425. def _timer_loop(self) -> None:
  426. """Prüft periodisch auf zeitbasiertes Flushing."""
  427. while self._running:
  428. threading.Event().wait(1.0) # 1 Sekunde warten
  429. with self._lock:
  430. elapsed = (datetime.now() - self._last_flush).total_seconds()
  431. if elapsed >= self._flush_interval and self._buffer:
  432. self._do_flush()
  433. def _do_flush(self) -> None:
  434. """Schreibt alle gepufferten Records."""
  435. for record in self._buffer:
  436. self._handler.handle(record)
  437. self._buffer.clear()
  438. self._last_flush = datetime.now()
  439. def emit(self, record: logging.LogRecord) -> None:
  440. """
  441. Puffert einen Log-Record.
  442. Args:
  443. record: Der zu puffernde Record.
  444. """
  445. with self._lock:
  446. self._buffer.append(record)
  447. # Sofortiges Flushing bei kritischen Levels
  448. if (
  449. self._flush_level is not None
  450. and record.levelno >= self._flush_level
  451. ):
  452. self._do_flush()
  453. elif len(self._buffer) >= self._capacity:
  454. self._do_flush()
  455. def flush(self) -> None:
  456. """Flusht den Puffer."""
  457. with self._lock:
  458. self._do_flush()
  459. self._handler.flush()
  460. def close(self) -> None:
  461. """Schließt den Handler."""
  462. self._running = False
  463. self.flush()
  464. self._timer_thread.join(timeout=2.0)
  465. self._handler.close()
  466. super().close()
  467. class FilteringHandler(logging.Handler):
  468. """
  469. Handler mit erweiterter Filterung.
  470. Unterstützt Filterung basierend auf Regex, Callables und Attributen.
  471. """
  472. def __init__(
  473. self,
  474. handler: logging.Handler,
  475. include_patterns: list[str] | None = None,
  476. exclude_patterns: list[str] | None = None,
  477. level_filter: Callable[[logging.LogRecord], bool] | None = None,
  478. ) -> None:
  479. """
  480. Initialisiert den Filtering-Handler.
  481. Args:
  482. handler: Der eigentliche Handler.
  483. include_patterns: Regex-Pattern für inkludierte Logger-Namen.
  484. exclude_patterns: Regex-Pattern für exkludierte Logger-Namen.
  485. level_filter: Optionale Funktion für Level-basierte Filterung.
  486. """
  487. super().__init__()
  488. self._handler = handler
  489. self._level_filter = level_filter
  490. import re
  491. self._include_patterns = (
  492. [re.compile(p) for p in include_patterns]
  493. if include_patterns else None
  494. )
  495. self._exclude_patterns = (
  496. [re.compile(p) for p in exclude_patterns]
  497. if exclude_patterns else None
  498. )
  499. def _should_emit(self, record: logging.LogRecord) -> bool:
  500. """Prüft, ob der Record emittiert werden soll."""
  501. # Include-Filter
  502. if self._include_patterns:
  503. if not any(p.search(record.name) for p in self._include_patterns):
  504. return False
  505. # Exclude-Filter
  506. if self._exclude_patterns:
  507. if any(p.search(record.name) for p in self._exclude_patterns):
  508. return False
  509. # Custom Level-Filter
  510. if self._level_filter:
  511. if not self._level_filter(record):
  512. return False
  513. return True
  514. def emit(self, record: logging.LogRecord) -> None:
  515. """
  516. Emittiert einen Record falls er den Filter passiert.
  517. Args:
  518. record: Der zu filternde Record.
  519. """
  520. if self._should_emit(record):
  521. self._handler.handle(record)
  522. def close(self) -> None:
  523. """Schließt den Handler."""
  524. self._handler.close()
  525. super().close()