Spec-Driven Dev
AI Agent Evals·Module 1 · Building Agents·L2·50 minAnalyze

Building Agents: Designing for Evaluability

Record model calls, tool calls, and permission decisions as nested spans, send them to self-hosted Langfuse, and read a trace to find the first failure.

Before this: L1 Building agents: foundations

What you'll learn
  • Explain why instrumentation must exist before the first real user.
  • Design a trace data model with nested spans, model calls, tool calls, permission denials, and prompt hashes.
  • Stand up self-hosted Langfuse with ClickHouse and send it a trace.
  • Read a trace and find the first thing that went wrong.

Why this matters

Thursday morning, a customer emails Pip: "Your chat assistant promised me a full refund yesterday. Where is it?" Pip forwards it to Dev with one line: "Did it?" Dev opens the database. Orders, yes. Refund requests, none. Chat logs? The app printed replies to the console, and the console was cleared at the last deploy. Dev cannot see what the customer asked, what the model saw, which tools ran, or what Sprout said. Dev cannot prove Sprout promised nothing, cannot reproduce the conversation, and cannot fix a failure nobody can look at. This lesson makes sure that the next time someone asks "did it?", the answer takes thirty seconds and starts with opening a trace.

1. If you did not record it, it did not happen

Instrumentation is the code that records what your agent does: every model call, every tool call, every decision, in a structured form you can query later. Observability is the property you get from it: the ability to answer "what happened, and why?" from the records alone, without rerunning anything.

The rule of this lesson is short. Instrument before traffic. The first real user must produce the first complete trace, because the first real user is often the first surprise.

The sampling rate is the fraction of conversations you record in full. Before launch and during early traffic, it is 100%. Forty conversations a day cost nothing to store, and every one is a lesson. At 20,000 a day you may sample a share and keep every trace with an error or a permission denial. Not yet.

2. The trace data model

A trace is the full record of one conversation. A span is one unit of work inside it. Spans nest: a turn (one customer message and everything it caused) contains model calls, and a model call contains the tool calls it requested. A span inside another span is a nested span, and it carries the parent_id of the outer one.

Every span has the same eight fields.

FieldMeaning
idUnique id for this span
parent_idThe span this one sits inside, or null at the top
nameThe kind of work: turn, model_call, tool_call:lookup_order
start, endTimestamps, so you can compute latency
inputWhat went in
outputWhat came out
metadataEverything else worth keeping: model id, tokens, tier, permission decision

The input of each model call above is a count ("messages": 2) to keep the example short. In a real trace, store the complete messages array. Section 5 explains why.

3. What to record

Three kinds of spans carry the important facts.

Model calls: model id, prompt hash, parameters (temperature, max tokens), token counts in and out, latency, and the full input and output. Tokens and latency feed the cost work in L9. The model id and prompt hash say which version of Sprout produced this behavior.

Tool calls: tool name, arguments, result, and error, if any. Store the whole result, not a summary. When the model invents a date, you need to see that the tool never returned one.

Permission decisions: every time canCall runs, record allowed or denied, the role, the tool, and the reason. A denial is not an error. It is the harness doing its job, and you will want to count them.

On the trace itself, record the session id (which conversation), the user id (who), the environment (dev, staging, prod), and the agent version.

Prompt hashes

A prompt hash is a short fingerprint of the exact system prompt text, computed with a hash function such as SHA-256 and truncated: sha256(system_prompt)[:8]. Change one character in the prompt and the hash changes.

Why a hash instead of a version number? Nobody forgets to compute a hash, and version numbers get skipped. The hash comes from the text itself, so it is always right.

4. Langfuse and ClickHouse

You could write traces to a file. Reading them is the problem. Langfuse is an open-source tool that stores traces, shows each one in a web UI as a nested tree, and exposes an API for querying and scoring them.

Under Langfuse sit two databases. Postgres holds metadata: projects, users, API keys, prompt versions. ClickHouse holds the spans. It is a column store: each column of a table is kept together on disk, so "average latency of tool_call:lookup_order spans this month" reads one column across millions of rows instead of every row in full. That is what makes analytics over a large trace set fast.

