Spans, traces, and what they cannot say
A trace is a tree of timed, attributed, parent-linked spans with a three-state status — a model precise enough to render and narrow enough that an agent’s intent, its retry semantics and its reasoning fall outside it entirely.
You now have a captured payload and a list of what is in it. This lesson is the vocabulary that makes it readable — not as trivia, but because four of the five hardest rendering decisions in this course turn out to be decided by a sentence in this specification, and one of those sentences will change how you draw a green check.
The model is small. A trace is a tree of spans. A span is a single operation with a start and an end. Everything else is detail. But the detail is where the component lives.
What a span is made of
The specification enumerates it directly. A span encapsulates a name; an immutable SpanContext; a parent, “in the form of a Span, SpanContext, or null”; a SpanKind; start and end timestamps; attributes; a list of links to other spans; a list of timestamped events; and a status (Tracing API, OTel 1.60.0). Four of those need unpacking before they are useful to you.
SpanContext, and where the tree comes from
The SpanContext is the part that travels. It is immutable, and it carries a TraceId (“a 16-byte array with at least one non-zero byte,” rendered as a 32-hex-character lowercase string), a SpanId (8 bytes, 16 hex characters), TraceFlags, and TraceState. The concepts page lists the parent as a separate field — “Parent span ID (empty for root spans)” — which is the whole tree mechanism: the root is the span with no parent, and every other span points at exactly one.
The wire format for that context is W3C Trace Context, a W3C Recommendation dated 23 November 2021, which defines a traceparent header of four hyphen-separated fields:
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ trace-id (16 bytes) parent-id trace-flags
version (8 bytes) 01 = sampledTwo details from that spec are worth carrying. The field the header calls parent-id is, in its own words, “the ID of this request as known by the caller (in some tracing systems, this is known as the span-id)” — so the same eight bytes are called two different things one layer apart, which is a fair warning about the rest of this domain. And trace-flags are explicitly “recommendations given by the caller rather than strict rules.” A trace is advisory, best-effort and sampled by design. Note also that the newer Level 2 of the spec is only a Candidate Recommendation Draft dated 28 March 2024, so the stable propagation story is still the 2021 document.
Status — the sentence that will change your component
StatusCode has exactly three values: Unset (“The default status”), Ok (“validated by an Application developer or Operator to have completed successfully”), and Error. The specification states they “form a total order: Ok > Error > Unset,” and that Description “MUST only be used with the Error StatusCode value.”
Then this, which is the load-bearing sentence of the whole lesson:
“Generally, Instrumentation Libraries SHOULD NOT set the status code toOk… SHOULD leave the status code asUnsetunless there is an error.” (Tracing API)
Read that as a renderer. It means a successful step does not say it succeeded. It says nothing. The obvious implementation — green when Ok, red when Error, grey otherwise — will paint an entire correct trace grey, and then a developer will “fix” it by treating Unset as success, which is the actual bug: Unset means nobody made a claim. A step that is still running is Unset. A step whose instrumentation crashed before it could record anything is Unset. A step that worked perfectly is also Unset. Collapsing those three into a green check is how a panel ends up asserting something the data never said, which is the failure mode the whole course is organised around.
Attributes, and why the message content is a string
Attribute values are constrained: “Values must be a non-null string, boolean, floating point value, integer, or an array of these values.” There is no object type and no nesting. That single constraint explains something you will hit within an hour of reading a real payload — a rich structured thing like a list of messages, or a tool’s arguments, arrives at your component as a JSON string that you have to parse, and that parse can fail, and the component needs a state for the case where it does.
Events and links
An event is “a structured log message (or annotation) on a Span, typically used to denote a meaningful, singular point in time during the Span’s duration.” The concepts page gives a clean heuristic for the choice a lot of instrumentation gets wrong: if the timestamp at which something happened is meaningful, it is an event; if the timestamp is not meaningful, it is an attribute. A link associates a span with spans in the same or a different trace, “implying a causal relationship” — the mechanism for a run triggered by another run, which is exactly the shape a multi-agent handoff takes.
SpanKind
Five values — SERVER, CLIENT, PRODUCER, CONSUMER, INTERNAL (the default). For agent telemetry the split you will actually see is CLIENT for a call that leaves the process and INTERNAL for one that does not, and it is a genuinely useful signal: it is the cheapest available answer to “did this step touch the outside world?”
Four things this model cannot tell you about an agent
Now the more important half. The span model is a general-purpose distributed-tracing model, and it was not designed with agents in mind. Four gaps matter, and each one is a decision your schema has to make rather than a fact it can read.
1. The tree shape is not a semantic fact. This is the one that surprises people. There is no normative rule that a tool execution is a child of the model call that requested it. The conventions’ own non-normative examples say of a chat → tool → chat sequence that “the relationship between below spans depends on how user application code is written”, and that they are likely to be siblings under an encompassing span, because it is the application code — not the model — that actually executes the tool between two model calls. So the nesting in your captured payload is an artifact of how that particular program was written. A component that hard-codes “tool calls are children of model calls” is encoding one application’s structure as if it were a law.
2. There is no concept of an attempt. A span is one operation that happened. Two attempts at the same logical thing are two spans, related by nothing except adjacency, identical attributes, and the fact that the first has an Error status. Whether those two spans are one row in your UI is a decision you make in the schema, and the specification will not help you make it.
3. There is no field for intent. Why a tool was chosen is not recorded anywhere, because it is not a fact the runtime has access to. Anything that reads like a reason is text a model generated about its own behaviour, and it belongs in a different visual register than a timestamp. The citations module is where that distinction gets its own treatment.
4. Absence is not evidence. Sampling is built into the model — that is what the Sampled trace flag is for — and the flags are advisory. A missing span may mean the step did not happen, or that it was not sampled, or that the instrumentation for that library does not exist. A trace view that renders a gap as a gap is fine. One that renders a gap as “nothing happened” is making a claim the telemetry cannot support.
One tension worth noticing early
The tracing spec says span names should be low-cardinality: “the most general string that identifies a (statistically) interesting class of Spans… ‘get_user’ is a reasonable name, while ‘get_user/314159’… is not a good name due to its high cardinality.” The GenAI conventions then prescribe span names like chat {gen_ai.request.model} and execute_tool {gen_ai.tool.name} — names that interpolate a value, deliberately, because model and tool are the dimensions people group by.
That is not a contradiction; both are bounded sets. But it tells you something about how to read span names in your renderer: the name is aclass label, and the instance-identifying information lives in attributes. If you find yourself parsing a span name to extract a value, the value is almost certainly available as an attribute, and parsing the name will break on the first runtime that formats it differently.
Retrieval check
Every span in your captured trace has status Unset. What can you conclude about the run?
Check your answer
Almost nothing, and that is the correct answer rather than a disappointing one. Unset is the default, and the specification tells instrumentation libraries to leave it that way unless there is an error. So an all-Unset trace is consistent with a run that succeeded completely, and it is also consistent with a run whose failures were never recorded as span errors — which is common, because a tool that returns an error payload to the model has not thrown, and nothing obliges the instrumentation to call that a span error.
Which means the actual failure signal for an agent run frequently is not in the status field at all. It is in the tool result — Anthropic’s is_error boolean, or an error string in the result payload — and your schema has to reconcile two independent notions of failure that can disagree. Treating span status as the single source of truth for success will produce a panel that shows a clean run alongside an answer the agent hedged because a tool failed.
Hands on
Annotate the captured payload against the span model
Done when: ARTIFACT.md’s module-one section names, for your own captured trace: which record is the root, whether tool executions are children or siblings of model calls, what every span’s status actually is, and which of the four gaps above your payload exhibits.
- Find the root. It is the span with no parent. If your payload has more than one, you have captured more than one trace, or the parent references are broken — both are worth knowing before you write a renderer that assumes one root.
- Draw the actual tree by hand, from the parent references only. Not the tree you expected — the one the references describe. Then answer in one sentence: in your payload, is a tool execution a child of a model call or a sibling of it? Write the answer down, because it is now a documented property of one runtime rather than an assumption.
- Tabulate the status of every span. Count how many are
Unset. If the answer is “all of them,” including the step you deliberately made fail, you have just found the most important thing in this exercise, and it goes inARTIFACT.mdas a constraint on the component. - Find the failure a second way — in the tool result payload rather than the span status. Write down both signals and whether they agree. Your schema needs a rule for what happens when they don’t.
- Check for parallelism: sort spans by start time and look for any pair where one starts before another ends. Note whether any exist. This is the cheapest possible test of whether your eventual tree component needs an overlap representation at all for this runtime.
- Bring the annotated tree into the chat, including the sentence about children versus siblings. That single sentence is the one most likely to be wrong, and the most expensive to be wrong about.
What this does not cover
This lesson stayed inside the general tracing model — the part that is marked stable and has been for years. It said nothing about the agent-specific vocabulary layered on top: which attributes name a model, a tool, an agent or a token count, and how much weight that vocabulary can bear given that it is explicitly still in development and has already moved repositories once. That is the conventions lesson, which comes next.
It also skipped sampling, span limits and span processors, all of which determine whether the trace you are handed is complete — they are in the specification linked below and are worth the read. And the whole question of what to do with a tree once you can read one waits for the altitudes lesson, in the module after this one.
Read this next — primary source
OpenTelemetry Tracing API specificationThe OpenTelemetry Authors / CNCF — the specification itself, not a vendor’s description of it; free. Page header reads “OTel 1.60.0 — Status: Stable, except where otherwise specified.”
This lesson takes the data model and skips the API. The specification is worth reading in full for the opposite half: the requirement-level language. Every sentence is graded MUST / SHOULD / MAY, and once you have read a few pages of it you stop reading telemetry as a description of what happened and start reading it as a description of what an instrumentation library was obliged to record — which is a different and much more useful reading. It also covers what this lesson leaves out entirely: sampling, span limits, span processors, and the rules about when a span may still be modified. All four determine whether the trace in front of you is complete.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.