rmi-backend/app/rag_observability.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

168 lines
5.4 KiB
Python

"""
RAG Observability - Langfuse tracing + local metrics fallback.
Traces: embedding latency, retrieval latency, cache hit rate, ingest rate.
Langfuse primary, Redis metrics fallback when Langfuse unavailable.
"""
import json
import logging
import os
import time
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
logger = logging.getLogger("rag.observability")
LANGFUSE_AVAILABLE = False
try:
from langfuse import Langfuse
LANGFUSE_KEY = os.getenv("LANGFUSE_PUBLIC_KEY", "")
LANGFUSE_SECRET = os.getenv("LANGFUSE_SECRET_KEY", "")
LANGFUSE_HOST = os.getenv("LANGFUSE_HOST", "http://langfuse-langfuse-web-1:3000")
if LANGFUSE_KEY and LANGFUSE_SECRET:
_langfuse = Langfuse(public_key=LANGFUSE_KEY, secret_key=LANGFUSE_SECRET, host=LANGFUSE_HOST)
LANGFUSE_AVAILABLE = True
logger.info("Langfuse observability enabled")
else:
logger.info("Langfuse keys not set - using local metrics")
except ImportError:
logger.info("Langfuse not installed - using local metrics")
@dataclass
class RAGMetrics:
"""Accumulated RAG metrics for local fallback."""
embedding_calls: int = 0
embedding_total_ms: float = 0
retrieval_calls: int = 0
retrieval_total_ms: float = 0
cache_hits: int = 0
cache_misses: int = 0
ingest_docs: int = 0
errors: int = 0
_metrics = RAGMetrics()
async def _save_local_metrics():
"""Persist metrics to Redis for dashboard."""
try:
import redis.asyncio as aioredis
r = aioredis.Redis(
host=os.getenv("REDIS_HOST", "rmi-redis"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD", ""),
decode_responses=True,
)
data = {
"embedding_calls": _metrics.embedding_calls,
"embedding_avg_ms": round(_metrics.embedding_total_ms / max(_metrics.embedding_calls, 1), 1),
"retrieval_calls": _metrics.retrieval_calls,
"retrieval_avg_ms": round(_metrics.retrieval_total_ms / max(_metrics.retrieval_calls, 1), 1),
"cache_hits": _metrics.cache_hits,
"cache_misses": _metrics.cache_misses,
"cache_hit_rate": round(_metrics.cache_hits / max(_metrics.cache_hits + _metrics.cache_misses, 1), 3),
"ingest_docs": _metrics.ingest_docs,
"errors": _metrics.errors,
}
await r.setex("rag:metrics", 3600, json.dumps(data))
await r.close()
except Exception:
pass
def trace_embedding(duration_ms: float, dims: int = 1024, model: str = "bge-m3"):
"""Record an embedding operation."""
_metrics.embedding_calls += 1
_metrics.embedding_total_ms += duration_ms
if LANGFUSE_AVAILABLE:
with suppress(Exception):
_langfuse.trace(
name="rag.embedding",
metadata={"duration_ms": duration_ms, "dims": dims, "model": model},
)
def trace_retrieval(duration_ms: float, collection: str, results: int, cache_hit: bool = False):
"""Record a retrieval operation."""
_metrics.retrieval_calls += 1
_metrics.retrieval_total_ms += duration_ms
if cache_hit:
_metrics.cache_hits += 1
else:
_metrics.cache_misses += 1
if LANGFUSE_AVAILABLE:
with suppress(Exception):
_langfuse.trace(
name="rag.retrieval",
metadata={
"duration_ms": duration_ms,
"collection": collection,
"results": results,
"cache_hit": cache_hit,
},
)
def trace_ingest(docs: int, collection: str):
"""Record an ingestion operation."""
_metrics.ingest_docs += docs
if LANGFUSE_AVAILABLE:
with suppress(Exception):
_langfuse.trace(
name="rag.ingest",
metadata={"docs": docs, "collection": collection},
)
def trace_error(error_type: str, detail: str = ""):
"""Record an error."""
_metrics.errors += 1
logger.warning(f"RAG error [{error_type}]: {detail[:200]}")
@asynccontextmanager
async def timed_embedding(dims: int = 1024, model: str = "bge-m3"):
"""Context manager for timing embedding operations."""
t0 = time.time()
try:
yield
finally:
trace_embedding((time.time() - t0) * 1000, dims, model)
@asynccontextmanager
async def timed_retrieval(collection: str, cache_hit: bool = False):
"""Context manager for timing retrieval operations."""
t0 = time.time()
result_count = 0
try:
yield
finally:
trace_retrieval((time.time() - t0) * 1000, collection, result_count, cache_hit)
def get_metrics() -> dict:
"""Return current metrics snapshot."""
return {
"embedding": {
"calls": _metrics.embedding_calls,
"avg_ms": round(_metrics.embedding_total_ms / max(_metrics.embedding_calls, 1), 1),
},
"retrieval": {
"calls": _metrics.retrieval_calls,
"avg_ms": round(_metrics.retrieval_total_ms / max(_metrics.retrieval_calls, 1), 1),
},
"cache": {
"hits": _metrics.cache_hits,
"misses": _metrics.cache_misses,
"hit_rate": round(_metrics.cache_hits / max(_metrics.cache_hits + _metrics.cache_misses, 1), 3),
},
"ingestion": {"total_docs": _metrics.ingest_docs},
"errors": _metrics.errors,
"langfuse": LANGFUSE_AVAILABLE,
}