Skip to main content
Instrumenting a workflow is two decisions, and you make both by choosing spans:

1. See and evaluate

Capture enough that you can read what happened at each step, time it, and grade it.

2. Replay

Decide which steps become mocks: spans that serve their recorded output instead of running again.
Both are per-span decisions, so a workflow wrapped in one span has made neither. It records one input, one output, and one duration, and nothing inside it can be read, graded, or mocked.

Goal 1: capture enough to see and evaluate

Give a step its own span when any of these is true:
  • It calls a model. Always. This is the span you iterate on, compare across experiments, and attach graders to.
  • It reads external mutable state (DB query, HTTP GET, object storage, vector search, cache). These are the spans you will want to mock on replay.
  • It writes external state (DB write, queue publish, email, charge, file write). Mark these to mock on replay so a replayed trace does not repeat the side effect.
  • It transforms the model output (parsing, validation, ranking, formatting), so a quality regression points at the model or at your post-processing.
  • It retries or loops, one span per attempt or iteration, so a trace shows how many attempts it really took.
Skip trivial in-memory helpers, per-item work inside a large loop (wrap the loop or the batch), and internals a framework integration already captures.

What one span costs you

Example

Spans nest by call stack in every SDK, so you never wire parents and children by hand.

Goal 2: decide what gets mocked on replay

Replay re-runs recorded inputs through your current code. The root always runs for real; each descendant either runs for real or serves its recorded output. Spans that serve recordings are mocks, and you choose them per span, at definition time, with mockOnReplay. Only descendants can be mocks: a root always executes. Mock the world, run your code. The point of a replay is to test a change against production scenarios, so anything that is not the change should be held fixed and cheap: Mark a span when you define it, and the default "marked" strategy does the rest:
Now a replay of summarize-doc feeds each historical document straight from its recording, runs your new prompt for real, and writes nothing. Note that this only works because read-doc and persist are spans: goal 2 is only available for steps you captured in goal 1.
Replay also needs a root whose own arguments are JSON-serializable, since that is what it re-runs the trace against. Prefer an id, a request object, or a message list over a live connection, a stream, or a class instance.

Pitfalls

One span around the whole workflow

One node. No prompt recorded, no per-step timing, and replay re-reads storage and re-runs the insert every time. This is the most common instrumentation mistake by a wide margin.

Calling the Vercel AI SDK without wrapping the model

A withSpan around a function that calls a model does not create a span for the model call. See the Vercel AI SDK integration.

Wrapping in an anonymous function

An input-less root is not replayable, because replay has nothing to re-run the trace against.

An unserializable root

The trace is still observable, but replay needs recorded inputs that round-trip.

Mocking the thing you are changing

Mocking the model call, or running with mock: "all", makes a replay return its recorded outputs and prove nothing about your change. Mock the setup around the step you are iterating on, never the step itself.

Marking nothing, then replaying against production

With no span marked, the default strategy mocks nothing and every descendant runs for real. If the workflow writes, a replay repeats the write for every trace in the run. Mark writes before the first replay, not after.

Hand-wrapping framework internals

If you use a framework integration, the handler, processor, or middleware already records the graph nodes, tools, and model calls. Wrapping them yourself produces duplicate spans. Add manual spans only for work above, alongside, or below the framework call.

Self-check

Open a trace in the web portal:
  • Is there more than one node?
  • Is there a span for the model call, carrying the prompt and the output?
  • Is every external read and write its own span, marked to mock on replay?