PieceJobWhy it is there
LangfuseTracing UI and APIHumans read traces; code queries and scores them
ClickHouseColumn store for spansFast aggregate questions over millions of spans
PostgresMetadataSmall, relational, transactional data

Here is a self-hosted setup as a sketch. The real compose file in the Langfuse docs also runs a worker, Redis, and object storage, and it will have changed by the time you read this. Copy the shape, not the values.

# Sketch only. Use the current compose file from the Langfuse docs for real values.
services:
  langfuse:
    image: langfuse/langfuse:3
    ports: ["3000:3000"]
    environment:
      DATABASE_URL: postgresql://pg:pg@postgres:5432/langfuse
      CLICKHOUSE_URL: http://clickhouse:8123
      CLICKHOUSE_USER: ch
      CLICKHOUSE_PASSWORD: ch
      NEXTAUTH_SECRET: change-me
      SALT: change-me
    depends_on: [postgres, clickhouse]
  postgres:
    image: postgres:16
    environment: { POSTGRES_USER: pg, POSTGRES_PASSWORD: pg, POSTGRES_DB: langfuse }
  clickhouse:
    image: clickhouse/clickhouse-server:24
    environment: { CLICKHOUSE_USER: ch, CLICKHOUSE_PASSWORD: ch }

Your agent needs three environment variables: a public key, a secret key, and the base URL of your Langfuse instance. Create the keys in the Langfuse UI after it starts.

export LANGFUSE_PUBLIC_KEY=pk-lf-...
export LANGFUSE_SECRET_KEY=sk-lf-...
export LANGFUSE_BASE_URL=http://localhost:3000

Here is the shape of an SDK call that creates a trace with a nested span. It is a sketch: method names differ between SDK versions.

// Sketch only. Check the Langfuse SDK docs for the exact method names in your version.
import { Langfuse } from "langfuse";
const langfuse = new Langfuse(); // reads the three env vars above

const trace = langfuse.trace({ name: "turn", sessionId: "sess_alex_01", userId: "cust_alex",
  metadata: { env: "dev", agent_version: "0.3.1", prompt_hash: "a1b2c3d4" } });
const gen = trace.generation({ name: "model_call", model: "claude-opus-5", input: messages });
gen.end({ output: response.content, usage: { input: 812, output: 41 } });
const tool = gen.span({ name: "tool_call:lookup_order", input: { order_id: "1042" } });
tool.end({ output: { status: "shipped" }, metadata: { tier: "T0", permission: "allowed" } });
await langfuse.flushAsync(); // send before the process exits

5. Designing for evaluability

Evaluability means a trace contains enough to judge whether the agent behaved correctly, and to run the same step again. Four habits get you there.

Store complete inputs so spans can be replayed. If a model-call span holds the full messages array, the exact tools list, and the parameters, you can send that request again and compare answers. If it holds "messages: 4," you cannot.

Use stable ids. The same customer, order, and session carry the same ids across traces, so "show me every conversation about order #1042" returns all of them.

Tag synthetic traces with their scenario id. A scenario is a scripted test conversation, and L3 builds hundreds of them. When Sprout runs a scenario, put scenario_id in the trace metadata, so a failing test leads straight to the trace that shows why.

Version everything. Agent version, prompt hash, tool schema version, and the version of the fictional world the scenarios run against. When behavior changes, one of these changed. If they are all recorded, you can find which.

6. Reading a trace

Reading a trace means walking the spans in order and finding the first failure: the earliest span where the agent's behavior stopped matching the spec. Not the span where the customer noticed. The first one.

Three questions, in order, every time you read a trace:

  1. What did the customer ask, and what did they get? That is the turn span.
  2. Walk each span. Does its output follow from its input and the spec?
  3. Stop at the first span where the answer is no. Write one sentence about it.

That one sentence is the raw material of L4, where you collect many of them and turn them into named failure modes.

7. Checklist: instrumentation review before traffic

Run through this list before the first real user, and again before every launch.

