Production-grade monitoring, tracing, and CI regression-gating for LLM-based RAG pipelines.
This package wraps any RAG pipeline with a non-invasive observability layer that provides:
- Langfuse distributed tracing — every step (
retrieve,rerank,generate) produces a nested span with prompt content, retrieved chunks, token counts, and USD cost - OpenTelemetry metrics — step latency histograms, error counters, query volume, context chunk distribution, and cost histograms exported to Prometheus/Grafana
- Pluggable extension hooks — clean lifecycle callbacks (
on_query_start,on_step_end,on_generation_llm_call, …) to wire custom logic without modifying core code - Telemetry circuit breaker — automatically bypasses tracing/metrics when the collector is down so pipeline latency is never degraded
- Basic keyword guardrails — case-insensitive, whole-word input/output filtering that raises immediately when a configured term appears; a lightweight safety net, not a substitute for a real content-safety/PII model
- Cost budget alerting — per-query and cumulative daily cost thresholds that fire a callback (Slack, PagerDuty, …) without blocking already-completed requests
- Retrieval quality metrics — precision@k, MRR, and hit-rate against a ground-truth relevant-chunk set, for eval harnesses that have judged relevance
- CI regression gating — baseline recording and automated threshold checks (latency, cost, faithfulness) that fail the build on degradation
Application Code
│
▼
MonitoredRAGPipeline ← thin wrapper, zero-copy delegation
├── Extensions (hooks)
│ ├── LangfuseTracingExtension → Langfuse UI (traces)
│ ├── OTelMetricsExtension → Prometheus (metrics)
│ ├── GuardrailExtension → raises ValueError
│ └── CostBudgetExtension → alert callback (non-blocking)
│
└── RAGPipeline (your existing pipeline)
├── _retrieve() ← instrumented in-place
├── _apply_reranker()
└── generator.generate()
Data flow: each pipeline step fires on_step_start / on_step_end hooks → the Langfuse extension creates a nested span, the OTel extension records a latency histogram point. Token usage is intercepted transparently from OpenAI / Anthropic clients via a thread-safe ContextVar patch.
Note on token interception: importing
monitoring.wrappersmonkeypatchesopenai.resources.chat.completions.Completions.createandanthropic.resources.messages.Messages.createprocess-wide, for as long as the process is alive — not just for calls made throughMonitoredRAGPipeline. If your process shares these clients with other libraries, they'll see wrappedcreate()calls too. This is required to capture token usage transparently without changing your pipeline's call sites; if that blast radius is a problem for your deployment, isolate the monitored pipeline in its own process.
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txtOr install as an editable package (after pyproject.toml is present):
pip install -e ".[dev]"from monitoring import MonitoredRAGPipeline, settings
from monitoring import OTelMetricsExtension, GuardrailExtension
monitored = MonitoredRAGPipeline(
pipeline=your_rag_pipeline,
extensions=[
OTelMetricsExtension(),
GuardrailExtension(blocked_keywords=["internal_secret"]),
],
)
# Standard query — fully traced and metered
answer, citations = monitored.query("How does RAG work?", use_reranker=True)
# Streaming query — measures time-to-first-token
for chunk in monitored.query_stream("Explain embeddings."):
print(chunk, end="", flush=True)from monitoring import CostBudgetExtension
def page_oncall(scope: str, actual: float, limit: float) -> None:
slack_client.post(f"RAG cost budget exceeded ({scope}): ${actual:.4f} > ${limit:.4f}")
monitored = MonitoredRAGPipeline(
pipeline=your_rag_pipeline,
extensions=[CostBudgetExtension(per_query_limit=0.05, daily_limit=50.0, on_budget_exceeded=page_oncall)],
)from monitoring import BaseExtension
class SlackAlerter(BaseExtension):
def on_query_error(self, exc: Exception) -> None:
slack_client.post(f"RAG query failed: {exc}")
monitored = MonitoredRAGPipeline(pipeline=..., extensions=[SlackAlerter()])All settings use the MONITOR_ environment variable prefix. Copy .env.example to .env and fill in your values:
| Variable | Default | Description |
|---|---|---|
MONITOR_ENABLED |
true |
Master on/off switch |
MONITOR_LANGFUSE_SECRET_KEY |
"" |
Langfuse secret key (sk-lf-…) |
MONITOR_LANGFUSE_PUBLIC_KEY |
"" |
Langfuse public key (pk-lf-…) |
MONITOR_LANGFUSE_HOST |
http://localhost:3000 |
Langfuse instance URL |
MONITOR_LANGFUSE_RELEASE |
dev |
Release / branch label |
MONITOR_OTEL_SERVICE_NAME |
rag-pipeline |
OTel service name |
MONITOR_OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4318 |
OTel collector endpoint |
MONITOR_PRICING_FILE |
pricing.json |
Per-token pricing data file |
MONITOR_BASELINE_DIR |
data/monitoring |
CI baseline storage directory |
MONITOR_CIRCUIT_BREAKER_THRESHOLD |
3 |
Failures before circuit opens |
MONITOR_CIRCUIT_BREAKER_COOLDOWN_SECONDS |
30.0 |
Cooldown before half-open retry |
MONITOR_MAX_QUEUE_SIZE |
10000 |
Async telemetry queue depth |
Spin up Langfuse, OTel Collector, Prometheus, and Grafana with a single command:
# Copy and fill in secrets first
cp .env.example .env
# Start the stack
docker compose -f infra/docker-compose.yml up -d
# Verify all services are reachable
python scripts/verify_infra.py| Service | URL |
|---|---|
| Langfuse UI | http://localhost:3000 |
| Prometheus | http://localhost:9090 |
| Grafana | http://localhost:3001 |
| OTel Collector (HTTP) | http://localhost:4318 |
Security: The compose file requires
POSTGRES_PASSWORD,LANGFUSE_NEXTAUTH_SECRET,LANGFUSE_SALT, andGF_ADMIN_PASSWORDto be set as environment variables — it will refuse to start if any are missing.
| Metric | Type | Labels | Description |
|---|---|---|---|
rag_retrieve_latency_seconds |
Histogram | — | Vector DB retrieval latency |
rag_rerank_latency_seconds |
Histogram | — | Re-ranker latency |
rag_generate_latency_seconds |
Histogram | — | LLM generation latency |
rag_query_total_latency_seconds |
Histogram | — | End-to-end query latency |
rag_{step}_errors_total |
Counter | error_type, trace_id |
Errors per step; trace_id links straight to the failing trace in Langfuse (attached only on errors — not on every request — to avoid blowing up metric cardinality) |
rag_queries_total |
Counter | — | Total query volume |
rag_context_chunks_per_query |
Histogram | — | Retrieved chunk count distribution |
rag_cost_per_query_dollars |
Histogram | — | USD cost per query |
rag_tokens_{prompt|completion|total} |
Histogram | — | Token usage distribution |
rag_time_to_first_token_seconds |
Histogram | — | Streaming TTFT |
rag_retrieval_precision_at_k |
Histogram | — | Precision@k against a ground-truth relevant-chunk set (call record_retrieval_quality() from an eval harness) |
rag_retrieval_reciprocal_rank |
Histogram | — | Reciprocal rank of the first relevant chunk; averaged over queries this is MRR |
rag_retrieval_hit |
Histogram | — | 1 if any retrieved chunk was relevant, else 0; averaged over queries this is hit-rate |
The regression gate fails the build if any metric degrades more than 20% from the recorded baseline.
# 1. Record the current baseline (runs once per branch)
python scripts/record_baseline.py --branch main
# 2. After a change, check for regressions
python scripts/check_regressions.py --branch mainPrompt change detection: the gate also computes a SHA-256 of the system prompt template. If the hash differs from the baseline, the PR comment is annotated with a warning so the team consciously validates behavior before merging.
The GitHub Actions workflow (.github/workflows/monitor.yml) runs this automatically on every push and pull request.
Model pricing is data-only — no code changes required:
// pricing.json
{
"my-provider": {
"my-model-v1": { "input": 0.002, "output": 0.006 }
}
}The PricingConfig class picks up file changes automatically via mtime-based caching.
pytest tests/ -v --cov=monitoring --cov=scripts --cov-report=term-missing94 tests, ~82% coverage. All tests are self-contained — no external services required.
Run an end-to-end demo using mock pipeline and generators (no API keys needed):
python scripts/demo_observability.pyFor the full experience — traces in the Langfuse UI and live dashboards in Grafana — bring up the local observability stack, run the demo (or your own pipeline) against it, then open:
http://localhost:3000for the Langfuse trace timeline (nestedretrieve→rerank→generatespans with cost/token annotations)http://localhost:3001for the Grafana latency/cost/error dashboards (provisioned automatically frominfra/grafana/dashboards/)
rag-observability-layer/
├── monitoring/ # Core package
│ ├── config.py # Settings (pydantic-settings) + PricingConfig
│ ├── tracing.py # Langfuse Tracer wrapper
│ ├── metrics.py # OTel MetricsCollector
│ ├── prompts.py # SHA-256 prompt version registry
│ ├── extensions.py # BaseExtension + CircuitBreaker + async worker
│ └── wrappers.py # MonitoredRAGPipeline
├── scripts/
│ ├── record_baseline.py # CI: save current metrics as baseline
│ ├── check_regressions.py # CI: compare metrics vs baseline, exit 1 on fail
│ ├── verify_infra.py # Health-check the local observability stack
│ ├── verify_gate.py # End-to-end integration verification
│ └── demo_observability.py # Runnable demo with mock pipeline
├── tests/ # pytest unit tests (94 tests)
├── infra/
│ ├── docker-compose.yml # Full local stack (Langfuse, OTel, Prometheus, Grafana)
│ ├── otel-collector/ # OTel Collector config
│ ├── prometheus/ # Prometheus scrape config
│ └── grafana/ # Dashboard + datasource provisioning
├── data/
│ └── monitoring/ # Runtime baseline storage (gitignored)
├── pricing.json # Per-token cost data (OpenAI, Anthropic)
├── pyproject.toml # Package config + pytest/coverage settings
├── requirements.txt # Runtime + dev dependencies
└── .env.example # Environment variable template