Capstone 11 — LLM Observability & Eval Dashboard
Langfuse went open-core, Arize Phoenix published the 2026 GenAI semconv mappings, and Helicone and Braintrust both doubled down on per-user cost attribution. Traceloop's OpenLLMetry became the default SDK instrumentation. The production shape is ClickHouse for traces, Postgres for metadata, Next.js for the UI, and a pool of eval jobs (DeepEval, RAGAS, LLM-judge) running over sampled traces. Build one self-hosted, ingest from at least six SDK families, and catch an injected regression in under five minutes.
Learning Objectives
- Build an ingest path where OpenTelemetry GenAI spans from six SDK families flow through a tail-sampling collector into ClickHouse (spans) and Postgres (metadata).
- Run batch eval jobs (DeepEval, RAGAS, a custom PII-leak LLM-judge) over sampled traces and write the scores back as eval spans linked to the parent trace.
- Detect drift by computing PSI on pooled prompt embeddings against a trailing baseline, and route threshold breaches through Prometheus and Alertmanager to Slack or PagerDuty.
- Measure time to detect on an injected PII-leak regression, and reason about how sampling rules and eval cadence affect whether you meet the five-minute bar.
Problem
LLM observability is the practice of capturing model calls as traces, scoring those traces for quality and safety, and alerting when behavior shifts. Any AI team running production traffic in 2026 keeps this plane next to the model: cost attribution, hallucination detection, drift monitoring, jailbreak signal, SLO dashboards, PII leak alerts. The open-source references (Langfuse, Phoenix, OpenLLMetry) converged on the OpenTelemetry GenAI semantic conventions as the ingest schema, so one SDK can now instrument OpenAI, Anthropic, Google, LangChain, LlamaIndex, and vLLM and emit compatible spans.
You will build a self-hosted dashboard that ingests from at least six SDK families, runs a small set of eval jobs over sampled traces, detects drift, and alerts. The measurement bar is concrete. Inject a regression on purpose (a prompt that starts producing PII), and the dashboard has to catch it and fire an alert in under five minutes.
Concept
The dashboard is a pipeline of four parts: ingest, storage, evals, and drift plus alerting. Ingest is OTLP HTTP. The SDK produces GenAI-semconv spans carrying attributes like gen_ai.system, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.response.id, llm.prompts, and llm.completions. Spans land in ClickHouse for columnar analytics, while metadata (users, sessions, apps) lands in Postgres.
Evals run as batch jobs over sampled traces. DeepEval scores faithfulness, toxicity, and answer relevance. RAGAS scores retrieval metrics when the trace carries retrieval context. Custom LLM-judges handle domain-specific checks such as PII leaks and off-policy responses. Every eval run writes back to the same ClickHouse as eval spans linked to the parent trace, so a score always sits next to the call it judged.
Drift detection watches embedding-space distributions over time (PSI or KL divergence on prompt embeddings) alongside eval-score trends. Alerts feed Prometheus Alertmanager, which routes to Slack or PagerDuty. The UI is Next.js 15 with Recharts.
Architecture
production apps:
OpenAI SDK + Anthropic SDK + Google GenAI SDK
LangChain + LlamaIndex + vLLM
|
v
OpenTelemetry SDK with GenAI semconv
|
v OTLP HTTP
collector (ingest, sample, fan-out)
|
+-------------+-----------+
v v v
ClickHouse Postgres S3 archive
(spans) (metadata) (raw events)
|
+---> eval jobs (DeepEval, RAGAS, LLM-judge)
| sampled or all-trace
| write eval spans back
|
+---> drift detector (PSI / KL on prompt embeddings)
|
+---> Prometheus metrics -> Alertmanager -> Slack / PagerDuty
|
v
Next.js 15 dashboard (Recharts)
Stack
- Ingest: OpenTelemetry SDKs + GenAI semantic conventions; OTLP HTTP transport
- Collector: OpenTelemetry Collector with tail-sampling processor (for cost control)
- Storage: ClickHouse for spans, Postgres for metadata, S3 for raw event archive
- Evals: DeepEval, RAGAS 0.2, Arize Phoenix evaluator pack, custom LLM-judge
- Drift: PSI / KL on pooled prompt embeddings (sentence-transformers) weekly
- Alerting: Prometheus Alertmanager -> Slack / PagerDuty
- UI: Next.js 15 App Router + Recharts + server actions
- SDKs supported out of the box: OpenAI, Anthropic, Google GenAI, LangChain, LlamaIndex, vLLM
Build It
-
Collector config. Set up the OpenTelemetry Collector with the OTLP HTTP receiver and a tail-sampler that keeps 100% of errored traces and 10% of successes. Export to ClickHouse and S3.
-
ClickHouse schema. Create a
spanstable with columns that mirror GenAI semconv:gen_ai_system,gen_ai_request_model,input_tokens,output_tokens,latency_ms,prompt_hash,trace_id,parent_span_id, plus a JSON bag for long payloads. Add secondary indexes on user_id and app_id. -
SDK coverage test. Write a small client app for each SDK (OpenAI, Anthropic, Google, LangChain, LlamaIndex, vLLM) and auto-instrument it with OpenLLMetry. Confirm each one produces canonical GenAI spans that land in ClickHouse.
-
Eval jobs. Schedule a job that reads the last 15 minutes of sampled traces and runs DeepEval faithfulness, toxicity, and answer relevance. The outputs are eval spans linked to the parent trace.
-
Custom LLM-judge. Build a PII-leak judge. Given a response, it calls a guard LLM to score the likelihood of a leak, and high-scoring responses go to a triage queue.
-
Drift detection. A weekly job computes PSI between this week's pooled prompt embeddings and the trailing 4-week baseline. If PSI is above the threshold, it alerts.
-
Dashboard. Build the Next.js 15 pages: overview (spans/sec, cost/user, p95 latency), traces (search and waterfall), evals (faithfulness trend, toxicity), drift (PSI over time), and alerts.
-
Alerting chain. A Prometheus exporter reads eval score aggregates and latency percentiles. Alertmanager routes warnings to Slack and critical breaches to PagerDuty.
-
Regression probe. Inject a bug so the evaluated chatbot leaks fake SSNs 1% of the time, then measure MTTR from the moment the bug is deployed to the Slack alert.
Use It
$ curl -X POST https://my-otel-collector/v1/traces -d @trace.json
[collector] accepted 1 trace, 3 spans
[clickhouse] inserted 3 spans (app=chat, user=u_42)
[eval] DeepEval faithfulness 0.82, toxicity 0.03
[drift] weekly PSI 0.08 (below 0.2 threshold)
[ui] live at https://obs.example.com
Ship It
outputs/skill-llm-observability.md is the deliverable. Point the dashboard at an LLM application and it ingests the traces, runs evals, alerts on drift, and shows a cost/user breakdown in Next.js.
| Weight | Criterion | How it is measured |
|---|---|---|
| 25 | Trace-schema coverage | Number of SDK families producing canonical GenAI spans (target: 6+) |
| 20 | Eval correctness | DeepEval / RAGAS scores vs hand-labeled set |
| 20 | Dashboard UX | MTTR on injected regression (under 5 minutes target) |
| 20 | Cost / scale | Sustained ingest at 1k spans/sec without backlog |
| 15 | Alerting + drift detection | Prometheus/Alertmanager chain exercised end to end |
| 100 |
My bet is that the sampler in step 1 and the probe in step 9 will collide, so I'd test them against each other early. The sampler keeps every errored trace but only 10% of successes, and a response that leaks a fake SSN is not an error. It is a successful call with bad content, which means it sits in the 10% bucket and most leaks never reach the PII judge from step 5. Exercise 5 shows the kind of keep-rule that changes this, but it keys on toxicity, which an SSN leak won't trip, so you would need a rule on a PII signal instead.
I'd also look hard at the timing before building the dashboard pages. Step 4 reads the last 15 minutes of sampled traces, and the Problem section sets the bar at an alert in under five minutes. The lesson never says how often that job runs, but if it runs once per window, the window alone can blow the bar, and the Dashboard UX row in the rubric is scored on that MTTR. I'd settle the eval cadence first and polish the charts after.
Exercises
-
Add custom instrumentation for the Haystack framework. Verify that canonical spans land in ClickHouse with faithful
gen_ai.*attributes. -
Swap DeepEval for Phoenix evaluators on the same traces. Measure the score drift between the two eval engines.
-
Sharpen the drift detector by computing PSI per app-id instead of globally. Show per-app drift trails.
-
Add a "user impact" page with cost-per-user and failure-rate-per-user sparklines.
-
Build a tail-sampling policy that keeps 100% of traces with toxicity > 0.5 plus a 10% stratified sample of the rest. Measure the sampling bias it introduces.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| GenAI semconv | "OTel LLM attributes" | The 2025 OpenTelemetry spec for LLM span attributes (system, model, tokens) |
| Tail sampling | "Post-trace sample" | The collector decides whether to keep or drop a trace after it completes, so it can see errors first |
| PSI | "Population stability index" | A drift metric that compares two distributions; > 0.2 typically signals meaningful drift |
| LLM-judge | "Eval as model" | An LLM that scores another LLM's output against a rubric (faithfulness, toxicity, PII) |
| Tail-sampling policy | "Keep-rule" | The rule that decides which traces to persist and which to drop; errored traces plus a sample rate |
| Eval span | "Linked eval trace" | A child span that carries an eval score and links back to the original LLM call span |
| Cost per user | "Unit economics" | Dollar cost attributed to a user_id over a window; a key product metric |
Further Reading
- Langfuse: the reference open-core observability platform
- Arize Phoenix: an alternate reference with strong drift support
- OpenLLMetry (Traceloop): the auto-instrumentation SDK family
- OpenTelemetry GenAI semantic conventions: the ingest schema
- Helicone: an alternate hosted observability platform
- Braintrust: an alternate eval-first platform
- ClickHouse documentation: the columnar span store
- DeepEval: the evaluator library
Code
- main.py (python, 258 lines)
- ts/package.json (json, 24 lines)
- ts/README.md (markdown, 41 lines)
- ts/src/index.ts (typescript, 123 lines)
- ts/src/pricing.ts (typescript, 33 lines)
- ts/src/rollup.ts (typescript, 49 lines)
- ts/src/server.ts (typescript, 83 lines)
- ts/src/spans.ts (typescript, 136 lines)
- ts/src/types.ts (typescript, 37 lines)
- ts/tests/rollup.test.ts (typescript, 86 lines)
- ts/tests/spans.test.ts (typescript, 79 lines)
- ts/tsconfig.json (json, 18 lines)