Evaluation & Observability

Knowing whether the thing you shipped is still working.

Introduction

A traditional bug is loud: a stack trace, a 500, a failing test. An AI regression is quiet. The system keeps returning fluent, confident, plausible answers, and the only thing that changed is that more of them are wrong. Lesson 12 covered evaluating prompts before you ship. This lesson is about the system after you ship it: what to trace, what to measure, and how to find out you have a problem before your users tell you.

1. Why Normal Monitoring Misses It

Your existing dashboards watch for errors and latency. An AI system degrading produces neither. Every request returns 200, in normal time, with a well-formed body. The failure is in the content, which nothing you currently monitor inspects.

ChangeWhat your dashboards showWhat actually happened
Provider updates the modelNothingTone shifted, a prompt workaround stopped being needed, output got longer
Someone edits the system promptNothingA tool stopped being triggered because the emphasis moved
Your document store is re-indexedNothingRetrieval now returns near-duplicates, answers got vaguer
Real user inputs driftNothingYour prompt was tuned for questions nobody asks any more

All four are invisible without content-level measurement. That is what the rest of this lesson builds.

2. Trace the Run, Not Just the Request

One user question can become several model calls and several tool calls. A single log line at the end tells you nothing about where the time or the tokens went. You want a span per step, nested under a span for the whole run:

What a Traced Run Records

UserAgentModelToolquestionspan: model.requesttool_use + tokensspan: tool.executeresult + ok flagspan: model.requestend_turn + tokensanswer

Figure 1: Every arrow is a span with a duration and attributes. The run is the parent span.

Everything in this lesson observes the same small agent: a customer-support loop with one tool that looks up order status. Save it as agent.py, because every later block imports from it:

# agent.py
"""agent.py - the order-status agent the rest of this lesson observes."""
import json

import anthropic

client = anthropic.Anthropic()

ORDERS = {
    "A-1042": {"order_id": "A-1042", "status": "delivered",
               "delivered_on": "2026-08-02", "carrier": "UPS"},
    "A-1099": {"order_id": "A-1099", "status": "in_transit",
               "eta": "2026-08-19", "carrier": "FedEx"},
}

TOOLS = [{
    "name": "get_order",
    "description": "Look up the status of a customer order by its id.",
    "input_schema": {
        "type": "object",
        "properties": {"order_id": {"type": "string"}},
        "required": ["order_id"],
    },
}]


def get_order(order_id):
    return ORDERS.get(order_id, {"error": "not_found", "order_id": order_id})


def run_agent(question, model="claude-haiku-4-5"):
    """The same loop as below, without the instrumentation."""
    messages = [{"role": "user", "content": question}]
    while True:
        resp = client.messages.create(
            model=model, max_tokens=400, tools=TOOLS, messages=messages,
        )
        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")
        messages.append({"role": "assistant", "content": resp.content})
        # tool_result content must be a string, never a raw dict.
        messages.append({"role": "user", "content": [
            {"type": "tool_result", "tool_use_id": b.id,
             "content": json.dumps(get_order(**b.input))}
            for b in resp.content if b.type == "tool_use"
        ]})


if __name__ == "__main__":
    print(run_agent("Where is order A-1042?"))
Expected Output:
Order A-1042 has been **delivered**! Here are the details:

- **Status:** Delivered
- **Delivery Date:** August 2, 2026
- **Carrier:** UPS

The package was delivered via UPS on August 2nd, 2026. If you need more specific tracking information or have questions about the delivery, you may want to check the UPS tracking website with the order number.

A context manager is enough to start observing it. You do not need a tracing vendor on day one, you need the data:

# Same loop as run_agent(), wrapped in spans. Durations and token counts vary run to run.
import json
import time
from contextlib import contextmanager

from agent import TOOLS, client, get_order

SPANS = []


@contextmanager
def span(name, **attrs):
    start = time.monotonic()
    rec = {"name": name, "attrs": attrs}
    SPANS.append(rec)
    try:
        yield rec
    finally:
        rec["ms"] = round((time.monotonic() - start) * 1000)


