AI EngineeringZero to ProductionHome·About·What’s new·Contact
Project 15 · Design Chapter

Multi-Agent Workflow with Observability

A multi-agent workflow you can actually see inside. Multi-agent systems fail in ways single calls don't — a tool errors three agents deep, costs balloon, a loop never converges — and without tracing you're debugging blind. This project pairs an orchestrated workflow with full observability: traces, metrics, and evals wired in from day one.

🎯 Advanced📈 LLMOps essential🔭 platform / SRE for AIobservability-first
Builds on LangGraph, LangSmith & LLMOpsThis joins L4/L5 (LangGraph workflows), I4 (tracing with LangSmith), and O4 (monitoring & governance). Where Project 13 builds a crew, this makes any multi-agent workflow operable — the difference between "it works on my laptop" and "we run it in production".

What this project teaches you to design

  • End-to-end tracing of a multi-step, multi-agent run.
  • The metrics that matter: latency, token cost, error rate, step count — per agent.
  • Online evals: scoring live outputs, not just a test set.
  • Debugging with traces: finding the step that actually failed.

The brief advanced

Picture the pager going off at 2am. A customer says the agent handed them a confidently wrong answer, and it cost $4 in tokens to produce. You open your logs and find a wall of print statements from five different agents interleaved by timestamp — none of them telling you which agent decided what, or where the run went off the rails. You spend an hour reconstructing a story the system should have handed you in ten seconds. Every engineer who has shipped a multi-agent system has lived some version of this, and it is exactly the pain this brief exists to remove.

Here's the analogy that makes the problem click. A single LLM call is like a receipt — one prompt in, one answer out, easy to read at a glance. A multi-agent workflow is more like a court transcript: many actors, nested exchanges, decisions that only make sense in the order they happened. You can't debug a transcript from a pile of shuffled sticky notes, which is what raw logs are. So the brief on this page is not "add more logging" — it's "make the tree of decisions visible," so a bad run can be expanded like a stack trace until you land on the one step that broke.

Why frame it as a brief at all, rather than jumping to code? Because observability is a design constraint, not a feature you sprinkle on at the end. The most common mistake teams make is shipping the workflow first and promising to "add tracing later" — and later never comes, because retrofitting instrumentation across five agents is far more work than baking it in from the first line. Read the problem statement below as the contract you're signing: every run, fully explainable after the fact, without re-running it.

single call — a receipt prompt → answer readable at a glance multi-agent run — a court transcript agent A agent B · tool agent C shuffled raw logs by timestamp can't tell which agent decided what → the brief: make the decision tree visible Receipt vs court transcript. One call reads at a glance; five agents interleaved into raw logs by timestamp do not. The brief isn't "log more" — it's make the tree of decisions expandable so a bad run reads like a stack trace.
🗺️ How to read this diagram

Compare the two halves across the dashed divider: the easy case on the left, the painful one the brief must fix on the right.

  • The left green box is a single LLM call — one prompt in, one answer out. There is nothing to reconstruct; it is a receipt you read at a glance.
  • The right red stack is the multi-agent run: agent A, agent B with a tool, agent C — many actors whose exchanges only make sense in order.
  • The dashed arrows into the "shuffled raw logs" box are the problem: those steps get interleaved by timestamp into a flat stream, so you can't tell which agent decided what — which is why the brief asks for a visible tree of decisions, not more logging.

In short: debugging one call is reading a receipt; debugging a multi-agent run from raw logs is reassembling a shredded transcript — the brief exists to hand you the tree instead.

"Our multi-agent system works — until it doesn't, and we can't tell why." A run produces a bad answer, or costs 10× what it should, or hangs. With five agents and dozens of LLM/tool calls per run, logs alone won't save you. Observability — structured traces of every step, cost/latency metrics, and automated quality scoring — turns an opaque black box into a system you can debug, optimize, and trust.

1 · Discovery — what can't you see today? advanced

Before you build a single instrument, you have to name what's dark. The instinct is to jump straight to "install a tracing library" — but that's answering a question you haven't asked yet. Discovery is the deliberate act of listing the questions your system cannot currently answer, because those questions are what your observability layer must be able to answer. Build tooling before you know the questions and you get the classic anti-pattern: a dashboard full of pretty charts that never once helped you fix an incident.

Think of it like a doctor ordering tests. A good doctor doesn't run every scan money can buy — they start from the symptoms and order the specific test that will change the diagnosis. The table below is that triage: each row is a symptom you've felt ("the answer was wrong — but which agent caused it?"), matched to the exact signal that would resolve it. The star ratings aren't decoration; they're a priority ranking, telling you which blind spots to close first when you can't do everything at once.

The mistake to avoid here is confusing data with answers. Teams often already emit oceans of logs and still can't say which step failed — because logs are a flat stream, and the questions in this table are all about structure and attribution (which agent, which run, how much did this step cost). Discovery reframes observability from "collect more" to "collect what makes these specific questions answerable in seconds."

