Anatomy of a Trace: What One Request Looks Like
A user asks your support assistant a question and gets a confident, wrong answer. The ticket lands on your desk with no stack trace, no logs, and no way to know which prompt version ran, which documents were retrieved, or how long the model took. This is the debugging void that LLM tracing exists to fill.
A trace is a tree of spans. Each span is one unit of work with a name, a start time, a duration, and a set of attributes. The root span covers the whole user request; child spans cover each stage inside it.
user request (root span, 1840ms)
├── prompt assembly (12ms) ← system prompt v14 + history + retrieved docs
├── retrieval (640ms) ← vector search over 3 chunks
│ └── rerank (210ms) ← cross-encoder rescoring
├── model call (1150ms) ← claude-sonnet-4-6, 1420 in / 380 out tokens
└── post-processing (38ms) ← citation validation, PII redaction
Read the waterfall top to bottom and the story is already visible: retrieval and reranking ate 850ms of the 1840ms total. Without spans, you would have optimized the model call — the wrong target.
Every span should carry the same core attributes so any stage can be compared against any other:
| Attribute | Why it exists |
|---|---|
prompt_version |
Pin the exact system prompt that ran; a bad answer is often a bad prompt |
model |
Model behavior changes across versions; you need to know which one answered |
input_tokens / output_tokens |
Cost and context-window pressure both live here |
duration_ms |
Latency budgets are enforced per stage |
retrieved_passages |
The documents the answer was grounded in; essential for incident replay |
tool_arguments |
What the model asked a tool to do, and what the tool returned |
Notice what is missing: the full user message and full model output. Those are large, often sensitive, and belong in a separate payload store linked by ID — not jammed into span attributes where they bloat every query. Traces answer what happened and where; payloads answer exactly what was said.
Instrumenting a Call: Spans, Attributes, and Context
The naive way to add tracing is to sprinkle print() calls around your LLM client. It works until the second service, the second model, or the second concurrent request — then the output is an interleaved mess with no shared identity. Structured tracing fixes this by giving every operation a span with a shared trace ID.
import time, uuid, json
class Span:
def __init__(self, name, trace_id, parent_id=None):
self.name = name
self.trace_id = trace_id # shared across the whole request
self.span_id = uuid.uuid4().hex[:8]
self.parent_id = parent_id # links child to parent
self.start = time.perf_counter()
self.attributes = {}
def set(self, key, value):
self.attributes[key] = value
def finish(self):
return {
'name': self.name,
'trace_id': self.trace_id,
'span_id': self.span_id,
'parent_id': self.parent_id,
'duration_ms': round((time.perf_counter() - self.start) * 1000, 1),
'attributes': self.attributes,
}
class Tracer:
def __init__(self):
self.spans = []
def span(self, name, parent=None):
trace_id = parent.trace_id if parent else uuid.uuid4().hex[:16]
s = Span(name, trace_id, parent.span_id if parent else None)
self.spans.append(s)
return s
The non-obvious line is trace_id = parent.trace_id if parent else .... A child span must inherit the parent's trace ID, not generate a new one — otherwise every stage becomes an isolated trace and the waterfall collapses. The parent_id is what rebuilds the tree.
Instrument a real call:
tracer = Tracer()
root = tracer.span('chat_request')
root.set('prompt_version', 'support-v14')
root.set('model', 'claude-sonnet-4-6')
retrieval = tracer.span('retrieval', parent=root)
retrieval.set('retrieved_passages', ['doc-102', 'doc-87', 'doc-41'])
time.sleep(0.05) # simulated vector search
retrieval.finish()
model = tracer.span('model_call', parent=root)
model.set('input_tokens', 1420)
model.set('output_tokens', 380)
time.sleep(0.11) # simulated generation
model.finish()
root.finish()
print(json.dumps(tracer.spans, indent=2))
Output (trimmed):
[
{"name": "retrieval", "trace_id": "9f2c...", "parent_id": "a1b2...", "duration_ms": 50.2, "attributes": {"retrieved_passages": ["doc-102", "doc-87", "doc-41"]}},
{"name": "model_call", "trace_id": "9f2c...", "parent_id": "a1b2...", "duration_ms": 110.4, "attributes": {"input_tokens": 1420, "output_tokens": 380}},
{"name": "chat_request", "trace_id": "9f2c...", "parent_id": null, "duration_ms": 160.8, "attributes": {"prompt_version": "support-v14", "model": "claude-sonnet-4-6"}}
]
Every span shares trace_id 9f2c..., so a collector can rebuild the full request from any service that emitted a piece of it. In production you would export these spans to OpenTelemetry rather than printing them — the span model is identical, only the exporter changes.
Propagating Trace IDs Across Service Boundaries
Your chat service calls a retrieval service, which calls an embedding service, which calls the model provider. Each is a separate process, often a separate deploy. If each generates its own trace ID, you get four unrelated traces and no way to answer 'what happened to user 42's request?'
Context propagation solves this by passing the trace identity through the call. The caller injects its trace ID and span ID into the outgoing request; the callee extracts them and uses them as its own trace ID and parent span ID.
TRACE_ID_HEADER = 'x-trace-id'
SPAN_ID_HEADER = 'x-span-id'
def inject(headers, span):
headers[TRACE_ID_HEADER] = span.trace_id
headers[SPAN_ID_HEADER] = span.span_id
return headers
def extract(headers, tracer, name):
trace_id = headers.get(TRACE_ID_HEADER)
parent_id = headers.get(SPAN_ID_HEADER)
if not trace_id:
return tracer.span(name) # no context: start a fresh trace
parent = SimpleNamespace(trace_id=trace_id, span_id=parent_id)
return tracer.span(name, parent=parent)
The retrieval service receives the request, calls extract, and its spans automatically join the caller's trace. The embedding service does the same one hop further. The result is one waterfall across three services.
Two failure modes to design for:
- Header loss through queues. If a request passes through a message queue, headers do not travel with the message body. Inject the trace ID into the message envelope instead, and extract it on the consumer.
- Clock skew. Span start times come from different machines. Never compute parent-child duration from timestamps across services — use the parent-child relationship, not wall-clock subtraction.
Propagation is also what makes incident replay possible: with a trace ID from a customer ticket, you can pull every span across every service and reconstruct the exact request.
Sampling and Storage: Keeping Traces Affordable
Full-fidelity tracing on every request is the right default for a debug session and the wrong default for production. At 50,000 requests per day with ten spans each, you are storing half a million spans daily — and most of them are boring, successful, identical requests.
Sampling decides which traces to keep. The two strategies answer different questions:
| Strategy | How it decides | Best for |
|---|---|---|
| Head-based | Random decision at the root span, propagated to children | Representative performance data; cheap |
| Tail-based | Buffer spans, decide after the request completes | Keeping every error and slow request; expensive |
Head-based sampling is a coin flip at the root: if the root span is sampled, every child is sampled with it, so the trace stays whole. Tail-based sampling waits until the request finishes, then keeps the trace only if it errored or exceeded a latency threshold. Tail-based catches the traces you actually debug, but it requires buffering every span in memory until the decision — a real cost at high traffic.
A practical middle ground: sample 100% of requests that error or exceed your latency budget, and 1% of healthy requests. You keep full visibility into failures and a statistical sample of normal behavior.
What you record matters as much as what you keep. Record retrieved passages and tool arguments in spans — they are the difference between 'the model answered wrong' and 'the model answered wrong because it was given the wrong document.' But redact PII before it reaches a span attribute. A trace that leaks customer emails into your observability backend is a data exposure incident, not a debugging tool.
Storage cost is the reason this topic sits next to metering token usage: traces and token usage are the two production signals you cannot debug without, and both need sampling and retention policies to stay affordable.
Quiz
1. Why must a child span inherit the parent's trace ID instead of generating its own?
2. What is the main advantage of tail-based sampling over head-based sampling?
3. Why should retrieved passages be recorded as span attributes?
4. What is the risk of putting full user messages and model outputs directly into span attributes?
Flashcards
Question
What is a span in LLM tracing?
Click to reveal answer
Answer
A single unit of work in a trace — one stage of a request such as retrieval or a model call — with a name, start time, duration, and attributes like prompt version, model, and token counts.
Question
Why must child spans inherit the parent's trace ID?
Click to reveal answer
Answer
The trace ID is the shared identity of a request. Inheriting it keeps every stage in one reconstructable trace; generating a new one per span would fragment the request into isolated traces.
Question
Head-based vs tail-based sampling?
Click to reveal answer
Answer
Head-based makes a random keep/drop decision at the root span and propagates it to children — cheap and representative. Tail-based buffers spans and decides after completion, keeping every error and slow request but costing more memory.
Question
What belongs in span attributes vs a separate payload store?
Click to reveal answer
Answer
Attributes hold small structured fields: prompt version, model, token counts, duration, retrieved passage IDs, tool arguments. Full user messages and model outputs go in a payload store linked by ID to avoid bloat and PII leakage.
Question
Why propagate trace IDs through message queues via the envelope?
Click to reveal answer
Answer
Headers do not travel with queue message bodies. Injecting the trace ID into the message envelope and extracting it on the consumer keeps the trace whole across asynchronous boundaries.
Revision Notes
Key Takeaways
- 1. A trace is a tree of spans; the root span is the user request, child spans are the stages, and the trace ID shared across all of them is what makes a request reconstructable.
- 2. Every span should carry prompt version, model, token counts, duration, retrieved passages, and tool arguments — the difference between 'the model answered wrong' and 'the model answered wrong because of the prompt it was given.'
- 3. Context propagation via headers (or message envelopes) keeps the trace whole across service boundaries; without it, every service emits an isolated fragment.
- 4. Sample debug traces at a higher rate than routine ones, and redact PII before it reaches span attributes.
Interview Tips
- • Walk through a concrete incident where a trace identified the failing stage in minutes — name the stage, the span attribute that gave it away, and the fix.
- • Explain head-based vs tail-based sampling and when each is the right default; interviewers probe whether you have operated traces at scale, not just read about them.
- • Describe what you put in span attributes vs a payload store, and why — the PII answer signals production maturity.
- • Connect tracing to metering and incident response: traces give request-level replay, metering gives aggregate spend, and both are needed to debug a cost or quality incident.
Cheat Sheet
Tracing LLM Calls — Cheat Sheet
Span model: one trace = one user request; child spans = stages (prompt assembly, retrieval, model call, post-processing).
Every span carries: prompt_version, model, input_tokens, output_tokens, duration_ms, retrieved_passages, tool_arguments.
Context propagation: caller injects x-trace-id / x-span-id headers; callee extracts and uses them as its trace ID and parent span ID. Through queues, put the trace ID in the message envelope.
Sampling:
- Head-based: random keep/drop at root, propagated to children. Cheap, representative.
- Tail-based: buffer, decide after completion. Keeps every error/slow trace. Costs memory.
- Practical: 100% of errors + slow requests, 1% of healthy.
Payloads vs attributes: full user messages and model outputs go in a payload store linked by ID — never in span attributes (bloat + PII risk).
Redaction: scrub PII before it reaches a span attribute. A trace that leaks customer emails is an exposure incident.
Links: metering token usage · incident response