messages = [{"role": "user", "content": "Where is order A-1042?"}]

with span("agent.run", question=messages[0]["content"]):
    while True:
        with span("model.request", model="claude-haiku-4-5") as s:
            resp = client.messages.create(
                model="claude-haiku-4-5", max_tokens=400,
                tools=TOOLS, messages=messages,
            )
            s["attrs"]["stop_reason"] = resp.stop_reason
            s["attrs"]["in"] = resp.usage.input_tokens
            s["attrs"]["out"] = resp.usage.output_tokens

        if resp.stop_reason != "tool_use":
            break

        messages.append({"role": "assistant", "content": resp.content})
        results = []
        for b in resp.content:
            if b.type == "tool_use":
                with span("tool.execute", tool=b.name, args=b.input) as s:
                    out = get_order(**b.input)
                    s["attrs"]["ok"] = "error" not in out
                results.append({"type": "tool_result", "tool_use_id": b.id,
                                "content": json.dumps(out)})
        messages.append({"role": "user", "content": results})

for sp in SPANS:
    print(f"{sp['ms']:>6}ms  {sp['name']:<16} {json.dumps(sp['attrs'])}")
Expected Output:
  3122ms  agent.run        {"question": "Where is order A-1042?"}
  1527ms  model.request    {"model": "claude-haiku-4-5", "stop_reason": "tool_use", "in": 574, "out": 75}
     0ms  tool.execute     {"tool": "get_order", "args": {"order_id": "A-1042"}, "ok": true}
  1595ms  model.request    {"model": "claude-haiku-4-5", "stop_reason": "end_turn", "in": 698, "out": 46}

That trace answers questions a single log line cannot. The run took 3.1 seconds, of which the tool took 0ms and the two model calls took 1,527ms and 1,595ms. If this were slow, optimising the tool would be pointless: the model calls are all of it. Note also that input tokens grew from 574 to 698 between calls, which is the conversation accumulating exactly as Lesson 17 describes.

Record these attributes on every span. They are the ones you will wish you had:

  • On model spans: model id, stop_reason, input and output tokens, cache read and write tokens, effort if you set it.
  • On tool spans: tool name, arguments, success or failure, duration.
  • On the run span: a trace id you can correlate with your normal application logs, and the number of loop iterations.
What never goes in a trace: raw prompts and completions containing customer data, API keys, retrieved documents with PII. Traces get shipped to third-party backends and kept for months. Log a hash or an id, redact the rest, and see Lesson 15.

3. The Metrics Worth Alerting On

Aggregate the spans and a handful of signals do most of the work:

MetricWhy it mattersA move usually means
Loop iterations per runCost and latency scale with itThe model is retrying, or a tool keeps failing
Tool error rateThe agent's hands are brokenAn integration changed, or arguments drifted
Tool call rateWhether tools get used at allA prompt edit changed triggering behaviour
Output tokens, p50 and p95Direct cost driverThe model changed, or effort was raised
Cache hit rateSilent cost regressionSomeone put something volatile in the prefix
stop_reason distributionTruncation and refusals hide hereA rise in max_tokens means answers are being cut off
Judge score, sampledThe only direct quality signalActual quality moved

stop_reason deserves a dashboard of its own. A creeping rise in max_tokens is answers being truncated mid-sentence, and it is the single most common production AI defect that nobody has an alert for.

4. Model-as-Judge in Production

You cannot have a human read every response, but you can have a cheap model read a sample of them against a rubric. Ask for structured output so the verdict is data rather than prose:

# Model-as-judge with a JSON schema.
import json

from agent import ORDERS, client

RUBRIC = """Score the ASSISTANT ANSWER against the rubric. Reply with JSON only:
{"grounded": true|false, "answers_question": true|false, "score": 1-5, "why": "..."}

grounded        : every factual claim appears in TOOL DATA
answers_question: it actually addresses the user's question"""

CANDIDATES = {
    "good": "Order A-1042 was delivered on 2026-08-02.",
    "hallucinated": "Order A-1042 is out for delivery and should arrive by 6pm tomorrow.",
}