blind spot (a question you can't answer) the signal that answers it which agent produced the bad output? per-step traces (I/O) where is cost / latency going? per-agent token + time is quality drifting? online evals + dashboards why did this run loop? the full trajectory start from the question → build only the signal that resolves it Questions first, tooling second. Each red question on the left is a blind spot you've felt; the green box it points to is the one signal that resolves it. Discovery builds the right column because of the left — not a dashboard of charts no incident ever needed.
🗺️ How to read this diagram

Read each row left-to-right: a question you can't answer today (red) points to the specific signal that would answer it (green).

  • The red boxes on the left are blind spots — symptoms you've lived: which agent failed, where cost went, whether quality is drifting, why a run looped.
  • The green boxes on the right are the exact observability signal each one needs: per-step traces, per-agent metrics, online evals, the full trajectory.
  • The blue arrows run from question to signal — the direction that matters. You start from the question and build only the signal that resolves it, which is why the tooling never becomes charts nobody uses.

In short: every instrument you build should trace back to a question you couldn't answer — the arrow always points question → signal, never the reverse.

Blind spotObservability answer
Which step/agent produced the bad output?⭐⭐⭐ per-step traces with inputs/outputs
Where is the cost/latency going?⭐⭐⭐ per-agent token + time metrics
Is quality drifting over time / by input type?⭐⭐ online evals + dashboards
Why did this run loop / not converge?⭐⭐ the full trajectory, visualized
Problem statement"We run multi-agent workflows in production but operate them blind — when one gives a wrong or expensive answer we can't pinpoint the step, and we can't tell if quality is drifting. We want every run fully traced, cost/latency/error metrics per agent, and automated quality scoring on live traffic, so we can debug fast and catch regressions before customers do."

2 · Architecture advanced

The failure mode this architecture prevents is a specific, expensive one: a team builds the workflow, it works in the demo, they ship it, and only then discover there's no way to see inside it. Now every fix means adding print statements, redeploying, waiting for the bug to recur, and reading the logs — a debugging loop measured in days. The whole shape of the diagram below exists to make that loop impossible, by treating "being able to see the run" as part of the build, not a follow-up ticket.

The key architectural idea is separation of concerns, and a good analogy is a flight recorder. The plane flies itself — the black box doesn't steer, it only records. Notice in the diagram that the workflow (top row) never has to know it's being watched; each step simply emits a span, and a completely separate observability layer collects, aggregates, and scores those emissions. That decoupling is why you can change the workflow without touching your monitoring, and change your monitoring without risking the workflow. The two never entangle.

Why does this belong at the architecture stage rather than being a coding detail? Because it dictates a boundary: the workflow's job is to produce signals cheaply and reliably; the observability layer's job is to consume them. Get that boundary wrong — say, by having agents write directly to your dashboard database — and a slow dashboard can now stall the actual agent. Read the diagram with that split in mind: solid arrows are work flowing forward, dashed arrows are signals dropping down into a system that is deliberately kept off the critical path.

THE WORKFLOW (LangGraph) agent A agent B + tool agent C result Observability layertraces · cost/latency metrics · online evals dashboardsalerts
🗺️ How to read this diagram

This is the whole system in one picture: a normal multi-agent workflow on top, and an observability layer underneath that watches every step. The point of the diagram is that the watching is built into the design, not bolted on later.

  • The top row is the workflow itself (a LangGraph graph): agent A → agent B + tool → agent C → result. The solid blue arrows are the normal flow of work from one agent to the next.
  • The dashed purple arrows drop down from each agent into the big Observability layer box. Read them as: every step emits a record of what it did — its inputs, outputs, tokens, timing, and any error — as it runs.
  • The Observability layer (center) is what this project builds: it collects those records (traces), turns them into cost/latency metrics, and runs online evals that score live outputs for quality.
  • The dashboards box on the right (fed by the final purple arrow) is the operator's view: charts and alerts that fire when cost spikes, errors burst, or quality drifts.

In short: Follow one run left-to-right along the top, and notice that at every box a dashed line also goes down. That downward line is the trace — it's how you can later replay exactly what each agent did.

The workflow (a LangGraph multi-agent graph) runs as normal — but every step emits a trace span (inputs, outputs, tokens, latency, errors) to an observability layer (LangSmith or an OpenTelemetry-based stack). That layer computes metrics, runs online evals on live outputs, and feeds dashboards + alerts. Instrumentation is a first-class part of the design, not an afterthought.

3 · Risk & operability model advanced

The scariest failures in a traced system aren't the loud ones — they're the silent ones. A crash pages you; a stack trace tells you where. But a multi-agent workflow that quietly gets 5% worse after a prompt change, or slowly doubles its token spend as one agent starts retrying, doesn't crash — it just erodes, and you find out from an angry customer or a shocking invoice weeks later. This section is about naming those quiet risks and pairing each with a control that makes it loud.

Notice the difference from a normal software risk table: here observability itself is both the safety net and a new risk. Tracing every step means you're now capturing whatever flowed through those steps — including customer PII — and storing it somewhere queryable. So the same layer that saves you from undebuggable failures can, if you're careless, become a compliance breach and a fresh attack surface. That's why redaction and access controls sit right in the risk table, not as an afterthought. The tool that watches your system also has to be watched.

loud failure — already visible a crash pages you,points to where silent risks → a control that makes each loud quality drift online evals runaway cost budget alerts PII in traces redaction the tool that watches the system must itself be watched (redact + access control) Make the silent failures loud. A crash already announces itself; drift, creeping cost, and captured PII do not. Each silent risk (red) is paired with a control (green) that turns it into a signal — and tracing itself becomes a risk, so redaction sits in the table, not after it.
🗺️ How to read this diagram

Cross the dashed divider: the easy failure on the left, the dangerous ones the controls address on the right.

  • The left box is a loud failure — a crash. It pages you and a stack trace points to where; you don't need this section for it.
  • The red boxes on the right are the silent risks: quality drift, runaway cost, and PII captured in traces. None of them crash — they erode quietly until a customer or an invoice tells you.
  • Each red risk pairs with a green control via the amber arrow — online evals, budget alerts, redaction — whose job is to turn a silent problem into a loud signal. The bottom line is the twist: the observability layer that watches everything must itself be watched, which is why redaction and access controls are risks in their own right.

In short: the risks that hurt are the ones that don't page you — each control here exists to make an invisible failure announce itself.

RiskControl
🔴 Silent quality regression after a changeOnline evals score live traffic; alert on score drop; compare against a baseline
🔴 Runaway cost undetectedPer-run token/cost tracking; budget alerts; cost attribution per agent
🟠 Undebuggable failuresFull trace of every step with I/O; jump straight to the failing span
🟠 Sensitive data captured in tracesRedact PII before logging; access controls on the trace store (O4)
🟠 Observability overhead / costSample high-volume traces; keep full traces for errors & a sample of successes
You cannot operate what you cannot seeThe number-one reason multi-agent systems fail in production isn't a bad model — it's that when something goes wrong, nobody can find where. Tracing is not optional tooling you add later; it's the control plane. Build the observability layer alongside the workflow, or you'll be reverse-engineering failures from customer complaints.

4 · Instrumentation, in practice advanced

If you've ever dreaded adding logging, this section is the good news. The mental picture most people have of instrumentation is grim: threading a logger through every function, remembering to log inputs and outputs, wrapping each call in try/except so failures still get recorded, and inevitably forgetting one of them. Done by hand, that boilerplate is so tedious that it's the first thing dropped under deadline pressure — which is exactly why so many systems ship blind.

The move that fixes this is the decorator. Think of it like a stamp on an envelope: you don't rewrite the letter to add postage, you press one mark on top and the postal system knows what to do. Adding @traceable above a function is that stamp — the function's own code doesn't change at all, but every call now silently records a span with its inputs, outputs, tokens, and timing. The instrumentation lives around the logic, not inside it, so you can add and remove it without ever risking the function's behavior.

The subtler point — and the reason nested spans matter — is that decorators compose into a tree automatically. When a traced agent calls other traced functions, their spans nest underneath it, mirroring the actual call structure. You get the court transcript for free, in the right hierarchy, without writing any "child of" bookkeeping. The one thing to internalize before the labs: the code below is only the shape a hosted tool gives you: the rest of this page rebuilds that same capturing in ~30 lines of plain Python, so the decorator stops being magic and becomes something you understand line by line.

add one decorator; nested calls record as nested spans @traceable research_agent() your code, unchanged wraps emits the span tree recorded research_agent · parent span retrieve · child span llm_answer · child span One decorator, a whole tree. @traceable wraps research_agent without touching its code; because that agent calls the traced retrieve and llm_answer, their spans nest underneath its parent span automatically — the hierarchy you later expand to find the broken step.
🗺️ How to read this diagram

Read the left column top-to-bottom first (what you write), then follow the dashed "emits" arrow rightward into the tree that gets recorded.

  • The purple box is the @traceable decorator; the blue arrow shows it wrapping the white research_agent() box below it — your function's own code is untouched.
  • The dashed "emits" arrow crosses into the light-blue panel on the right: this is the span tree the wrapped call records at run time, not code you write.
  • Inside the panel, the green box is the parent span for research_agent; the two white boxes below are child spans for retrieve and llm_answer. The dashed elbow connectors show they nest under the parent — because those functions were called inside it — with no "child of" bookkeeping from you.

In short: you add one line, and the call structure of your agents becomes a tree you can expand — the parent is the agent, the children are the calls it made.

Requires: pip install langsmith

observability (shape — LangSmith tracing)# Tracing is often just configuration + decorators
from langsmith import traceable

@traceable(run_type="chain")
def research_agent(query):
    hits = retrieve(query)      # nested spans captured automatically
    return llm_answer(query, hits)

# online eval: score every Nth live run
@traceable
def score_run(inputs, outputs):
    return {"faithfulness": judge(outputs, inputs),
            "cost_usd": outputs.usage.cost}
▶ How this works

This is the shape of production tracing with LangSmith — a preview of what you'll build by hand in the labs below. The big idea: you rarely write logging code by hand; you add a decorator and the library records each call for you.

  1. from langsmith import traceable pulls in the decorator. A decorator is a line starting with @ placed just above a function; it wraps that function to add behaviour — here, recording a trace — without changing the function's own code.
  2. @traceable(run_type="chain") on research_agent means: every time this agent runs, capture a span (one entry in the trace). Because retrieve and llm_answer are called inside it, their spans nest underneath automatically — that's the tree you inspect later.
  3. The second function, score_run, is an online eval: it takes a run's inputs and outputs and returns quality numbers — here a "faithfulness" judge score and the run's "cost_usd". Running this on live traffic is how you catch quality drift.

Try this: Notice you didn't write any 'save to database' code — the @traceable decorator does the capturing. The rest of this page rebuilds this exact idea in ~30 lines of plain Python so you can see what the decorator is really doing.

Trace the tree, not just the leavesThe power of tracing multi-agent systems is the hierarchy: a run contains agents, agents contain LLM/tool calls, each with its own I/O, tokens, and timing. When the final answer is wrong, you expand the tree and find the exact span that went sideways — instead of guessing across five agents. LangSmith (I4) gives this out of the box; OpenTelemetry does it vendor-neutrally (O4).

5 · Observability surface advanced

"Observability" is one word, but it's not one thing — and treating it as one is where teams go wrong. Someone buys a tracing tool, sees traces flowing, and declares the system observable — then is blindsided when quality drifts, because traces alone never told them whether the answers were good. The three pillars are distinct jobs, and skipping any one leaves a specific hole.

The classic framing is traces, metrics, and evals, and an analogy keeps them straight. Traces are the security-camera footage: the full, replayable record of one particular run, for when you need to see exactly what happened. Metrics are the dashboard gauges: numbers aggregated across many runs — average latency, total cost, error rate — for spotting that something is off before you go to the footage. And evals are the quality inspector: they don't ask "did it run?" but "was the output actually good?", which no amount of timing or token-counting can answer.

The table below lays out that surface component by component, and the notes carry the real engineering judgment — which pieces are the load-bearing "control plane" (🟢), which must be sampled to stay affordable (🟠), and which are non-negotiable when customer data is involved (🔴). Read it as a menu with a required course: you can phase in dashboards and online evals, but tracing is the foundation everything else is computed from, and redaction is mandatory the moment real user data touches a span.

three distinct jobs — metrics and evals are both computed from traces Traces security-camera footage one run, replayable Metrics dashboard gauges aggregated over runs Evals the quality inspector was the output good? Trace foundation the step tree every pillar is built on Traces, metrics, evals — one word, three jobs. Traces record a single run in full; metrics aggregate many runs into gauges; evals judge whether outputs were actually good. Metrics and evals are both derived from the trace foundation, which is why tracing is the required course and the others phase in on top.
🗺️ How to read this diagram

Scan the three top boxes left to right — the three pillars — then notice all three arrows rise from the single box along the bottom.

  • Traces (green) are the full, replayable footage of one run — what you reach for to see exactly what happened.
  • Metrics (blue) are gauges: latency, cost, and error rate aggregated across many runs, for spotting trouble before you open the footage.
  • Evals (amber) are the quality inspector: they answer "was the output good?" — a question no amount of timing or token-counting can.
  • The bottom box and the three upward arrows are the payoff: metrics and evals aren't separate data sources, they're computed from the trace foundation. That's why tracing is non-negotiable and the rest builds on it.

In short: tracing is the ground floor; metrics and evals are two views built on top of the same captured runs.

ComponentDoesNote
Tracing (LangSmith / OTel)Capture the full step tree per run🟢 the control plane (I4/O4)
MetricsLatency, tokens, cost, error rate — per agent🟢 aggregated over runs
Online evalsScore live outputs (faithfulness, quality)🟠 sampled to control cost
Dashboards & alertsSurface drift, cost spikes, error bursts🟢 the operator's view
Trace redactionStrip PII before storage🔴 required for customer data

6 · Evaluation expert

Here's the twist that catches people: the thing you're evaluating in this project isn't the agents — it's the observability layer itself. It's tempting to grade the workflow's answers, but that's Project 13's job. What decides whether this build succeeded is a different question: when a run breaks, does the trace actually lead you to the broken step, or do you still end up guessing? A tracing system that captures everything but never shortens a debugging session has failed, no matter how full its dashboards look.

That's why the payoff metric in the table is MTTR — mean time to repair, or here, time-to-debug. It's the honest scoreboard. An analogy: buying a smoke detector isn't the goal; the goal is getting out of the house faster when there's a fire. Trace completeness, per-agent cost attribution, and alert precision are all worth measuring, but they're inputs to the one number that proves the layer earns its keep — how fast a trace turns a vague complaint into a named root cause.

The other distinction this section forces is offline versus online evaluation, and conflating them is a common trap. Offline evals run a fixed, curated test set — they gate deploys by checking known cases before ship. Online evals score sampled live traffic — they watch for the drift, novel inputs, and edge cases a frozen test set can never contain. You need both: offline is the exam you pass to graduate, online is the ongoing health check that catches you getting sick afterward. A system with only offline evals looks green right up until reality serves it something the test set never imagined.

offline gates the deploy · online watches production — you need both Offline evals fixed, curated test set checks known cases 🚀 deploy Online evals sampled live traffic catches drift / new cases gate after "the exam you passto graduate" "the ongoing health check" Two evals, opposite sides of the deploy. Offline evals gate the deploy on a fixed test set before ship; online evals score sampled live traffic after ship to catch the drift and edge cases a frozen set never had. Neither replaces the other.
🗺️ How to read this diagram

Read it left to right along the arrows, with the purple deploy box in the middle as the dividing line.

  • On the left, offline evals (blue) run a fixed, curated test set. The "gate" arrow into the deploy box means they must pass before anything ships — the green box below reframes this as "the exam you pass to graduate".
  • The purple deploy box is the moment of ship; everything to its right happens in production.
  • On the right, online evals (amber) score sampled live traffic after deploy, catching drift and inputs the test set never imagined — the green box below calls this "the ongoing health check".

In short: offline is the pre-ship gate, online is the post-ship watch — a system with only the left half looks green until reality hands it something new.

Eval / metricMeasures
Trace completenessEvery step captured; failures point to the right span
Cost/latency per run & per agentWhere the budget goes; regressions after changes
Online quality scoreFaithfulness/quality on live traffic over time (drift)
Alert precisionAlerts fire on real problems, not noise
MTTR (time-to-debug)How fast a trace leads you to the root cause — the payoff
Online evals catch what test sets missA fixed test set (Ch 5) checks known cases; online evals score real production traffic and catch drift, new failure modes, and edge cases your test set never had. Sample them to control cost, and alert when the score drops. Offline evals gate deploys; online evals watch production.

7 · Phased rollout expert

The temptation is to build the whole observability platform before you turn anything on. Resist it. Teams that try to ship tracing, per-agent metrics, dashboards, alerts, online evals, and PII redaction all at once usually ship none of it — the scope collapses under its own weight, and meanwhile production stays blind. The phased ladder below exists because each phase delivers real value on its own, and each earns the right to build the next.

The ordering isn't arbitrary — it's sequenced by payoff-per-effort. Phase 1 (just trace everything, in dev) is cheap and instantly transformative: your debugging goes from "guess and redeploy" to "expand the tree." Only once you can see runs do metrics and dashboards (Phase 2) become meaningful, because a dashboard is just traces aggregated. And online evals (Phase 3) come last because they're the most expensive and depend on everything before them — you can't score live traffic you aren't yet capturing. Notice too that redaction lands in Phase 2, right as data starts being stored at scale: safety arrives exactly when the risk does.

Read the red rung as the non-negotiable floor. The common, costly mistake is running a multi-agent workflow in production with no tracing at all — or worse, tracing that quietly logs customer PII unredacted into a queryable store. The phases are a menu you can pace to your team's capacity, but that last rung is not optional: it's the line that separates "operable system" from "incident waiting to happen."

each phase earns the right to build the next — climb, don't skip NEVER · untraced or PII unredacted in prod Phase 1 · Trace everything (dev) cheapest, instantly transformative Phase 2 · Metrics + redact PII dashboards; only once you see runs Phase 3 · Online evals + governance most expensive, depends on all above build order → the red rung is the floor — below it is an incident waiting A ladder ordered by payoff-per-effort. Phase 1 (trace) is cheap and transformative; metrics and redaction (Phase 2) only make sense once you can see runs; online evals (Phase 3) are costliest and depend on everything below. The red rung — untraced or unredacted in production — is the non-negotiable floor.
🗺️ How to read this diagram

Read it bottom-to-top: you climb the ladder one rung at a time, and each rung depends on the one below it.

  • Phase 1 (trace everything) sits just above the floor — the cheapest, most transformative step, turning debugging from "guess and redeploy" into "expand the tree."
  • Phase 2 (metrics, dashboards, PII redaction) comes next because a dashboard is just traces aggregated — it's meaningless until Phase 1 exists. Redaction lands here, exactly as data starts being stored at scale.
  • Phase 3 (online evals + governance) is the top rung: costliest and dependent on everything below, since you can't score live traffic you aren't yet capturing. The red rung at the top of the picture is the "never" line — running untraced or logging unredacted PII in production is the non-negotiable floor you stay above.

In short: climb in order — trace, then measure, then score — and never drop below the red rung of an untraced, unredacted production system.

Phase 1 · Trace everything — instrument the workflow; view full traces in dev. Debugging gets dramatically faster immediately. (I4)
Phase 2 · Metrics + dashboards — cost/latency/error per agent; alerts on spikes. Redact PII in traces. (O4)
Phase 3 · Online evals + governance — score sampled live runs, alert on quality drift, close the loop into offline evals. (Ch 5 + O4)
Never — run a multi-agent workflow in production untraced, or log customer PII into traces unredacted.

Skills & course map expert

SkillLearn it in
Multi-agent workflows & stateL4 · L5
Tracing with LangSmithI4
Monitoring, governance, OTelO4
The workflow being observedProject 13
Offline + online evalsCh 5
Cost, scaling, deployO3 · Ch 6
🛠️ Hands-on build — everything below is on this pageThe rest of this page is the complete, self-contained build: set up from an empty folder, paste in every file, run it (with a mock, so no API key is needed), and pass the tests. Follow it top to bottom — no other page required.

What you need before you startPython 3.10+. The tracer is pure standard-library Python, so everything — spans, metrics, PII redaction, finding the failing step — is built and tested offline. No key needed.

By the end you will have

  • A @traced decorator that records a span per step, even on error.
  • A run tree you can inspect to find the failing/expensive step.
  • A metrics rollup (total time, error count, cost).
  • PII redaction applied before any span is stored.

How to use this page expert

Steps in order. terminal = run it; file = create it with the exact contents shown.

Step 1 · Folder + venv + install expert

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1 — run in your terminal
terminalmkdir -p observability/tests
cd observability
python3 -m venv .venv
source .venv/bin/activate     # Windows: .venv\Scripts\Activate.ps1
pip install pytest
pip freeze > requirements.txt
(.venv) ... Successfully installed pytest-8.3.4

Step 2 · The tracer expert

Create trace.py. A @traced decorator records a span for every wrapped call — critically, in a finally block so failures are captured too. A redact helper strips PII before storage.

Step 2 — create this file

observability/trace.py

trace.py"""A minimal tracer: one span per wrapped call, captured even on error."""
import re, time, functools

RUN = {"spans": []}       # collected spans for the current run
PII = re.compile(r"\b(\d{3}-\d{2}-\d{4}|\d{16})\b")


def reset():
    RUN["spans"] = []


def redact(value) -> str:
    return PII.sub("[REDACTED]", str(value))


def traced(name: str, tokens: int = 0):
    """Wrap a function so each call appends a span with I/O, ms, error."""
    def deco(fn):
        @functools.wraps(fn)
        def wrap(*a, **k):
            span = {"name": name, "input": redact(a),
                    "error": None, "tokens": tokens}
            t0 = time.perf_counter()
            try:
                out = fn(*a, **k)
                span["output"] = redact(out)
                return out
            except Exception as e:
                span["error"] = str(e)
                raise
            finally:
                span["ms"] = (time.perf_counter() - t0) * 1000
                RUN["spans"].append(span)   # recorded even on error
        return wrap
    return deco
▶ How this works

This file is the heart of the project: a tiny tracer. It defines a @traced decorator that records one span (a little dictionary describing a single step) every time a wrapped function runs — and, crucially, even when that function crashes.

  1. RUN = {"spans": []} is a shared list where every span gets appended. PII = re.compile(...) is a pattern that matches things that look like a Social Security number (123-45-6789) or a 16-digit card number.
  2. redact(value) turns any value into text and replaces anything matching that PII pattern with "[REDACTED]" — so customer secrets never get stored in a trace.
  3. traced(name, tokens=0) is the decorator. When you write @traced("planner", tokens=300) above a function, deco then wrap replace it with a version that does bookkeeping around the real call. @functools.wraps(fn) just keeps the original function's name/docs.
  4. Inside wrap: it builds the span (recording the redacted input and token count), starts a timer with time.perf_counter(), then runs the real function in a try. On success it saves the redacted output; on failure it saves the error message and re-raises.
  5. The finally block runs no matter what — success or crash — recording the elapsed ms and appending the span to RUN. That guarantee is the whole trick (next box).

What the output means: Nothing prints yet — this file only defines tools. After a workflow runs, RUN["spans"] holds one dictionary per step, each with name, input, output, error, tokens, and ms.

Try this: Read the try / except / finally shape slowly: try attempts the work, except catches a crash, and finally always runs the cleanup. Here 'cleanup' means 'record the span' — which is why even failures show up in the trace.

The finally is the whole pointThe span is appended even when the step raises — so a failure appears in the trace with its error and timing instead of vanishing. That's what lets you jump straight to the broken step instead of guessing across many agents.

Step 3 · Metrics rollup expert

Create metrics.py. It summarises a run's spans into total time, error count, and a cost estimate.

Step 3 — create this file

observability/metrics.py

metrics.py"""Roll a run's spans up into the metrics an operator watches."""

PRICE_PER_TOKEN = 0.00001


def rollup(run: dict) -> dict:
    spans = run["spans"]
    return {
        "steps": len(spans),
        "total_ms": round(sum(s["ms"] for s in spans), 2),
        "errors": sum(1 for s in spans if s["error"]),
        "cost_usd": round(sum(s["tokens"] for s in spans) * PRICE_PER_TOKEN, 4),
    }


def failing_step(run: dict):
    """Return the name of the first span that errored, or None."""
    for s in run["spans"]:
        if s["error"]:
            return s["name"]
    return None
▶ How this works

Raw spans are detailed but hard to eyeball. This file rolls them up into the four numbers an operator actually watches — step count, total time, error count, and estimated cost — plus a helper to name the step that failed.

  1. PRICE_PER_TOKEN = 0.00001 is a made-up price so we can turn token counts into dollars. Real prices differ per model, but the math is identical.
  2. rollup(run) reads run["spans"] and returns a summary dictionary. len(spans) is the number of steps; sum(s["ms"] for s in spans) adds every step's time; sum(1 for s in spans if s["error"]) counts how many steps recorded an error.
  3. cost_usd multiplies the total tokens across all spans by the price per token. round(..., 2) and round(..., 4) just trim the numbers to a readable length.
  4. failing_step(run) walks the spans in order and returns the name of the first one whose error isn't empty, or None if nothing failed. This is the one-line answer to 'which step broke?'.

What the output means: rollup gives you something like {'steps': 4, 'total_ms': 0.02, 'errors': 0, 'cost_usd': 0.0322}; failing_step gives a step name (e.g. "researcher") or None.

Try this: These are ordinary Python comprehensions — sum(... for s in spans if ...) reads as 'for each span, maybe add this'. Change the if s["error"] to count something else, like steps over 100ms, and you've invented a new metric.

Step 4 · Instrument a workflow & run it expert

Create workflow.py — a small multi-step "crew" with each step traced, including one step that fails, so you can see the trace pinpoint it.

Step 4 — create this file

observability/workflow.py

workflow.py"""A traced multi-step workflow — no model needed to see observability."""
from trace import traced, RUN, reset
from metrics import rollup, failing_step


@traced("planner", tokens=300)
def planner(q): return ["sub1", "sub2"]


@traced("researcher", tokens=1200)
def researcher(subq): return f"finding for {subq}"


@traced("writer", tokens=500)
def writer(findings): return "REPORT: " + "; ".join(findings)


def run(question):
    reset()
    subs = planner(question)
    findings = [researcher(s) for s in subs]
    report = writer(findings)
    return report


if __name__ == "__main__":
    print(run("why did churn rise?"))
    print("--- run tree ---")
    for s in RUN["spans"]:
        print(f"  {s['name']:12} {s['ms']:6.2f}ms  tok={s['tokens']}  "
              f"err={s['error']}")
    print("--- metrics ---")
    print(rollup(RUN))
▶ How this works

Now we put the tracer to work. This is a pretend multi-agent 'crew' — a planner, a researcher, and a writer — with each step wrapped in @traced. No language model is needed; the functions return canned strings so you can focus purely on what observability shows you.

  1. from trace import traced, RUN, reset and from metrics import rollup, failing_step pull in the tools you just built. This is why the files must live in the same folder.
  2. Each agent is a normal function with a @traced(...) decorator giving it a name and a token count: planner (300 tokens) returns two sub-questions, researcher (1200) answers one, writer (500) joins the findings into a report. The big token number on researcher is a hint that research will dominate cost.
  3. run(question) is the workflow: it calls reset() to clear old spans, gets sub-questions from the planner, runs the researcher on each one (a list comprehension), then writes the report. Because every step is traced, RUN["spans"] fills up automatically as this runs.
  4. The if __name__ == "__main__": block runs only when you execute the file directly. It prints the report, then loops over RUN["spans"] to print a readable run tree (name, ms, tokens, error per step), then prints the rollup metrics.

What the output means: You get the final report string, a line-per-step run tree, and the metrics summary — see the terminal box just below.

Try this: The f"{s['name']:12}" bits are f-string alignment: :12 pads the name to 12 characters so the columns line up. Change researcher's tokens and watch cost_usd move in the metrics.

Step 4 — run it
terminalpython workflow.py
REPORT: finding for sub1; finding for sub2
--- run tree ---
  planner        0.01ms  tok=300  err=None
  researcher     0.00ms  tok=1200  err=None
  researcher     0.00ms  tok=1200  err=None
  writer         0.01ms  tok=500  err=None
--- metrics ---
{'steps': 4, 'total_ms': 0.02, 'errors': 0, 'cost_usd': 0.0322}
▶ How this works

This is what python workflow.py actually prints. It's the first time you see observability paying off: the run tree and metrics were produced automatically, just because each step was @traced.

  1. The first line is the workflow's real output: the joined report.
  2. Under --- run tree ---, each line is one span: the step name, how long it took in ms, its token count, and its error (None = no error). Notice researcher appears twice — once per sub-question — which matches the loop in run().
  3. Under --- metrics ---, the rollup dictionary summarises the whole run: 4 steps, near-zero total time (the functions do no real work), 0 errors, and $0.0322 estimated cost.

What the output means: Times are tiny here because the fake agents return instantly; in a real system those numbers would be seconds and dollars, and this same tree would show you exactly where they went.

Try this: Add a fourth agent (or a second writer call) and re-run: the run tree grows a line and the metrics update on their own — you never touched the printing or metrics code.

The run tree pinpoints cost and failureEach step's tokens and timing are right there. In a real system the two researcher spans are usually the most expensive — you can see it immediately instead of guessing. Add a failing step and the trace shows exactly which one broke (tested next).

Step 5 · Tests (no key) expert

Step 5 — create this file

observability/tests/test_trace.py

tests/test_trace.py"""Offline tracer + metrics tests — no key."""
import pytest
from trace import traced, RUN, reset, redact
from metrics import rollup, failing_step


def setup_function():
    reset()


def test_span_records_io_and_ms():
    @traced("step")
    def f(x): return x + 1
    f(1)
    span = RUN["spans"][0]
    assert span["name"] == "step" and "ms" in span


def test_error_span_is_captured():
    @traced("boom")
    def f(): raise ValueError("nope")
    with pytest.raises(ValueError):
        f()
    assert RUN["spans"][0]["error"] == "nope"   # captured despite raising


def test_rollup_counts_and_costs():
    @traced("a", tokens=100)
    def a(): return 1
    a(); a()
    m = rollup(RUN)
    assert m["steps"] == 2 and m["errors"] == 0
    assert m["cost_usd"] > 0


def test_pii_redacted_before_store():
    @traced("pii")
    def f(x): return x
    f("my ssn 123-45-6789")
    assert "[REDACTED]" in RUN["spans"][0]["input"]


def test_failing_step_is_findable():
    @traced("ok")
    def ok(): return 1
    @traced("bad")
    def bad(): raise RuntimeError("x")
    ok()
    with pytest.raises(RuntimeError):
        bad()
    assert failing_step(RUN) == "bad"


def test_redact_leaves_clean_text():
    assert redact("hello world") == "hello world"
▶ How this works

These tests prove the tracer behaves — and they run with no API key and no network, because the whole tracer is plain Python. Each test is a small, focused claim about one behaviour.

  1. setup_function() runs before every test and calls reset(), so spans from one test can't leak into the next — a common source of confusing failures.
  2. test_span_records_io_and_ms wraps a trivial function, calls it, and checks the recorded span has the right name and a timing field. test_error_span_is_captured makes a function that raises, wraps the call in pytest.raises(ValueError) (which says 'we expect this to throw'), then asserts the error was still recorded — proving the finally works.
  3. test_rollup_counts_and_costs calls a traced function twice and checks the metrics say 2 steps, 0 errors, and a positive cost. test_pii_redacted_before_store passes in a fake SSN and asserts "[REDACTED]" ended up in the stored input.
  4. test_failing_step_is_findable runs one good step and one bad step and asserts failing_step(RUN) returns "bad" — the payoff of the whole project. test_redact_leaves_clean_text confirms redaction doesn't mangle ordinary text.

What the output means: Run with python -m pytest tests/ -v you should see all 6 passed — each line is one of the behaviours above proven true.

Try this: Comment out the finally block in trace.py and re-run: test_error_span_is_captured should fail, because the crashing step's span never gets recorded. That failing test is the safety net working.

Step 5 — run the tests
terminalpython -m pytest tests/ -v
tests/test_trace.py::test_span_records_io_and_ms PASSED
tests/test_trace.py::test_error_span_is_captured PASSED
tests/test_trace.py::test_rollup_counts_and_costs PASSED
tests/test_trace.py::test_pii_redacted_before_store PASSED
tests/test_trace.py::test_failing_step_is_findable PASSED
tests/test_trace.py::test_redact_leaves_clean_text PASSED

6 passed in 0.04s
✅ What each test proves
TestProves
span records I/O + msevery step is observable
error span capturedfailures appear in the trace, don't vanish
rollup counts + costsper-run metrics are correct
PII redacted before storecustomer data never lands in a span
failing step findableyou can pinpoint the broken step — the payoff
redact leaves clean textredaction doesn't mangle normal output

Step 6 · Go live with LangSmith (optional) expert

Your @traced has the same shape as LangSmith's @traceable. To get hosted dashboards, install and swap the decorator — no other changes:

Step 6 — swap the decorator
terminalpip install langsmith
export LANGSMITH_API_KEY="ls-your-key-here"
workflow.py (change the import + decorator)from langsmith import traceable

@traceable(run_type="chain")       # same shape — dashboards for free
def researcher(subq): return f"finding for {subq}"
▶ How this works

The finale: because your @traced decorator has the same shape as LangSmith's real @traceable, going from your homemade tracer to a hosted, production dashboard is essentially a one-line change.

  1. In the terminal, pip install langsmith adds the real library and export LANGSMITH_API_KEY=... gives it credentials (like the API keys used elsewhere in the course).
  2. In workflow.py, you swap from trace import traced for from langsmith import traceable, and change the decorator to @traceable(run_type="chain"). The function body doesn't change at all.
  3. Now the same run you saw in the terminal is captured to LangSmith's hosted UI, where the trace tree, metrics, and evals are shown as interactive dashboards — no dashboard code to write.

Try this: Keep your local @traced for the tests (fast and free) and use @traceable only in the running app. That split — cheap local tracing for CI, hosted tracing for production — is exactly how real teams operate.

Tests stay on your tracerKeep the tests on the local @traced (fast, free). Only the running app needs LangSmith. Sample online evals in production to bound cost, and alert when a quality score drops.

Troubleshooting — every error you might hit expert

⚠️ If something doesn't match
What you seeWhat it means & the fix
ModuleNotFoundError: traceRun pytest from inside observability/.
Failures don't appear in spansThe span must be appended in a finally block, as written.
Spans leak between runsCall reset() at the start of each run (and in setup_function).
PII in a spanConfirm redact() wraps both input and output before storing.
Name clash with stdlib traceRun inside the project folder so your local trace.py is found first; or rename it to tracer.py.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Scaffold: a span collectorBeginner

Context: An agent tracer is, at its core, spans-in-a-list: one dict per step. Getting that shape right first — a stable set of keys — is what everything downstream reads.

Your task: Create a module-level RUN = {"spans": []} and a record(name, ms, tokens, error=None) that appends one span dict, and prove it collects in call order.

Requirements:

  • Each span is a dict with name, ms, tokens, and error keys
  • A reset clears the run between demos
  • record appends spans in the order called
  • Prove the count and the name order after two records

💡 Hint: Fix the span schema now; every later rung (decorator, tree, rollup, sampling) reads these same keys, so keep them stable.

Show solution

Design. The whole system is spans-in-a-list; everything later reads that list. " "Get the shape right first: one dict per step with a stable set of keys.

RUN = {"spans": []}

def reset():
    RUN["spans"] = []

def record(name, ms, tokens=0, error=None):
    RUN["spans"].append(
        {"name": name, "ms": ms, "tokens": tokens, "error": error})

reset()
record("planner", 1.2, tokens=300)
record("writer", 0.4, tokens=500)
print(len(RUN["spans"]))                 # 2
print([s["name"] for s in RUN["spans"]])  # ['planner', 'writer']
Exercise 2 · Core feature: the @traced decoratorIntermediate

Context: Instrumentation is orthogonal to business logic, so a decorator is the right seam. It must time the wrapped function and record a span even when the function raises, then re-raise.

Your task: Turn record into a @traced(name, tokens) decorator that auto-times a function and appends a span, capturing the error before re-raising.

Requirements:

  • Uses functools.wraps to preserve the wrapped function
  • Times with a monotonic clock and stores milliseconds
  • A raised exception is recorded in the span, then re-raised
  • The span is appended in a finally so it's never lost
  • Demonstrate a span captured for a function that raises

💡 Hint: try/except/finally is the shape: return in try, record the error in except and re-raise, and append the span in finally so timing survives an exception.

Show solution

Design. A decorator is the right seam: instrumentation is orthogonal to logic. " "Use try/except/finally so a raised error is still captured before it propagates.

import time, functools
RUN = {"spans": []}
def reset(): RUN["spans"] = []

def traced(name, tokens=0):
    def deco(fn):
        @functools.wraps(fn)
        def wrap(*a, **k):
            span = {"name": name, "tokens": tokens, "error": None}
            t0 = time.perf_counter()
            try:
                return fn(*a, **k)
            except Exception as e:
                span["error"] = str(e); raise
            finally:
                span["ms"] = round((time.perf_counter() - t0) * 1000, 3)
                RUN["spans"].append(span)
        return wrap
    return deco

@traced("boom", tokens=10)
def boom(): raise ValueError("nope")

reset()
try: boom()
except ValueError: pass
print(RUN["spans"][0]["error"], "ms" in RUN["spans"][0])  # nope True
Exercise 3 · Harder variant: nested spans + a run treeAdvanced

Context: Real workflows nest — a writer calls a summarizer — so spans need a parent to form a tree. A stack of active span ids gives each new span its parent, and depth gives indentation.

Your task: Track a parent stack so each span carries a parent, and render the run as an indented tree; handle a nested call that errors.

Requirements:

  • Each span gets a stable id and a parent (the current stack top)
  • The decorator pushes on enter and pops in finally
  • Depth is the ancestor count, used for indentation
  • A nested error still pops the stack correctly
  • Render the run as an indented tree

💡 Hint: On enter, the parent is whatever is on top of the stack; push self, and always pop in finally so an exception can't corrupt the parent chain.

Show solution

Design. Keep a stack of active span ids. On enter, the current top is the parent; push self; " "on exit (finally) pop. Depth = ancestor count -> indentation.

import time, functools
RUN = {"spans": []}
STACK = []
def reset(): RUN["spans"].clear(); STACK.clear()

def traced(name):
    def deco(fn):
        @functools.wraps(fn)
        def wrap(*a, **k):
            sid = len(RUN["spans"])
            span = {"id": sid, "name": name,
                    "parent": STACK[-1] if STACK else None, "error": None}
            RUN["spans"].append(span); STACK.append(sid)
            t0 = time.perf_counter()
            try: return fn(*a, **k)
            except Exception as e: span["error"] = str(e); raise
            finally:
                span["ms"] = round((time.perf_counter()-t0)*1000, 2)
                STACK.pop()
        return wrap
    return deco

@traced("summarize")
def summarize(): return "s"
@traced("writer")
def writer(): return summarize() + "!"

reset(); writer()
def depth(s):
    d = 0; p = s["parent"]
    while p is not None: d += 1; p = RUN["spans"][p]["parent"]
    return d
for s in RUN["spans"]:
    print("  " * depth(s) + s["name"])   # writer / (indented) summarize
Exercise 4 · Subtle correctness: PII redaction before storage + p95 latencyExpert

Context: Two production-grade refinements. A leak of PII into the trace store is a breach even if never displayed, so redaction must happen at record time; and operators page on p95, not the mean.

Your task: Redact SSN and 16-digit-card patterns in span I/O before storage, and add a p95_ms to the rollup.

Requirements:

  • A regex redacts SSN and 16-digit numbers to a marker
  • Redaction runs at record time, before anything is stored
  • p95 uses nearest-rank on the sorted values and is safe on tiny samples
  • The rollup reports step count, total ms, p95 ms, and error count
  • Show p95 tracks the tail where the mean would hide it

💡 Hint: For p95 on a small list, sort and index ceil(0.95·n)−1, clamped to the last element; redact before append, never at display time.

Show solution

Design. Redaction must happen at record time (a leak into storage is a breach even if never " "displayed). p95 needs care on small samples: index ceil(0.95*n)-1 on the sorted list.

import re, math
PII = re.compile(r"\b(\d{3}-\d{2}-\d{4}|\d{16})\b")

def redact(v): return PII.sub("[REDACTED]", str(v))

def p95(values):
    if not values: return 0.0
    xs = sorted(values)
    idx = math.ceil(0.95 * len(xs)) - 1        # nearest-rank, clamp to last
    return xs[max(0, min(idx, len(xs) - 1))]

def rollup(spans):
    ms = [s["ms"] for s in spans]
    return {"steps": len(spans),
            "total_ms": round(sum(ms), 2),
            "p95_ms": round(p95(ms), 2),
            "errors": sum(1 for s in spans if s["error"])}

print(redact("ssn 123-45-6789 card 1234567812345678"))
spans = [{"ms": m, "error": None} for m in [5,6,7,8,9,10,100]]
print(rollup(spans))   # p95 tracks the tail (100), mean would hide it
Exercise 5 · Production concerns: sampling + a cost budget alarmProfessional

Context: You can't store every span at scale and cost can run away, so add head sampling that keeps runs wholly or not at all — but never drops a failing run — plus a per-run budget alarm.

Your task: Add deterministic head sampling (keep 1-in-N runs, always keep errors) and a budget guard that flags a run whose estimated cost exceeds a ceiling.

Requirements:

  • Sampling keys on a stable run id so a trace is kept or dropped whole
  • Runs that errored bypass sampling and are always kept
  • Cost = tokens × price, compared to a per-run ceiling
  • The budget guard emits a routable alarm when over budget
  • Show ~1-in-N kept, an error always kept, and an over-budget alarm

💡 Hint: Hash the run id modulo N for a deterministic keep/drop, but short-circuit to keep when the run errored — you always want the failing traces.

Show solution

Design. Sample on a stable key (run id hash) so a run is wholly kept or dropped — never " "half a trace. Errors bypass sampling (you always want failing traces). Cost = tokens x price; compare " "to a per-run budget and emit an alarm the operator can route.

PRICE = 0.00001                     # $/token
def should_keep(run_id, sample_n, had_error):
    if had_error: return True                       # never drop failures
    return (hash(str(run_id)) % sample_n) == 0      # deterministic 1-in-N

def budget_alarm(spans, ceiling_usd):
    cost = sum(s.get("tokens", 0) for s in spans) * PRICE
    over = cost > ceiling_usd
    return {"cost_usd": round(cost, 4), "over_budget": over,
            "alarm": "PAGE" if over else None}

kept = [rid for rid in range(10) if should_keep(rid, 5, had_error=False)]
print("kept run ids:", kept)                 # ~1-in-5
print(should_keep("r7", 5, had_error=True)) # True -- error always kept
print(budget_alarm([{"tokens": 900000}], ceiling_usd=5.0))  # over_budget True
Exercise 6 · Real-world: SLO burn-rate alerting over a rolling windowIndustry scenario

Context: Turn the tracer into an on-call tool. A single threshold flaps; Google's SRE pattern requires two windows to agree — a short window catches fast burns, a long window suppresses noise.

Your task: Given a stream of runs, compute SLO compliance over a rolling window and fire a multi-window burn-rate alert (fast and slow windows must both exceed the multiplier).

Requirements:

  • Track error observations in a short and a long rolling window
  • Error budget = 1 − SLO
  • Burn rate = observed error fraction ÷ error budget
  • Page only when both windows exceed the burn-rate multiplier
  • A single spike in the short window alone does not page

💡 Hint: Use two deque(maxlen=...) windows and require both burn rates to clear the multiplier before paging — that agreement is what stops the alert from flapping.

Show solution

Design. A single threshold flaps; the SRE pattern requires two windows to agree — a " "short window catches fast burns, a long window suppresses noise. Burn rate = observed error fraction / " "error budget. Page only when both short and long windows exceed the multiplier.

from collections import deque

class BurnRateAlerter:
    def __init__(self, slo=0.99, short=5, long=60, mult=14.4):
        self.budget = 1 - slo          # allowed error fraction
        self.short = deque(maxlen=short)
        self.long = deque(maxlen=long)
        self.mult = mult

    def observe(self, errored: bool):
        e = 1 if errored else 0
        self.short.append(e); self.long.append(e)

    def _burn(self, window):
        if not window: return 0.0
        rate = sum(window) / len(window)
        return rate / self.budget if self.budget else 0.0

    def page(self):
        # multi-window: both must exceed the multiplier -> real, fast burn
        return self._burn(self.short) >= self.mult and \
               self._burn(self.long) >= self.mult

a = BurnRateAlerter(short=5, long=10)
for _ in range(10): a.observe(errored=True)    # sustained outage
print(a.page())                                 # True -- both windows burning
b = BurnRateAlerter(short=5, long=10)
for i in range(10): b.observe(errored=(i == 0)) # one blip
print(b.page())                                 # False -- doesn't flap

✓ You are done when…

  • python workflow.py prints a run tree and metrics.
  • python -m pytest tests/ -v shows 6 passed.
  • A failing step is findable via failing_step().
  • You know the LangSmith swap is a one-line decorator change.
📁 Your finished folder
observability/
├─ .venv/
├─ requirements.txt
├─ trace.py            (@traced decorator + redact)
├─ metrics.py          (rollup + failing_step)
├─ workflow.py         (traced multi-step workflow)
└─ tests/
   └─ test_trace.py    (6 offline tests)
📋 Staff-level self-scoring — can you actually operate this system?
DimensionMeets the barAbove the bar
Trace completenessEvery step is captured as a nested span with I/O, tokens, and timing; failures point to the right span.You can expand any failed run and land on the exact failing span across agents — no guessing.
Cost signalPer-run and per-agent token/cost is tracked; you know where the budget goes.Cost is attributed per agent and alerted on spikes; regressions after a change are caught, not billed.
Latency signalPer-run and per-agent latency is measured; slow spans are visible.Latency regressions after a change are caught automatically; the slowest span is always findable.
Online quality signalLive outputs are scored (faithfulness/quality) on a sample; drift is visible over time.Online evals gate nothing but watch everything; a score drop alerts before customers complain.
Alerting is preciseAlerts exist for quality drops, cost spikes, and error bursts.Alerts fire on real problems, not noise; you can state the alert precision and the last false alarm.
Trace hygiene & MTTRPII is redacted before storage; the trace store has access controls; high-volume traces are sampled.Time-to-debug (MTTR) is measured — a trace leads to root cause fast — and full traces are kept for all errors.

Score each row 0 (missing) / 1 (meets) / 2 (above). 0–4: a prototype — keep building. 5–8: a solid build you could take to review. 9–12: staff-level — production-defensible. Any dimension at 0 blocks shipping regardless of the total.

Knowledge check check yourself

✓ Knowledge check

In the tracer, why is each span appended in a finally block, and why does that detail matter for debugging?

Show answer
The finally block runs whether the step succeeds or raises, so a failing step's span (with its error and timing) is still recorded instead of vanishing. That guarantee is what lets you jump straight to the broken step instead of guessing across many agents.
✓ Knowledge check

The project distinguishes offline evals from online evals. What does each one do, and why aren't they interchangeable?

Show answer
Offline evals run a fixed test set to gate deploys on known cases; online evals score sampled live production traffic to catch drift, new failure modes, and edge cases a test set never had. Offline evals gate deploys, online evals watch production — you need both.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in