part = 1 / 5

What Phoenix actually stores

Five nouns appear in the Phoenix interface: session, turn, trace, root span, span. One is a database row with real content, one is a container holding nothing but timestamps, one is a query predicate with two competing definitions, one is an attribute convention, and one is a styling decision. Let's take them in order of how real they are.

spanfirst-class
A database row with a JSON attribute bag, events, status, and cached token counts. The only noun with content of its own.
tracederived
A row that exists to be pointed at: ids and timestamps, nothing else. Latency, cost, tokens, input, output — all computed from its spans at query time.
root spanderived
Not a row at all — a WHERE clause. Phoenix currently ships two versions of the clause that disagree when spans go missing.
sessionconvention
A row created because some span carried a session.id attribute. First writer wins; disagreements are silently ignored.
turnUI fiction
No table, no column, no API type. A turn is a trace, wearing its root span's input and output as a costume.

The span: the one real thing

Spans arrive over OTLP — Phoenix runs a gRPC collector endpoint (src/phoenix/server/grpc_server.py) and an HTTP one (POST /v1/traces, protobuf only, src/phoenix/server/api/routers/v1/traces.py:293). Each span lands as a row in spans with its identifiers (span_id, nullable parent_id, trace_rowid), name, kind, start/end times, a JSON attributes column, events, and status (src/phoenix/db/models.py:817).

On top of raw OTel, Phoenix expects OpenInference semantic conventions — a vocabulary of attribute keys that says "this span is an LLM call" (openinference.span.kind), "here is its prompt" (llm.input_messages.*), "here is what it cost" (llm.token_count.prompt). Conventions are how flat attribute bags acquire meaning; they are also just strings, which means they are promises, not guarantees. Phoenix trusts them at ingest: token counts get denormalized into real columns, and — notably — cumulative token counts get written onto every ancestor span via upward propagation at insert time (src/phoenix/db/insertion/span.py:164). Remember that write-amplification trick; it comes back in Part 5.

The trace: a container containing nothing

Phoenix does materialize a traces table, which makes it more of a "thing" here than in OTel proper, where a trace is nothing but a shared id. But look at the columns (src/phoenix/db/models.py:758): trace_id, a project foreign key, a nullable session foreign key, start_time, end_time. That's the entire record. No attributes, no status, no input, no output, no cost.

So a Phoenix trace is best understood as a rendezvous point: a row that exists so that spans, a session pointer, and annotations have something to join against. It is a label, not a box. This has a user-facing consequence worth stating plainly: you cannot say anything about a trace directly. You can't tag it, you can't set metadata on it, you can't mark it failed. Anything trace-shaped you want to express must be smuggled in through a span — in practice, the root span — or attached after the fact as an annotation. Whether that's a defect or a discipline is the subject of Part 2.

The root span: a predicate, currently two predicates

Since the trace row is empty, the root span does a lot of work: it is the trace's face in every list view, the source of the trace's "input" and "output," and — as we'll see — the body double that receives the trace's annotations in the sessions UI. Which makes it uncomfortable that "root span" means two different things in the codebase:

Why would a parent "never arrive"? Because spans export when they end. A crashed process, a sampled-out parent, a collector hiccup, or simply a root span that hasn't finished yet — all of these produce a trace whose spans reference a parent that isn't in the table. Try it:

The root span resolver Click a span to make it "never arrive"
GraphQL Trace.rootSpan — strict parent_id IS NULL
Span list with rootSpansOnly — orphan-aware CTE
Drop handle_message and the two definitions diverge: the trace list and the sessions page (both built on rootSpan) show an empty-faced trace, while the trace's own span view happily promotes the orphan. Same trace, two answers to "what is this?"

The session: an attribute with a table

Sessions are OpenInference's answer to "this request was part of a conversation." The contract: the application stamps session.id (and nothing else — conversation.id is not honored) onto its spans, and Phoenix groups traces that share the stamp. The mechanics are worth knowing exactly, because they're where the abstraction leaks (src/phoenix/db/insertion/span.py:62–99):

Session derivation, first-writer-wins Assign session.id attributes; spans bind in arrival order
Arrival order is end-time order, so the deepest, fastest span usually writes first. Put sess-A on the tool span and sess-B on the root: the trace lands in sess-A and nobody is told about sess-B. There is no UI surface, log line, or annotation that records the conflict.

The practical upshot for instrumentation authors: session identity is the application's job, done span-by-span, with no referee. Phoenix cannot validate it, only believe it. This is the single biggest difference between "sessions" in Phoenix and sessions in systems that treat conversations as first-class server-side objects — a comparison Part 3 makes concrete.

The turn: a costume the trace wears

The sessions detail page shows a conversation: alternating user and assistant messages, one pair per "turn." Here is what a turn is made of (app/src/pages/trace/SessionDetailsTraceList.tsx):

Every one of those is a reasonable move locally, and the stack of them is how you get an interface that quietly depends on four assumptions at once: the trace has a root span (see resolver above), the root span carries chat-shaped I/O, one trace equals one conversational exchange, and annotating the root span is the same as annotating the turn. All four hold for a simple chatbot. Part 5 is about the systems for which none of them hold.

Annotations: the capability/interface gap, in one table

Phoenix's annotation model is genuinely good: annotations are separate rows (LLM, code, or human annotator; label + score + explanation) attached to a target, which sidesteps OTel's span immutability entirely. The schema supports four targets (src/phoenix/db/models.py): spans, traces, sessions, and retrieved documents. The interface supports fewer:

LevelDB tableAPI mutationsUI: viewUI: create
Spanspan_annotations
Documentdocument_annotations✓ (per retrieved chunk)
Tracetrace_annotationspartial — no dedicated flow; evals land here
Sessionproject_session_annotations✓ (createProjectSessionAnnotations)✓ (summaries, timeseries)✗ — no create/edit surface exists

So: the data model can annotate a session; the API can annotate a session; the UI shows session annotations that arrived through the API; and a human looking at a session in the UI cannot make one. The button that looks like it does this annotates a span instead. This isn't a data modeling problem — it's the interface failing to keep up with the model, which is precisely the gap Part 4 proposes to close.

part 1 in one paragraph Phoenix stores spans. Traces and sessions are join keys with timestamps; root spans are a predicate (two predicates, until someone unifies them); turns are presentation. The annotation system is built for all three levels but only surfaced for one and a half. None of this is hidden — it's all right there in models.py — but the interface presents the derived things with the same visual confidence as the real thing, and the seams show exactly where you'd predict: missing roots, conflicting session ids, and annotation buttons that aim one level below where they point.