- Published on
The Langfuse Tracing Data Model — How Trace, Observation, and Score Hold One Execution
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Opening — The Data Model Comes Before the Dashboard
- Three Layers — Trace, Observation, Score
- Three Kinds of Observation — Span, Generation, Event
- Why Nesting Is Necessary — The Tree One RAG Run Leaves
- Two Layers Above the Trace — Session and User
- Score — Where Evaluation Results Attach
- Knowing the Storage Shape Keeps Your Queries Honest
- Closing — What to Decide Now and What to Fix Later
- Try It Yourself
- Series
- References
Opening — The Data Model Comes Before the Dashboard
Adding tracing to an LLM application usually starts the same way. Install the SDK, drop in the keys, watch traces land in the UI, feel satisfied. Then two months later someone asks "which retrieval steps did last week's low-quality answers go through?" and you are stuck.
This series covers Langfuse from an implementation angle. Not a tool comparison — how the data actually gets collected, where it is stored, and how it comes back out. The first post is the data model. Understand it and the other five read easily.
Component and configuration names here were verified against the official documentation on 2026-08-15. Langfuse's architecture differs by version, so re-check the docs for the version you are running. This series is written against self-hosted major version v4. The versioning page marks v2 as end of life, v3 as deprecated, and v4 as generally available.
Three Layers — Trace, Observation, Score
The data model documentation defines three core objects.
A trace represents a single request or operation. One user turn in a chatbot, from question to answer, is one trace. In the documentation's own words, it is the logical grouping of observations that share the same trace_id.
An observation is an individual step the application performed inside that trace. A model call, a tool execution, a retrieval step each become one observation. The important part is that they nest to reflect the structure of the application. Not a flat list — a tree.
A score is an evaluation result. The scores documentation describes it as the universal object for storing evaluation results. Human ratings, LLM-as-a-judge output, and code evaluator output all land here in the same shape.
The common misconception is to imagine a trace as a container table holding observations. The actual storage shape is different, and the section below returns to it.
Three Kinds of Observation — Span, Generation, Event
An observation is not one type. The documentation names generation and event as the LLM-specific types, and the Python SDK exposes three classes: LangfuseSpan, LangfuseGeneration, and LangfuseEvent.
| Type | What it holds | Timing | Typical use |
|---|---|---|---|
| span | A general unit of work | Has a start and an end | Retrieval, preprocessing, tool calls |
| generation | A model call | Has a start and an end | Carries model name, token usage, cost |
| event | A point-in-time occurrence | A single instant | Guardrail fired, cache hit |
The reason generation exists separately is clear: cost and tokens only live here. According to the token and cost tracking documentation, usage lands in usage_details and cost in cost_details. Usage types are arbitrary strings that differ by provider, and at the simplest level they are just input and output. When usage is ingested or inferred and a matching model definition carries prices, Langfuse calculates cost at ingestion time.
The practical implication is direct. Record a model call as a span and it disappears from cost aggregation. If you run self-hosted models, you have to register a model definition with a regex pattern and prices in project settings before cost attaches at all.
Why Nesting Is Necessary — The Tree One RAG Run Leaves
Why a tree and not a flat log becomes obvious the moment you draw one real execution. Here is roughly what a single user question leaves behind as it passes through a RAG pipeline. The following is illustrative.
trace chat-request [user_id, session_id, tags, metadata]
└─ span rag-pipeline 1,840 ms
├─ span retrieve 420 ms
│ ├─ generation embed-query 90 ms usage_details, cost_details
│ └─ span vector-search 310 ms metadata: top_k, index
├─ span rerank 260 ms
│ └─ generation rerank-call 250 ms
├─ generation answer 1,090 ms model, usage_details, cost_details
└─ event guardrail-hit metadata: rule=pii
A flat log cannot answer what this tree answers. Breaking a 1.8 second response into 0.42 seconds of retrieval and 1.09 seconds of answer generation requires the parent-child relationship. Failures work the same way. Which step raised the exception, and what its input was, only survives when that step is its own observation.
Agents push this further. You cannot know how many tool calls will happen before the run starts, so sibling nodes with the same name repeat. A run that looped three times and one that looped ten times land under the same trace name, and the difference shows up only as the count and depth of child observations. A query for "runs where tool calls exceeded five" only holds together on top of this structure.
Two Layers Above the Trace — Session and User
One trace does not describe a conversation, because a multi-turn interaction leaves multiple traces. Langfuse groups them into a session. The documentation states the relationship as one session to many traces, and says the session identifier can be any US-ASCII string under 200 characters.
The user axis attaches as a separate attribute. The data model documentation names four trace-level attributes.
user_id— the end-user identifiersession_id— a conversation or interaction groupingtags— feature or workflow categorizationmetadata— arbitrary keys and values
On top of these sit environment for deployment context, and release and version for application versions. When you decide these values determines which axes you can query later. Attach them late and the past data does not have them.
There is one more behaviour the documentation is explicit about. Trace-level attributes are automatically propagated to every observation inside the trace. That sentence leads straight into the next section and into the storage-layer post later in this series.
Score — Where Evaluation Results Attach
A score is made of four fields: name, value, data type, and an optional comment. The documentation lists four data types.
| Data type | Value shape | Use |
|---|---|---|
| NUMERIC | Float | Continuous measures such as accuracy or relevance |
| CATEGORICAL | Predefined string | Classification into known options |
| BOOLEAN | 0 or 1 | Pass and fail |
| TEXT | Free string, 1 to 500 characters | Qualitative annotation |
TEXT carries a restriction. The documentation states that TEXT scores cannot be used in experiments, LLM-as-a-judge evaluators, or analytics. Free-form prose is for humans to read, not an aggregation axis.
There are four places a score can attach: traces, observations, sessions, and dataset runs. Attaching to the trace is the most common, and that is end-to-end evaluation. The change worth noting in v4 is that evaluators moved from trace level down to observation level. The versioning documentation names this as one of the shifts from v3 to v4. It means a single retrieval step in the middle of a pipeline can now be evaluated on its own.
Knowing the Storage Shape Keeps Your Queries Honest
Back to the part deferred above. The data model documentation describes storage this way: conceptually there is one observations table, and each row holds the observation-level data plus a copy of the trace-level attributes.
That single sentence settles three practical things.
- Denormalization is the default. A query filtering on
user_idneeds no join. Design joins as if this were a relational database and you are pointed the wrong way. - Trace attributes are not cheap to change after the fact, because a copy exists on every observation row.
- You need a storage engine built for this. Scanning wide, highly repetitive rows in bulk is exactly what columnar storage is good at. That is why Langfuse puts traces, observations, and scores in ClickHouse, and the third post in this series looks at that closely.
The transport is worth knowing too. The documentation says Langfuse batches traces locally and sends them in the background, so the request path is never blocked synchronously. And the SDKs sit on top of the OpenTelemetry standard. How both of those behave in practice is the second post.
Closing — What to Decide Now and What to Fix Later
Some parts of the data model are easy to change later and some are not. Adding a new score, redrawing a dashboard, and adding tags can happen any time. What values fill user_id and session_id, where the boundary of a single trace sits, and which steps get their own observation have to be decided early. Change them later and the link to past data breaks.
The check you can run today is simple. Write down three questions you were asked last month and see whether the structure you record right now can answer them. If it cannot, what is missing is not a dashboard but instrumentation.
If the tool choice itself is not settled yet, read Comparing LLM Production Monitoring Platforms first. This series is the story after you have decided on Langfuse.
Try It Yourself
- DuckDB Data Playground — flatten a nested tree into a table and work out the parent-child relationships as queries.
- PostgreSQL Playground — modelling traces and observations relationally is the fastest way to feel why denormalization wins here.
Series
References
- Langfuse data model: https://langfuse.com/docs/observability/data-model
- Langfuse scores overview: https://langfuse.com/docs/evaluation/scores/overview
- Langfuse sessions: https://langfuse.com/docs/observability/features/sessions
- Langfuse token and cost tracking: https://langfuse.com/docs/observability/features/token-and-cost-tracking
- Langfuse versioning: https://langfuse.com/self-hosting/versioning