CheckHow to verify
Every model call is a span with model id, prompt hash, params, tokens, latency, full input and outputOpen one trace; every field is present
Every tool call is a span with name, arguments, result, and errorForce a not_found and see it recorded
Every permission decision is a span with role, tool, decision, reasonAsk for a refund as a customer; see denied
The trace carries session id, user id, environment, agent versionFilter by each in the UI
Sampling rate is 100%Count traces against conversations for one hour
Synthetic runs carry scenario_idRun one scenario; search for its id
PII policy is written and card numbers are maskedSend a fake card number; it does not appear
The SDK flushes on exit and on shutdownStop the server; the last trace is present

Summary

  • Instrument before the first real user and record 100% of conversations until volume forces sampling.
  • A trace is one conversation; a span is one unit of work; spans nest through parent_id and share eight fields.
  • Record model calls (model, prompt hash, params, tokens, latency), tool calls (name, args, result, error), and permission decisions (allowed or denied, with a reason).
  • Langfuse is the UI and API, ClickHouse stores spans for fast analytics, and Postgres holds metadata.
  • A trace is evaluable when you can replay any span, group by stable ids and scenario ids, and name every version. Read traces top down and mark the first failure.

Key terms in this lesson

Check your understanding

Pick an answer to see why it is right or wrong. These are graded in code, instantly.

1.Why must instrumentation exist before the first real user?
2.In the trace data model, what is a span?
3.What is a prompt hash for?
4.In a self-hosted Langfuse setup, what does ClickHouse do?
5.A trace shows: the first model call requests lookup_order("1707"); the tool returns not_found; the second model call replies "It arrives Tuesday." Which span is the first failure?

Apply it

Write your answer in your own words. Eve grades it against a rubric and gives you feedback, the same way an LLM judge works in Lesson 5.

Short answerGraded by Eve · 5 criteriaup to ~150 words

Which fields belong on a tool-call span, and why does each one matter when you read the trace later?

0 words
Free responseGraded by Eve · 6 criteria

Design the span structure for a two-step request from Alex (role customer): look up order #1042, then request a refund. List every span with its name, its parent, its input, its output, and the metadata you would attach. Write it as JSON or as an indented list.

0 words
Instructor notes(for whoever is teaching this lesson)

Talking points

  • The refund complaint is the thread. "Did it?" is a thirty-second question when traces exist and an unanswerable one when they do not.
  • Instrument before traffic and record 100% until volume forces sampling.
  • The data model is small: eight fields, nesting through parent_id.
  • Three kinds of spans carry the facts: model calls, tool calls, and permission decisions. The permission span is the one everyone forgets.
  • Prompt hashes: nobody forgets to compute a hash, and everybody forgets to bump a version number.
  • Evaluability pays off later: replay in L4 and L8, scenario ids in L3 and L6, versions whenever behavior changes.
  • Reading a trace means finding the first failure, not the last symptom.

Live demo idea

Start Langfuse before class. Run Sprout on "Where is order #1042?" and click through the trace live: the tree, the token counts, the full tool result, the prompt hash in metadata. Then run "When will order #1707 arrive?" and let the model invent a date. Ask the class to find the first failure by walking the tree top down. Most will click the last span first; use that to teach the "walk from the top" rule. Without Langfuse, print the JSON from section 2 and do the walk on paper.

Common misconceptions

  • "Logging is tracing." Logs are lines. Traces are structured, nested, and queryable.
  • "The last span is the failure." The last span is where the symptom shows. The first failure is usually earlier.
  • "A summary of the input is enough." Without the complete messages array you cannot replay the span.
  • "Prompt versions are enough." Version numbers get skipped. A hash of the text cannot be wrong.
  • "Traces are internal, so privacy does not apply." Traces are a database of customer conversations: mask card numbers, control access, set retention.

Timing

Fifty minutes: story and section 1, six; data model with the JSON, ten; what to record and prompt hashes, eight; Langfuse and ClickHouse with the demo, ten; evaluability, six; reading a trace, six; checklist, four.

If you only have 20 minutes

Show the four-span JSON and walk the parent_id chain (six minutes). Do the invented-date trace read from section 6 together (six minutes). Show one trace and point at the prompt hash and the permission field (five minutes). Hand out the section 7 checklist and assign sections 3 through 5 as reading.