Evaluation and Tracing with LangSmith
O4 taught you to trace and evaluate agents by hand. LangSmith is the productized version: a platform that captures every step of every run, turns them into datasets, runs evals against them, and lets you compare versions — closing the LLMOps loop with a real tool instead of print statements.
LANGSMITH_API_KEY) plus LANGSMITH_TRACING=true and pip install langsmith, and an API key for whichever model vendor you follow (toggle the Claude | OpenAI tabs on the code below):- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install openai
.env file (never in code) and let the zero-argument client pick it up from the environment.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.Learning objectives
- Explain what LangSmith adds over hand-rolled tracing (O4) and evals (Ch 5).
- Instrument an app to send traces, framework or not.
- Turn real traces into datasets and run offline evaluations.
- Use LLM-as-judge and custom evaluators inside the platform.
- Compare prompt/model versions and wire an eval gate into CI (O3).
30-second recap
- Evaluation & testing: A golden set of real questions plus cheap deterministic checks and an LLM judge, run as a regression gate before every change ships.
Why a platform beats print statements advanced
Picture the 2am page you don't want. Your agent gave a customer a confidently wrong answer, the ticket's been escalated, and all you have to reconstruct what happened is a wall of print() output interleaved from a dozen concurrent requests. You grep for the user's question, find three matching lines, and realize you never logged which tool the agent called or what the retriever returned. The run is gone. That helpless feeling — I can't see what my own system did — is the exact problem an observability platform exists to kill.
Here's the honest framing, because it's easy to feel like reaching for a platform is admitting your O4 code wasn't good enough. It was good enough — for learning. Rolling your own span() helper in O4 was the right way to understand tracing, the same way building a stack from scratch teaches you what a stack is. But hand-rolled logging hits a wall the moment you have real traffic: thousands of runs you need to search, two prompt versions you need to compare across a hundred cases, a product manager who wants to inspect a failure without reading Python. Those aren't code problems you can out-clever — they're storage, search, and UI problems, and building that yourself means building a second product alongside your actual one.
The analogy that makes this click: think of the difference between writing print(timestamp) in your code versus wiring up a real logging service like Datadog or CloudWatch. Both record events — but one is a string in a terminal that scrolls away, and the other is durable, indexed, queryable, and shareable across a team. LangSmith is that same jump, applied specifically to LLM runs. It doesn't invent a new idea; every concept — the trace tree, the span, the golden set, the LLM judge — you already met in O4 and Chapter 5. What it adds is the boring, essential infrastructure that turns those concepts from "a thing you did once by hand" into "a thing your whole team does continuously at scale."
The common beginner mistake is treating tracing as an afterthought — something you bolt on when a bug appears. By then it's too late: the run you needed to see already happened untraced. The discipline is to trace from the start, so the data is already sitting there when the incident lands. The other half of the mistake is thinking tracing and evaluation are separate chores. They aren't — and that unity is the platform's real trick. The traces you capture become your evaluation dataset, and your evaluations run over that same trace data. Capture is the foundation everything else stacks on; read the table below as a job-by-job map from O4/Ch 5 to what the platform hands you, but keep that one-loop picture in mind rather than seeing five disconnected features.
Your O4 span() helper works — until you have thousands of runs, want to search them, compare two prompt versions across a hundred cases, or let a non-engineer inspect a failure. Hand-rolled logging doesn't scale to those. A tracing/eval platform does: it stores runs durably, makes them searchable and visual, and connects tracing to evaluation so the two reinforce each other.
print() is a string in a terminal that scrolls away and can only be grepped; a platform stores the same run durably, indexed and shareable — the storage/search/UI problem you don't want to build yourself.
Read it as two panels side by side: the same LLM runs, handled two ways. Left is what you get with bare print(); right is what a platform hands you.
- The left (red) panel stacks two
print(run)lines that flow down a dashed arrow into "scrolls away · grep-only · gone" — the run isn't stored, so once the terminal scrolls it's lost, and search means grepping if you happened to log the right field. - The right (green) panel shows the same runs landing in a durable store that is searchable, visual, and shared across the team — indexed, queryable, and replayable later.
- The payoff is the contrast: the platform doesn't add a new idea, it solves the boring storage, search, and UI problem that hand-rolled logging never can at scale.
In short: print = a string that scrolls away; a platform = the same run made durable, searchable, and shareable.
| Job (from O4/Ch 5) | By hand | With LangSmith |
|---|---|---|
| See one run's steps | Read log lines, reconstruct the tree | Visual trace tree, timed & costed per step |
| Search runs | grep, if you logged the right fields | Filter/search over all runs |
| Build a golden set | Copy inputs into a file manually | Promote real traces into a dataset in a click |
| Run evals | Write a harness (Ch 5) | Run evaluators over a dataset; store scores |
| Compare versions | Eyeball two outputs | Side-by-side diff across the whole dataset |
Lab I4.1 · Tracing an app advanced
Everything in this chapter is built on one primitive, so it's worth dwelling on: the trace. If you skip capturing runs, you have nothing to search, nothing to promote into a dataset, nothing to score, and nothing to monitor. That's why this is Lab I4.1 and not an afterthought — get capture right and the rest of the platform simply falls out of it. A trace is just a faithful recording of what one run of your code did: the input it received, the output it produced, how long each step took, how much it cost in tokens, and where — if anywhere — it blew up.
Think of it like the black box flight recorder on an aircraft. Nobody wants the plane to crash, but if it does, the recorder is the difference between "we have no idea what happened" and "engine two failed at 04:12, here's the exact sequence." Your LLM app will produce surprising outputs — that's the nature of a probabilistic system — and the trace is the recorder that lets you replay the surprise instead of shrugging at it. The tempting shortcut is to reach for tracing only after something breaks; the discipline, as with a flight recorder, is that it's already running before anyone needs it.
The one idea that trips people up here: a trace is not a single log line, it's a nested tree of spans. A span is one timed step — the model call, the retriever lookup, a tool invocation — and spans nest inside each other the way function calls do. Your top-level answer() is the root; the retrieve step and the model call are its children; a tool the model triggers is a grandchild. This is exactly the step-tree you built by hand in O4, and it's what lets you answer "where did the time go?" and "which step failed?" instead of just "the run was slow" or "the run errored." A flat log can't do that; a tree can.
Here's the part that feels almost too easy: you get that whole tree from a single decorator. LangSmith auto-instruments LangChain and LangGraph with zero code changes (just environment variables), and for everything else — including the raw-SDK apps you wrote back in C2 — it gives you @traceable, a one-line wrapper you place above any function. Decorate your model call, your retriever, and each tool, and the platform assembles the nested tree for you and stores it durably. The lab below is the smallest possible version: one decorated function, one call, one fully-captured run.
The foundation is capturing runs. LangSmith auto-instruments LangChain/LangGraph, and offers a decorator/wrapper for arbitrary code — so even your raw-SDK apps (C2) get traced.
Read it top to bottom, the way calls nest: the box at the top is the whole run, and everything below it is a step that happened inside.
- The top box,
answer(), is the root span — the entire run, timed at 255 ms. - The two arrows down to
retrieve(40 ms) andmodel call(210 ms) are its children — the timed steps it ran, exactly the step-tree from O4. - The arrow from
model calldown totool call(12 ms) is a grandchild: a tool the model triggered, nested one level deeper. - The payoff is the per-step timing: because the tree carries each span's duration, you see the 210 ms model call is where the time went — a flat log would only tell you "the run took 255 ms."
In short: spans nest like function calls, so the trace tells you which step was slow or failed — not just that the run was.
shellpip install langsmith
export LANGSMITH_API_KEY=... # plus LANGSMITH_TRACING=true
A traced model call — in Claude or OpenAI
LangSmith's @traceable decorator wraps any function, so the tracing scaffolding is identical for both vendors — only the model call inside changes. Claude uses client.messages.create(..., messages=[…]) and reads the first text block; OpenAI uses client.responses.create(..., input=…) and reads r.output_text. Toggle the tab to switch SDK.
trace.pyfrom langsmith import traceable
from anthropic import Anthropic
client = Anthropic()
@traceable # captures inputs, outputs, timing, errors
def answer(question: str) -> str:
r = client.messages.create(
model="claude-opus-4-8", max_tokens=512,
messages=[{"role":"user","content": question}],
)
return next(b.text for b in r.content if b.type == "text")
answer("What is prompt caching?") # run appears in LangSmith, fully traced
trace.pyfrom langsmith import traceable
from openai import OpenAI
client = OpenAI()
@traceable # captures inputs, outputs, timing, errors
def answer(question: str) -> str:
r = client.responses.create(
model="gpt-5.5", max_output_tokens=512,
input=question,
)
return r.output_text
answer("What is prompt caching?") # run appears in LangSmith, fully traced
This is the smallest possible traced app. "Tracing" means recording what your code did on each run — the inputs it got, the output it produced, how long it took, and any error — so you can inspect it later instead of guessing. Here a single decorator turns an ordinary function that calls the model into one whose every run is captured by LangSmith.
- The two
importlines bring intraceable(LangSmith's recorder) and the model client —Anthropicfor Claude,OpenAIfor OpenAI (toggle the tab above). The client is built once so the function can reuse it. @traceableis a decorator — a line placed directly above a function that wraps it with extra behaviour. This one records a span (one timed step) every timeanswer()runs: what question came in, what text went out, how many milliseconds it took, and whether it crashed. You did not have to write any logging.- Inside, the model call (
client.messages.create(...)on Claude,client.responses.create(...)on OpenAI) is the actual request — it names the model, caps the reply length, and passes the user'squestion. - The return line reads the answer out of the reply — the first text block via
next(b.text ...)on Claude, orr.output_texton OpenAI. That returned string is what LangSmith records as this span's output. - The last line,
answer("What is prompt caching?"), simply calls the function once. Because it is decorated, that single call now shows up as a full trace in the LangSmith UI.
What the output means: Nothing dramatic prints locally — the payoff is remote: a new run appears in LangSmith showing the input question, the model's answer, the timing, and the token cost, all captured automatically.
Try this: Add a second @traceable function (say a fake retrieve() step) and call it from inside answer(). LangSmith will nest the two into a tree — parent answer with retrieve as a child — which is the whole trace-tree idea from O4, built for you.
@traceable nests — that's the whole trace treeDecorate your model call, your retriever, and each tool, and LangSmith assembles the nested tree from O4 automatically: which step ran, how long, what it cost, what errored. For a LangChain/LangGraph app you often get this with zero code changes — just the env vars. It's your hand-rolled span(), but it builds the tree for you and stores it durably.Lab I4.2 · From traces to datasets advanced
Where do the test cases for an LLM app actually come from? This is the question that quietly sinks most evaluation efforts. The instinct is to sit down and invent a golden set — dream up twenty questions you think users will ask, write down what you think the right answers are, and call it a test suite. It feels productive. It's also a trap, because you're testing the app against your imagination of how it's used, not how it's actually used. The cases that break in production are almost always the ones you'd never have thought to write: the weirdly-phrased question, the edge-case document, the input in a language you forgot to handle.
LangSmith's answer — and the single most important habit in this chapter — is to build your evaluation set from real traces instead of from guesses. Because you're already capturing every run (Lab I4.1), you have a growing pile of real inputs and real outputs. When a run is interesting — a genuinely great answer worth locking in, or a production failure worth never repeating — you promote it into a dataset with one click. The reference answer you attach is the correct output, which for a failure means the answer it should have given. Your golden set is now grown from the field, not the whiteboard.
The mental model to hold is a flywheel, not a pipeline. A pipeline runs once, start to finish, and stops. A flywheel is a loop that gets heavier and harder to knock over every time it turns: traces feed the dataset, the dataset feeds evaluations, and the failures those evaluations surface feed back into the dataset as new permanent cases. Each turn, your test suite gets a little more comprehensive and your app a little harder to break. This is the productized form of the O4 habit "every incident becomes a test case" — LangSmith just makes promoting a trace to a golden case a button instead of a copy-paste chore.
The common mistake worth naming is treating the dataset as a one-time artifact you build at the start of the project and never touch again. A static golden set rots: your app changes, your users change, new failure modes appear, and a test suite frozen in month one stops catching anything by month six. The whole point of the loop below is that the dataset is living — every real failure you find becomes a case that can never silently regress again. Read the diagram as exactly that: a circle, with the dashed arrow closing the loop being the part that matters most.
The platform's superpower is the loop between the two halves: real traces become your evaluation dataset. When a run is interesting — a great answer, or a production failure — you promote it into a dataset. Over time your golden set (Ch 5) is built from real usage, not guesses.
This diagram shows the flywheel at the heart of LangSmith: real runs feed a dataset, the dataset feeds evaluations, and the failures those evaluations find get promoted back into the dataset — so the system keeps getting harder to break.
- Read it left to right. The first green box,
prod traces, is the real runs your live app produced (captured by the tracing from Lab I4.1). - The arrow into
dataset(the "golden set") means you promote interesting runs — great answers, or bad ones — into a saved collection of test cases. This is your evaluation data, built from real usage instead of guesswork. - The next arrow into
evaluation("scored") means the evaluators from Lab I4.3 run over that dataset and produce scores. - The curved dashed arrow looping back from evaluation to the first box is the key part: when an eval surfaces a failure, you add that case to the dataset. The red caption spells it out — failures found → promoted to the dataset → can't regress again.
In short: It is a loop, not a line: trace → collect → evaluate → feed failures back. Every bug you find becomes a permanent test, so the same mistake can never sneak back in.
Lab I4.3 · Running evaluators expert
A dataset alone doesn't tell you anything — it's just questions and answers sitting in a drawer. The thing that turns it into a signal is an evaluator: a small function that looks at what your app produced for a given input and scores how good it was. Without evaluators, "we have 200 golden cases" is a filing cabinet; with them, it's a quality gauge you can read at a glance. This section is where the two halves of the platform finally connect — the traces you captured became a dataset, and now the dataset gets graded.
The cleanest way to think about an evaluator is that it's exactly like a unit test's assert, but returning a score instead of pass/fail — and crucially, one that can handle the fact that language has no single right answer. A normal test asserts add(2, 2) == 4 and that's the end of it. But "was this support answer helpful?" has no ==. So evaluators come in a spectrum of strictness, and matching the evaluator to the question is the real skill. Some questions do have an exact answer (did it return valid JSON? does the output contain the order number?) — for those you write a plain deterministic rule, fast and free and perfectly reliable. Other questions are irreducibly fuzzy (was the tone right? is the answer grounded in the retrieved context?) — for those you reach for an LLM-as-judge: a second model call that reads the output and grades it against a rubric, exactly the Chapter 5 technique.
The mistake nearly everyone makes is jumping straight to an LLM judge for everything because it feels the most "AI." It's the wrong default. A judge is slower, costs money on every example, and — this is the part that bites — is itself a fallible LLM that can be confidently wrong about whether your answer was good. If a cheap deterministic rule can answer the question ("does the JSON parse?"), use it: it's faster, free, and never hallucinates. Save the judge for the genuinely subjective calls it's the only tool for, and even then, spot-check its verdicts against your own the way Chapter 5 warned. The platform's contribution isn't deciding what to measure — that judgment stays yours — it's giving you a place to run whatever evaluators you write across the whole dataset and store every score over time.
The beautiful part of the interface you'll see below is that all three flavours — deterministic, judge, and anything custom you dream up — share one shape: a function that takes the run and the example and returns a {"key": ..., "score": ...} dict. Because a custom evaluator is just "any Python you can write," a latency budget, a cost cap, or a tool-call-correctness check all become first-class tracked metrics with no special API. Write the rule; the platform charts it.
With a dataset, you run evaluators over it — the same eval types from Chapter 5, executed and scored by the platform.
{"key", "score"} dict, so the platform charts them identically.
Read the top two panels as a fork — you pick a lane based on the kind of question — then follow both arrows down to the one shape they share.
- The left (green) panel is for questions with an exact answer (does the JSON parse? does the output contain the order number?): use a deterministic rule — fast, free, and it never hallucinates.
- The right (amber) panel is for irreducibly fuzzy questions (is the tone right? is the answer grounded?): reach for an LLM-as-judge — but note the cost, the latency, and that the judge is itself a fallible model.
- The two arrows converge on the bottom box: whichever flavour you write — deterministic, judge, or any custom Python — it returns the same
{"key", "score"}dict, which is why the platform can run and chart them all the same way.
In short: pick the cheapest evaluator that can answer the question; the judge is a last resort, not the default.
Requires: pip install langsmith
evaluate.pyfrom langsmith import evaluate
def exact_match(run, example): # deterministic evaluator
return {"key":"correct",
"score": run.outputs["output"] == example.outputs["expected"]}
def grounded_judge(run, example): # LLM-as-judge evaluator (Ch 5)
verdict = judge_model_says_grounded(run.outputs, example.inputs)
return {"key":"grounded", "score": verdict}
evaluate(
my_app, # the function/chain under test
data="my-golden-set", # the dataset
evaluators=[exact_match, grounded_judge],
) # scores stored & visualized per example
Once you have a dataset (a saved list of example inputs plus their expected answers), an evaluator is a small function that scores how well your app did on each example. This block defines two evaluators and then runs them across the whole dataset with one call to evaluate().
exact_match(run, example)is a deterministic evaluator — no AI, just a fixed rule. Every evaluator receives therun(what your app actually produced) and theexample(the saved input + expected answer). It comparesrun.outputs["output"]toexample.outputs["expected"]and returns a score ofTrue/False.- The returned dict —
{"key": "correct", "score": ...}— is the required shape:keynames the metric so LangSmith can chart it, andscoreis the value. grounded_judge(run, example)is an LLM-as-judge evaluator: instead of an exact rule it asks another model whether the answer was grounded in the input. That is the Chapter-5 idea — use a model to grade subjective quality — plugged into the same score-dict interface.evaluate(my_app, data="my-golden-set", evaluators=[...])ties it together: runmy_appon every example in the dataset namedmy-golden-set, then apply both evaluators to each result. LangSmith stores and visualises the scores.
What the output means: For each example you get two scores — a true/false correct and a grounded verdict — aggregated in the LangSmith UI so you can see, at a glance, what fraction of your golden set the current version passes.
Try this: Add a third evaluator that checks a latency budget — return {"key":"fast","score": run.latency < 2.0} — and pass it in the evaluators list. Any Python rule you can write becomes a tracked metric.
| Evaluator type | What it checks | From… |
|---|---|---|
| Deterministic | Exact match, regex, JSON-schema validity, contains-X | Ch 5 deterministic evals |
| LLM-as-judge | Groundedness, helpfulness, tone, rubric adherence | Ch 5 LLM-judge |
| Custom | Any Python: latency budget, cost cap, tool-call correctness | Your logic |
| Human | Manual annotation queues for the subjective calls | Human review (O4) |
Comparing versions & the eval gate expert
Here's a scenario every LLM engineer lives through. You tweak a prompt — add one sentence, reword an instruction — run it against a couple of examples in your head, the outputs look better, and you ship it. A week later someone reports the assistant is now getting a category of question wrong that it used to nail. Your innocent one-sentence change fixed the case you were looking at and quietly broke three you weren't. This is the single most common way LLM apps regress, and it happens because prompt changes are invisible: there's no compiler error, no failing test, nothing to warn you that helping here hurt there.
The fix is to stop trusting your eyes and start trusting the dataset. Because you've stored evaluators and scores, you can run version A (today's app) and version B (your change) over the same golden set and compare them head to head. This is just A/B testing, the discipline that's been standard in web engineering for decades — you don't guess whether the new checkout button converts better, you measure it against the old one on the same traffic. Version comparison brings that rigor to prompts and models: change one thing, re-run the eval, read the result.
But the crucial word is per-example, and this is the part beginners miss. The lazy comparison looks at one number — "version A scored 0.82, version B scored 0.85, ship B." That average is a liar. A version can raise the mean while breaking a handful of specific cases, because the improvement on many easy examples mathematically drowns out a catastrophic regression on a few critical ones. Imagine B gets 5% better at FAQ questions but suddenly fails the one safety-critical case that must never fail — the average still goes up, and you'd have shipped a disaster. A per-example, side-by-side diff catches exactly this: you see the row that went from pass to fail, in red, no matter what the mean did. Always read the rows, not just the total.
Once you're comparing on data, the last step writes itself: turn the comparison into a gate. This is the O3 eval-gate discipline made concrete — the same idea as a CI test suite that blocks a merge when a test fails. Wire the evaluate() call into your pipeline so every prompt or model change re-runs the golden set, and fail the build if the score drops below threshold or any critical case regresses. Now no prompt change ships on vibes; it ships only after clearing the dataset. In production the same idea becomes a canary: roll B out to a slice of traffic and confirm it holds before going wide. The diagram traces that whole path — two versions, one dataset, a per-example compare, and a gate on the far end.
The payoff of storing evals: compare two versions of your app across the whole dataset. Change a prompt or swap a model, re-run the eval, and see per-example whether you improved or regressed — the safe-rollout discipline from O3, with data instead of vibes.
This diagram shows how you decide, with data, whether a change is safe to ship. You run two versions of your app over the same dataset and let the scores — not a gut feeling — decide.
- On the left are two boxes,
version A(today's app) andversion B(your change — a new prompt or a swapped model). Both are run against the same golden set. - The arrow leads to
compare("per example"): LangSmith lines the two runs up example by example, not just as one average, so you can see exactly which cases got better and which got worse. - The final arrow reaches
gate— the go/no-go decision. The caption states the rule: ship B only if it doesn't regress the dataset. - Why per-example matters: a version can raise the average score while quietly breaking a few important cases. The side-by-side comparison catches that; a single average would hide it.
In short: This is the O3 "eval gate": compare A vs B on the same data, then let the score gate the deploy. In CI, a regression fails the build automatically — no prompt change ships on vibes.
evaluate() call can run in your pipeline: on every prompt/model change, run the golden-set eval and fail the build if scores drop below threshold. That's the O3 eval gate made concrete — no prompt change ships without clearing the dataset. LangSmith stores the history so you can see quality trend over releases (the O4 continuous-eval idea).Online monitoring expert
You can pass every offline eval and still be shipping a broken app. That sounds contradictory until you see why: your golden set only tests the cases you've collected, and production is a firehose of inputs you've never seen. A model provider silently updates the model behind the API, a new customer segment starts asking questions in a style your dataset doesn't cover, an upstream document source changes format — none of that shows up in an offline eval, because offline evals look backwards at a fixed dataset. The world moves on after your last eval run, and the only way to know is to watch the live system.
The clean way to hold the distinction: offline evaluation is the exam you study for; online monitoring is the job you do afterwards. The exam (your golden set) proves you're ready before you deploy — it catches regressions at the gate. Monitoring proves you're still doing well after, on real work you couldn't have rehearsed. You need both, and they're not interchangeable: passing the exam doesn't mean you'll never make a mistake on the job, and doing the job well doesn't retroactively prove your exam was comprehensive. Skip either and you have a blind spot — either you ship regressions, or you never notice drift.
Because LangSmith is already recording every production run as a trace, monitoring is almost free: it's the same trace data, aggregated into dashboards instead of inspected one run at a time. You watch the four signals from O4 — quality (run a sampled LLM judge over live traffic, since you can't judge every run), cost (tokens add up fast at scale), latency (the P95 your users actually feel, not the average), and errors (tool failures, timeouts, refusals) — and you set alerts so a spike pages you instead of waiting for a customer complaint. This is O4's monitoring pillar with a UI bolted on. And notice the through-line of the whole chapter: monitoring, offline evals, and datasets all run off the same captured traces. That's why Lab I4.1 was the foundation — no traces, no monitoring, no evals, nothing.
Beyond offline evals, LangSmith runs on live traffic too: dashboards over production traces (the four signals from O4 — quality via sampled judges, cost, latency, errors), and alerts. This is O4's monitoring pillar with a UI, closing the loop from "trace one run" to "watch the whole system."
Read the two top boxes as before-vs-after: the same question ("is it good?") asked at two different moments in a deploy's life.
- The left (amber) box is offline eval — you score a fixed golden set before deploy. It's the exam you study for: it catches regressions at the gate.
- The right (green) box is online monitoring — you watch live traffic after deploy. It's the job you do afterwards, on inputs you couldn't have rehearsed.
- The arrow down from monitoring leads to the four O4 signal cells —
qual(a sampled LLM judge),cost,lat(P95 latency), anderrin red — and a spike fires an alert instead of waiting for a complaint. - The muted note is the through-line: both halves run off the same captured traces, so tracing (Lab I4.1) is the foundation everything else stacks on.
In short: offline = exam before deploy; online = watching the job after — both fed by the same traces.
Not the only tool expert
It would be a shame to walk away from this chapter thinking you learned "LangSmith." You didn't — you learned observability for LLM apps, and LangSmith was the concrete tool we used to make it tangible, the way you might learn version control through Git without confusing the two. Tools churn: vendors get acquired, pricing changes, a better product appears, your company standardizes on something else. If your knowledge is "which button to click in LangSmith," it expires the day you switch tools. If your knowledge is "capture traces, build datasets from real runs, run deterministic and judge evaluators, compare versions, gate CI, monitor prod," it transfers to every platform, forever. Learn the jobs, not the UI.
And the jobs really are portable, because the whole space is converging on shared foundations. A growing number of these platforms trace on top of OpenTelemetry — the same open standard used for tracing ordinary microservices — which means your trace data isn't locked to one vendor's format. The landscape includes LangSmith, other hosted observability/eval products, and mature open-source options you can self-host; they differ in polish and integration, not in the fundamental jobs they do.
So how do you actually choose? Exactly the way you reasoned about stack layers in O2 — by your constraints, not by brand or hype. Three questions decide it. First, your stack: if you're deep in LangChain/LangGraph, LangSmith's zero-config auto-instrumentation is a real gravity well; if you're on the raw SDK or a mixed stack, an OpenTelemetry-based tool may fit more cleanly. Second, and often decisive, your data-residency needs: a hosted platform means your prompts and outputs leave your infrastructure, which for a regulated industry can be a non-starter — that's when self-hosted or enterprise deployments earn their cost. Third, budget: usage-based pricing on a high-traffic app adds up, and an open-source self-host trades engineering time for a lower bill. Weigh those three against how well each tool does the six jobs above, and the choice makes itself.
LangSmith is popular and tightly integrated with LangChain/LangGraph, but the capabilities — tracing, datasets, evaluators, version comparison, monitoring — are the transferable knowledge. Other observability/eval platforms and open-source options (including OpenTelemetry-based tracing) offer the same jobs. Pick by your stack, data-residency needs (self-host vs hosted), and budget, exactly as you chose stack layers in O2.
Read it top to bottom: the durable knowledge is on top, the replaceable choice is in the middle, and the tie-breakers are at the bottom.
- The top grid of six boxes is the portable knowledge — trace, datasets, evaluators, compare, gate CI, monitor. These are the jobs; they transfer to every platform, so learning them (not "which button in LangSmith") is what lasts.
- The arrow drops to "pick a vendor": the tool is just a swappable slot beneath the jobs — LangSmith, another hosted product, or a self-hosted OpenTelemetry-based option all fill it.
- The three green boxes are the constraints that decide the slot:
stack fit(LangChain gravity vs raw SDK),data residency(hosted means prompts leave your infra — often the decisive one), andbudget(usage pricing vs self-host engineering time).
In short: the six jobs are permanent; the vendor is a choice you make on stack, residency, and budget.
Common pitfalls expert
The failure modes of observability are subtle, because a broken setup still looks like it's working. A dashboard is showing green numbers, evals are passing, traces are flowing — so it's tempting to assume you're covered. The pitfalls below are exactly the cases where that comfortable green hides a real gap, and every one of them maps back to a principle from this chapter or an earlier one. Read the table not as trivia but as the difference between observability that protects you and observability that gives you false confidence.
Two are worth calling out because they're the ones that quietly cause the most damage. The first is tracing with no data-handling plan — the moment you send traces to a hosted service, you're shipping full prompts and outputs off your infrastructure, and those may contain PII, secrets, or confidential content (O4, T1). Observability that leaks data isn't a feature, it's an incident waiting to happen; redact, control access, and check residency before you flip tracing on, not after. The second is building a golden set from imagination instead of promoting real traces — the whole flywheel of this chapter collapses if your test cases are guesses, because you end up perfectly passing evals for inputs no real user ever sends. The rest of the table is the same lesson applied to judges (they're fallible — spot-check them), to monitoring (offline isn't enough — watch live traffic for drift), to rollout (compare versions before shipping), and to trace depth (decorate every step, not just the top call, or your tree has holes exactly where the bug hides).
| Pitfall | Fix |
|---|---|
| Tracing with no data-handling plan | Redact PII/secrets; control access; check residency (O4, T1) |
| Building a golden set from imagination | Promote real traces (incl. failures) into the dataset |
| Trusting the LLM judge blindly | Judges are fallible (Ch 5); spot-check & calibrate |
| Evals only offline | Also monitor live traffic for drift (O4) |
| Prompt changes with no version comparison | Compare across the dataset before shipping (O3) |
| Only tracing the top-level call | Decorate steps/tools so you get the full tree |
Exercises expert
Exercise I4.1 — Trace an agent
Context: The payoff of a tracing platform is seeing exactly where an agent run failed and what it cost — the O4 exercise, now inside a real tool.
Your task: Instrument an agent from an earlier lesson with @traceable (or LangChain auto-tracing) on the model call and each tool, then run a query that triggers a tool error.
Requirements:
- Trace the model call and every tool as separate spans
- Run a query that deliberately makes a tool fail
- Confirm the trace tree pinpoints which span failed
- Confirm the trace shows the cost/latency of the run
💡 Hint: Auto-tracing wraps each step for you; the goal is to read the resulting tree and find the failing span, not to hand-log.
Exercise I4.2 — Build a dataset from failures
Context: The evaluation flywheel starts the moment you promote a real failure into a golden case — after which it can never regress silently.
Your task: Run your app on 10 inputs, find the 2–3 that produce bad answers, and promote them into a dataset as golden cases with the expected output.
Requirements:
- Exercise the app on ~10 real inputs
- Identify 2–3 genuinely bad outputs
- Add each as a dataset example with the correct expected output
- Note that these cases now guard against silent regression
💡 Hint: Bad outputs are the highest-value eval cases — curate the failures rather than inventing synthetic inputs.
Exercise I4.3 — Version comparison
Context: This is Chapter 5's manual A/B test, now scaled and stored: two prompt versions, scored across a dataset, with the winner kept for a reason.
Your task: Write two prompt versions for a task, run evaluate() for each over your dataset with a deterministic and a judge evaluator, compare per-example, and keep the winner.
Requirements:
- Two prompt versions, one dataset
- Score each with both a deterministic evaluator and an LLM-judge
- Compare per-example, not just on the aggregate
- Keep the winner and write down why
- Watch for a version that improves the mean but regresses specific cases
💡 Hint: The per-example diff is what catches a regression an average would hide — that's why you gate on the dataset, not a single example.
Show what to look for
Watch for a version that improves the average but regresses specific cases — the per-example diff catches what an aggregate score hides. That's exactly why you compare across the dataset, not just on one example, before gating a deploy.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Observability platforms like LangSmith start from one primitive: a trace that records what each run did. You'll model that primitive rather than read log lines.
Your task: Model one traced call offline as a decorator that captures inputs, output, latency, and any error — the job LangSmith's @traceable does.
Requirements:
- Implement it as a decorator using only the standard library
- Capture the function name, inputs, latency in ms, and error (None on success)
- Record the run even when the wrapped function raises, then re-raise
- Store captured runs somewhere inspectable and print one
💡 Hint: A try/finally around the call lets you time and record the run on both the success and the exception path; functools.wraps keeps the name.
Show solution
Tracing = recording what each run did. Model it in stdlib (the real thing is @traceable):
import time, functools
RUNS = []
def traced(fn):
@functools.wraps(fn)
def wrap(*a, **k):
t0 = time.perf_counter()
err = None
try:
out = fn(*a, **k)
return out
except Exception as e:
err = repr(e); raise
finally:
RUNS.append({"name": fn.__name__, "inputs": {"args": a},
"latency_ms": round((time.perf_counter()-t0)*1000, 2),
"error": err})
return wrap
@traced
def answer(q):
return f"echo: {q}"
answer("what is prompt caching?")
print(RUNS[-1]) # captured inputs, latency, error=None
That is exactly the job LangSmith's decorator does — you just don't hand-roll or read log lines.
Context: A real trace is not a flat log — it's a tree of timed spans (retrieve → generate → parse). That structure is what lets a platform beat grep.
Your task: Model a span tree: a parent run with nested, individually-timed child spans, and print the tree with indentation.
Requirements:
- A span has a name, a duration, and a list of child spans
- Support arbitrary nesting (a child can have its own children)
- Print the tree indented by depth, showing each span's time
- Build a small example (e.g. answer → retrieve, generate → format)
💡 Hint: A tiny recursive node class plus a recursive print indented by depth is all you need; the visual, timed tree is the whole payoff.
Show solution
Traces are step-trees, not flat logs. Runnable:
class Span:
def __init__(self, name):
self.name, self.children, self.ms = name, [], 0
def child(self, name, ms):
s = Span(name); s.ms = ms; self.children.append(s); return s
def show(span, depth=0):
print(" "*depth + f"- {span.name} ({span.ms} ms)")
for c in span.children:
show(c, depth+1)
root = Span("answer")
root.child("retrieve", 40)
gen = root.child("generate", 210)
gen.child("format", 5)
root.ms = 255
show(root)
The visual, timed tree is what makes a platform beat grep: you see where the time and the failure actually went.
Context: LangSmith's flywheel turns real traffic into evaluation data: promote good runs into a golden dataset instead of inventing inputs by hand.
Your task: Given a list of captured runs, build a dataset of {input, reference_output} examples, dropping any run that errored or produced no output.
Requirements:
- Input is a list of run dicts carrying input, output, and error
- Keep only runs with no error and a non-empty output
- Emit each survivor as an
{input, reference}example - Report how many examples resulted (and that errored runs were dropped)
💡 Hint: A single list comprehension with the error/output filter is enough — the curation rule is the lesson, not the plumbing.
Show solution
A dataset is just curated examples pulled from real traffic. Runnable:
runs = [
{"input": "reset password?", "output": "Go to Settings > Security", "error": None},
{"input": "refund policy?", "output": "30 days", "error": None},
{"input": "crash case", "output": None, "error": "Timeout"},
]
def to_dataset(runs):
return [{"input": r["input"], "reference": r["output"]}
for r in runs if r["error"] is None and r["output"]]
ds = to_dataset(runs)
for ex in ds:
print(ex)
print(len(ds), "examples (errored run dropped)")
Real traffic is the best source of eval cases — you promote the good ones instead of inventing inputs by hand.
Context: An evaluator scores an app's output against a dataset's reference. Real suites mix deterministic checks with an LLM-as-judge; both are just scoring functions.
Your task: Model two evaluators — exact-match and a keyword "LLM-judge" stand-in — and a harness that reports per-example and mean scores over a dataset.
Requirements:
- Exact-match returns 1.0/0.0 on a normalized string comparison
- The judge stand-in scores by whether the reference is contained in the output
- The harness runs a given evaluator over the dataset and returns per-example scores plus the mean
- Run both evaluators over the same dataset and show they can disagree
💡 Hint: Keep the evaluator as a parameter to the harness so swapping exact-match for the judge is a one-line change — exactly how LangSmith runs custom evaluators.
Show solution
Evaluation = score outputs against references. Model the harness offline:
dataset = [
{"input": "capital of France?", "reference": "Paris"},
{"input": "2+2?", "reference": "4"},
]
def my_app(q):
return {"capital of France?": "Paris", "2+2?": "four"}[q]
def exact_match(pred, ref):
return 1.0 if pred.strip().lower() == ref.strip().lower() else 0.0
def judge_contains(pred, ref): # stand-in for LLM-as-judge
return 1.0 if ref.lower() in pred.lower() else 0.0
def run_eval(dataset, evaluator):
scores = [evaluator(my_app(ex["input"]), ex["reference"]) for ex in dataset]
return scores, sum(scores)/len(scores)
s, mean = run_eval(dataset, exact_match)
print("exact-match:", s, "mean", mean) # [1.0, 0.0] 0.5
s, mean = run_eval(dataset, judge_contains)
print("judge:", s, "mean", mean)
LangSmith runs your evaluators (exact, custom, or LLM-as-judge) over the dataset and stores the scores; the logic is what you're modeling here.
Context: The reason to store datasets and evaluators is version comparison: proving a prompt or model change actually helped, across the whole set, not on one lucky example.
Your task: Run two app versions over one dataset and report the per-example diff and the mean score delta.
Requirements:
- Score v1 and v2 with the same evaluator over the same dataset
- Show a per-example comparison flagging improvements and regressions
- Compute the mean delta between the two versions
- Make it obvious when the average improves but a specific case regressed
💡 Hint: Zip the two score lists with the dataset and label each row improved/regressed; the per-example view catches what the aggregate hides.
Show solution
Side-by-side scoring across the whole set beats eyeballing two outputs. Runnable:
dataset = [{"q": "a", "ref": "Paris"}, {"q": "b", "ref": "4"}, {"q": "c", "ref": "blue"}]
v1 = {"a": "Paris", "b": "four", "c": "blue"}
v2 = {"a": "Paris", "b": "4", "c": "blue"}
def score(app):
return [1.0 if app[ex["q"]].lower() == ex["ref"].lower() else 0.0
for ex in dataset]
s1, s2 = score(v1), score(v2)
for ex, a, b in zip(dataset, s1, s2):
flag = "" if a == b else (" <- improved" if b > a else " <- regressed")
print(f"{ex['q']}: v1={a} v2={b}{flag}")
print("mean delta:", round(sum(s2)/len(s2) - sum(s1)/len(s1), 3))
This is how you prove a prompt/model change helped rather than hoping — the platform just does it over hundreds of cases.
Context: The LLMOps loop closes in CI: an eval gate blocks a deploy when quality drops. This is the O3 release-control pattern, wired to an exit code.
Your task: Model an eval gate that blocks the deploy if the mean score is below a threshold OR any critical example regressed versus the last release, returning a CI-style exit code.
Requirements:
- Fail if mean score < a threshold
- Fail if any named critical example scores worse than its previous score
- Return whether it passed plus the human-readable reasons
- Map the decision to an exit code (0 deploy / 1 block), as CI would
- Show both a passing run and a blocked run (e.g. a safety regression)
💡 Hint: Accumulate reasons into a list; empty reasons means pass. The critical-example check needs both the current and the previous per-example scores.
Show solution
The gate turns eval into a release control (O3). Runnable, returns a CI-style exit code:
def eval_gate(mean_score, per_example_now, per_example_prev,
threshold=0.9, critical=("safety-01",)):
reasons = []
if mean_score < threshold:
reasons.append(f"mean {mean_score:.2f} < {threshold}")
for k in critical:
if per_example_now.get(k, 0) < per_example_prev.get(k, 0):
reasons.append(f"critical '{k}' regressed")
passed = not reasons
return passed, reasons
now = {"safety-01": 1.0, "faq-02": 1.0}
prev = {"safety-01": 1.0, "faq-02": 1.0}
ok, why = eval_gate(0.95, now, prev)
print("deploy" if ok else "BLOCK", why) # deploy []
now2 = {"safety-01": 0.0, "faq-02": 1.0} # a safety regression
ok, why = eval_gate(0.95, now2, prev)
exit_code = 0 if ok else 1
print("deploy" if ok else "BLOCK", why, "exit", exit_code) # BLOCK ... exit 1
Closing the LLMOps loop: traces become datasets, datasets feed evals, and the eval gate stops a regression before it ships. LangSmith hosts each step; the real client wraps your app with @traceable and its evaluate() API (needs the SDK).
✓ Checkpoint — you can move on when you can…
- Say what LangSmith adds over hand-rolled O4 tracing and Ch 5 evals.
- Instrument an app (framework or raw SDK) to capture traces.
- Promote real traces into a dataset and explain the flywheel.
- Run deterministic, judge, and custom evaluators over a dataset.
- Compare versions and wire an eval gate into CI (O3).
Knowledge check check yourself
What does LangSmith add over the hand-rolled span() tracing from O4, and why is tracing the foundation for everything else?
Show answer
Describe the trace→dataset→eval flywheel and why comparing versions per-example (not just on average) matters before gating a deploy.