Skip to content

Courses 1 open · 3 courses

AI EngineeringStart with Day 1 · The AI Engineer: Role, Skills & How to Use This Course, 0 of 100 doneStart →
Machine LearningBeing written · opens 2027Coming soon
System DesignBeing written · opens 2027Coming soon

AIDay 86 of 100Production

Monitoring, Online Evals & Feedback Loops

49 min read

Two weeks after launch, Orbit 3.2.1 shipped rewritten pages for configuration, workers and memory troubleshooting, and a botched re-index dropped all three from Atlas's index. Answers about prefetch memory growth now cite the old 2.x page or say "I don't know". Latency and error rate look perfect and nothing alerts; only a slow rise in thumbs-down hints at it. Today you build the instruments that notice, and the loop that turns each miss into a test.

Short on time? The 30-second version
  • Latency and error metrics can stay green while answers go wrong: quality needs its own signals.
  • Record one structured event per request, keyed by a request id that scores, feedback and traces share, and alert on percentiles rather than means.
  • Online evals are reference-free: code checks on 100% of traffic, a validated judge on a hash-based sample, both after the response is sent.
  • An alert people trust fires on a symptom, waits for min_samples over a window, and compares with a baseline; severity decides page or ticket.
  • Every confirmed failure becomes a scrubbed, reviewed, versioned eval case (ask v3), so the next release is gated on what broke in production.
Jump to the cheat sheet →

At a Glance

Green Dashboards, Wrong Answers

During the stale-index week, Atlas's Day 42 dashboard showed p95 latency 2.9 s, error rate 0.5% and flat cost per request. Every number was green, yet roughly one answer in eight was wrong, because the pages it needed weren't in the index. Status codes and timers tell you whether the system responded, not whether the response was right. To see quality drift you need signals about the answers themselves.

Smoke Detectors, Tastings and a Suggestion Box

Think of a restaurant. The smoke detector (errors and latency) catches fires, but it can't tell you the soup is bland. So the chef also tastes a few dishes every night (online evals), and there's a suggestion box by the door (feedback). Every complaint that turns out to be real becomes a recipe test for the next menu (the dataset loop).

Try it by hand. In the last 5 minutes Atlas served 20 requests. Three failed a citation check. Eight users clicked a rating, and 2 of those were thumbs-down.

Signal Count Rate Normal (baseline)
Failed an online check 3 of 20 15% 1%
Thumbs-down 2 of 8 rated 25% 5%
Server errors 0 of 20 0% 0.5%

Both quality rates are far above normal while errors look fine. Is that enough to wake someone up? Three failures could be bad luck: with only 20 requests, an observed 15% is consistent with a true rate anywhere from about 5% to 36% (see A12 and Go Deeper). That's why every alert rule you'll write has a minimum sample count and compares with a baseline instead of a guessed constant.

Name the Parts

  • Event: one structured row per request: tenant, route, status, latency, time to first token (TTFT), tokens, dollars, and flags (cached, fallback, degraded). A metric is a number computed over a window of events, such as p95 latency (95% of requests are faster than this; p50 is the median). An SLI (service-level indicator) is a metric you make promises about.
  • Online evals: grading live traffic without a reference answer. Cheap code checks run on 100% of requests. An LLM judge runs on a fixed sample (here 10%), off the request path, in a background worker (the RQ job queue from Day 78).
  • Feedback: explicit (thumbs up or down) and implicit (the user rephrases the same question within two minutes), always linked to a request id.
  • Symptom-based alert: a rule on something users feel (errors, slowness, wrong answers, spend), with a window, a minimum sample count and a baseline. Noisy rules cause alert fatigue: so many alerts that people stop reading them.
  • Feedback → dataset loop: confirmed failures become reviewed eval cases that gate the next release.

One Event per Request

Start with the smallest thing that works: FastAPI middleware that writes one SQLite row per request, on the compact app shape from Day 77.

# script_id: day_086_monitoring_feedback/first_event_log
# One structured event per request, written by FastAPI middleware into SQLite.
# Install: uv add fastapi httpx   Env: none   Services: none (TestClient runs the app in-process)
import sqlite3
import time
import uuid

from fastapi import FastAPI, Request
from fastapi.testclient import TestClient

