AI EngineeringZero to ProductionHome·About·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

"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

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 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 Aagent B + toolagent Cresult. 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

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

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

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

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

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 comprehensionssum(... 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