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
WHEREclause. Phoenix currently ships two versions of the clause that disagree when spans go missing. - sessionconvention
- A row created because some span carried a
session.idattribute. 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.
- Latency is a hybrid property,
end_time − start_time(models.py:772). - Cost is aggregated from
span_costsby a dataloader at query time (Trace.py:445). - Tokens are summed from span columns; the traces table has none.
- "The trace's input/output" — the thing every user actually wants — doesn't exist. The UI substitutes the root span's
input.valueandoutput.value.
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:
-
The GraphQL
Trace.rootSpanfield means strictlyparent_id IS NULL(src/phoenix/server/api/dataloaders/trace_root_spans.py:26). -
The span-list queries mean
parent_id IS NULLor "the parent never arrived" — an orphan-aware CTE, on by default (src/phoenix/server/api/types/Trace.py:308, andorphan_span_as_root_span: bool = Truein the REST API,routers/v1/spans.py:157).
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:
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):
- The id is read from any span in the trace, not specifically the root.
- The first span to arrive carrying a
session.idbinds the whole trace to that session. - Every subsequent
session.idin the same trace — including a conflicting one — is silently ignored. - The
project_sessionsrow stores a session id string and a time range that widens as traces attach (models.py:735). Like the trace, it contains nothing else.
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):
- A turn is a trace — each trace in the session renders as exactly one turn.
- The user message is the root span's
input.value; the assistant message is itsoutput.value(SessionTurnDetail, line 320 on). - The "user" label is parsed out of root-span attributes.
- The turn's Annotate button opens the span annotation dialog against the root span (line 281).
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:
| Level | DB table | API mutations | UI: view | UI: create |
|---|---|---|---|---|
| Span | span_annotations | ✓ | ✓ | ✓ |
| Document | document_annotations | ✓ | ✓ | ✓ (per retrieved chunk) |
| Trace | trace_annotations | ✓ | ✓ | partial — no dedicated flow; evals land here |
| Session | project_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.
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.