db = sqlite3.connect("events_demo.db", check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS events (request_id, ts, route, status, latency_ms)")
app = FastAPI()

@app.middleware("http")
async def record_event(request: Request, call_next):
    rid, t0 = request.headers.get("x-request-id") or uuid.uuid4().hex[:12], time.perf_counter()
    response = await call_next(request)
    latency_ms = round((time.perf_counter() - t0) * 1000, 1)
    db.execute("INSERT INTO events VALUES (?, ?, ?, ?, ?)",
               (rid, time.time(), request.url.path, response.status_code, latency_ms))
    db.commit()
    response.headers["x-request-id"] = rid      # the client quotes it when it sends feedback
    return response

@app.post("/v1/ask")
def ask(body: dict) -> dict:
    return {"answer": "Keep [worker] prefetch at 100 or below.", "citations": ["troubleshooting-memory"]}

TestClient(app).post("/v1/ask", json={"question": "Safe prefetch on 3.2.1?"})
print(db.execute("SELECT * FROM events ORDER BY ts DESC LIMIT 1").fetchone())
('5f0c2e9a41b7', 1762514410.38, '/v1/ask', 200, 2.4)     # your numbers will differ

The x-request-id header matters most: it's the key that later joins scores, feedback and traces to this row. Now the realistic version. RequestEvent carries the fields later days need (Day 87 sets variant during a canary), and EventLog stores scores that arrive after the response.

# script_id: day_086_monitoring_feedback/atlas_monitoring
# atlas/monitoring.py: one event per request, scores, metrics, online checks, sampling, alerts.
# Install: uv add pydantic   Env: ATLAS_LOG_DIR (default logs), ORBIT_DATA   Services: none (SQLite)
# Builds on Day 42's latency summary. At scale, the same two tables live in Postgres.
from __future__ import annotations

import functools
import hashlib
import math
import os
import re
import sqlite3
import threading
import time
from dataclasses import asdict, dataclass, fields
from pathlib import Path
from typing import Callable, Literal

LOG_DIR = Path(os.environ.get("ATLAS_LOG_DIR", "logs"))
DATA_DIR = Path(os.environ.get("ORBIT_DATA", "datasets/orbit"))
Source = Literal["code", "judge", "user"]


@dataclass(kw_only=True)
class RequestEvent:
    request_id: str
    ts: float
    tenant_id: str
    route: str
    model: str
    variant: str = "control"        # Day 87 sets this during a canary
    status: int
    latency_ms: float
    ttft_ms: float | None = None
    input_tokens: int = 0
    output_tokens: int = 0
    cost_usd: float = 0.0
    cached: bool = False            # Day 85 semantic-cache hit
    fallback: bool = False          # Day 84 CallMeta flags
    degraded: bool = False
    citations: int = 0


@dataclass(frozen=True)
class Score:
    request_id: str
    name: str
    passed: bool
    source: Source
# script_id: day_086_monitoring_feedback/atlas_monitoring
COLS = [f.name for f in fields(RequestEvent)]


class EventLog:
    """Append-only SQLite log: one row per request, plus scores that arrive later."""

    def __init__(self, path: Path = LOG_DIR / "atlas_events.db") -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        self.db = sqlite3.connect(path, check_same_thread=False, timeout=5)
        self.db.execute("PRAGMA journal_mode=WAL")       # readers don't block the writer
        self.db.execute("PRAGMA synchronous=NORMAL")     # safe with WAL, far fewer fsyncs
        self.lock = threading.Lock()                     # one writer at a time
        self.write(f"CREATE TABLE IF NOT EXISTS events ({', '.join(COLS)}, PRIMARY KEY (request_id))")
        self.write("CREATE TABLE IF NOT EXISTS scores (request_id, name, passed, source, ts)")
        self.write("CREATE INDEX IF NOT EXISTS events_ts ON events (ts)")

    def write(self, sql: str, params: tuple | list = ()) -> None:
        with self.lock, self.db:                         # `with db` commits, or rolls back
            self.db.execute(sql, params)

    def record(self, e: RequestEvent) -> None:
        self.write(f"INSERT OR REPLACE INTO events VALUES ({', '.join('?' * len(COLS))})",
                   list(asdict(e).values()))

    def add_score(self, request_id: str, name: str, passed: bool, source: Source) -> None:
        self.write("INSERT INTO scores VALUES (?, ?, ?, ?, ?)",
                   (request_id, name, int(passed), source, time.time()))

    def get(self, request_id: str) -> RequestEvent | None:
        row = self.db.execute("SELECT * FROM events WHERE request_id = ?", (request_id,)).fetchone()
        return RequestEvent(**dict(zip(COLS, row))) if row else None

    def window(self, minutes: int, now: float | None = None, **filters: str) -> list[RequestEvent]:
        """Events in (now - minutes, now], optionally filtered, e.g. tenant_id="acme"."""
        assert set(filters) <= set(COLS), f"unknown filter: {set(filters) - set(COLS)}"
        now = time.time() if now is None else now
        sql = "SELECT * FROM events WHERE ts > ? AND ts <= ?" + "".join(f" AND {k} = ?" for k in filters)
        rows = self.db.execute(sql, [now - minutes * 60, now, *filters.values()])
        return [RequestEvent(**dict(zip(COLS, r))) for r in rows]

    def scores(self, events: list[RequestEvent]) -> list[Score]:
        ids, out = [e.request_id for e in events], []
        for i in range(0, len(ids), 900):                # stay under SQLite's variable limit
            chunk = ids[i:i + 900]
            sql = f"SELECT request_id, name, passed, source FROM scores WHERE request_id IN ({','.join('?' * len(chunk))})"
            out += [Score(r, n, bool(p), s) for r, n, p, s in self.db.execute(sql, chunk)]
        return out

Two production details. WAL (write-ahead logging) mode lets the dashboard read while the API writes; without it, a second writer soon gets sqlite3.OperationalError: database is locked. The lock keeps one writer per process. Several API processes should write to Postgres instead.

Metrics That Matter

metrics() turns a window of events and their scores into the numbers you alert on. Cost was computed per event from separate input and output prices, because output tokens cost 5× more on gpt-6-luna.

# script_id: day_086_monitoring_feedback/atlas_monitoring
def pct(values: list[float], q: float) -> float:
    """Nearest-rank percentile: q% of values are at or below the result."""
    if not values:
        return math.nan
    s = sorted(values)
    return s[max(0, math.ceil(q / 100 * len(s)) - 1)]


def rate(flags: list[bool]) -> float:
    return sum(flags) / len(flags) if flags else math.nan


def metrics(events: list[RequestEvent], scores: list[Score] = ()) -> dict[str, float]:
    """RED metrics, tokens and dollars, and quality, for one window of events."""
    ok = [e for e in events if e.status < 500]          # 4xx is the caller's problem, not an outage
    lat = [e.latency_ms for e in ok]
    m = {"n": len(events), "error_rate": rate([e.status >= 500 for e in events]),
         "p50_ms": pct(lat, 50), "p95_ms": pct(lat, 95), "mean_ms": sum(lat) / len(lat) if lat else math.nan,
         "ttft_p95_ms": pct([e.ttft_ms for e in ok if e.ttft_ms is not None], 95),
         "usd_total": sum(e.cost_usd for e in events),
         "usd_per_req": sum(e.cost_usd for e in events) / len(events) if events else math.nan,
         "cache_hit": rate([bool(e.cached) for e in ok]), "fallback": rate([bool(e.fallback) for e in ok]),
         "degraded": rate([bool(e.degraded) for e in ok]), "citation_rate": rate([e.citations > 0 for e in ok])}
    code: dict[str, bool] = {}                          # a request passes only if every check passed
    for s in scores:
        if s.source == "code":
            code[s.request_id] = code.get(s.request_id, True) and s.passed
    judge = [s.passed for s in scores if s.source == "judge"]
    thumbs_down = [not s.passed for s in scores if s.source == "user" and s.name == "thumbs"]
    return m | {"n_checked": len(code), "online_pass_rate": rate(list(code.values())),
                "n_judged": len(judge), "judge_pass_rate": rate(judge),
                "n_rated": len(thumbs_down), "thumbs_down_rate": rate(thumbs_down)}

Break it: averages hide tails. Ninety requests at 0.8 s and ten at 6 s average 1.3 s, which looks healthy, but the p95 is 6 s: one user in ten waits that long. Alert on percentiles, never on the mean. The demo at the end of this script prints exactly that.

Online Evals Without Answer Keys

Offline evals (Day 47) compare answers with gold labels. Live traffic has no gold labels, so online evals ask questions you can answer from the request alone:

  • Does every citation name a real doc, and one that retrieval returned? (compact Day 44 checks)
  • Does the answer talk about the right version? Orbit 3.2.1 is current, so a 2.x answer to a 3.x question fails.
  • Did Atlas say "I don't know"? One abstention is fine; a jump in the abstain rate after a docs release is the stale-index smell.
  • Does the output contain an email address or a key? (compact Day 73 scrub)
# script_id: day_086_monitoring_feedback/atlas_monitoring
CURRENT_MAJOR = "3"                                   # Orbit 3.2.1 is current
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
SECRET = re.compile(r"\b(?:sk|atk)[-_][A-Za-z0-9_-]{12,}")   # OpenAI-style and Atlas (Day 83) keys


@dataclass
class Answer:                                         # compact Day 28 Answer
    text: str
    citations: list[str]


@dataclass(frozen=True)
class CheckResult:
    name: str
    passed: bool


@functools.cache
def corpus_ids() -> frozenset[str]:
    return frozenset(p.stem for p in (DATA_DIR / "docs").glob("*.md"))


def majors(text: str) -> set[str]:
    """Major versions named like "2.x" or "3.2.1" (not "0.5 s", not the "2.x" inside "3.2.x")."""
    return set(re.findall(r"(?<![\d.])([1-9])\.(?:x\b|\d+\.(?:\d+|x\b))", text))


def scrub(text: str) -> str:                          # compact Day 73 regex scrub
    return SECRET.sub("[SECRET]", EMAIL.sub("[EMAIL]", text))


def run_online_checks(event: RequestEvent, answer: Answer, hits: list[str],
                      question: str = "") -> list[CheckResult]:
    """Reference-free: no gold answer needed, so these run on 100% of traffic.
    `event` is there so you can vary checks by route or tenant."""
    return [
        CheckResult("cites_existing_doc", set(answer.citations) <= corpus_ids()),
        CheckResult("cites_retrieved", set(answer.citations) <= set(hits)),
        CheckResult("version_matches", majors(answer.text) <= majors(question) | {CURRENT_MAJOR}),
        CheckResult("answered", "i don't know" not in answer.text.lower()),
        CheckResult("no_pii", scrub(answer.text) == answer.text),
    ]

The judge is slower and costs money, so it grades a sample. Pick the sample by hashing the request id instead of calling random(). Then the same request is always in or out, so a retry or a second worker never judges it twice.

# script_id: day_086_monitoring_feedback/atlas_monitoring
Judge = Callable[[str, Answer], "bool | None"]


def in_sample(request_id: str, rate: float = 0.1) -> bool:
    """Deterministic: the same request id is in or out on every run, in every process."""
    h = int.from_bytes(hashlib.sha256(request_id.encode()).digest()[:8], "big")
    return h < rate * 2**64


def score_request(log: EventLog, event: RequestEvent, answer: Answer, hits: list[str],
                  question: str = "", judge: Judge | None = None, rate: float = 0.1) -> None:
    """Runs after the response is sent: FastAPI BackgroundTasks here, an RQ worker at scale."""
    for c in run_online_checks(event, answer, hits, question):
        log.add_score(event.request_id, c.name, c.passed, "code")
    if judge is not None and in_sample(event.request_id, rate):
        verdict = judge(question, answer)             # None: the judge refused or broke, so no score
        if verdict is not None:
            log.add_score(event.request_id, "judge_grounded", verdict, "judge")

In production the judge job runs on the Day 78 RQ worker. Use the judge prompt you validated against human labels on Day 46; an unvalidated judge on live traffic produces confident noise.

# script_id: day_086_monitoring_feedback/online_judge
# atlas/jobs.py addition: a sampled, validated judge that runs in an RQ worker, never inline.
# Install: uv add "openai>=3.46,<4" "rq>=3.0" redis pydantic
# Env: OPENAI_API_KEY, ATLAS_JUDGE_MODEL, REDIS_URL, ORBIT_DATA
# Services: Redis + `rq worker judge --url $REDIS_URL --with-scheduler` (Day 78). ≈ $0.0003 per judgment.
import os
from typing import Literal

from openai import OpenAI
from pydantic import BaseModel
from redis import Redis
from rq import Queue, Retry

from atlas_monitoring import DATA_DIR, Answer, EventLog, in_sample   # atlas.monitoring in R5

JUDGE_MODEL = os.environ.get("ATLAS_JUDGE_MODEL", "gpt-6-luna")
JUDGE_PROMPT = """You grade one answer from Atlas, an assistant for Orbit (current version 3.2.1).
Fail it if it relies on Orbit 2.x behaviour for a 3.x question, if the passages don't support
its claims, or if it answers a different question. Otherwise pass. Reason briefly first."""
client = OpenAI()


class Verdict(BaseModel):
    reasoning: str
    verdict: Literal["pass", "fail"]


def judge_job(request_id: str, question: str, text: str, citations: list[str]) -> None:
    """Runs in the worker process and writes its score back to the event log."""
    docs = [DATA_DIR / "docs" / f"{d}.md" for d in citations]
    passages = "\n\n".join(p.read_text()[:3000] for p in docs if p.exists()) or "(none cited)"
    r = client.responses.parse(model=JUDGE_MODEL, instructions=JUDGE_PROMPT, text_format=Verdict,
                               input=f"Question: {question}\n\nAnswer: {text}\n\nPassages:\n{passages}")
    if r.output_parsed is not None:                   # None = refusal: record nothing
        EventLog().add_score(request_id, "judge_grounded", r.output_parsed.verdict == "pass", "judge")


def maybe_enqueue_judge(q: Queue, request_id: str, question: str, answer: Answer, rate: float = 0.1) -> bool:
    if not in_sample(request_id, rate):
        return False
    q.enqueue(judge_job, request_id, question, answer.text, answer.citations,
              job_timeout=60, result_ttl=600, retry=Retry(max=2, interval=[10, 30]))
    return True


if __name__ == "__main__":
    q = Queue("judge", connection=Redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379/0")))
    stale = Answer("On Orbit 2.x, set ORBIT_PREFETCH in the environment.", ["environment-variables-2x"])
    print("enqueued:", maybe_enqueue_judge(q, "demo-1", "What prefetch value is safe on Orbit 3.2.x?", stale, rate=1.0))

This script needs a key and Redis, so the offline stub below uses a fake judge with the same (question, answer) -> bool | None shape.

Capturing Feedback

Feedback is only useful when it points at a request. The API returns request_id, and the UI sends it back with the rating. Two safety rules from Day 83 apply: check that the caller owns the request (return 404 for someone else's id, so you don't confirm it exists), and scrub free-text comments on write.

# script_id: day_086_monitoring_feedback/feedback
# atlas/feedback.py: thumbs tied to a request id, owner-checked, plus policy-controlled content capture.
# Install: uv add fastapi pydantic   Env: ATLAS_LOG_DIR   Services: none
from __future__ import annotations

import json
import time
from dataclasses import dataclass
from typing import Callable, Literal

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field

from atlas_monitoring import Answer, EventLog, scrub    # atlas.monitoring in R5


class Feedback(BaseModel):
    request_id: str = Field(max_length=64)
    rating: Literal["up", "down"]
    reason: Literal["wrong", "unhelpful", "unsafe", "other"] | None = None
    comment: str | None = Field(default=None, max_length=1000)


@dataclass(frozen=True)
class Principal:                  # compact Day 83 Principal: only the field used here
    tenant_id: str


def assert_owner(p: Principal, owner_tenant: str) -> None:         # Day 83
    if owner_tenant != p.tenant_id:
        raise HTTPException(404, "not found")   # 404, not 403: a 403 confirms the id exists


def feedback_router(log: EventLog, current: Callable[..., Principal]) -> APIRouter:
    log.write("CREATE TABLE IF NOT EXISTS feedback (request_id, ts, rating, reason, comment)")
    log.write("CREATE TABLE IF NOT EXISTS content (request_id PRIMARY KEY, user_id, question, answer, citations)")
    router = APIRouter()

    @router.post("/v1/feedback", status_code=204)
    def give_feedback(fb: Feedback, p: Principal = Depends(current)) -> None:
        event = log.get(fb.request_id)
        if event is None:
            raise HTTPException(404, "not found")
        assert_owner(p, event.tenant_id)
        comment = scrub(fb.comment) if fb.comment else None          # scrub on write (Day 73)
        log.write("INSERT INTO feedback VALUES (?, ?, ?, ?, ?)",
                  (fb.request_id, time.time(), fb.rating, fb.reason, comment))
        log.add_score(fb.request_id, "thumbs", fb.rating == "up", "user")

    return router
# script_id: day_086_monitoring_feedback/feedback
def store_content(log: EventLog, request_id: str, user_id: str, question: str, answer: Answer,
                  capture: bool = True) -> None:
    """Keep question/answer text only where policy allows (Day 73), scrubbed before it's stored."""
    if capture:
        log.write("INSERT OR REPLACE INTO content VALUES (?, ?, ?, ?, ?)",
                  (request_id, user_id, scrub(question), scrub(answer.text), json.dumps(answer.citations)))


def content_for(log: EventLog, request_id: str) -> dict | None:
    row = log.db.execute("SELECT c.*, e.tenant_id, e.ts FROM content c JOIN events e USING (request_id) "
                         "WHERE c.request_id = ?", (request_id,)).fetchone()
    keys = ["request_id", "user_id", "question", "answer", "citations", "tenant_id", "ts"]
    return dict(zip(keys, row)) if row else None

Content capture (storing question and answer text) is a privacy decision: Day 73 keeps it off by default for real users. Here capture is a parameter, so a tenant without consent still gets events and scores, only no text. In the Day 79 chat UI, Gradio's chatbot.like(...) event passes a gr.LikeData with .index and .liked; the dashboard script includes a handler that posts it to this route. A thumbs-down on a cached answer (Day 85) should also flag that cache entry for re-checking. Remember that feedback improves your datasets and prompts, not the model: no weights change.

Alerts People Don't Ignore

A good alert fires on a symptom users feel, waits for enough samples, and compares with normal. Each AlertRule has a window (how far back to look), min_samples (stay quiet below this), an optional baseline_days (compare with the same window N days ago, so daily traffic patterns cancel out) and a severity: page wakes someone, ticket waits for working hours.

# script_id: day_086_monitoring_feedback/atlas_monitoring
@dataclass(frozen=True)
class AlertRule:
    name: str
    metric: str
    op: Literal[">", "<"]
    threshold: float                   # absolute, or a multiple of the baseline when baseline_days is set
    window_min: int
    min_samples: int
    severity: Literal["page", "ticket"]
    baseline_days: int | None = None   # compare with the same window N days earlier


@dataclass(frozen=True)
class Alert:
    rule: str
    severity: str
    value: float
    limit: float
    n: int
    ts: float


SAMPLES = {"online_pass_rate": "n_checked", "judge_pass_rate": "n_judged", "thumbs_down_rate": "n_rated"}


def window_metrics(log: EventLog, minutes: int, end: float) -> dict[str, float]:
    ev = log.window(minutes, now=end)
    m = metrics(ev, log.scores(ev))
    return m | {"usd_per_hour": m["usd_total"] * 60 / minutes}


def evaluate_alerts(log: EventLog, rules: list[AlertRule], now: float | None = None) -> list[Alert]:
    now, fired = time.time() if now is None else now, []
    for r in rules:
        cur, count = window_metrics(log, r.window_min, now), SAMPLES.get(r.metric, "n")
        if cur[count] < r.min_samples:
            continue                                   # too little data to judge: stay quiet
        limit = r.threshold
        if r.baseline_days:
            base = window_metrics(log, r.window_min, now - r.baseline_days * 86400)
            if base[count] < r.min_samples:
                continue                               # no trustworthy baseline yet
            limit = r.threshold * base[r.metric]
        value = cur[r.metric]
        if (value > limit) if r.op == ">" else (value < limit):
            fired.append(Alert(r.name, r.severity, round(value, 4), round(limit, 4), int(cur[count]), now))
    return fired
# script_id: day_086_monitoring_feedback/atlas_monitoring
RULES = [
    AlertRule("availability", "error_rate", ">", 0.02, 5, 50, "page"),
    AlertRule("latency_p95", "p95_ms", ">", 1.5, 15, 50, "page", baseline_days=1),
    AlertRule("spend", "usd_per_hour", ">", 1.00, 60, 20, "ticket"),
    AlertRule("online_quality", "online_pass_rate", "<", 0.95, 30, 100, "page", baseline_days=1),
    AlertRule("judge_quality", "judge_pass_rate", "<", 0.9, 60, 20, "ticket", baseline_days=1),
    AlertRule("thumbs_down", "thumbs_down_rate", ">", 2.0, 60, 30, "ticket", baseline_days=1),
]

if __name__ == "__main__":                    # the by-hand example: 20 requests, 3 bad, 2 thumbs-down
    import tempfile
    log, now = EventLog(Path(tempfile.mkdtemp()) / "demo.db"), time.time()
    q = "What prefetch value is safe on Orbit 3.2.x?"
    for i in range(20):
        e = RequestEvent(request_id=f"r{i:02}", ts=now - 60 + i, tenant_id="acme", route="/v1/ask",
                         model="gpt-6-luna", status=200, latency_ms=900 + 30 * i, citations=1)
        log.record(e)
        stale = i in (4, 11, 17)
        text = "On Orbit 2.x, set ORBIT_PREFETCH." if stale else "On 3.2.1 keep prefetch at 100 or below."
        score_request(log, e, Answer(text, ["troubleshooting-memory"]), ["troubleshooting-memory"], q)
        if i in (0, 4, 7, 9, 13, 15, 17, 19):
            log.add_score(e.request_id, "thumbs", i not in (4, 17), "user")
    m = window_metrics(log, 5, now)
    print({k: round(m[k], 3) for k in ("n", "online_pass_rate", "n_rated", "thumbs_down_rate")})
    slow = [800.0] * 90 + [6000.0] * 10
    print(f"mean {sum(slow) / len(slow):.0f} ms vs p95 {pct(slow, 95):.0f} ms")
    print("sample:", sum(in_sample(f"req-{i}") for i in range(1000)), "of 1000; stable:", in_sample("r04") == in_sample("r04"))
    print("alerts:", evaluate_alerts(log, RULES, now) or "none (n=20 is below every min_samples)")
{'n': 20, 'online_pass_rate': 0.85, 'n_rated': 8, 'thumbs_down_rate': 0.25}
mean 1320 ms vs p95 6000 ms
sample: 93 of 1000; stable: True
alerts: none (n=20 is below every min_samples)
# your numbers will differ

The rates look alarming, yet nothing fires. That's the point: three failures on a quiet tenant at 3 a.m. shouldn't wake anyone. Delivery here is the return value. Print it, or post it to a webhook only behind an explicit opt-in flag, so a demo never pages a real person.

Something to Measure: the Traffic Replay

Alerts need traffic. replay_traffic() sends a seeded mix of Orbit questions (half from eval/qa_gold.jsonl, 30% from eval/retrieval_queries.jsonl, the rest issue titles) as the tenants acme, zephyr and demo, at a target rate. Days 87, 88 and 90 reuse it, so its signature stays stable. Offline, it drives a stub API in-process: the same event log, checks and feedback route, with fake retrieval and a fake judge. scenario="stale_index" drops three docs from the stub's index, like the botched re-index in the hook.

# script_id: day_086_monitoring_feedback/stub_api
# An offline stand-in for the Day 77 Atlas API: fake retrieval and answers, real logging and scoring.
# Install: uv add fastapi httpx uvicorn pydantic   Env: ORBIT_DATA, ATLAS_LOG_DIR, ATLAS_SCENARIO
# Serve it over HTTP: ATLAS_SCENARIO=stale_index python stub_api.py --serve   (port 8000)
from __future__ import annotations

import hashlib
import json
import os
import random
import sys
import time
from typing import Callable

from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException

from atlas_monitoring import DATA_DIR, Answer, EventLog, RequestEvent, corpus_ids, score_request
from feedback import Principal, feedback_router, store_content

STALE_DOCS = {"configuration-reference", "concepts-workers", "troubleshooting-memory"}
KEYS = {"key-acme": "acme", "key-zephyr": "zephyr", "key-demo": "demo"}   # toy keys; Day 83 stores hashes
PRICE_IN, PRICE_OUT = 0.10, 0.50    # gpt-6-luna USD per 1M tokens, as of 2026-11; verify at the pricing page


def current_principal(authorization: str = Header("")) -> Principal:
    tenant = KEYS.get(authorization.removeprefix("Bearer "))
    if tenant is None:
        raise HTTPException(401, "invalid API key")
    return Principal(tenant)


def _load_gold() -> dict[str, list[str]]:
    gold = {}
    for name, q, docs in (("qa_gold", "question", "citations"), ("retrieval_queries", "query", "relevant_doc_ids")):
        for line in (DATA_DIR / f"eval/{name}.jsonl").read_text().splitlines():
            if line.strip():
                row = json.loads(line)
                gold[row[q]] = row[docs]
    return gold


GOLD = _load_gold()


def fake_retrieve(question: str) -> list[str]:
    """Gold docs for known questions, two hash-picked docs for anything else."""
    if question in GOLD:
        return list(GOLD[question])
    docs, h = sorted(corpus_ids()), int(hashlib.sha256(question.encode()).hexdigest(), 16)
    return sorted({docs[h % len(docs)], docs[(h // 7) % len(docs)]})
# script_id: day_086_monitoring_feedback/stub_api
def fake_answer(question: str, needed: list[str], scenario: str, rng: random.Random) -> tuple[Answer, list[str]]:
    hits = [d for d in needed if not (scenario == "stale_index" and d in STALE_DOCS)]
    if hits == needed:
        return Answer(f"Per the {needed[0]} page (Orbit 3.2.1): ...", needed), hits
    roll = rng.random()                         # three ways a stale index shows up
    if roll < 0.5:                              # answers from the old 2.x page
        old = "environment-variables-2x"
        return Answer("On Orbit 2.x, set ORBIT_PREFETCH in the environment.", [old]), hits + [old]
    if roll < 0.8:
        return Answer("I don't know: the docs I found don't cover this.", []), hits
    return Answer("Keep [worker] prefetch at 100 or below.", needed), hits    # cites pages it never retrieved


def fake_judge(question: str, answer: Answer) -> bool:
    """Stand-in for online_judge: fails 2.x answers, plus ~3% seeded disagreement."""
    noise = int(hashlib.sha256((question + answer.text).encode()).hexdigest(), 16) % 100 < 3
    return ("2.x" not in answer.text) != noise
# script_id: day_086_monitoring_feedback/stub_api
def create_stub_app(log: EventLog, scenario: str = "baseline", clock: Callable[[], float] = time.time,
                    seed: int = 0, judge: Callable = fake_judge) -> FastAPI:
    app, rng = FastAPI(title=f"Atlas stub ({scenario})"), random.Random(seed)

    @app.post("/v1/ask")
    def ask(body: dict, bg: BackgroundTasks, p: Principal = Depends(current_principal),
            x_user_id: str = Header("anon")) -> dict:
        question, rid = str(body.get("question", "")), f"{rng.getrandbits(64):016x}"
        answer, hits = fake_answer(question, fake_retrieve(question), scenario, rng)
        latency = rng.lognormvariate(7.0, 0.6)                 # median ~1.1 s, p95 ~2.9 s
        tin, tout = rng.randint(3500, 5500), rng.randint(150, 450)
        e = RequestEvent(request_id=rid, ts=clock(), tenant_id=p.tenant_id, route="/v1/ask",
                         model="gpt-6-luna", status=500 if rng.random() < 0.005 else 200,
                         latency_ms=round(latency, 1), ttft_ms=round(latency * 0.3, 1),
                         input_tokens=tin, output_tokens=tout, citations=len(answer.citations),
                         cost_usd=(tin * PRICE_IN + tout * PRICE_OUT) / 1e6)
        log.record(e)       # the real app records in middleware; the stub logs its simulated latency
        if e.status == 500:
            raise HTTPException(500, "upstream error")
        store_content(log, rid, x_user_id, question, answer)
        bg.add_task(score_request, log, e, answer, hits, question, judge)    # after the response
        return {"request_id": rid, "answer": answer.text, "citations": answer.citations}

    app.include_router(feedback_router(log, current_principal))
    return app


if __name__ == "__main__":
    app = create_stub_app(EventLog(), os.environ.get("ATLAS_SCENARIO", "baseline"))
    if "--serve" in sys.argv:
        import uvicorn
        uvicorn.run(app, port=8000)
    else:
        from fastapi.testclient import TestClient
        tc, acme, zephyr = TestClient(app), {"authorization": "Bearer key-acme"}, {"authorization": "Bearer key-zephyr"}
        r = tc.post("/v1/ask", json={"question": "What prefetch value is safe on Orbit 3.2.x?"}, headers=acme).json()
        print(r)
        down = {"request_id": r["request_id"], "rating": "down", "reason": "wrong"}
        print("zephyr rates acme's answer:", tc.post("/v1/feedback", json=down, headers=zephyr).status_code)
        print("acme rates its own answer:", tc.post("/v1/feedback", json=down, headers=acme).status_code)
{'request_id': '629f6fbed82c07cd', 'answer': 'Per the troubleshooting-memory page (Orbit 3.2.1): ...', 'citations': ['troubleshooting-memory', 'concepts-workers', 'configuration-reference']}
zephyr rates acme's answer: 404
acme rates its own answer: 204
# your numbers will differ

Now the replay client. It speaks plain HTTP through the stdlib, and every moving part (send, sleep, on_result) is a parameter, so the same function drives a live server, the in-process stub, or a test.

# script_id: day_086_monitoring_feedback/replay_traffic
# scripts/replay_traffic.py: replay a seeded mix of Orbit questions against an Atlas API.
# Install: none for live runs (stdlib); offline mode needs fastapi httpx   Env: ORBIT_DATA
# Live:    ATLAS_REPLAY_KEYS='{"acme": "...", "zephyr": "...", "demo": "..."}' python replay_traffic.py --url http://localhost:8000
# Offline: python replay_traffic.py   (in-process stub API; no key, no services). Reused on Days 87, 88, 90.
from __future__ import annotations

import json
import math
import os
import random
import sys
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Callable

DATA_DIR = Path(os.environ.get("ORBIT_DATA", "datasets/orbit"))
Send = Callable[[str, dict, dict], tuple[int, dict]]        # (path, json body, headers) -> (status, body)


@dataclass
class ReplayReport:
    sent: int
    ok: int
    errors: int
    p50_ms: float
    p95_ms: float


def _jsonl(path: Path) -> list[dict]:
    return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]


def build_workload(n: int, seed: int = 0) -> list[str]:
    rng = random.Random(seed)
    pools = [[r["question"] for r in _jsonl(DATA_DIR / "eval/qa_gold.jsonl")],
             [r["query"] for r in _jsonl(DATA_DIR / "eval/retrieval_queries.jsonl")],
             [r["title"] for r in _jsonl(DATA_DIR / "issues/issues.jsonl")]]
    return [rng.choice(rng.choices(pools, weights=[0.5, 0.3, 0.2])[0]) for _ in range(n)]
# script_id: day_086_monitoring_feedback/replay_traffic
def http_send(base_url: str, timeout: float = 30) -> Send:
    def send(path: str, body: dict, headers: dict) -> tuple[int, dict]:
        req = urllib.request.Request(base_url.rstrip("/") + path, json.dumps(body).encode(),
                                     {"content-type": "application/json", **headers})
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                raw = r.read()
                return r.status, json.loads(raw) if raw else {}
        except urllib.error.HTTPError as e:
            return e.code, {}
        except OSError:                                   # refused, reset, timed out
            return 599, {}
    return send


def replay_traffic(base_url: str, *, keys: dict[str, str], n: int, rps: float, seed: int = 0,
                   send: Send | None = None, sleep: Callable[[float], None] = time.sleep, workers: int = 8,
                   on_result: Callable[[str, str, str, int, dict], None] | None = None) -> ReplayReport:
    """keys maps tenant -> API key. Requests go out at `rps`, as one of 20 users per tenant."""
    send, rng = send or http_send(base_url), random.Random(seed)
    plan = [(q, rng.choice(sorted(keys)), f"user-{rng.randrange(20)}") for q in build_workload(n, seed)]
    lat: list[float] = []
    statuses: list[int] = []

    def one(q: str, tenant: str, user: str) -> None:
        t0 = time.perf_counter()
        status, body = send("/v1/ask", {"question": q}, {"authorization": f"Bearer {keys[tenant]}", "x-user-id": user})
        lat.append((time.perf_counter() - t0) * 1000)
        statuses.append(status)
        if on_result:
            on_result(q, tenant, user, status, body)

    with ThreadPoolExecutor(workers) as pool:
        futures = []
        for job in plan:
            futures.append(pool.submit(one, *job))
            if workers == 1:
                futures[-1].result()              # serial mode keeps a simulated clock in step
            sleep(1 / rps)
        for f in futures:
            f.result()                            # surface exceptions from worker threads
    ok, s = sum(200 <= c < 300 for c in statuses), sorted(lat)
    p = lambda q: round(s[max(0, math.ceil(q / 100 * len(s)) - 1)], 1) if s else math.nan
    return ReplayReport(len(plan), ok, len(plan) - ok, p(50), p(95))

The offline demo replays a healthy hour yesterday and the stale-index hour today, with simulated people: 30% rate an answer, bad answers get a thumbs-down 60% of the time (good ones 4%), and some users rephrase after a bad answer. A VirtualClock makes an hour of traffic replay in seconds.

# script_id: day_086_monitoring_feedback/replay_traffic
class VirtualClock:
    def __init__(self, t: float) -> None:
        self.t = t

    def now(self) -> float:
        return self.t

    def sleep(self, dt: float) -> None:
        self.t += dt


def offline_demo(path: Path, *, n: int = 300, rps: float = 0.1, seed: int = 0,
                 on_tick: Callable | None = None) -> tuple:
    """Returns (log, {scenario: (end_ts, ReplayReport)}). on_tick(log, now) runs after each stale request."""
    from fastapi.testclient import TestClient
    from atlas_monitoring import EventLog
    from stub_api import KEYS, create_stub_app
    for suffix in ("", "-wal", "-shm"):
        Path(f"{path}{suffix}").unlink(missing_ok=True)
    log, rng, end, phases = EventLog(path), random.Random(seed), time.time(), {}
    keys = {tenant: key for key, tenant in KEYS.items()}
    for i, (scenario, offset) in enumerate((("baseline", 86400), ("stale_index", 0))):
        clock = VirtualClock(end - offset - n / rps)
        tc = TestClient(create_stub_app(log, scenario, clock.now, seed=seed + i))
        send = lambda p, b, h: (lambda r: (r.status_code, r.json() if r.content else {}))(tc.post(p, json=b, headers=h))

        def people(q: str, tenant: str, user: str, status: int, body: dict) -> None:
            if status != 200:
                return
            h, bad = {"authorization": f"Bearer {keys[tenant]}", "x-user-id": user}, any(
                s in body["answer"] for s in ("2.x", "don't know"))
            if rng.random() < 0.3:
                down = rng.random() < (0.6 if bad else 0.04)
                send("/v1/feedback", {"request_id": body["request_id"], "rating": "down" if down else "up"}, h)
            if bad and rng.random() < 0.3:        # implicit signal: ask again 40 s later
                clock.sleep(40)
                send("/v1/ask", {"question": "In other words: " + q}, h)

        def tick(dt: float) -> None:
            clock.sleep(dt)
            if on_tick and scenario == "stale_index":
                on_tick(log, clock.now())

        report = replay_traffic("in-process", keys=keys, n=n, rps=rps, seed=seed + i, send=send,
                                sleep=tick, workers=1, on_result=people)
        phases[scenario] = (clock.now(), report)
    return log, phases
# script_id: day_086_monitoring_feedback/replay_traffic
if __name__ == "__main__":
    if "--url" in sys.argv:
        url = sys.argv[sys.argv.index("--url") + 1]
        print(replay_traffic(url, keys=json.loads(os.environ["ATLAS_REPLAY_KEYS"]), n=200, rps=2.0))
        sys.exit()
    from atlas_monitoring import LOG_DIR, RULES, evaluate_alerts, window_metrics
    log, phases = offline_demo(LOG_DIR / "atlas_events.db")
    rows = {name: window_metrics(log, 90, end) for name, (end, _) in phases.items()}
    print(f"{'metric':<18}{'baseline':>10}{'stale_index':>13}")
    for k in ("n", "p50_ms", "p95_ms", "error_rate", "usd_per_req", "citation_rate",
              "online_pass_rate", "judge_pass_rate", "thumbs_down_rate"):
        print(f"{k:<18}{rows['baseline'][k]:>10.4g}{rows['stale_index'][k]:>13.4g}")
    print("replay:", phases["stale_index"][1])
    for a in evaluate_alerts(log, RULES, now=phases["stale_index"][0]):
        print(f"ALERT [{a.severity}] {a.rule}: {a.value} vs limit {a.limit} (n={a.n})")

Run python replay_traffic.py (no key needed):

metric              baseline  stale_index
n                        300          312
p50_ms                  1119         1163
p95_ms                  3224         2561
error_rate                 0     0.003205
usd_per_req        0.0005999    0.0005998
citation_rate              1       0.9678
online_pass_rate           1       0.8778
judge_pass_rate            1       0.9706
thumbs_down_rate     0.04819      0.08696
replay: ReplayReport(sent=300, ok=300, errors=0, p50_ms=3.3, p95_ms=4.7)
ALERT [page] online_quality: 0.8938 vs limit 0.95 (n=160)
# your numbers will differ

Read the table. Latency, errors and cost barely move (the p95 gap is noise between two random hours). The online pass rate falls from 100% to about 88%, and online_quality pages. Thumbs-down nearly doubles but sits just under its 2× limit on ~90 ratings. The judge saw only ~30 sampled answers and catches one failure mode (2.x answers), so it stays quiet. Cheap checks on every request detect first; users and the judge confirm later, and Exercise 2 measures how much later. Offline, the replay's own p50_ms/p95_ms are in-process timings, and the stub's simulated latency lives in the event log. With --url, the report measures real client-side latency.

From Failures to Test Cases

Each production failure is a free test case, if you handle it carefully. Select thumbs-down ∪ failed checks ∪ judge fails, deduplicate (compact Day 43 normalisation), and write draft EvalCases with source="production" to a review file. Text was scrubbed when it was stored. A person confirms each draft and fills in must_cite, and only reviewed cases go into ask v3, whose manifest names v2 as its parent. Cases from paying tenants stay in tenant-scoped datasets, because their questions can carry private details. v3 is the dataset that Day 87's release gate runs on.

# script_id: day_086_monitoring_feedback/feedback_to_dataset
# atlas/feedback.py addition: failures -> deduplicated draft eval cases -> human review -> dataset v3.
# Install: uv add pydantic fastapi httpx   Env: ORBIT_DATA, ATLAS_LOG_DIR   Services: none
from __future__ import annotations

import hashlib
import json
import re
import time
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, Field

from atlas_monitoring import LOG_DIR, EventLog
from feedback import content_for

SHARED_TENANTS = {"demo", "orbit-maintainers"}       # everyone else's cases stay tenant-scoped


class EvalCase(BaseModel):                           # compact Day 43 schema
    id: str
    input: str
    must_cite: list[str] = Field(default_factory=list)
    tags: list[str] = Field(default_factory=list)
    tenant: str = "demo"
    source: Literal["seed", "synthetic", "production"] = "seed"
    status: Literal["draft", "reviewed"] = "draft"


def failures(log: EventLog, since: float) -> dict[str, set[str]]:
    """request_id -> why it was picked: a thumbs-down, a failed code check or a judge fail."""
    rows = log.db.execute("SELECT s.request_id, s.source || ':' || s.name FROM scores s JOIN events e "
                          "USING (request_id) WHERE e.ts >= ? AND s.passed = 0", (since,))
    out: dict[str, set[str]] = {}
    for rid, why in rows:
        out.setdefault(rid, set()).add(why)
    return out


def dedup_key(question: str) -> str:                 # compact Day 43 normalisation
    return " ".join(re.findall(r"[a-z0-9.\-]+", question.lower().removeprefix("in other words: ")))


def feedback_to_cases(log: EventLog, since: float) -> list[EvalCase]:
    cases: dict[tuple[str, str], EvalCase] = {}
    for rid, why in failures(log, since).items():
        c = content_for(log, rid)
        if c is None:                                # content capture was off for this request
            continue
        key = (c["tenant_id"], dedup_key(c["question"]))
        digest = hashlib.sha256("|".join(key).encode()).hexdigest()[:8]
        case = cases.setdefault(key, EvalCase(id=f"prod-{digest}", input=c["question"],
                                              tenant=c["tenant_id"], source="production"))
        case.tags = sorted(set(case.tags) | why)
    return list(cases.values())
# script_id: day_086_monitoring_feedback/feedback_to_dataset
def save_dataset(name: str, version: str, cases: list[EvalCase], parent: str | None,
                 root: Path = Path("evals")) -> dict:
    """Compact Day 43 save: parent cases + reviewed new ones, with a manifest naming the parent."""
    def read(v: str) -> list[EvalCase]:
        f = root / name / v / "cases.jsonl"
        return [EvalCase.model_validate_json(x) for x in f.read_text().splitlines()] if f.exists() else []
    merged = {c.id: c for c in (read(parent) if parent else []) + [c for c in cases if c.status == "reviewed"]}
    body = "".join(c.model_dump_json() + "\n" for c in merged.values())
    out = root / name / version
    out.mkdir(parents=True, exist_ok=True)
    (out / "cases.jsonl").write_text(body)
    manifest = {"name": name, "version": version, "parent": parent, "count": len(merged),
                "sha256": hashlib.sha256(body.encode()).hexdigest()[:12], "created": time.strftime("%Y-%m-%d")}
    (out / "manifest.json").write_text(json.dumps(manifest, indent=2))
    return manifest


if __name__ == "__main__":
    from replay_traffic import offline_demo
    from stub_api import fake_retrieve
    log, phases = offline_demo(LOG_DIR / "atlas_events.db")
    drafts = feedback_to_cases(log, since=phases["stale_index"][0] - 3 * 3600)
    review = Path("review/ask_v3_candidates.jsonl")
    review.parent.mkdir(exist_ok=True)
    review.write_text("".join(c.model_dump_json() + "\n" for c in drafts))
    shared = [c for c in drafts if c.tenant in SHARED_TENANTS]
    print(f"{len(drafts)} drafts ({len(shared)} shareable) -> {review}")
    print(next((c for c in shared if "code:version_matches" in c.tags), shared[0]).model_dump_json())
    # SIMULATED REVIEW. A person does this: keep real failures, fill must_cite, flip the status.
    for c in shared[:10]:
        if any(t.startswith(("code:", "judge:")) for t in c.tags):
            c.must_cite, c.status = fake_retrieve(c.input.removeprefix("In other words: ")), "reviewed"
    print(save_dataset("ask", "v3", shared, parent="v2"))
30 drafts (10 shareable) -> review/ask_v3_candidates.jsonl
{"id":"prod-59f20f33","input":"How do I stop an Orbit worker on Windows?","must_cite":[],"tags":["code:answered","code:cites_retrieved","code:version_matches"],"tenant":"demo","source":"production","status":"draft"}
{'name': 'ask', 'version': 'v3', 'parent': 'v2', 'count': 9, 'sha256': '40c6187eae8e', 'created': '2026-11-07'}
# your numbers will differ

The parent v2 doesn't exist offline, so v3 holds only the new cases; in your repo it extends Day 43's v2. Notice that thumbs-down alone also flags a few good answers: that's why a person reviews every draft.

A Dashboard You'll Actually Open

A dashboard earns its keep when it answers three questions: is it broken, for whom, and can I see an example? This one has tiles for six SLIs, a per-5-minute trend of rates, and the 10 worst requests by failed checks. The tenant dropdown matters, because a regression that only hits acme disappears in the global average.

# script_id: day_086_monitoring_feedback/dashboard
# atlas/ui/dashboard.py: a Gradio Blocks dashboard over the event log, plus a like-button handler.
# Install: uv add "gradio>=6.31,<7" pandas   Env: ATLAS_LOG_DIR   Services: none
# Run: python dashboard.py --serve   (without --serve: builds the UI, prints a text report, exits)
from __future__ import annotations

import json
import math
import sys
import urllib.request
from pathlib import Path

import gradio as gr
import pandas as pd

from atlas_monitoring import LOG_DIR, EventLog, RequestEvent, Score, metrics

TILES = ["n", "p95_ms", "error_rate", "usd_per_req", "online_pass_rate", "thumbs_down_rate"]


def trend_frame(ev: list[RequestEvent], sc: list[Score]) -> pd.DataFrame:
    by_id: dict[str, list[Score]] = {}
    for s in sc:
        by_id.setdefault(s.request_id, []).append(s)
    t0, buckets = min((e.ts for e in ev), default=0.0), {}
    for e in ev:
        buckets.setdefault(int((e.ts - t0) // 300) * 5, []).append(e)
    rows = []
    for minute, es in sorted(buckets.items()):
        m = metrics(es, [s for e in es for s in by_id.get(e.request_id, [])])
        rows += [{"minute": minute, "series": k, "value": m[k]} for k in ("online_pass_rate", "citation_rate", "error_rate")]
    return pd.DataFrame(rows, columns=["minute", "series", "value"]).dropna()


def worst_frame(ev: list[RequestEvent], sc: list[Score], k: int = 10) -> pd.DataFrame:
    failed: dict[str, list[str]] = {}
    for s in sc:
        if not s.passed:
            failed.setdefault(s.request_id, []).append(f"{s.source}:{s.name}")
    worst = sorted(ev, key=lambda e: (-len(failed.get(e.request_id, [])), -e.latency_ms))[:k]
    return pd.DataFrame([{"request_id": e.request_id, "tenant": e.tenant_id, "latency_ms": e.latency_ms,
                          "failed": ", ".join(failed.get(e.request_id, []))} for e in worst])
# script_id: day_086_monitoring_feedback/dashboard
def snapshot(log: EventLog, minutes: float, tenant: str, now: float | None = None) -> list:
    ev = log.window(int(minutes), now, **({} if tenant == "all" else {"tenant_id": tenant}))
    sc = log.scores(ev)
    m = metrics(ev, sc)
    tiles = [None if math.isnan(m.get(k, math.nan)) else round(m[k], 4) for k in TILES]
    return [*tiles, trend_frame(ev, sc), worst_frame(ev, sc)]


def build_dashboard(log: EventLog, now: float | None = None) -> gr.Blocks:
    with gr.Blocks(title="Atlas monitoring") as demo:
        with gr.Row():
            tenant = gr.Dropdown(["all", "acme", "zephyr", "demo"], value="all", label="Tenant")
            minutes = gr.Slider(15, 1440, value=60, step=15, label="Window (minutes)")
        with gr.Row():
            tiles = [gr.Number(label=k) for k in TILES]
        trend = gr.LinePlot(x="minute", y="value", color="series", title="Rates per 5-minute bucket")
        worst = gr.Dataframe(label="Worst 10 requests: search the request id in your traces (Day 42)")
        for event in (demo.load, gr.Timer(30).tick, tenant.change, minutes.change):
            event(lambda mins, t: snapshot(log, mins, t, now), [minutes, tenant], [*tiles, trend, worst])
    return demo


def make_like_handler(api_url: str, api_key: str):
    """For the Day 79 chat UI: chatbot.like(handler, [request_ids]), where request_ids is a
    gr.State dict {message index: request id} that your chat function fills in."""
    def handler(like: gr.LikeData, request_ids: dict) -> None:
        idx = like.index if isinstance(like.index, int) else like.index[0]
        if request_ids.get(idx) is None or not isinstance(like.liked, bool):
            return                                     # unknown message, or a non-thumbs reaction
        body = {"request_id": request_ids[idx], "rating": "up" if like.liked else "down"}
        req = urllib.request.Request(f"{api_url}/v1/feedback", json.dumps(body).encode(),
                                     {"content-type": "application/json", "authorization": f"Bearer {api_key}"})
        urllib.request.urlopen(req, timeout=10).close()
    return handler


if __name__ == "__main__":
    path, now = LOG_DIR / "atlas_events.db", None
    if "--demo" in sys.argv or not path.exists():      # no real log yet: fill one with the replay
        from replay_traffic import offline_demo
        _, phases = offline_demo(path)
        now = phases["stale_index"][0]
    log = EventLog(path)
    demo = build_dashboard(log, now)
    if "--serve" in sys.argv:
        demo.launch()
    else:
        *tiles, trend, worst = snapshot(log, 60, "all", now)
        print(dict(zip(TILES, tiles)))
        print(worst.head(5).to_string(index=False))
{'n': 268, 'p95_ms': 2457.5, 'error_rate': 0.0037, 'usd_per_req': 0.0006, 'online_pass_rate': 0.8727, 'thumbs_down_rate': 0.1081}
      request_id tenant  latency_ms                            failed
eed175405631ca9f   demo      1267.1 code:version_matches, user:thumbs
68d52eb618ede6c3   demo      1188.7 code:version_matches, user:thumbs
0d589a58c842c19a   acme       811.1 code:version_matches, user:thumbs
e0463f9f83a81a4e zephyr       471.2 code:version_matches, user:thumbs
2af346e9a3706225   demo      2624.0                     code:answered
# your numbers will differ

Without --serve it prints the same numbers as a text report, which is also your fallback when the UI is down. With --serve, open the printed local URL.

Trade-offs: Sampling, Cost and Detection Speed

The judge sample rate trades money for detection speed. Assume Atlas's ~4,000 requests a day (Day 85), a busy hour of 6 requests a minute, and ≈ $0.0003 per judgment on gpt-6-luna (~2.6K input / 100 output tokens; as of 2026-11, verify):

Judge sample Judgments per day Judge cost per day Time to 20 judged in a busy hour
1% 40 ≈ $0.01 ~5.5 h
10% 400 ≈ $0.12 ~33 min
100% 4,000 ≈ $1.20 ~3 min

Rule of thumb: run code checks on everything, and size the judge sample so its rule reaches min_samples within the detection time you can live with. Raise the rate temporarily during a rollout (Day 87). When NOT to build all this: an internal tool with 50 questions a day is better served by a person reading every conversation weekly. And don't write baseline rules before you have a week of baseline.

In production

  • Keep content capture off by default for real users (Day 73). Events, scores and alerts need no text.
  • Give each tenant its own view and alert routing (Day 83), and never show one tenant's examples to another.
  • Vendor tools (Phoenix, Langfuse, LangSmith) give you trace UIs and annotation queues. Keep this event log anyway as the source of truth for alerts and datasets.
  • Keep label cardinality low (the number of distinct values per field): tenant, route, model and variant are fine; user ids and question text never become metric labels.
  • Set and document a retention period per table: events longest, captured content shortest, versioned datasets kept.

Commit it to R5

Block(s) R5 file
atlas_monitoring atlas/monitoring.py
online_judge atlas/jobs.py (add judge_job, maybe_enqueue_judge)
feedback, feedback_to_dataset atlas/feedback.py (main block → scripts/build_dataset.py)
replay_traffic scripts/replay_traffic.py
stub_api tests/stub_api.py
dashboard atlas/ui/dashboard.py

Go Deeper (optional)

Go deeper: how many samples before a drop is real?

In words: a rate measured on a few requests is fuzzy, and the fuzz shrinks with the square root of the sample size. With toy numbers: 3 bad answers out of 20 gives a 95% interval of roughly 5% to 36%, while 30 out of 200 narrows it to roughly 11% to 21%. To tell a regression from noise, the bad rate must clear the baseline's fuzz.

The formula below is the standard one-sided sample-size estimate for a proportion. It asks: how many samples until a true bad rate p1 is distinguishable from baseline p0 with 5% false alarms and 80% detection? Big drops need few samples; subtle ones need many. That's why online_quality (a big, cheap signal on 100% of traffic) fires long before the judge rule.

# script_id: day_086_monitoring_feedback/detection_math
# How many samples before a quality drop is real? Wilson interval + a sample-size estimate.
# Install: none (stdlib)
import math


def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
    """95% interval for a rate seen as k out of n; behaves well for small n and rates near 0."""
    p, z2 = k / n, z * z
    centre = (p + z2 / (2 * n)) / (1 + z2 / n)
    half = z * math.sqrt(p * (1 - p) / n + z2 / (4 * n * n)) / (1 + z2 / n)
    return round(centre - half, 3), round(centre + half, 3)


def samples_needed(p0: float, p1: float, z_alpha: float = 1.645, z_power: float = 0.84) -> int:
    """n to tell a bad rate p1 from baseline p0: 5% false alarms, 80% detection, one-sided."""
    num = z_alpha * math.sqrt(p0 * (1 - p0)) + z_power * math.sqrt(p1 * (1 - p1))
    return math.ceil((num / (p1 - p0)) ** 2)


print("3 bad of 20:", wilson(3, 20), "| 30 bad of 200:", wilson(30, 200))
for p0, p1 in [(0.01, 0.12), (0.01, 0.03), (0.05, 0.10)]:
    print(f"bad rate {p0:.0%} -> {p1:.0%}: ~{samples_needed(p0, p1)} samples")
3 bad of 20: (0.052, 0.36) | 30 bad of 200: (0.107, 0.206)
bad rate 1% -> 12%: ~16 samples
bad rate 1% -> 3%: ~236 samples
bad rate 5% -> 10%: ~150 samples

The last line is the thumbs-down case: a doubling from 5% to 10% needs well over 100 ratings, which at a 30% rating rate is hundreds of requests. Explicit feedback is valuable for what is wrong, and slow for when.

Go deeper: burn-rate alerts on an SLO

An SLO (service-level objective) is a target for an SLI over a period, such as "99% of answers pass online checks over 30 days". The 1% you're allowed to fail is the error budget. A burn-rate alert asks how fast you're spending it: a bad rate of 12% against a 1% budget is a burn rate of 12, which would use up a month's budget in about 2.5 days.

Teams pair a short window with a long one. The short window makes the alert fire fast; the long window keeps it from firing on a brief blip. Page only when both windows burn fast; open a ticket for slow burns. The Google SRE books describe this pattern in detail (⚠ verify the exact window and multiplier recommendations there before copying them). In AlertRule terms, a burn-rate rule is two rules on the same metric with different windows, combined with AND. You set real SLOs for Atlas on Day 90.

Go deeper: DIY event log vs an observability platform

You built the event log by hand so every part is visible. In practice teams pair it with a platform:

  • Phoenix (local, open source): phoenix serve gives you a UI and an OTLP endpoint on http://localhost:6006. register(project_name=..., auto_instrument=True) from phoenix.otel traces responses.create, using OpenInference attributes (llm.model_name, llm.token_count.*) rather than gen_ai.*.
  • Langfuse (OTel-based) and LangSmith (@traceable, wrap_openai) add hosted dashboards, annotation queues and dataset tooling.
  • OpenTelemetry GenAI semantic conventions define attribute names like gen_ai.request.model and gen_ai.usage.input_tokens. They're still marked Development, so names can change; pin your instrumentation versions.

What stays yours either way: the definition of "good" (your checks and judge), the alert rules and their thresholds, and the path from failures to reviewed dataset cases. Platforms store and display; they don't know that a 2.x answer to a 3.x question is wrong. Exercise 4 sends today's events to Phoenix as spans so you can compare the two views.

Common Pitfalls

  • Alerting on averages. Mean latency looks fine while p95 doubles, because a slow tail hides in the mean. Alert on percentiles.
  • Alerts that fire on 3 requests at 3 a.m. A fixed threshold with no minimum pages on noise, and soon nobody reads the pages. Add min_samples and a window, and compare with a baseline instead of a guess.
  • Judging on the request path. Users wait seconds longer and you pay for every request. Run the judge in a background worker on a sample.
  • Random sampling that changes on every run. random() < 0.1 judges a retried request twice and a re-run differently. Hash the request id with in_sample().
  • PII or tenant secrets copied into the eval set. Production text flows straight into a shared dataset. Scrub on write (Day 73), review every draft, and keep tenant-private cases in tenant-scoped datasets.
  • sqlite3.OperationalError: database is locked. Several writers share a SQLite file without WAL. Turn on PRAGMA journal_mode=WAL and keep one writer per process, or move to Postgres.

Key Takeaways

  • Latency and error metrics can stay green while answers go wrong: quality needs its own signals.
  • Record one structured event per request, keyed by a request id that scores, feedback and traces share, and alert on percentiles rather than means.
  • Online evals are reference-free: code checks on 100% of traffic, a validated judge on a hash-based sample, both after the response is sent.
  • An alert people trust fires on a symptom, waits for min_samples over a window, and compares with a baseline; severity decides page or ticket.
  • Every confirmed failure becomes a scrubbed, reviewed, versioned eval case (ask v3), so the next release is gated on what broke in production.

Quick Reference

Monitoring, Online Evals & Feedback Loops, on one card

API / patternWhat it doesWhen to useGotcha
EventLog.record(RequestEvent(...))one row per requestevery request, in middlewareWAL mode, one writer
metrics(events, scores)RED, $, quality ratesdashboards, alertspercentiles, not means
run_online_checks(event, answer, hits)reference-free checks100% of traffic, after the responsean abstention can be correct
in_sample(request_id, 0.1)deterministic samplechoosing what to judgenever random()
AlertRule(..., min_samples, baseline_days)symptom-based ruleavailability, p95, $, qualityno baseline, no alert
POST /v1/feedbackthumbs tied to a requestchat UI like buttonassert_owner, scrub comments
feedback_to_cases(log, since)failures → draft casesweekly, or after an incidentdrafts need human review
replay_traffic(url, keys=, n=, rps=)seeded load with real questionsstaging, canaries, drillsoffline timings are in-process

Exercises

  1. Warm-up. Add a thumbs_down_rate tile per tenant to the dashboard. Which tenant looks worst during the stale-index hour, and how many ratings is that based on?

    Peek at the approach
    # script_id: day_086_monitoring_feedback/solution_ex1
    # Per-tenant thumbs-down tiles. Put the Row inside build_dashboard and add the tiles to its outputs.
    import gradio as gr
    
    from atlas_monitoring import LOG_DIR, EventLog, metrics
    from replay_traffic import offline_demo
    
    TENANTS = ("acme", "zephyr", "demo")
    
    
    def thumbs_by_tenant(log: EventLog, minutes: int, now: float | None = None) -> list[tuple[float | None, int]]:
        out = []
        for t in TENANTS:
            ev = log.window(minutes, now, tenant_id=t)
            m = metrics(ev, log.scores(ev))
            out.append((round(m["thumbs_down_rate"], 3) if m["n_rated"] else None, m["n_rated"]))
        return out
    
    
    log, phases = offline_demo(LOG_DIR / "atlas_events.db")
    now = phases["stale_index"][0]
    with gr.Blocks() as demo:
        with gr.Row():
            tiles = [gr.Number(label=f"thumbs_down_rate · {t}") for t in TENANTS]
        demo.load(lambda: [rate for rate, _ in thumbs_by_tenant(log, 60, now)], None, tiles)
    for t, (rate, n) in zip(TENANTS, thumbs_by_tenant(log, 60, now)):
        print(f"{t:<7} thumbs_down_rate={rate} from {n} ratings")
    
    acme    thumbs_down_rate=0.087 from 23 ratings
    zephyr  thumbs_down_rate=0.091 from 33 ratings
    demo    thumbs_down_rate=0.083 from 36 ratings
    # your numbers will differ
    

    With only 23 to 36 ratings per tenant, the differences between tenants are mostly noise (see Go Deeper). Show the count next to the rate so nobody overreacts.

  2. Core. Replay the stale_index scenario and report the time-to-detect for each alert rule. Which signals catch silent quality drift, and what sets the delay?

    Peek at the approach
    # script_id: day_086_monitoring_feedback/solution_ex2
    # Time-to-detect per rule: evaluate every rule once per simulated minute of the incident.
    from atlas_monitoring import LOG_DIR, RULES, evaluate_alerts
    from replay_traffic import offline_demo
    
    first: dict[str, float] = {}
    state = {"start": None, "last": 0.0}
    
    
    def tick(log, now: float) -> None:
        state["start"] = state["start"] or now
        if now - state["last"] < 60:
            return
        state["last"] = now
        for a in evaluate_alerts(log, RULES, now):
            first.setdefault(a.rule, (now - state["start"]) / 60)
    
    
    offline_demo(LOG_DIR / "atlas_events.db", on_tick=tick)
    for r in RULES:
        when = f"{first[r.name]:.0f} min" if r.name in first else "never fired"
        print(f"{r.name:<15} {when:<12} (window {r.window_min} min, min_samples {r.min_samples})")
    
    availability    never fired  (window 5 min, min_samples 50)
    latency_p95     never fired  (window 15 min, min_samples 50)
    spend           never fired  (window 60 min, min_samples 20)
    online_quality  19 min       (window 30 min, min_samples 100)
    judge_quality   never fired  (window 60 min, min_samples 20)
    thumbs_down     23 min       (window 60 min, min_samples 30)
    # your numbers will differ
    

    The 100% code checks page first. Their delay is mostly min_samples: 100 checked requests at 6 a minute take about 17 minutes. Thumbs-down crosses its limit at minute 23 but is back under it by the end of the hour (the main replay shows no thumbs alert), which is what a noisy signal near its threshold looks like. The judge never fires on a 10% sample. Lowering min_samples detects sooner and pages more often on noise.

  3. Core. Add the implicit signal: the same user asks a near-identical question within 2 minutes. Record it as a rephrased score on the earlier request, and compare its volume with explicit thumbs-down on both replays.

    Peek at the approach
    # script_id: day_086_monitoring_feedback/solution_ex3
    # Implicit feedback: a quick, near-identical re-ask marks the earlier answer as unhelpful.
    import re
    
    from atlas_monitoring import LOG_DIR, EventLog, metrics
    from replay_traffic import offline_demo
    
    
    def terms(q: str) -> set[str]:
        return set(re.findall(r"[a-z0-9.\-]{3,}", q.lower())) - {"other", "words"}
    
    
    def mark_rephrases(log: EventLog, start: float, end: float, within_s: float = 120, overlap: float = 0.6) -> int:
        rows = log.db.execute("SELECT c.request_id, e.tenant_id, c.user_id, c.question, e.ts FROM content c JOIN events e "
                              "USING (request_id) WHERE e.ts BETWEEN ? AND ? ORDER BY e.tenant_id, c.user_id, e.ts",
                              (start, end)).fetchall()
        flagged = 0
        for prev, cur in zip(rows, rows[1:]):
            a, b = terms(prev[3]), terms(cur[3])
            if prev[1:3] == cur[1:3] and cur[4] - prev[4] <= within_s and a and len(a & b) / len(a | b) >= overlap:
                log.add_score(prev[0], "rephrased", False, "user")
                flagged += 1
        return flagged
    
    
    log, phases = offline_demo(LOG_DIR / "atlas_events.db")
    for name, (end, _) in phases.items():
        ev = log.window(90, now=end)
        n = mark_rephrases(log, end - 90 * 60, end)
        m = metrics(ev, log.scores(ev))
        downs = round(m["thumbs_down_rate"] * m["n_rated"])
        print(f"{name:<12} requests={len(ev)} rephrased={n} thumbs_down={downs} of {m['n_rated']} rated")
    
    baseline     requests=300 rephrased=0 thumbs_down=4 of 83 rated
    stale_index  requests=312 rephrased=11 thumbs_down=8 of 92 rated
    # your numbers will differ
    

    The rephrase signal needs no click, so it covers users who never rate. It's also noisier (people rephrase for many reasons), so treat it as a ticket-level signal and as a selector for review, not as a page.

  4. Stretch (optional). Send the replayed events to a local Phoenix as OpenTelemetry spans and rebuild the "worst requests" view there. What does the platform give you, and what still has to come from your own checks?

    Peek at the approach
    # script_id: day_086_monitoring_feedback/solution_ex4
    # Events -> OTel spans in local Phoenix. Install: uv add arize-phoenix arize-phoenix-otel
    # Services: `phoenix serve` (UI and OTLP/HTTP on http://localhost:6006). Exits cleanly if it isn't running.
    import urllib.request
    
    from opentelemetry import trace
    from phoenix.otel import register
    
    from atlas_monitoring import LOG_DIR
    from replay_traffic import offline_demo
    
    try:
        urllib.request.urlopen("http://localhost:6006", timeout=2).close()
    except OSError:
        print("Phoenix isn't running: start it with `phoenix serve`, then re-run.")
        raise SystemExit(0)
    provider = register(project_name="atlas-monitoring", endpoint="http://localhost:6006/v1/traces",
                        protocol="http/protobuf", batch=True)
    tracer = trace.get_tracer("atlas.monitoring")
    log, phases = offline_demo(LOG_DIR / "atlas_events.db")
    events = log.window(90, now=phases["stale_index"][0])
    failed: dict[str, list[str]] = {}
    for s in log.scores(events):
        if not s.passed:
            failed.setdefault(s.request_id, []).append(s.name)
    for e in events:
        start = int(e.ts * 1e9)
        span = tracer.start_span(f"chat {e.model}", kind=trace.SpanKind.CLIENT, start_time=start, attributes={
            "gen_ai.operation.name": "chat", "gen_ai.provider.name": "openai", "gen_ai.request.model": e.model,
            "gen_ai.usage.input_tokens": e.input_tokens, "gen_ai.usage.output_tokens": e.output_tokens,
            "atlas.request_id": e.request_id, "atlas.tenant_id": e.tenant_id,
            "atlas.failed_checks": ",".join(failed.get(e.request_id, [])) or "none"})
        span.end(end_time=start + int(e.latency_ms * 1e6))
    provider.force_flush()
    print(f"sent {len(events)} spans to project atlas-monitoring; filter on atlas.failed_checks in the UI")
    

    Phoenix gives you storage, search and a span timeline for free. It doesn't know which answers are wrong: atlas.failed_checks comes from your checks. Filter syntax varies between Phoenix versions (⚠ verify in your UI). Spans are verified here; OTel metrics support differs by platform, so check the platform docs before exporting metrics.

References & Further Reading

Next · Day 87 · 43 minShipping Changes SafelyThe stale index is fixed and dataset v3 now holds the cases that caught it.← Previous: Day 85 · Caching & Cost Optimization at Scale
Not tapped yet