168 lines
5.4 KiB
Python
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,
|
|
}
|