Building AI Agents with Open Source Models
Track 6: Quality & Observability OS Track · Module 18 of 20
⏱ 80 min 📊 Intermediate
🔌
Open Source Track — Zero Cloud Required The full observability stack — Prometheus, Grafana, and Alertmanager — runs locally via Docker Compose. No Datadog account, no cloud credits, no API keys. All metrics stay on your machine. The instrumented agent targets the Mistral-7B + Ollama setup from earlier modules.

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

Before the Pain

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.

Definition: The Eval–Monitor Split

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)
WhenBefore deploy (offline)After deploy (online, continuous)
Data sourceCurated golden setReal production traffic
Coverage100% of test cases5–10% sampled for quality
CatchesRegressions before releaseRegressions after release, drift, new failure modes
LatencyMinutes (run before merge)Seconds (alerts trigger in real-time)
Why It Matters: The Numbers

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.

With the "why" established, let's define exactly what to measure. Not every metric is equally useful — the four golden signals are the minimum useful set, chosen because each one maps to a distinct class of failure.

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.

Four Golden Signals — Live Dashboard (simulated)
p95 Latency
p50: — · p99: —
Error Rate
tool: — · timeout: — · parse: —
💰
Token Spend
session avg: — · p95: —
Quality Score
last 1h: — sampled
Four signals: p95 Latency (4.2s, ok), Error Rate (1.8%, ok), Token Spend (1,240/query, ok), Quality Score (0.84, ok).
Latency (p50/p95/p99)

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.

Failure mode: model confused by input → more tool calls → chain grows → p99 spikes first.
Error Rate

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).

Failure mode: Ollama model update changes output format → parse errors spike before anything else.
💰 Token Spend

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.

Failure mode: model starts over-explaining (hedging, repetition) → token count per query grows 40% → GPU queue backs up.
Quality Score

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.

Failure mode: system prompt change shifts reasoning style → scores drift from 0.87 to 0.71 over two weeks before anyone notices.
Why percentiles beat the average — a 100-request example

“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.

Open Source Difference

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.

Now that we know what to measure, let's build the infrastructure to measure it. The entire stack runs locally — no cloud account required.

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).

version: '3.8' services: prometheus: image: prom/prometheus:v2.51.2 container_name: agent-prometheus ports: - "9090:9090" volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.retention.time=15d' grafana: image: grafana/grafana:10.4.2 container_name: agent-grafana ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=changeme volumes: - grafana-data:/var/lib/grafana depends_on: - prometheus alertmanager: image: prom/alertmanager:v0.27.0 container_name: agent-alertmanager ports: - "9093:9093" volumes: - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro volumes: prometheus-data: grafana-data:

Create prometheus.yml to tell Prometheus where to scrape. Your agent will expose metrics on port 8000.

global: scrape_interval: 15s evaluation_interval: 15s rule_files: - "alert_rules.yml" alerting: alertmanagers: - static_configs: - targets: ['alertmanager:9093'] scrape_configs: - job_name: 'agent' static_configs: - targets: ['host.docker.internal:8000'] # On Linux, replace host.docker.internal with your host IP

Step 2: Instrument Your Agent

WHAT — Four metric types from 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;
// });
# curl http://localhost:8000/metrics # HELP agent_requests_total Total agent run requests # TYPE agent_requests_total counter agent_requests_total{status="success"} 247.0 agent_requests_total{status="error"} 5.0 # HELP agent_run_duration_seconds Agent run latency in seconds # TYPE agent_run_duration_seconds histogram agent_run_duration_seconds_bucket{le="0.5"} 12.0 agent_run_duration_seconds_bucket{le="1.0"} 89.0 agent_run_duration_seconds_bucket{le="2.0"} 178.0 agent_run_duration_seconds_bucket{le="4.0"} 231.0 agent_run_duration_seconds_bucket{le="+Inf"} 252.0 agent_run_duration_seconds_sum 531.4 agent_run_duration_seconds_count 252.0 # HELP agent_quality_score Rolling average LLM-as-judge quality score # TYPE agent_quality_score gauge agent_quality_score 0.847
✓ What Just Happened?

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.

Sampling Pipeline — 5% of Traffic to LLM Judge
PRODUCTION TRAFFIC (100 requests)
Reservoir
Sampler (5%)
LLM Judge
(Mistral-7B)
Score
Stored
Alert if
Score < 0.7
📈 100 requests processed
🎯 5 sampled for scoring
⭐ avg score: 0.83
✓ No alert triggered
100 requests arrive. 5% (5 requests) are reservoir-sampled. Each is scored by an LLM judge. Average score 0.83 — no alert. Score is stored in Prometheus gauge.
Definition: Reservoir Sampling

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.

Definition: Model Drift

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));
  }
}
✓ What Just Happened?

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

MLOps Feedback Cycle — Production to Eval and Back
Five-step cycle: Deploy → Monitor (Prometheus) → Detect Drift (z-score alert) → Collect Failures (JSONL + labels) → Fix & Re-eval (M18 harness) → Deploy.

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.

WHAT — A 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.

When to Use This vs Grafana

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

TimeQueryTokensDurationStatus
""" @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...
TimeQueryTokensDurationStatus
`; app.get('/', (_req: Request, res: Response) => res.send(DASH_HTML)); app.listen(5001, () => console.log('Dashboard at http://localhost:5001'));
✓ What Just Happened?

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?

A
The eval suite is too large and takes too long to run.
B
The production input distribution has drifted from the eval test set, causing silent quality degradation that offline evals cannot detect.
C
The model is experiencing high latency, which causes response truncation.
D
The Docker container ran out of memory and is silently killing requests.

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?

A
Use reservoir sampling to select 5–10% of traffic (250–500 queries/day) for scoring, biasing toward error cases.
B
Score every query but run the judge asynchronously so it doesn't block the response.
C
Score only the first 100 queries of each day — early traffic is representative enough.
D
Score only queries that triggered at least one tool call, since those are more complex.

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?

A
The Prometheus scrape interval is too short.
B
The new model version changed its output format, breaking the agent's JSON parser — a classic model drift event.
C
The agent's tool schema is invalid and Ollama is rejecting it.
D
Parse errors are always within normal range and can be ignored.

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?

A
The agent is running faster and completing tasks with fewer steps.
B
Users are submitting longer queries than usual.
C
The model has started generating longer, more verbose responses — possibly over-hedging, repeating itself, or failing to terminate cleanly.
D
Prometheus is double-counting tokens due to a misconfigured histogram bucket.

5. Which Prometheus metric type is the correct choice for tracking agent run latency so that Grafana can compute p95 and p99 values?

A
Counter — because latency counts accumulate over time.
B
Histogram — because it distributes observations into configurable buckets, enabling percentile computation via histogram_quantile().
C
Gauge — because latency is a value that goes up and down.
D
Summary — because Prometheus Summaries are always more accurate than Histograms.

6. What is the purpose of the for: 2m clause in an AlertManager rule?

A
The alert fires after 2 minutes of no data (dead-man's switch).
B
AlertManager waits 2 minutes before sending the first notification to group related alerts.
C
The condition must remain true continuously for 2 minutes before the alert fires — preventing false positives from transient spikes.
D
The alert automatically resolves after 2 minutes even if the condition is still true.
0/6