aggregator.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. # -*- coding: utf-8 -*-
  2. """
  3. Metriken-Aggregator für Satellites.
  4. Aggregiert Metriken über Zeit und Satellites
  5. für Analyse und Reporting.
  6. """
  7. from __future__ import annotations
  8. import logging
  9. import statistics
  10. from collections import defaultdict
  11. from dataclasses import dataclass, field
  12. from datetime import datetime, timedelta
  13. from enum import Enum, auto
  14. from typing import Any, Callable
  15. from trixy_core.satellite.metrics.collector import (
  16. SatelliteMetricsCollector,
  17. MetricValue,
  18. SatelliteMetrics,
  19. )
  20. class AggregationType(Enum):
  21. """Aggregations-Typ."""
  22. SUM = auto() # Summe
  23. AVG = auto() # Durchschnitt
  24. MIN = auto() # Minimum
  25. MAX = auto() # Maximum
  26. COUNT = auto() # Anzahl
  27. MEDIAN = auto() # Median
  28. P95 = auto() # 95. Perzentil
  29. P99 = auto() # 99. Perzentil
  30. STDDEV = auto() # Standardabweichung
  31. class TimeWindow(Enum):
  32. """Zeitfenster für Aggregation."""
  33. MINUTE = 60
  34. FIVE_MINUTES = 300
  35. FIFTEEN_MINUTES = 900
  36. HOUR = 3600
  37. DAY = 86400
  38. @dataclass
  39. class AggregatedMetric:
  40. """Aggregierte Metrik."""
  41. name: str
  42. aggregation: AggregationType
  43. value: float
  44. window: TimeWindow
  45. sample_count: int
  46. start_time: datetime
  47. end_time: datetime
  48. satellite_ids: list[str] = field(default_factory=list)
  49. def to_dict(self) -> dict[str, Any]:
  50. """Konvertiert zu Dictionary."""
  51. return {
  52. "name": self.name,
  53. "aggregation": self.aggregation.name,
  54. "value": round(self.value, 4),
  55. "window": self.window.name,
  56. "sample_count": self.sample_count,
  57. "start_time": self.start_time.isoformat(),
  58. "end_time": self.end_time.isoformat(),
  59. "satellite_count": len(self.satellite_ids),
  60. }
  61. class MetricsAggregator:
  62. """
  63. Aggregiert Metriken über Zeit und Satellites.
  64. Bietet verschiedene Aggregationsfunktionen und
  65. zeitbasierte Analysen.
  66. Beispiel:
  67. aggregator = MetricsAggregator(collector)
  68. # Durchschnittliche Latenz über alle Satellites
  69. avg_latency = aggregator.aggregate(
  70. metric_name="latency",
  71. aggregation=AggregationType.AVG,
  72. window=TimeWindow.FIVE_MINUTES,
  73. )
  74. # Top Satellites nach Fehlerrate
  75. top_errors = aggregator.get_top_satellites(
  76. metric_name="error_count",
  77. limit=10,
  78. )
  79. """
  80. def __init__(
  81. self,
  82. collector: SatelliteMetricsCollector,
  83. logger: logging.Logger | None = None,
  84. ) -> None:
  85. """
  86. Initialisiert den Aggregator.
  87. Args:
  88. collector: Metriken-Collector
  89. logger: Logger-Instanz
  90. """
  91. self.collector = collector
  92. self.logger = logger or logging.getLogger(__name__)
  93. # Cache für berechnete Aggregationen
  94. self._cache: dict[str, tuple[datetime, AggregatedMetric]] = {}
  95. self._cache_ttl = timedelta(seconds=30)
  96. def _get_metric_values(
  97. self,
  98. metric_name: str,
  99. window: TimeWindow,
  100. satellite_ids: list[str] | None = None,
  101. ) -> tuple[list[float], list[str]]:
  102. """
  103. Sammelt Metrik-Werte aus dem Collector.
  104. Args:
  105. metric_name: Name der Metrik
  106. window: Zeitfenster
  107. satellite_ids: Optional: Spezifische Satellites
  108. Returns:
  109. Tuple (Werte, Satellite-IDs)
  110. """
  111. cutoff = datetime.now() - timedelta(seconds=window.value)
  112. values: list[float] = []
  113. satellites: list[str] = []
  114. all_metrics = self.collector.get_all_metrics()
  115. for sat_id, metrics in all_metrics.items():
  116. if satellite_ids and sat_id not in satellite_ids:
  117. continue
  118. if metrics.last_updated < cutoff:
  119. continue
  120. # Standard-Metriken
  121. value = None
  122. if metric_name == "latency":
  123. value = metrics.latency_ms
  124. elif metric_name == "packet_loss":
  125. value = metrics.packet_loss_percent
  126. elif metric_name == "bandwidth":
  127. value = metrics.bandwidth_kbps
  128. elif metric_name == "messages_sent":
  129. value = float(metrics.messages_sent)
  130. elif metric_name == "messages_received":
  131. value = float(metrics.messages_received)
  132. elif metric_name == "error_count":
  133. value = float(metrics.error_count)
  134. elif metric_name == "reconnect_count":
  135. value = float(metrics.reconnect_count)
  136. if value is not None:
  137. values.append(value)
  138. satellites.append(sat_id)
  139. return values, satellites
  140. def _calculate_aggregation(
  141. self,
  142. values: list[float],
  143. aggregation: AggregationType,
  144. ) -> float:
  145. """Berechnet Aggregation für Werte."""
  146. if not values:
  147. return 0.0
  148. if aggregation == AggregationType.SUM:
  149. return sum(values)
  150. elif aggregation == AggregationType.AVG:
  151. return statistics.mean(values)
  152. elif aggregation == AggregationType.MIN:
  153. return min(values)
  154. elif aggregation == AggregationType.MAX:
  155. return max(values)
  156. elif aggregation == AggregationType.COUNT:
  157. return float(len(values))
  158. elif aggregation == AggregationType.MEDIAN:
  159. return statistics.median(values)
  160. elif aggregation == AggregationType.P95:
  161. return self._percentile(values, 95)
  162. elif aggregation == AggregationType.P99:
  163. return self._percentile(values, 99)
  164. elif aggregation == AggregationType.STDDEV:
  165. return statistics.stdev(values) if len(values) > 1 else 0.0
  166. return 0.0
  167. def _percentile(self, values: list[float], percentile: int) -> float:
  168. """Berechnet Perzentil."""
  169. if not values:
  170. return 0.0
  171. sorted_values = sorted(values)
  172. index = (percentile / 100) * (len(sorted_values) - 1)
  173. if index.is_integer():
  174. return sorted_values[int(index)]
  175. lower = sorted_values[int(index)]
  176. upper = sorted_values[int(index) + 1]
  177. fraction = index - int(index)
  178. return lower + (upper - lower) * fraction
  179. def aggregate(
  180. self,
  181. metric_name: str,
  182. aggregation: AggregationType = AggregationType.AVG,
  183. window: TimeWindow = TimeWindow.FIVE_MINUTES,
  184. satellite_ids: list[str] | None = None,
  185. use_cache: bool = True,
  186. ) -> AggregatedMetric:
  187. """
  188. Aggregiert eine Metrik.
  189. Args:
  190. metric_name: Name der Metrik
  191. aggregation: Aggregations-Typ
  192. window: Zeitfenster
  193. satellite_ids: Optional: Spezifische Satellites
  194. use_cache: Cache verwenden
  195. Returns:
  196. Aggregierte Metrik
  197. """
  198. # Cache prüfen
  199. cache_key = f"{metric_name}:{aggregation.name}:{window.name}"
  200. if use_cache and cache_key in self._cache:
  201. cached_time, cached_result = self._cache[cache_key]
  202. if datetime.now() - cached_time < self._cache_ttl:
  203. return cached_result
  204. # Werte sammeln
  205. values, satellites = self._get_metric_values(
  206. metric_name, window, satellite_ids
  207. )
  208. # Aggregieren
  209. result_value = self._calculate_aggregation(values, aggregation)
  210. end_time = datetime.now()
  211. start_time = end_time - timedelta(seconds=window.value)
  212. result = AggregatedMetric(
  213. name=metric_name,
  214. aggregation=aggregation,
  215. value=result_value,
  216. window=window,
  217. sample_count=len(values),
  218. start_time=start_time,
  219. end_time=end_time,
  220. satellite_ids=satellites,
  221. )
  222. # Cache aktualisieren
  223. self._cache[cache_key] = (datetime.now(), result)
  224. return result
  225. def aggregate_multiple(
  226. self,
  227. metric_names: list[str],
  228. aggregations: list[AggregationType],
  229. window: TimeWindow = TimeWindow.FIVE_MINUTES,
  230. ) -> dict[str, dict[str, AggregatedMetric]]:
  231. """
  232. Aggregiert mehrere Metriken mit mehreren Aggregationen.
  233. Args:
  234. metric_names: Liste von Metrik-Namen
  235. aggregations: Liste von Aggregations-Typen
  236. window: Zeitfenster
  237. Returns:
  238. Dict[metric_name][aggregation_name] -> AggregatedMetric
  239. """
  240. results: dict[str, dict[str, AggregatedMetric]] = {}
  241. for metric_name in metric_names:
  242. results[metric_name] = {}
  243. for aggregation in aggregations:
  244. result = self.aggregate(metric_name, aggregation, window)
  245. results[metric_name][aggregation.name] = result
  246. return results
  247. def get_top_satellites(
  248. self,
  249. metric_name: str,
  250. limit: int = 10,
  251. ascending: bool = False,
  252. ) -> list[tuple[str, float]]:
  253. """
  254. Holt Top-Satellites nach Metrik.
  255. Args:
  256. metric_name: Metrik-Name
  257. limit: Maximale Anzahl
  258. ascending: Aufsteigend sortieren
  259. Returns:
  260. Liste von (satellite_id, value) Tupeln
  261. """
  262. values, satellites = self._get_metric_values(
  263. metric_name,
  264. TimeWindow.HOUR,
  265. )
  266. if not values:
  267. return []
  268. # Zusammenführen
  269. satellite_values = list(zip(satellites, values))
  270. # Sortieren
  271. satellite_values.sort(key=lambda x: x[1], reverse=not ascending)
  272. return satellite_values[:limit]
  273. def get_metric_distribution(
  274. self,
  275. metric_name: str,
  276. window: TimeWindow = TimeWindow.HOUR,
  277. buckets: int = 10,
  278. ) -> dict[str, Any]:
  279. """
  280. Berechnet Verteilung einer Metrik.
  281. Args:
  282. metric_name: Metrik-Name
  283. window: Zeitfenster
  284. buckets: Anzahl der Buckets
  285. Returns:
  286. Verteilungs-Informationen
  287. """
  288. values, _ = self._get_metric_values(metric_name, window)
  289. if not values:
  290. return {
  291. "buckets": [],
  292. "min": 0,
  293. "max": 0,
  294. "count": 0,
  295. }
  296. min_val = min(values)
  297. max_val = max(values)
  298. if min_val == max_val:
  299. return {
  300. "buckets": [{"start": min_val, "end": max_val, "count": len(values)}],
  301. "min": min_val,
  302. "max": max_val,
  303. "count": len(values),
  304. }
  305. bucket_size = (max_val - min_val) / buckets
  306. bucket_counts = [0] * buckets
  307. for value in values:
  308. bucket_index = min(
  309. int((value - min_val) / bucket_size),
  310. buckets - 1
  311. )
  312. bucket_counts[bucket_index] += 1
  313. bucket_info = []
  314. for i in range(buckets):
  315. start = min_val + i * bucket_size
  316. end = start + bucket_size
  317. bucket_info.append({
  318. "start": round(start, 4),
  319. "end": round(end, 4),
  320. "count": bucket_counts[i],
  321. })
  322. return {
  323. "buckets": bucket_info,
  324. "min": min_val,
  325. "max": max_val,
  326. "count": len(values),
  327. }
  328. def get_satellite_comparison(
  329. self,
  330. metric_name: str,
  331. satellite_ids: list[str],
  332. window: TimeWindow = TimeWindow.HOUR,
  333. ) -> dict[str, dict[str, float]]:
  334. """
  335. Vergleicht Metrik über mehrere Satellites.
  336. Args:
  337. metric_name: Metrik-Name
  338. satellite_ids: Zu vergleichende Satellites
  339. window: Zeitfenster
  340. Returns:
  341. Dict mit Statistiken pro Satellite
  342. """
  343. result: dict[str, dict[str, float]] = {}
  344. for sat_id in satellite_ids:
  345. values, _ = self._get_metric_values(
  346. metric_name, window, [sat_id]
  347. )
  348. if values:
  349. result[sat_id] = {
  350. "current": values[0] if values else 0.0,
  351. "avg": statistics.mean(values) if values else 0.0,
  352. "min": min(values) if values else 0.0,
  353. "max": max(values) if values else 0.0,
  354. }
  355. else:
  356. result[sat_id] = {
  357. "current": 0.0,
  358. "avg": 0.0,
  359. "min": 0.0,
  360. "max": 0.0,
  361. }
  362. return result
  363. def get_health_summary(self) -> dict[str, Any]:
  364. """
  365. Erstellt Gesundheits-Übersicht basierend auf Metriken.
  366. Returns:
  367. Gesundheits-Übersicht
  368. """
  369. window = TimeWindow.FIVE_MINUTES
  370. avg_latency = self.aggregate("latency", AggregationType.AVG, window)
  371. p99_latency = self.aggregate("latency", AggregationType.P99, window)
  372. max_packet_loss = self.aggregate("packet_loss", AggregationType.MAX, window)
  373. total_errors = self.aggregate("error_count", AggregationType.SUM, window)
  374. # Gesundheitsstatus bestimmen
  375. health_status = "healthy"
  376. issues = []
  377. if avg_latency.value > 100:
  378. health_status = "degraded"
  379. issues.append(f"Hohe durchschnittliche Latenz: {avg_latency.value:.1f}ms")
  380. if p99_latency.value > 500:
  381. health_status = "degraded"
  382. issues.append(f"Hohe P99-Latenz: {p99_latency.value:.1f}ms")
  383. if max_packet_loss.value > 5:
  384. health_status = "degraded"
  385. issues.append(f"Hoher Paketverlust: {max_packet_loss.value:.1f}%")
  386. if total_errors.value > 10:
  387. health_status = "unhealthy"
  388. issues.append(f"Viele Fehler: {int(total_errors.value)}")
  389. return {
  390. "status": health_status,
  391. "issues": issues,
  392. "metrics": {
  393. "avg_latency_ms": round(avg_latency.value, 2),
  394. "p99_latency_ms": round(p99_latency.value, 2),
  395. "max_packet_loss_percent": round(max_packet_loss.value, 2),
  396. "total_errors": int(total_errors.value),
  397. },
  398. "satellite_count": avg_latency.sample_count,
  399. }
  400. def clear_cache(self) -> None:
  401. """Löscht den Cache."""
  402. self._cache.clear()