My demo agent got one instruction: pay the vendor 0.25 ETH, then withdraw 1 ETH from the vault. Jaeger shows that run as 13 spans. Every one of them carries the trace ID ad2bb276e4162eb8240ae6da1418c7a8.
A span is one operation, a trace is all of them
| Span | Trace | |
|---|---|---|
| What it is | One unit of work: an LLM call, a tool call, a send | The path of a request, here one agent run |
| ID | Span ID, 8 bytes (16 hex characters) | Trace ID, 16 bytes (32 hex characters) |
| Holds | Name, times, attributes, events, links, status, parent ID | Nothing of its own |
| In this run | send 31337 is 7d4ae38f72dcc62f |
13 spans with the ID above |
The last column is the part people miss. A trace has no record of its own. Your backend collects the spans with the same trace ID and rebuilds the tree from their parent IDs (OpenTelemetry traces).
Parent IDs make the tree
Every span names its parent’s span ID, except the root, which has none. Here is the real run, with each span’s ID:
invoke_agent scripted-model 8334389a156b7405 (root, no parent)├─ step 1 85060f6a3968c350│ ├─ chat scripted-model 663c7fb4bbfbe7c9│ └─ execute_tool pay_vendor a2b40d3b18065b98│ ├─ send 31337 7d4ae38f72dcc62f│ └─ confirm 31337 c16b039d3ac7c712 success├─ step 2 5659d93628e2ca4a│ ├─ chat scripted-model 88775ded822b3845│ └─ execute_tool withdraw_from_vault c35dd3745ec1bb8a│ ├─ send 31337 316f3f59999a43ec│ └─ confirm 31337 0014335169b58fe0 reverted└─ step 3 d8954f629a11cc56 └─ chat scripted-model 01f25c86b2bab452The AI SDK emitted the agent, step, chat and tool spans. The invoke_agent, chat and execute_tool names come from the GenAI semantic conventions, which are still in Development status. hashspan added send and confirm. Because they share the trace ID, the reverted withdrawal sits right under the tool call that caused it. This post shows the same run without them.
How does a span get its parent?
From the active span. Start a span while another one is active, and the SDK gives it the same trace ID and records the active span as its parent.
import { trace } from '@opentelemetry/api';import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
const exporter = new InMemorySpanExporter();new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }).register();const tracer = trace.getTracer('learn-demo');
await tracer.startActiveSpan('invoke_agent treasury-agent', async (agent) => { await tracer.startActiveSpan('execute_tool pay_vendor', async (tool) => { tracer.startSpan('send 31337').end(); // a child of the active span: execute_tool tool.end(); }); agent.end();});
for (const s of exporter.getFinishedSpans()) { const { traceId, spanId } = s.spanContext(); console.log(s.name.padEnd(28), traceId, spanId, s.parentSpanContext?.spanId ?? '(root)');}send 31337 e9de5ec038380ef8d93baa1114cacdeb 54cd9c6148d52556 d35e923db7496ee1execute_tool pay_vendor e9de5ec038380ef8d93baa1114cacdeb d35e923db7496ee1 242f4a55addcdbbainvoke_agent treasury-agent e9de5ec038380ef8d93baa1114cacdeb 242f4a55addcdbba (root)One trace ID, three span IDs, and each child points at its parent. Spans finish child first, so the root prints last. register() also installs a context manager, which keeps the active span across await.