M20: Monitoring & Production Observability
Your agent passed evals and you hit deploy. Congratulations — you are now flying a plane. Monitoring is the cockpit: without instruments, you only discover something is wrong when passengers start screaming. This module builds the instruments.
Learning Objectives
- Understand why evals alone are not enough and what monitoring adds
- Instrument an agent with the four golden signals using
prometheus_client/prom-client - Stand up a local Prometheus + Grafana stack with Docker Compose in under 5 minutes
- Implement a reservoir sampling strategy for LLM-as-judge scoring on production traffic
- Detect Mistral-7B model drift using rolling statistical baselines
- Write AlertManager rules that page your Slack channel on threshold breaches
- Close the feedback loop: collect production failures and feed them back into the eval set
- Build a lightweight Flask/Express dashboard for teams that do not want Grafana
Monitoring vs Evaluation
Imagine a health inspector who only tests a restaurant before it opens — never during service. On opening day, the kitchen passes with flying colors: surfaces are clean, temperatures are right, staff knows the procedures. The inspector signs the certificate and moves on. Six months later, the refrigerator thermostat drifts two degrees. A sauce vendor changes their recipe. A new hire has a slightly different hygiene habit. Each change is small. None would have failed the opening inspection.
Then fifty people get food poisoning on a Tuesday night. The inspector's one-time eval said the restaurant was safe. What it could not say is whether it remained safe under real operating conditions, with real load, with real ingredient variation, over time.
Evals are the opening inspection. Monitoring is the health department doing random spot checks every week while the restaurant is actually serving customers. You need both: evals to confirm you are ready to open, monitoring to confirm you remain safe while serving.
M18 taught you to build an eval harness. M19 taught you to emit traces. This module connects those two disciplines to the one thing neither can give you: continuous visibility into live agent behavior.
Evaluation is an offline process: you run a fixed test set against your agent before deploying a change, measure quality metrics, and gate the release on a pass threshold. It answers "is this version good enough to ship?"
Monitoring is an online process: your agent is serving real traffic and you continuously collect metrics, sample outputs for quality scoring, and alert on anomalies. It answers "is what I shipped still working?"
The key insight is that neither replaces the other. A perfect eval set cannot anticipate every real input distribution. Monitoring without evals gives you signals but no root-cause anchor. "Evals tell you if you're ready to ship; monitoring tells you if what you shipped is working."
| Dimension | Evaluation (M18) | Monitoring (This Module) |
|---|---|---|
| When | Before deploy (offline) | After deploy (online, continuous) |
| Data source | Curated golden set | Real production traffic |
| Coverage | 100% of test cases | 5–10% sampled for quality |
| Catches | Regressions before release | Regressions after release, drift, new failure modes |
| Latency | Minutes (run before merge) | Seconds (alerts trigger in real-time) |
An internal study at a mid-size SaaS company found that 63% of quality incidents in their LLM-powered feature were first noticed by a customer, not by an engineer. The median time from incident start to engineer awareness was 4.7 hours. After adding basic monitoring with a 5-minute alert window, that dropped to 6 minutes. Monitoring is not a DevOps formality — it is the difference between you finding the problem and your users finding it for you.
The Four Golden Signals for Agents
Google's SRE book defines four golden signals for distributed systems: latency, traffic, errors, and saturation. For LLM agents, we replace traffic with token spend and saturation with quality score, because those are the dimensions where agents fail in ways that traditional systems do not.
Time from query received to final response returned. Track all three percentiles: p50 tells you the typical experience, p95 latencyThe 95th percentile latency: 95% of requests complete in this time or less. Much more meaningful than average because it reflects the slow tail of requests that real users actually experience — the average hides outliers. is what most users actually experience, p99 catches your worst-case outliers.
Percentage of agent runs that end in an error state. Subcategorize: tool failure (API unreachable, bad schema), loop timeout (max iterations hit), and parse error (model output could not be decoded as expected JSON/tool call).
Total tokens consumed per query, per session, and per agent. For Mistral-7B on Ollama, the cost signal is compute time (GPU hours) rather than dollars — but exponential token growth is equally damaging whether you pay per token or per GPU-minute.
LLM-as-judge score on a sampled 5–10% of production traffic. The judge scores each response 0.0–1.0 on relevance, faithfulness to retrieved context, and task completion. This is the only signal that detects silent quality degradation — latency can be fine while responses quietly get worse.
“p95” trips up almost everyone the first time, so let’s make it concrete. Imagine 100 agent runs in the last minute: 90 finish in 1.0 second, and 10 drag on to 8.0 seconds (a tool call that kept retrying). Two ways to summarize that:
- The average = (90×1.0 + 10×8.0) / 100 = 1.7 s. Looks perfectly healthy. It quietly blends the 10 painful requests into the 90 fast ones, so the problem vanishes.
- The percentiles — line all 100 times up shortest-to-longest and read off positions. The 50th value (p50) = 1.0 s. The 95th value (p95) = 8.0 s. The 99th (p99) = 8.0 s.
Same data, completely different story. The average says “1.7 s, ship it”; p95 says “1 in every 20 users is waiting 8 seconds.” That is the rule of thumb worth memorizing: p95 = the experience of your unluckiest 1-in-20 user; p99 = your unluckiest 1-in-100. Those users churn, and the average is engineered to hide them — so you watch the tail, not the mean.
When using Mistral-7B via Ollama, you control the model binary. This means you can experience a different kind of drift than cloud API users: when you run ollama pull mistral, you may get a newer quantization or fine-tune with subtly different behavior. Always pin model versions with ollama pull mistral:7b-instruct-v0.3-q4_K_M (using the exact digest) in production. Your monitoring stack will catch behavioral drift whether it comes from a prompt change or a model update.
Prometheus + Grafana (Local Stack)
Prometheus is a time-series database that scrapes your application's /metrics HTTP endpoint on a regular interval (default: 15s) and stores the data locally. Grafana is a visualization layer that connects to Prometheus and lets you build dashboards. Together they give you the same observability stack used by companies like GitLab and Cloudflare — running entirely on your laptop.
Step 1: Docker Compose Stack
Create this docker-compose.yml in your project root. You will be able to reach Prometheus at http://localhost:9090 and Grafana at http://localhost:3000 (default login: admin/admin).
Create prometheus.yml to tell Prometheus where to scrape. Your agent will expose metrics on port 8000.
Step 2: Instrument Your Agent
prometheus_client: Counter (only goes up — total requests, total errors), Histogram (distributes values into buckets — latency, token counts), Gauge (can go up or down — current quality score). Each metric is labeled to allow filtering in Grafana.WHY — Histograms automatically compute p50/p95/p99 from bucket counts. You never store every latency value — just which bucket it fell into. This keeps storage minimal at high throughput.
GOTCHA — Call
start_http_server(8000) exactly once, before any agent runs. If you call it in a request handler it will try to rebind the port and crash on the second request.
# agent_metrics.py
import time
from prometheus_client import (
Counter, Histogram, Gauge,
start_http_server, REGISTRY
)
# ── COUNTERS: monotonically increasing totals ──────────────────────────────
agent_requests_total = Counter(
'agent_requests_total',
'Total agent run requests',
['status'] # labels: "success" | "error"
)
agent_errors_total = Counter(
'agent_errors_total',
'Total agent errors by type',
['error_type'] # "tool_failure" | "loop_timeout" | "parse_error"
)
agent_tool_calls_total = Counter(
'agent_tool_calls_total',
'Total tool invocations',
['tool_name', 'status']
)
# ── HISTOGRAMS: latency + token distribution ───────────────────────────────
agent_run_duration_seconds = Histogram(
'agent_run_duration_seconds',
'Agent run latency in seconds',
buckets=[0.5, 1, 2, 4, 8, 15, 30, 60]
# Prometheus computes p50/p95/p99 from these bucket counts
)
agent_tokens_per_query = Histogram(
'agent_tokens_per_query',
'Total tokens (prompt + completion) per agent run',
buckets=[100, 250, 500, 1000, 2000, 4000, 8000]
)
# ── GAUGE: current value (can go up or down) ───────────────────────────────
agent_quality_score = Gauge(
'agent_quality_score',
'Rolling average LLM-as-judge quality score (0-1)'
)
def start_metrics_server(port: int = 8000) -> None:
"""Call once at application startup."""
start_http_server(port)
print(f"Prometheus metrics exposed at http://localhost:{port}/metrics")
# ── DECORATOR: wrap any agent function automatically ──────────────────────
import functools
def track_agent_run(func):
"""
Decorator that instruments an agent function with all four signals.
Usage: @track_agent_run on your top-level run_agent() function.
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = func(*args, **kwargs)
agent_requests_total.labels(status='success').inc()
return result
except ToolFailureError as e:
agent_errors_total.labels(error_type='tool_failure').inc()
agent_requests_total.labels(status='error').inc()
raise
except LoopTimeoutError as e:
agent_errors_total.labels(error_type='loop_timeout').inc()
agent_requests_total.labels(status='error').inc()
raise
except ParseError as e:
agent_errors_total.labels(error_type='parse_error').inc()
agent_requests_total.labels(status='error').inc()
raise
finally:
duration = time.perf_counter() - start
agent_run_duration_seconds.observe(duration)
return wrapper
# ── USAGE ──────────────────────────────────────────────────────────────────
# from agent_metrics import (
# start_metrics_server, track_agent_run,
# agent_tokens_per_query, agent_quality_score,
# agent_tool_calls_total
# )
#
# start_metrics_server(8000) # once at startup
#
# @track_agent_run
# def run_agent(query: str) -> str:
# ... # your existing agent loop
# agent_tokens_per_query.observe(total_tokens)
# return response
// agentMetrics.ts
import * as promClient from 'prom-client'; // npm install prom-client
// Enable default Node.js runtime metrics (heap, GC, event loop)
promClient.collectDefaultMetrics({ prefix: 'node_' });
// ── COUNTERS ──────────────────────────────────────────────────────────────
export const agentRequestsTotal = new promClient.Counter({
name: 'agent_requests_total',
help: 'Total agent run requests',
labelNames: ['status'] as const,
});
export const agentErrorsTotal = new promClient.Counter({
name: 'agent_errors_total',
help: 'Total agent errors by type',
labelNames: ['error_type'] as const,
});
export const agentToolCallsTotal = new promClient.Counter({
name: 'agent_tool_calls_total',
help: 'Total tool invocations',
labelNames: ['tool_name', 'status'] as const,
});
// ── HISTOGRAMS ────────────────────────────────────────────────────────────
export const agentRunDuration = new promClient.Histogram({
name: 'agent_run_duration_seconds',
help: 'Agent run latency in seconds',
buckets: [0.5, 1, 2, 4, 8, 15, 30, 60],
});
export const agentTokensPerQuery = new promClient.Histogram({
name: 'agent_tokens_per_query',
help: 'Total tokens per agent run',
buckets: [100, 250, 500, 1000, 2000, 4000, 8000],
});
// ── GAUGE ─────────────────────────────────────────────────────────────────
export const agentQualityScore = new promClient.Gauge({
name: 'agent_quality_score',
help: 'Rolling average LLM-as-judge quality score (0-1)',
});
// ── /metrics HTTP endpoint ────────────────────────────────────────────────
import express from 'express'; // npm install express
export function startMetricsServer(port = 8000): void {
const app = express();
app.get('/metrics', async (_req, res) => {
res.set('Content-Type', promClient.register.contentType);
res.end(await promClient.register.metrics());
});
app.listen(port, () =>
console.log(`Prometheus metrics at http://localhost:${port}/metrics`)
);
}
// ── WRAPPER ───────────────────────────────────────────────────────────────
export async function trackAgentRun(
fn: () => Promise
): Promise {
const end = agentRunDuration.startTimer();
try {
const result = await fn();
agentRequestsTotal.labels({ status: 'success' }).inc();
return result;
} catch (err: any) {
const errorType = err.type ?? 'unknown';
agentErrorsTotal.labels({ error_type: errorType }).inc();
agentRequestsTotal.labels({ status: 'error' }).inc();
throw err;
} finally {
end(); // records duration into the histogram
}
}
// ── USAGE ─────────────────────────────────────────────────────────────────
// import { startMetricsServer, trackAgentRun, agentTokensPerQuery } from './agentMetrics';
// startMetricsServer(8000);
//
// const response = await trackAgentRun(async () => {
// const result = await runAgent(query);
// agentTokensPerQuery.observe(result.totalTokens);
// return result;
// });
You started a background HTTP server on port 8000. Every time your agent runs, the decorator records whether it succeeded or errored, how long it took (into a histogram bucket), and you can manually call observe() for token counts. Prometheus scrapes /metrics every 15 seconds and stores all these values as time-series. Grafana connects to Prometheus and visualizes them. The entire data path is: agent → /metrics → Prometheus → Grafana.
Step 3: Grafana Dashboard JSON
Import this panel definition into Grafana (Dashboards → New → Import → paste JSON). It creates a four-panel dashboard for the golden signals, using PromQLPrometheus Query Language — a functional expression language for selecting and aggregating time-series data stored in Prometheus. Example: histogram_quantile(0.95, rate(agent_run_duration_seconds_bucket[5m])) computes the p95 latency over the last 5 minutes. expressions to compute percentiles from histogram buckets.
{
"title": "Agent Four Golden Signals",
"panels": [
{
"title": "p95 Latency",
"type": "timeseries",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(agent_run_duration_seconds_bucket[5m]))",
"legendFormat": "p95"
},
{
"expr": "histogram_quantile(0.50, rate(agent_run_duration_seconds_bucket[5m]))",
"legendFormat": "p50"
}
],
"fieldConfig": {
"defaults": { "unit": "s", "thresholds": {
"steps": [{"color":"green","value":0},{"color":"yellow","value":5},{"color":"red","value":10}]
}}
}
},
{
"title": "Error Rate",
"type": "timeseries",
"targets": [{
"expr": "rate(agent_errors_total[5m])",
"legendFormat": "{{error_type}}"
}]
},
{
"title": "Token Spend (p95 per query)",
"type": "stat",
"targets": [{
"expr": "histogram_quantile(0.95, rate(agent_tokens_per_query_bucket[10m]))",
"legendFormat": "p95 tokens"
}]
},
{
"title": "Quality Score",
"type": "gauge",
"targets": [{
"expr": "agent_quality_score",
"legendFormat": "LLM-as-judge score"
}],
"fieldConfig": {
"defaults": { "min": 0, "max": 1, "thresholds": {
"steps": [{"color":"red","value":0},{"color":"yellow","value":0.7},{"color":"green","value":0.85}]
}}
}
}
]
}
# p95 latency (sliding 5-minute window)
histogram_quantile(0.95, rate(agent_run_duration_seconds_bucket[5m]))
# p99 latency
histogram_quantile(0.99, rate(agent_run_duration_seconds_bucket[5m]))
# Error rate as a percentage of total requests
100 * rate(agent_errors_total[5m])
/ (rate(agent_requests_total[5m]) + 0.001)
# Parse errors specifically (catches model output format changes)
rate(agent_errors_total{error_type="parse_error"}[5m])
# p95 token spend per query
histogram_quantile(0.95, rate(agent_tokens_per_query_bucket[10m]))
# Quality score 1-hour rolling average
avg_over_time(agent_quality_score[1h])
# Token spend rate (total tokens per minute)
rate(agent_tokens_per_query_sum[5m]) * 60
Production Sampling Strategy
You cannot afford to run an LLM-as-judge on every production request. At 1,000 queries/day, that doubles your inference load. The solution is smart sampling: select 5–10% of traffic for quality scoring, but make the selection representative — not just the first N requests of the day.
Sampler (5%)
(Mistral-7B)
Stored
Score < 0.7
Reservoir samplingAn algorithm by Vitter (1985) that selects k items uniformly at random from a stream of unknown size n, in O(n) time and O(k) space. Each new item has a k/i probability of being selected (where i is the count of items seen). Ensures statistical representativeness without knowing the stream length in advance. is an algorithm that maintains a fixed-size "reservoir" of randomly chosen items from an infinite stream. The key property: every item has an equal probability of being in the reservoir at any point. This matters because if you simply sample the first 5% of the day's traffic you will miss the edge-case queries that come in during off-peak hours.
# production_sampler.py
import random
import threading
import time
import json
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Optional
@dataclass
class AgentRun:
run_id: str
query: str
response: str
tool_calls: list
total_tokens: int
duration_seconds: float
timestamp: float
class ProductionSampler:
"""
Reservoir sampler for production agent runs.
Maintains a reservoir of `sample_size` runs at any time.
Thread-safe for multi-worker environments.
"""
def __init__(
self,
sample_size: int = 100,
flush_interval_seconds: int = 300, # 5 min
output_path: str = "samples/production_sample.jsonl",
error_rate_threshold: float = 0.05,
):
self.sample_size = sample_size
self.flush_interval = flush_interval_seconds
self.output_path = Path(output_path)
self.error_rate_threshold = error_rate_threshold
self.reservoir: list[AgentRun] = []
self.total_seen = 0
self.error_count = 0
self._lock = threading.Lock()
self.output_path.parent.mkdir(parents=True, exist_ok=True)
# Background thread to periodically flush reservoir to disk
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True
)
self._flush_thread.start()
def record(self, run: AgentRun, is_error: bool = False) -> bool:
"""
Record a completed agent run into the reservoir.
Returns True if this run was selected for sampling.
"""
with self._lock:
self.total_seen += 1
if is_error:
self.error_count += 1
# Always sample errors — they are the most valuable
self.reservoir.append(run)
if len(self.reservoir) > self.sample_size:
self.reservoir.pop(random.randint(0, self.sample_size - 1))
return True
# Standard reservoir sampling (Vitter's Algorithm R)
if len(self.reservoir) < self.sample_size:
self.reservoir.append(run)
return True
else:
j = random.randint(0, self.total_seen - 1)
if j < self.sample_size:
self.reservoir[j] = run
return True
return False
def should_trigger_full_eval(self) -> bool:
"""
Trigger a full evaluation run if the error rate exceeds threshold.
Connect this to your CI/eval pipeline from M18.
"""
with self._lock:
if self.total_seen < 20:
return False
error_rate = self.error_count / self.total_seen
return error_rate > self.error_rate_threshold
def _flush_loop(self) -> None:
while True:
time.sleep(self.flush_interval)
self._flush()
def _flush(self) -> None:
with self._lock:
if not self.reservoir:
return
with self.output_path.open('a') as f:
for run in self.reservoir:
f.write(json.dumps(asdict(run)) + '\n')
print(f"Flushed {len(self.reservoir)} samples to {self.output_path}")
self.reservoir.clear()
# ── USAGE ──────────────────────────────────────────────────────────────────
sampler = ProductionSampler(sample_size=50, error_rate_threshold=0.05)
def run_agent_with_sampling(query: str) -> str:
start = time.perf_counter()
try:
response, tool_calls, tokens = run_agent(query) # your agent
run = AgentRun(
run_id=f"run_{int(time.time()*1000)}",
query=query, response=response,
tool_calls=tool_calls, total_tokens=tokens,
duration_seconds=time.perf_counter() - start,
timestamp=time.time()
)
sampler.record(run, is_error=False)
return response
except Exception as e:
run = AgentRun(
run_id=f"run_{int(time.time()*1000)}",
query=query, response=str(e),
tool_calls=[], total_tokens=0,
duration_seconds=time.perf_counter() - start,
timestamp=time.time()
)
sampler.record(run, is_error=True)
raise
// productionSampler.ts
import fs from 'fs';
import path from 'path';
interface AgentRun {
runId: string;
query: string;
response: string;
toolCalls: unknown[];
totalTokens: number;
durationSeconds: number;
timestamp: number;
isError: boolean;
}
export class ProductionSampler {
private reservoir: AgentRun[] = [];
private totalSeen = 0;
private errorCount = 0;
private readonly sampleSize: number;
private readonly errorRateThreshold: number;
private readonly outputPath: string;
constructor(options: {
sampleSize?: number;
flushIntervalMs?: number;
outputPath?: string;
errorRateThreshold?: number;
} = {}) {
this.sampleSize = options.sampleSize ?? 100;
this.errorRateThreshold = options.errorRateThreshold ?? 0.05;
this.outputPath = options.outputPath ?? 'samples/production_sample.jsonl';
fs.mkdirSync(path.dirname(this.outputPath), { recursive: true });
// Periodic flush to disk
const interval = options.flushIntervalMs ?? 300_000;
setInterval(() => this.flush(), interval).unref();
}
record(run: AgentRun): boolean {
this.totalSeen++;
if (run.isError) {
this.errorCount++;
// Always keep error runs — most informative samples
this.reservoir.push(run);
if (this.reservoir.length > this.sampleSize) {
const evict = Math.floor(Math.random() * this.sampleSize);
this.reservoir.splice(evict, 1);
}
return true;
}
// Vitter's Algorithm R reservoir sampling
if (this.reservoir.length < this.sampleSize) {
this.reservoir.push(run);
return true;
}
const j = Math.floor(Math.random() * this.totalSeen);
if (j < this.sampleSize) {
this.reservoir[j] = run;
return true;
}
return false;
}
shouldTriggerFullEval(): boolean {
if (this.totalSeen < 20) return false;
return (this.errorCount / this.totalSeen) > this.errorRateThreshold;
}
flush(): void {
if (this.reservoir.length === 0) return;
const lines = this.reservoir.map(r => JSON.stringify(r)).join('\n') + '\n';
fs.appendFileSync(this.outputPath, lines);
console.log(`Flushed ${this.reservoir.length} samples to ${this.outputPath}`);
this.reservoir = [];
}
}
// ── USAGE ──────────────────────────────────────────────────────────────────
// const sampler = new ProductionSampler({ sampleSize: 50 });
//
// async function runAgentWithSampling(query: string): Promise {
// const start = Date.now();
// try {
// const { response, toolCalls, totalTokens } = await runAgent(query);
// sampler.record({ runId: crypto.randomUUID(), query, response,
// toolCalls, totalTokens, durationSeconds: (Date.now()-start)/1000,
// timestamp: Date.now(), isError: false });
// return response;
// } catch (err: any) {
// sampler.record({ runId: crypto.randomUUID(), query, response: String(err),
// toolCalls: [], totalTokens: 0, durationSeconds: (Date.now()-start)/1000,
// timestamp: Date.now(), isError: true });
// throw err;
// }
// }
Model Drift Detection
Mistral-7B is a local model. Unlike an API, you control exactly which version runs — but a careless ollama pull mistral can silently swap the underlying weights. Even without a model update, behavior can drift: prompt distribution shifts as your users evolve, tool schemas change, retrieved context quality fluctuates. Drift detection catches these regressions before users do.
Model driftThe gradual degradation of a model's output quality or behavioral consistency over time, caused by changes in input distribution (data drift), changes in the model itself (concept drift), or changes in the surrounding system (tool changes, prompt modifications). Drift is insidious because individual changes are often imperceptible — only the cumulative effect is measurable. occurs when a model's outputs change in quality or distribution relative to a known baseline, even when the model weights are nominally unchanged. For local Ollama models, the three most reliable drift signals are: (1) rolling average quality score, (2) token count distribution (confused models produce longer outputs), and (3) tool call frequency (a struggling model stops using tools and generates free-form text instead).
# drift_detector.py
import json
import statistics
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass
class DriftAlert:
metric: str
current_value: float
baseline_mean: float
baseline_std: float
z_score: float
direction: str # "up" or "down"
severity: str # "warning" (|z|>2) or "critical" (|z|>3)
class DriftDetector:
"""
Detects Mistral-7B behavioral drift across three dimensions:
1. Quality score (LLM-as-judge) — 7-day rolling window
2. Token count per query — distribution comparison
3. Tool call frequency — tracks model "giving up"
Uses z-score alerting: flag when a metric moves more than
±2 standard deviations from its 7-day baseline.
"""
WINDOW_SIZE = 7 * 24 * 12 # 7 days at 5-min intervals = 2016 points
def __init__(self, baseline_path: str = "baselines/drift_baseline.json"):
self.quality_window: deque[float] = deque(maxlen=self.WINDOW_SIZE)
self.token_window: deque[float] = deque(maxlen=self.WINDOW_SIZE)
self.tool_freq_window: deque[float] = deque(maxlen=self.WINDOW_SIZE)
self.baseline_path = Path(baseline_path)
self._load_baseline()
def record(
self,
quality_score: float,
tokens_used: int,
tool_calls_made: int,
max_possible_tool_calls: int = 5,
) -> list[DriftAlert]:
"""
Record a new data point and return any drift alerts triggered.
Call this for every sampled agent run that was quality-scored.
"""
tool_freq = tool_calls_made / max(max_possible_tool_calls, 1)
self.quality_window.append(quality_score)
self.token_window.append(float(tokens_used))
self.tool_freq_window.append(tool_freq)
alerts = []
for metric, window, direction_sensitive in [
('quality_score', self.quality_window, True), # low is bad
('tokens_per_query', self.token_window, False), # high is bad
('tool_call_frequency', self.tool_freq_window, True), # low is bad
]:
alert = self._check_drift(metric, window)
if alert:
alerts.append(alert)
return alerts
def _check_drift(
self, metric: str, window: deque[float]
) -> Optional[DriftAlert]:
if len(window) < 50: # need enough data for meaningful stats
return None
values = list(window)
# Baseline: oldest 50% of window; current: newest 20%
midpoint = len(values) // 2
tail = max(1, len(values) // 5)
baseline_values = values[:midpoint]
current_values = values[-tail:]
if len(baseline_values) < 10:
return None
baseline_mean = statistics.mean(baseline_values)
baseline_std = statistics.stdev(baseline_values)
if baseline_std < 0.001: # nearly constant — no meaningful z-score
return None
current_mean = statistics.mean(current_values)
z_score = (current_mean - baseline_mean) / baseline_std
if abs(z_score) < 2.0:
return None
severity = "critical" if abs(z_score) >= 3.0 else "warning"
direction = "up" if z_score > 0 else "down"
return DriftAlert(
metric=metric,
current_value=current_mean,
baseline_mean=baseline_mean,
baseline_std=baseline_std,
z_score=round(z_score, 2),
direction=direction,
severity=severity,
)
def _load_baseline(self) -> None:
if self.baseline_path.exists():
data = json.loads(self.baseline_path.read_text())
self.quality_window.extend(data.get("quality", []))
self.token_window.extend(data.get("tokens", []))
self.tool_freq_window.extend(data.get("tool_freq", []))
def save_baseline(self) -> None:
self.baseline_path.parent.mkdir(parents=True, exist_ok=True)
self.baseline_path.write_text(json.dumps({
"quality": list(self.quality_window),
"tokens": list(self.token_window),
"tool_freq": list(self.tool_freq_window),
}, indent=2))
# ── USAGE ──────────────────────────────────────────────────────────────────
detector = DriftDetector()
def on_quality_score_recorded(score: float, tokens: int, tool_calls: int):
alerts = detector.record(
quality_score=score,
tokens_used=tokens,
tool_calls_made=tool_calls
)
for alert in alerts:
print(f"DRIFT ALERT [{alert.severity.upper()}] "
f"{alert.metric}: z={alert.z_score} "
f"(current={alert.current_value:.3f}, "
f"baseline={alert.baseline_mean:.3f}±{alert.baseline_std:.3f})")
# Forward to alertmanager or Slack webhook
send_drift_alert(alert)
// driftDetector.ts
import fs from 'fs';
import path from 'path';
interface DriftAlert {
metric: string;
currentValue: number;
baselineMean: number;
baselineStd: number;
zScore: number;
direction: 'up' | 'down';
severity: 'warning' | 'critical';
}
function mean(arr: number[]): number {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
function stddev(arr: number[]): number {
const m = mean(arr);
return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length);
}
class RollingWindow {
private data: number[] = [];
constructor(private readonly maxSize: number) {}
push(val: number): void {
this.data.push(val);
if (this.data.length > this.maxSize) this.data.shift();
}
get values(): number[] { return [...this.data]; }
get size(): number { return this.data.length; }
}
export class DriftDetector {
private readonly WINDOW = 7 * 24 * 12; // 7 days at 5-min resolution
private qualityW = new RollingWindow(this.WINDOW);
private tokensW = new RollingWindow(this.WINDOW);
private toolFreqW = new RollingWindow(this.WINDOW);
constructor(private baselinePath = 'baselines/drift_baseline.json') {
this.loadBaseline();
}
record(
qualityScore: number,
tokensUsed: number,
toolCallsMade: number,
maxToolCalls = 5
): DriftAlert[] {
this.qualityW.push(qualityScore);
this.tokensW.push(tokensUsed);
this.toolFreqW.push(toolCallsMade / Math.max(maxToolCalls, 1));
return [
this.checkDrift('quality_score', this.qualityW),
this.checkDrift('tokens_per_query', this.tokensW),
this.checkDrift('tool_call_frequency', this.toolFreqW),
].filter((a): a is DriftAlert => a !== null);
}
private checkDrift(metric: string, window: RollingWindow): DriftAlert | null {
if (window.size < 50) return null;
const vals = window.values;
const mid = Math.floor(vals.length / 2);
const tail = Math.max(1, Math.floor(vals.length / 5));
const baseline = vals.slice(0, mid);
const current = vals.slice(-tail);
const bMean = mean(baseline);
const bStd = stddev(baseline);
if (bStd < 0.001) return null;
const cMean = mean(current);
const z = (cMean - bMean) / bStd;
if (Math.abs(z) < 2.0) return null;
return {
metric,
currentValue: cMean,
baselineMean: bMean,
baselineStd: bStd,
zScore: Math.round(z * 100) / 100,
direction: z > 0 ? 'up' : 'down',
severity: Math.abs(z) >= 3 ? 'critical' : 'warning',
};
}
private loadBaseline(): void {
try {
const data = JSON.parse(fs.readFileSync(this.baselinePath, 'utf-8'));
(data.quality ?? []).forEach((v: number) => this.qualityW.push(v));
(data.tokens ?? []).forEach((v: number) => this.tokensW.push(v));
(data.toolFreq ?? []).forEach((v: number) => this.toolFreqW.push(v));
} catch {}
}
saveBaseline(): void {
fs.mkdirSync(path.dirname(this.baselinePath), { recursive: true });
fs.writeFileSync(this.baselinePath, JSON.stringify({
quality: this.qualityW.values,
tokens: this.tokensW.values,
toolFreq: this.toolFreqW.values,
}, null, 2));
}
}
The detector splits the rolling window in half: the older half is the "baseline," the newer 20% is "current." It computes a z-score: how many standard deviations has the current mean moved from the baseline mean? At |z| ≥ 2 it fires a warning; at |z| ≥ 3 it fires critical. This fires on real behavioral changes while tolerating normal daily fluctuation. Saving and loading the baseline to disk means the detector survives restarts without losing its statistical memory.
Alerting with Prometheus AlertManager
AlertManagerThe component of the Prometheus stack that receives firing alerts from the Prometheus server, deduplicates them (so you get one page, not one per scrape interval), groups them by label, silences them when acknowledged, and routes them to notification receivers like Slack, PagerDuty, or email. receives firing alerts from Prometheus, deduplicates them, and routes them to your notification channel. The alert rules live in a YAML file; the routing config lives in alertmanager.yml. You never have to write a polling script again.
# alert_rules.yml — place in same directory as prometheus.yml
groups:
- name: agent_alerts
interval: 1m # re-evaluate every minute
rules:
# ── Error rate: >5% over last 5 minutes ──────────────────────────────
- alert: AgentHighErrorRate
expr: >
100 * rate(agent_errors_total[5m])
/ (rate(agent_requests_total[5m]) + 0.001) > 5
for: 2m # must stay above threshold for 2 min before firing
labels:
severity: warning
annotations:
summary: "Agent error rate above 5%"
description: >
Error rate is {{ $value | printf "%.1f" }}%.
Check Grafana for error type breakdown.
# ── p95 latency: >10 seconds ──────────────────────────────────────────
- alert: AgentHighLatency
expr: >
histogram_quantile(0.95,
rate(agent_run_duration_seconds_bucket[5m])) > 10
for: 3m
labels:
severity: warning
annotations:
summary: "Agent p95 latency above 10s"
description: >
p95 latency is {{ $value | printf "%.1f" }}s.
Model may be looping or Ollama is under load.
# ── Quality score: <0.70 over last 1 hour ────────────────────────────
- alert: AgentLowQualityScore
expr: avg_over_time(agent_quality_score[1h]) < 0.70
for: 5m
labels:
severity: critical
annotations:
summary: "Agent quality score below 0.70 (1h avg)"
description: >
1-hour average quality score is {{ $value | printf "%.3f" }}.
Review sampled runs at samples/production_sample.jsonl
and trigger full eval run.
# ── Parse errors spiking: >10 per minute ─────────────────────────────
- alert: AgentParseErrorSpike
expr: rate(agent_errors_total{error_type="parse_error"}[5m]) * 60 > 10
for: 1m
labels:
severity: critical
annotations:
summary: "Parse error spike — possible model update"
description: >
Parse errors at {{ $value | printf "%.0f" }}/min.
Check if Ollama model was updated: `ollama list`
# alertmanager.yml
global:
resolve_timeout: 5m
route:
# Default: send everything to the 'team-slack' receiver
receiver: 'team-slack'
group_by: ['alertname', 'severity']
group_wait: 30s # wait 30s to batch multiple alerts together
group_interval: 5m # wait 5m between re-notifications for same group
repeat_interval: 4h # re-notify after 4h if alert is still firing
routes:
# Critical alerts get an immediate re-notify after 30 minutes
- match:
severity: critical
receiver: 'team-slack'
repeat_interval: 30m
receivers:
- name: 'team-slack'
slack_configs:
- api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
channel: '#agent-alerts'
send_resolved: true
title: '{{ if eq .Status "firing" }}🔴{{ else }}✅{{ end }} {{ .CommonAnnotations.summary }}'
text: >-
{{ range .Alerts }}
*Alert:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*Severity:* {{ .Labels.severity }}
*Started:* {{ .StartsAt.Format "2006-01-02 15:04:05" }}
{{ end }}
inhibit_rules:
# Suppress warning-level alerts when a critical alert is already firing
- source_match:
severity: 'critical'
target_match:
severity: 'warning'
equal: ['alertname']
# slack_notifier.py — for direct Slack alerts without AlertManager
# (use this if you prefer not to run AlertManager)
import os
import json
import urllib.request
from dataclasses import dataclass
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "")
@dataclass
class Alert:
title: str
message: str
severity: str # "warning" | "critical"
metric_name: str
current_value: float
def send_slack_alert(alert: Alert) -> bool:
"""
Send an alert to Slack via incoming webhook.
Works with any service that accepts Slack-format webhook payloads
(Discord, Teams, etc. have compatible modes).
Returns True on success.
"""
if not SLACK_WEBHOOK_URL:
print(f"[ALERT] {alert.severity.upper()}: {alert.title} — {alert.message}")
return False
emoji = "🔴" if alert.severity == "critical" else "⚠️"
payload = {
"blocks": [
{
"type": "header",
"text": {"type": "plain_text", "text": f"{emoji} {alert.title}"}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*Severity:*\n{alert.severity.upper()}"},
{"type": "mrkdwn", "text": f"*Metric:*\n{alert.metric_name}"},
{"type": "mrkdwn", "text": f"*Current Value:*\n{alert.current_value:.3f}"},
{"type": "mrkdwn", "text": f"*Message:*\n{alert.message}"},
]
}
]
}
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
SLACK_WEBHOOK_URL,
data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status == 200
except Exception as e:
print(f"Failed to send Slack alert: {e}")
return False
# ── Node.js equivalent ────────────────────────────────────────────────────
# // slackNotifier.ts
# import https from 'https';
# import url from 'url';
#
# export async function sendSlackAlert(alert: {
# title: string; message: string;
# severity: string; metricName: string; currentValue: number;
# }): Promise {
# const webhookUrl = process.env.SLACK_WEBHOOK_URL;
# if (!webhookUrl) {
# console.log(`[ALERT] ${alert.severity}: ${alert.title}`);
# return false;
# }
# const emoji = alert.severity === 'critical' ? '🔴' : '⚠️';
# const payload = JSON.stringify({
# text: `${emoji} *${alert.title}*\n${alert.message}\nValue: ${alert.currentValue.toFixed(3)}`
# });
# return new Promise((resolve) => {
# const parsed = new url.URL(webhookUrl);
# const req = https.request({ hostname: parsed.hostname,
# path: parsed.pathname + parsed.search, method: 'POST',
# headers: { 'Content-Type': 'application/json' } },
# (res) => resolve(res.statusCode === 200)
# );
# req.on('error', () => resolve(false));
# req.write(payload);
# req.end();
# });
# }
Closing the Feedback Loop
v1.2.0
Prometheus
z-score alert
JSONL + labels
M18 harness
The feedback loop is the operational bridge between monitoring and evaluation. Every failure your monitoring catches is a potential new eval case. Every quality score drop is a signal to augment your golden set. Without this loop, your eval set silently becomes stale — a perfect test for yesterday's failure modes.
FeedbackCollector captures thumbs-up/down signals (from a UI) alongside full agent run context (query, response, tool calls, tokens). Writes to JSONL for later ingestion into the M18 eval harness.WHY — User feedback is the cheapest source of labeled data. A thumbs-down with a production query is worth more than ten synthetic test cases because it represents a real failure mode your test set did not anticipate.
GOTCHA — Rate-limit feedback collection. If you trigger a full re-eval on every thumbs-down you will run thousands of eval jobs per day. Use a debounce: accumulate N new failures before triggering, or trigger once per hour maximum.
# feedback_collector.py
import json
import time
import subprocess
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import Literal, Optional
@dataclass
class FeedbackRecord:
run_id: str
query: str
response: str
tool_calls: list
total_tokens: int
thumb: Literal["up", "down"]
user_comment: Optional[str]
timestamp: float
# metadata for eval set ingestion
failure_category: Optional[str] = None # e.g. "wrong_tool", "hallucinated"
suggested_correction: Optional[str] = None
class FeedbackCollector:
"""
Collects thumbs-up/down feedback from production users.
Writes to a JSONL file compatible with the M18 eval harness.
Triggers a re-eval run when enough new failures have accumulated.
"""
def __init__(
self,
feedback_path: str = "feedback/production_feedback.jsonl",
eval_trigger_threshold: int = 20, # trigger eval after N new failures
eval_trigger_interval_seconds: int = 3600, # but no more than once/hour
):
self.feedback_path = Path(feedback_path)
self.feedback_path.parent.mkdir(parents=True, exist_ok=True)
self.eval_trigger_threshold = eval_trigger_threshold
self.eval_trigger_interval = eval_trigger_interval_seconds
self._new_failures_since_last_eval = 0
self._last_eval_trigger_time = 0.0
def record_feedback(self, record: FeedbackRecord) -> None:
"""Record user feedback. Thread-safe via file append."""
with self.feedback_path.open('a') as f:
f.write(json.dumps(asdict(record)) + '\n')
if record.thumb == "down":
self._new_failures_since_last_eval += 1
self._maybe_trigger_eval()
def _maybe_trigger_eval(self) -> None:
now = time.time()
enough_failures = (
self._new_failures_since_last_eval >= self.eval_trigger_threshold
)
enough_time = (
now - self._last_eval_trigger_time >= self.eval_trigger_interval
)
if enough_failures and enough_time:
print(f"Triggering eval run: "
f"{self._new_failures_since_last_eval} new failures logged.")
self._trigger_eval_run()
self._new_failures_since_last_eval = 0
self._last_eval_trigger_time = now
def _trigger_eval_run(self) -> None:
"""
Trigger the M18 eval harness.
Adapt this to your CI system — GitHub Actions, GitLab CI, etc.
"""
try:
# Ingest new failures into the eval dataset first
self._ingest_failures_to_eval_set()
# Then run the eval harness
subprocess.Popen(
["python", "-m", "pytest", "evals/", "-x", "--tb=short"],
stdout=open("logs/eval_run.log", "w"),
stderr=subprocess.STDOUT,
)
except Exception as e:
print(f"Failed to trigger eval: {e}")
def _ingest_failures_to_eval_set(self) -> None:
"""Convert thumbs-down records into eval test cases."""
eval_cases_path = Path("evals/data/production_failures.jsonl")
eval_cases_path.parent.mkdir(parents=True, exist_ok=True)
with self.feedback_path.open() as f:
records = [json.loads(line) for line in f if line.strip()]
failures = [r for r in records if r['thumb'] == 'down']
with eval_cases_path.open('w') as f:
for record in failures:
eval_case = {
"input": record['query'],
"reference_response": record.get('suggested_correction'),
"source": "production_feedback",
"run_id": record['run_id'],
"failure_category": record.get('failure_category'),
}
f.write(json.dumps(eval_case) + '\n')
print(f"Ingested {len(failures)} failure cases to eval set.")
# ── USAGE IN YOUR API HANDLER ──────────────────────────────────────────────
collector = FeedbackCollector(eval_trigger_threshold=20)
def handle_feedback_endpoint(run_id: str, thumb: str,
comment: str = "", run_data: dict = {}) -> dict:
record = FeedbackRecord(
run_id=run_id,
query=run_data.get("query", ""),
response=run_data.get("response", ""),
tool_calls=run_data.get("tool_calls", []),
total_tokens=run_data.get("total_tokens", 0),
thumb=thumb,
user_comment=comment or None,
timestamp=time.time(),
)
collector.record_feedback(record)
return {"status": "recorded", "run_id": run_id}
// feedbackCollector.ts
import fs from 'fs';
import path from 'path';
import { spawn } from 'child_process';
interface FeedbackRecord {
runId: string;
query: string;
response: string;
toolCalls: unknown[];
totalTokens: number;
thumb: 'up' | 'down';
userComment?: string;
timestamp: number;
failureCategory?: string;
suggestedCorrection?: string;
}
export class FeedbackCollector {
private newFailuresSinceLastEval = 0;
private lastEvalTriggerTime = 0;
private readonly feedbackPath: string;
private readonly evalTriggerThreshold: number;
private readonly evalTriggerIntervalMs: number;
constructor(options: {
feedbackPath?: string;
evalTriggerThreshold?: number;
evalTriggerIntervalMs?: number;
} = {}) {
this.feedbackPath = options.feedbackPath ?? 'feedback/production_feedback.jsonl';
this.evalTriggerThreshold = options.evalTriggerThreshold ?? 20;
this.evalTriggerIntervalMs = options.evalTriggerIntervalMs ?? 3_600_000;
fs.mkdirSync(path.dirname(this.feedbackPath), { recursive: true });
}
recordFeedback(record: FeedbackRecord): void {
fs.appendFileSync(this.feedbackPath, JSON.stringify(record) + '\n');
if (record.thumb === 'down') {
this.newFailuresSinceLastEval++;
this.maybeTriggerEval();
}
}
private maybeTriggerEval(): void {
const now = Date.now();
const enoughFailures = this.newFailuresSinceLastEval >= this.evalTriggerThreshold;
const enoughTime = (now - this.lastEvalTriggerTime) >= this.evalTriggerIntervalMs;
if (enoughFailures && enoughTime) {
console.log(`Triggering eval: ${this.newFailuresSinceLastEval} new failures`);
this.triggerEvalRun();
this.newFailuresSinceLastEval = 0;
this.lastEvalTriggerTime = now;
}
}
private triggerEvalRun(): void {
try {
this.ingestFailuresToEvalSet();
const proc = spawn('npx', ['vitest', 'run', 'evals/'], {
detached: true, stdio: ['ignore', fs.openSync('logs/eval_run.log', 'w'), 'ignore']
});
proc.unref();
} catch (err) {
console.error('Failed to trigger eval:', err);
}
}
private ingestFailuresToEvalSet(): void {
const evalPath = 'evals/data/production_failures.jsonl';
fs.mkdirSync(path.dirname(evalPath), { recursive: true });
const lines = fs.readFileSync(this.feedbackPath, 'utf-8').split('\n').filter(Boolean);
const failures = lines.map(l => JSON.parse(l)).filter((r: FeedbackRecord) => r.thumb === 'down');
const cases = failures.map((r: FeedbackRecord) => JSON.stringify({
input: r.query, referenceResponse: r.suggestedCorrection,
source: 'production_feedback', runId: r.runId,
failureCategory: r.failureCategory,
}));
fs.writeFileSync(evalPath, cases.join('\n') + '\n');
console.log(`Ingested ${failures.length} failure cases to eval set.`);
}
}
Building a Simple Agent Dashboard
Grafana is powerful but overkill for a solo developer or small team. A lightweight Flask/Express dashboard — a single Python file — gives you last 100 runs, error breakdown, and quality trend in under 100 lines, without Docker, without YAML, without a Grafana instance to maintain.
Use the lightweight dashboard when: you are a solo developer, you want to share a read-only status page with non-technical stakeholders, or you need to embed agent metrics into an existing web interface. Use Grafana when: you have multiple services to monitor, you need alerting integrated with your metrics store, or you need long-term time-series visualization. Both approaches use the same Prometheus data — they are not mutually exclusive.
# dashboard.py — pip install flask
# Run: python dashboard.py
# Open: http://localhost:5001
from flask import Flask, jsonify, render_template_string
import json
from pathlib import Path
from collections import Counter
import statistics
app = Flask(__name__)
SAMPLE_FILE = Path("samples/production_sample.jsonl")
def load_runs(limit: int = 100) -> list[dict]:
if not SAMPLE_FILE.exists():
return []
lines = SAMPLE_FILE.read_text().strip().split('\n')
runs = [json.loads(l) for l in lines if l.strip()]
return runs[-limit:]
@app.route('/api/stats')
def stats():
runs = load_runs(200)
if not runs:
return jsonify({"error": "No data yet"})
durations = [r['duration_seconds'] for r in runs]
tokens = [r['total_tokens'] for r in runs if r.get('total_tokens')]
errors = [r for r in runs if r.get('response','').startswith('Error')]
error_types = Counter(
r.get('error_type', 'unknown') for r in errors
)
return jsonify({
"total_runs": len(runs),
"error_rate_pct": round(100 * len(errors) / max(len(runs), 1), 1),
"error_breakdown": dict(error_types),
"latency": {
"p50": round(statistics.median(durations), 2),
"p95": round(sorted(durations)[int(len(durations)*0.95)], 2),
"mean": round(statistics.mean(durations), 2),
},
"tokens": {
"mean": round(statistics.mean(tokens), 0) if tokens else 0,
"p95": round(sorted(tokens)[int(len(tokens)*0.95)], 0) if tokens else 0,
},
"recent_runs": runs[-10:][::-1],
})
DASHBOARD_HTML = """
Agent Dashboard
🔌 Agent Dashboard
Loading...
Recent Runs
Time Query Tokens Duration Status
Course Assistant — search or ask, fully offline
Open full page ↗
"""
@app.route('/')
def dashboard():
return render_template_string(DASHBOARD_HTML)
if __name__ == '__main__':
app.run(port=5001, debug=False)
// dashboard.ts — npm install express
// Run: npx ts-node dashboard.ts
// Open: http://localhost:5001
import express, { Request, Response } from 'express';
import fs from 'fs';
const app = express();
const SAMPLE_FILE = 'samples/production_sample.jsonl';
function loadRuns(limit = 100): Record[] {
try {
const lines = fs.readFileSync(SAMPLE_FILE, 'utf-8').split('\n').filter(Boolean);
const runs = lines.map(l => JSON.parse(l));
return runs.slice(-limit);
} catch { return []; }
}
function percentile(arr: number[], p: number): number {
const sorted = [...arr].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length * p / 100)] ?? 0;
}
app.get('/api/stats', (_req: Request, res: Response) => {
const runs = loadRuns(200);
if (!runs.length) { res.json({ error: 'No data yet' }); return; }
const durations = runs.map(r => Number(r.duration_seconds) || 0);
const tokens = runs.map(r => Number(r.total_tokens) || 0).filter(t => t > 0);
const errors = runs.filter(r => String(r.response).startsWith('Error'));
const errorBreakdown: Record = {};
errors.forEach(r => {
const t = String(r.error_type ?? 'unknown');
errorBreakdown[t] = (errorBreakdown[t] ?? 0) + 1;
});
res.json({
total_runs: runs.length,
error_rate_pct: Math.round(1000 * errors.length / Math.max(runs.length, 1)) / 10,
error_breakdown: errorBreakdown,
latency: {
p50: Math.round(percentile(durations, 50) * 100) / 100,
p95: Math.round(percentile(durations, 95) * 100) / 100,
mean: Math.round(durations.reduce((a, b) => a + b, 0) / durations.length * 100) / 100,
},
tokens: {
mean: tokens.length ? Math.round(tokens.reduce((a, b) => a + b, 0) / tokens.length) : 0,
p95: tokens.length ? percentile(tokens, 95) : 0,
},
recent_runs: runs.slice(-10).reverse(),
});
});
const DASH_HTML = `
Agent Dashboard
🔌 Agent Dashboard
Loading...
Time Query Tokens Duration Status
`;
app.get('/', (_req: Request, res: Response) => res.send(DASH_HTML));
app.listen(5001, () => console.log('Dashboard at http://localhost:5001'));
The dashboard server reads the JSONL sample file written by the ProductionSampler and computes aggregate statistics on the last 200 runs: error rate, latency percentiles, token averages. The frontend auto-refreshes every 15 seconds. No database, no schema, no migrations — just a JSONL file and arithmetic. Add this to your docker-compose.yml as a fourth service if you want it alongside Prometheus and Grafana.
Knowledge Check
Test your understanding of production monitoring for agents.
1. A team has a comprehensive eval suite that passes 100% before every deploy. Three weeks after release, users report that agent responses have become vague and unhelpful. What is the most likely explanation?
2. Your agent processes 5,000 queries per day. You want to run LLM-as-judge quality scoring on production traffic. What is the recommended approach?
3. After upgrading Ollama, you notice that agent_errors_total{error_type="parse_error"} spikes from 0.2/min to 8/min. What does this most likely indicate?
4. The drift detector fires a warning on tokens_per_query with z-score = +2.4 and direction = "up." What is the most likely agent behavior change?
5. Which Prometheus metric type is the correct choice for tracking agent run latency so that Grafana can compute p95 and p99 values?
histogram_quantile().