for label, candidate in CANDIDATES.items():
    r = client.messages.create(
        model="claude-haiku-4-5", max_tokens=300,
        messages=[{"role": "user", "content":
            f"{RUBRIC}\n\nUSER QUESTION: Where is order A-1042?\n"
            f"TOOL DATA: {json.dumps(ORDERS['A-1042'])}\n"
            f"ASSISTANT ANSWER: {candidate}"}],
        # Structured output, so the verdict is parseable rather than prose.
        output_config={"format": {"type": "json_schema", "schema": {
            "type": "object",
            "properties": {
                "grounded": {"type": "boolean"},
                "answers_question": {"type": "boolean"},
                "score": {"type": "integer"},
                "why": {"type": "string"},
            },
            "required": ["grounded", "answers_question", "score", "why"],
            "additionalProperties": False,
        }}},
    )
    verdict = json.loads(next(b.text for b in r.content if b.type == "text"))
    print(f"{label:<14} -> grounded={verdict['grounded']!s:<5} "
          f"score={verdict['score']}  {verdict['why'][:60]}")
Expected Output:
good           -> grounded=True  score=5  The assistant answer is fully grounded in the tool data, acc
hallucinated   -> grounded=False score=1  The assistant answer claims the order is 'out for delivery'

The judge caught the hallucination. Both answers are fluent and confident; the second one invented a delivery window that appears nowhere in the tool data, and scored 1 against 5. That is a signal no amount of latency monitoring would have produced.

The two fields are not equally trustworthy, though. Running that same good answer past the judge ten times returned grounded=True every single time, while score came back 5 on eight runs and 2 on the other two, where the judge read "where is my order" literally and docked the answer for giving a date instead of a location. Booleans on specific questions are stable; a 1-5 opinion is not. Alert on the boolean, and treat the number as something you average over many samples or not at all.

Three rules keep this from becoming its own problem:

  • Sample, do not judge everything. Judging every response doubles your call volume and your bill. One to five percent detects a trend fine.
  • Judge on specifics, not vibes. "Is this good?" produces noise. "Does every factual claim appear in the tool data?" produces a signal.
  • Pin the judge model and version it. If the judge changes, your quality metric moves for reasons that have nothing to do with your system. Treat a judge change like a schema migration.
Grounding is the highest-value rubric. For any RAG or tool-using system, "is every claim supported by the retrieved context" catches the failure mode that actually hurts users, and unlike helpfulness it has a mostly objective answer.

5. A Regression Gate in CI

Keep a golden set of questions with known-good expectations and run it on every change to a prompt, a tool, or a model. Here is a first attempt, using substring assertions:

# A golden set with substring assertions. Imports the same agent.py from section 2.
from agent import run_agent

GOLDEN = [
    ("Where is order A-1042?",             "delivered"),
    ("Has A-1042 shipped?",                "delivered"),
    ("What's the status of order A-1042?", "delivered"),
    ("Tell me about order A-9999.",        "not found"),
]

passed = 0
for question, expect in GOLDEN:
    text = run_agent(question).lower()
    ok = expect in text            # substring assertion
    passed += ok
    print(f"  {'PASS' if ok else 'FAIL'}  {question:<34} expected '{expect}'")

rate = passed / len(GOLDEN)
print(f"\npass rate: {passed}/{len(GOLDEN)} = {rate:.0%}   threshold: 90%")
print("CI would", "PASS" if rate >= 0.9 else "FAIL", "this build")
Expected Output:
  PASS  Where is order A-1042?             expected 'delivered'
  PASS  Has A-1042 shipped?                expected 'delivered'
  PASS  What's the status of order A-1042? expected 'delivered'
  FAIL  Tell me about order A-9999.        expected 'not found'

pass rate: 3/4 = 75%   threshold: 90%
CI would FAIL this build

The gate failed. Before assuming a regression, look at what the agent actually said for order A-9999:

# The agent's real answer to the failing case.
I wasn't able to find order A-9999 in the system. The order doesn't appear to exist or may have been entered incorrectly.

