feat: Resilienz (Fallback-Ketten) und Metriken (#5)

- Fallback-Provider (app/providers/fallback.py) fuer STT/LLM/TTS: Provider-Kette
  der Reihe nach; Config *_FALLBACK; build_orchestrator baut Ketten (dedupliziert)
- LLM-Stream-Fallback nur solange kein Token gesendet wurde
- Metriken (app/metrics.py): In-Memory Counter/Timer, keine externe Dependency
- HTTP-Middleware (Requests/Latenz/Status je Pfad); Pipeline-Stufen-Timing stt/llm/tts;
  Fallback-/Fehlerzaehler; GET /api/metrics (JSON + Prometheus)
- Tests: 58 gruen (+6); Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur, .env.example)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 05:19:07 +02:00
commit 6422444017
13 changed files with 452 additions and 23 deletions

82
app/metrics.py Normal file
View file

@ -0,0 +1,82 @@
"""Schlanke In-Memory-Metriken (Counter + Timer) fuer einen Prozess.
Bewusst ohne externe Dependency. Fuer mehrere Instanzen/Prozesse spaeter durch
einen gemeinsamen Backend (z. B. Prometheus-Exporter) ersetzbar.
"""
import threading
import time
from collections import defaultdict
class Metrics:
def __init__(self):
self._lock = threading.Lock()
self._counters: dict[str, float] = defaultdict(float)
self._timers: dict[str, list] = defaultdict(lambda: [0.0, 0]) # [sum, count]
@staticmethod
def _key(name: str, labels: dict | None) -> str:
if not labels:
return name
rendered = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
return f"{name}{{{rendered}}}"
def inc(self, name: str, labels: dict | None = None, value: float = 1.0) -> None:
with self._lock:
self._counters[self._key(name, labels)] += value
def observe(self, name: str, seconds: float, labels: dict | None = None) -> None:
with self._lock:
agg = self._timers[self._key(name, labels)]
agg[0] += seconds
agg[1] += 1
def snapshot(self) -> dict:
with self._lock:
counters = dict(self._counters)
timers = {
key: {
"sum": agg[0],
"count": agg[1],
"avg": (agg[0] / agg[1] if agg[1] else 0.0),
}
for key, agg in self._timers.items()
}
return {"counters": counters, "timers": timers}
def prometheus(self) -> str:
snap = self.snapshot()
lines = []
for key, value in sorted(snap["counters"].items()):
lines.append(f"{key} {value}")
for key, agg in sorted(snap["timers"].items()):
base, _, labels = key.partition("{")
suffix = ("{" + labels) if labels else ""
lines.append(f"{base}_sum{suffix} {agg['sum']}")
lines.append(f"{base}_count{suffix} {agg['count']}")
return "\n".join(lines) + "\n"
def reset(self) -> None:
with self._lock:
self._counters.clear()
self._timers.clear()
metrics = Metrics()
class timer:
"""Context-Manager: misst die Dauer und schreibt sie als Timer-Beobachtung."""
def __init__(self, name: str, labels: dict | None = None):
self.name = name
self.labels = labels
def __enter__(self):
self._start = time.perf_counter()
return self
def __exit__(self, *exc):
metrics.observe(self.name, time.perf_counter() - self._start, self.labels)
return False