# Traces, Spans and Observations — LLM Observability (Langfuse / LangSmith)

Source: https://www.skillbyai.com/en/llm-observability/lo-traces-spans

> Describe a trace as a tree of timed steps and read parent-child relationships in a trace view.

## A trace is a tree of steps

A **trace** represents one end-to-end unit of work, usually one user request such as "answer this chat message". Inside it are **spans**: timed steps with a name, start and end time, input, output and metadata. Spans nest. The top-level span (the root) might be `chat-request`; under it sit children such as `retrieve-docs` and `llm-call`, and an agent step can have its own children for each tool call. Every span except the root records its **parent**, which is how the tool draws the tree, and each span's start and end times show how long that step took. Naming differs between tools: **Langfuse** calls the steps *observations* (with types such as span, generation and event), while **LangSmith** calls each step a *run* and the tree a *run tree*. OpenTelemetry simply calls them spans. The idea is the same everywhere: one tree per request, so you can open a slow or wrong answer and see exactly which step misbehaved.

## From request to trace tree

A request enters the app, the root span opens, child spans record retrieval and generation, and the finished tree is sent to the observability backend in the background.

![Four connected stages: request arrives, root span opens, child spans record steps, trace is exported.](assets/figures/llm-observability/section-2-map.svg) — Figure 2.1: request -> root span -> child spans -> export to backend

## Tracing nesting with a toy tracer

This tiny tracer keeps a stack of open spans, which is roughly how SDKs track the current parent. Follow how each span finds its parent.

```python
from contextlib import contextmanager

stack = []

@contextmanager
def span(name):
    parent = stack[-1] if stack else None
    stack.append(name)
    print(f"start {name} (parent={parent})")
    try:
        yield
    finally:
        stack.pop()
        print(f"end   {name}")

with span("chat-request"):
    with span("retrieve-docs"):
        pass
    with span("llm-call"):
        pass
```

- Line 16: Entering the outer `with` opens the root span. The stack is empty, so it has no parent.
- Line 17: The nested `with` opens a child. The top of the stack is `chat-request`, so that becomes the parent.
- Line 18: The block finishes, the `finally` pops the span and records its end.
- Line 19: The second child opens. The stack again holds only the root, so it is a sibling of the retrieval span, not its child.
- Line 20: The LLM span closes and pops.
- Line 16: Leaving the outer block closes the root last: a parent always ends after its children.

## Running the toy tracer

The real output of the flow above. Real SDKs use context variables instead of a global list so async tasks and threads keep separate stacks.

```python
from contextlib import contextmanager

stack = []

@contextmanager
def span(name):
    parent = stack[-1] if stack else None
    stack.append(name)
    print(f"start {name} (parent={parent})")
    try:
        yield
    finally:
        stack.pop()
        print(f"end   {name}")

with span("chat-request"):
    with span("retrieve-docs"):
        pass
    with span("llm-call"):
        pass
```

Output:

```
start chat-request (parent=None)
start retrieve-docs (parent=chat-request)
end   retrieve-docs
start llm-call (parent=chat-request)
end   llm-call
end   chat-request
```

**Quiz:** In a trace tree, what is the relationship between a retrieval span and an LLM span that both run directly inside the root span?

- [ ] The LLM span is the parent of the retrieval span
- [ ] They belong to two different traces
- [x] They are siblings: both are children of the root span
- [ ] The retrieval span is the root

*Answer:* They are siblings: both are children of the root span. Both were opened while the root was the current span, so both record the root as their parent, making them siblings.