Could you please double-check the order ID? Order IDs might be formatted differently than expected, so if you have any additional information about the order (like the customer name, approximate date, or a different order ID format), I'd be happy to help you look it up again.

The answer is correct. It looked up a nonexistent order, did not find it, said so clearly, and offered to help. It simply said it "wasn't able to find" the order where the test demanded the literal string "not found". The test was wrong, not the model.

This is the defining bug of AI test suites, and it happened here on the first run rather than being invented for illustration. Substring assertions encode one phrasing of a correct answer, so they fail on paraphrase and quietly train the team to ignore the suite. Assert on meaning instead:

Asserting on phrasing
assert "not found" in text.lower()
Fails when the model says it wasn't able to find the order, which is an equally correct answer. Flaky by construction.
Asserting on meaning
judge(answer,
  "Says the order could not be "
  "found. Any phrasing is fine.")
Passes on any correct phrasing and still fails when the model invents an order that does not exist.
# The same golden set, asserted by judge instead of substring. The A-9999 case now passes on any correct phrasing.
import json

from agent import client, run_agent


def judge(answer, criterion):
    """Ask a cheap model whether the answer meets the criterion, in any wording."""
    r = client.messages.create(
        model="claude-haiku-4-5", max_tokens=300,
        messages=[{"role": "user", "content":
            "Does the ANSWER satisfy the CRITERION? Wording does not have to match.\n"
            f"CRITERION: {criterion}\nANSWER: {answer}"}],
        output_config={"format": {"type": "json_schema", "schema": {
            "type": "object",
            "properties": {"meets": {"type": "boolean"}, "why": {"type": "string"}},
            "required": ["meets", "why"],
            "additionalProperties": False,
        }}},
    )
    return json.loads(next(b.text for b in r.content if b.type == "text"))


# Replace the substring assertion with a semantic one.
GOLDEN = [
    ("Where is order A-1042?",      "States the order was delivered on 2026-08-02."),
    ("Tell me about order A-9999.", "Says the order could not be found. Any phrasing is fine."),
]

for question, criterion in GOLDEN:
    answer = run_agent(question)
    verdict = judge(answer, criterion)
    print(f"  {'PASS' if verdict['meets'] else 'FAIL'}  {question}")
Expected Output:
  PASS  Where is order A-1042?
  PASS  Tell me about order A-9999.

Keep exact assertions where the output genuinely is exact: a JSON schema, an enum value, a tool getting called with a specific id. Use a judge everywhere the answer is prose.

6. When Quality Drops: What Changed?

An AI system has three inputs that can move independently, and diagnosis is mostly about working out which one did:

Three Things That Change

Quality droppedThe modelprovider updated itYour codeprompt or toolsThe datacorpus or user inputPin and re-run evalsBisect the diffDiff the index

Figure 2: Version all three and the question is answerable in minutes. Version none and it is guesswork.

The practical consequence: pin model ids explicitly rather than floating, keep prompts in version control with the code that uses them, and stamp your retrieval index with a build id. Then every quality alert has three candidate diffs instead of an unbounded search.

Key Takeaways

  • AI regressions return 200s - error rate and latency dashboards cannot see them, because the failure is in the content.
  • Trace per step, not per request - the real run above spent 3,122ms total with 0ms in the tool; without spans you would optimise the wrong thing.
  • Alert on stop_reason - a rise in max_tokens is silent truncation, the most common unmonitored AI defect.
  • Sample a judge, do not judge everything - a few percent detects trends without doubling your bill.
  • Ground the rubric in the retrieved context - "is every claim supported" is objective; "is this good" is noise.
  • Trust the judge's booleans, not its 1-5 - ten runs on one answer gave grounded=True ten times and a score of 5 only eight.
  • Substring assertions are flaky by construction - a real run failed here because "wasn't able to find" is not "not found". The test was wrong.
  • Version the model, the prompt, and the index - so a quality drop has three candidate causes instead of infinite ones.
  • Never put prompts or retrieved documents in traces - they leave your system and persist.