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.
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 spot | Observability 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 |
2 · Architecture advanced
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
| Risk | Control |
|---|---|
| 🔴 Silent quality regression after a change | Online evals score live traffic; alert on score drop; compare against a baseline |
| 🔴 Runaway cost undetected | Per-run token/cost tracking; budget alerts; cost attribution per agent |
| 🟠 Undebuggable failures | Full trace of every step with I/O; jump straight to the failing span |
| 🟠 Sensitive data captured in traces | Redact PII before logging; access controls on the trace store (O4) |
| 🟠 Observability overhead / cost | Sample high-volume traces; keep full traces for errors & a sample of successes |
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}
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.
from langsmith import traceablepulls 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.@traceable(run_type="chain")onresearch_agentmeans: every time this agent runs, capture a span (one entry in the trace). Becauseretrieveandllm_answerare called inside it, their spans nest underneath automatically — that's the tree you inspect later.- 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.
5 · Observability surface advanced
| Component | Does | Note |
|---|---|---|
| Tracing (LangSmith / OTel) | Capture the full step tree per run | 🟢 the control plane (I4/O4) |
| Metrics | Latency, tokens, cost, error rate — per agent | 🟢 aggregated over runs |
| Online evals | Score live outputs (faithfulness, quality) | 🟠 sampled to control cost |
| Dashboards & alerts | Surface drift, cost spikes, error bursts | 🟢 the operator's view |
| Trace redaction | Strip PII before storage | 🔴 required for customer data |
6 · Evaluation expert
| Eval / metric | Measures |
|---|---|
| Trace completeness | Every step captured; failures point to the right span |
| Cost/latency per run & per agent | Where the budget goes; regressions after changes |
| Online quality score | Faithfulness/quality on live traffic over time (drift) |
| Alert precision | Alerts fire on real problems, not noise |
| MTTR (time-to-debug) | How fast a trace leads you to the root cause — the payoff |
7 · Phased rollout expert
Skills & course map expert
| Skill | Learn it in |
|---|---|
| Multi-agent workflows & state | L4 · L5 |
| Tracing with LangSmith | I4 |
| Monitoring, governance, OTel | O4 |
| The workflow being observed | Project 13 |
| Offline + online evals | Ch 5 |
| Cost, scaling, deploy | O3 · Ch 6 |
By the end you will have
- A
@traceddecorator 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
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.
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
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.
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.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.traced(name, tokens=0)is the decorator. When you write@traced("planner", tokens=300)above a function,decothenwrapreplace it with a version that does bookkeeping around the real call.@functools.wraps(fn)just keeps the original function's name/docs.- Inside
wrap: it builds thespan(recording the redacted input and token count), starts a timer withtime.perf_counter(), then runs the real function in atry. On success it saves the redacted output; on failure it saves the error message and re-raises. - The
finallyblock runs no matter what — success or crash — recording the elapsedmsand appending the span toRUN. 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.
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.
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
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.
PRICE_PER_TOKEN = 0.00001is a made-up price so we can turn token counts into dollars. Real prices differ per model, but the math is identical.rollup(run)readsrun["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.cost_usdmultiplies the total tokens across all spans by the price per token.round(..., 2)andround(..., 4)just trim the numbers to a readable length.failing_step(run)walks the spans in order and returns thenameof the first one whoseerrorisn't empty, orNoneif 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.
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))
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.
from trace import traced, RUN, resetandfrom metrics import rollup, failing_steppull in the tools you just built. This is why the files must live in the same folder.- 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 onresearcheris a hint that research will dominate cost. run(question)is the workflow: it callsreset()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.- The
if __name__ == "__main__":block runs only when you execute the file directly. It prints the report, then loops overRUN["spans"]to print a readable run tree (name, ms, tokens, error per step), then prints therollupmetrics.
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.
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}
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.
- The first line is the workflow's real output: the joined report.
- Under
--- run tree ---, each line is one span: the stepname, how long it took inms, its token count, and its error (None= no error). Noticeresearcherappears twice — once per sub-question — which matches the loop inrun(). - Under
--- metrics ---, therollupdictionary summarises the whole run:4steps, near-zero total time (the functions do no real work),0errors, and$0.0322estimated 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.
Step 5 · Tests (no key) expert
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"
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.
setup_function()runs before every test and callsreset(), so spans from one test can't leak into the next — a common source of confusing failures.test_span_records_io_and_mswraps a trivial function, calls it, and checks the recorded span has the right name and a timing field.test_error_span_is_capturedmakes a function thatraises, wraps the call inpytest.raises(ValueError)(which says 'we expect this to throw'), then asserts the error was still recorded — proving thefinallyworks.test_rollup_counts_and_costscalls a traced function twice and checks the metrics say 2 steps, 0 errors, and a positive cost.test_pii_redacted_before_storepasses in a fake SSN and asserts"[REDACTED]"ended up in the stored input.test_failing_step_is_findableruns one good step and one bad step and assertsfailing_step(RUN)returns"bad"— the payoff of the whole project.test_redact_leaves_clean_textconfirms 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.
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
| Test | Proves |
|---|---|
| span records I/O + ms | every step is observable |
| error span captured | failures appear in the trace, don't vanish |
| rollup counts + costs | per-run metrics are correct |
| PII redacted before store | customer data never lands in a span |
| failing step findable | you can pinpoint the broken step — the payoff |
| redact leaves clean text | redaction 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:
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}"
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.
- In the terminal,
pip install langsmithadds the real library andexport LANGSMITH_API_KEY=...gives it credentials (like the API keys used elsewhere in the course). - In
workflow.py, you swapfrom trace import tracedforfrom langsmith import traceable, and change the decorator to@traceable(run_type="chain"). The function body doesn't change at all. - 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.
@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
| What you see | What it means & the fix |
|---|---|
ModuleNotFoundError: trace | Run pytest from inside observability/. |
| Failures don't appear in spans | The span must be appended in a finally block, as written. |
| Spans leak between runs | Call reset() at the start of each run (and in setup_function). |
| PII in a span | Confirm redact() wraps both input and output before storing. |
Name clash with stdlib trace | Run 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.
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']
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.wrapsto 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
finallyso 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
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
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
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
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.pyprints a run tree and metrics.python -m pytest tests/ -vshows 6 passed.- A failing step is findable via
failing_step(). - You know the LangSmith swap is a one-line decorator change.
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)
| Dimension | Meets the bar | Above the bar |
|---|---|---|
| Trace completeness | Every 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 signal | Per-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 signal | Per-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 signal | Live 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 precise | Alerts 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 & MTTR | PII 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
In the tracer, why is each span appended in a finally block, and why does that detail matter for debugging?
Show answer
The project distinguishes offline evals from online evals. What does each one do, and why aren't they interchangeable?