The collapsible step tree
Rendering a span tree is a recursion problem with three hard cases the naive version gets wrong: parallel siblings, a retried step that must not look like two steps, and a run still streaming in.
Altitude 02 is a list of rows. You know how to render a tree; the recursion is not the problem and this lesson will not spend a paragraph on it. The problem is that three shapes in real agent telemetry have no obvious row, and the obvious rendering of each one states something the data never said.
Two calls that ran at the same time. One call that took two attempts. A run that has not finished arriving. Every trace panel meets all three, and the naive version gets all three wrong in the same direction: it renders a plausible story instead of an incomplete one.
Naming these three as the hard cases is this course’s own engineering analysis, built on documented gaps in the span model rather than on anything OpenTelemetry enumerates. The specification does not discuss rendering at all. What it does is state, in each case, that the fact your row wants to assert is not one it carries.
Case one: parallel siblings
Two enrichment calls ran at once. The runtime emitted two spans. Your component stacks them vertically, because that is what a list does, and a reviewer reads vertical order as causal order and concludes the second call used the first call’s output.
The overlap is recoverable, but only from timestamps, and only if you went looking. Sorting by start time does not fix this — it produces the same vertical list with a defensible sort key, which is worse, because now the sequence looks deliberate.
Underneath it is the finding from the span-model lesson, and it is the most load-bearing negative fact in this course. There is no normative rule that a tool execution nests under 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 the spans are likely to be siblings under an encompassing span. So the nesting in your payload is an artifact of one program, and sibling rows carry no ordering claim beyond the timestamps on them.
What the row has to do, then, is narrow: say concurrency out loud, or say nothing about order. Rendering overlapping siblings with a shared start marker, or grouping them under a “ran together” label, both work. A bare vertical list works too, as long as it does not decorate itself with the connective tissue — numbered ordinals, downward arrows, an indent that implies descent — that turns adjacency into sequence.
Case two: the retried step
The specification is blunt about this one by omission. 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 an Error status on the first. There is no attempt number, no parent-of-attempts, no field that says “this is the same work again.”
So one row that reads enrich_property, 2 attempts, first timed out is a claim your component makes. It is the right claim. It is still a claim, and that has two consequences most implementations miss.
The grouping key is a heuristic you author. Same tool name, identical serialized arguments, adjacent in time, earlier one errored — that is a reasonable rule and it is a rule, not a reading. Write it down where a colleague can find it, because the day it is wrong, the component will have hidden a genuinely duplicated call by merging it into one row, which is the same category of lie as splitting one call into two.
Grouping belongs at altitude 02 and nowhere below it. The step list shows logical operations, so it groups. The full trace shows what was emitted, so it shows both spans, unmerged, with their own statuses and their own durations. If the inspector also groups, there is no altitude left at which a reader can check the grouping rule against the data, and the invariant from the altitudes lesson — no fact above that cannot be traced below — is broken by the component that was supposed to enforce it.
Case three: the run still arriving
This is the one that looks easiest and is the deepest, because the distinguishing fact is not in the span model at all.
Start with status. Unset is the default, and the specification tells instrumentation libraries to leave it that way unless there is an error. A span that is still running is Unset. A span that finished perfectly is also Unset. Status separates those two not at all.
Next try the end timestamp: an open span has none. Better, and still not enough, because a span with no end timestamp is also what you get from a process that died mid-step, an exporter that dropped the closing record, or a span whose closing record simply has not reached you yet in a streaming export. A missing end is a missing end. It is not a running step.
Then try absence: the branch you expect has no spans yet. This is the weakest signal of the three, because a trace is sampled and advisory by construction. W3C Trace Context states that trace flags are “recommendations given by the caller rather than strict rules”, and the tracing specification’s own sampling and span-limit machinery exists precisely so that not everything is recorded. A gap may mean not-yet, or never, or not-sampled, and the span data cannot tell you which.
What the three have in common
Each case is the view being asked to assert a relationship the data does not carry. Order, in the parallel case. Sameness, in the retry case. Liveness, in the streaming case. In all three the naive rendering is not a bug in the drawing code; it is the drawing code answering a question nobody decided.
Which means the fix is in the same place all three times. Each relationship becomes an explicit, named field on your run object, set by a rule you wrote, and the tree renders that field rather than inferring it from row position. A component that computes “these two are the same call” inside its render pass has put a policy decision somewhere no reviewer will ever find it.
Check your recall
Answer from memory — no scrolling back.
Hands on
Handle the three cases in your own tree
Done when: ARTIFACT.md’s “Tree cases handled” section marks each of parallel siblings, retried step and still-streaming run as handled or not, and states for each one the exact field the component reads and the rule that sets it — with the grouping heuristic written out in full.
- Add the three relationships to your run object as explicit fields, not as things the renderer works out. A concurrency grouping, an attempts-of-one-operation grouping, and a liveness flag. Each is set by a named rule outside the component.
- Write the grouping heuristic in full sentences before you implement it, including what it does when arguments differ slightly, when three attempts happen rather than two, and when two identical calls really were two separate pieces of work. That last case is the one that makes this a heuristic rather than a reading.
- Render altitude 02 and altitude 03 from the same run object and check the asymmetry deliberately: the step list groups, the full trace does not. Open both against the same run and confirm you can get from the grouped row to the two underlying spans without leaving the component.
- Take the liveness flag out of the trace entirely. Pass it in. Then test the three states you can now distinguish — live with an open branch, ended with an unclosed span, and a gap where nothing arrived — and make sure each one draws differently. If two of them look the same, the component is still inferring.
- Run it against your captured payload from module one. If that payload has no overlapping spans, construct the case by hand from two records you already have, changing only the timestamps. You need the shape on screen, and waiting for a runtime to produce one is a way of not building it.
- Bring the grouping heuristic into the chat, in words. It is the piece of this component most likely to be quietly wrong, and the sentence “two identical adjacent calls that were genuinely separate work would render as one row” is either an accepted trade-off you can defend or a bug you have not met yet.
What this does not cover
The tree now has rows that do not lie about order, sameness or liveness. What it does not have is anything inside a row. A step list row carries enough of a tool’s arguments to identify what it acted on — the record id, the URL, the query — and the complete arguments and results live one altitude down, where they are the most useful and most dangerous content in the whole component. That is the tool-call inspector lesson, which closes this module and is where redaction gets decided.
This lesson also said nothing about per-step timing beyond a duration column, and nothing at all about cost. Both are the cost and latency module, which takes the token counts sitting on your model-call spans and is deliberate about the difference between a number that was emitted and a number that was multiplied by a price off a vendor page.
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.” Fetched 2026-09-05.
The span-model lesson took the anatomy from this document. Read it again for the three sections that lesson skipped, because they are what this one runs into: sampling, span limits, and span processors. Together they decide whether the tree in front of you is the whole tree, which is the question underneath the streaming case — a branch that has not arrived and a branch that will never arrive are the same shape on screen, and the specification is where you learn how many ways the second one can happen. The requirement-level language matters here too: almost every rule about completeness is a SHOULD or a MAY, which is the specification telling you plainly that a complete trace is not something a renderer may assume.
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.