Split View: Langfuse 트레이싱 데이터 모델 — trace, observation, score가 한 번의 실행을 담는 방식
Langfuse 트레이싱 데이터 모델 — trace, observation, score가 한 번의 실행을 담는 방식
- 들어가며 — 대시보드보다 데이터 모델이 먼저다
- 세 층으로 나뉜다 — trace, observation, score
- observation이 갈리는 세 갈래 — span, generation, event
- 중첩이 필요한 이유 — RAG 한 번의 실행이 남기는 트리
- trace 위에 얹히는 두 층 — session과 user
- score — 평가 결과가 붙는 자리
- 저장 형태를 알아야 질의가 어긋나지 않는다
- 마치며 — 먼저 정할 것과 나중에 고칠 것
- 직접 해보기
- 시리즈
- 참고 자료
들어가며 — 대시보드보다 데이터 모델이 먼저다
LLM 애플리케이션에 트레이싱을 붙이는 작업은 보통 이렇게 시작합니다. SDK를 설치하고, 키를 넣고, 화면에 트레이스가 들어오는 것을 확인하고 만족합니다. 그리고 두 달쯤 뒤에 "지난주에 답변 품질이 떨어진 요청들이 어떤 검색 단계를 거쳤는지" 같은 질문이 들어오면 그때 막힙니다.
이 시리즈는 Langfuse를 구현 관점에서 다룹니다. 도구 비교가 아니라 데이터가 실제로 어떻게 수집되고 어디에 저장되며 어떻게 다시 꺼내지는지를 봅니다. 첫 글은 데이터 모델입니다. 이걸 먼저 이해해야 나머지 다섯 편이 읽힙니다.
구성과 설정 이름은 2026-08-15에 공식 문서에서 확인했습니다. Langfuse는 버전에 따라 아키텍처가 달라지므로 사용 중인 버전의 문서를 다시 확인하세요. 이 시리즈가 기준으로 삼은 것은 자체 호스팅 메이저 버전 v4입니다. 문서의 버전 정책 페이지는 v2를 수명 종료, v3를 폐기 예정, v4를 정식 출시로 표시하고 있습니다.
세 층으로 나뉜다 — trace, observation, score
Langfuse의 데이터 모델 문서가 정의하는 핵심 객체는 세 가지입니다.
trace는 한 번의 요청 또는 작업을 나타냅니다. 챗봇에 사용자가 한 번 말을 걸어 답을 받기까지가 하나의 trace입니다. 문서의 표현으로는 같은 trace_id를 공유하는 observation들의 논리적 묶음입니다.
observation은 그 안에서 애플리케이션이 수행한 개별 단계입니다. 모델 호출, 도구 실행, 검색 단계가 각각 하나의 observation이 됩니다. 중요한 것은 이들이 애플리케이션 구조를 반영해 중첩된다는 점입니다. 평평한 목록이 아니라 트리입니다.
score는 평가 결과입니다. 스코어 문서는 이를 평가 결과를 저장하는 범용 객체로 설명합니다. 사람이 손으로 매긴 점수, LLM 심사 결과, 코드 평가 결과가 전부 같은 형태로 여기에 모입니다.
흔한 오해는 trace를 observation을 담는 컨테이너 테이블로 상상하는 것입니다. 실제 저장 형태는 다르며, 이 부분은 아래에서 다시 다룹니다.
observation이 갈리는 세 갈래 — span, generation, event
observation은 하나의 타입이 아닙니다. 문서는 LLM에 특화된 타입으로 generation과 event를 언급하고, 파이썬 SDK는 LangfuseSpan, LangfuseGeneration, LangfuseEvent 세 클래스를 노출합니다.
| 타입 | 무엇을 담나 | 시간 | 특징 |
|---|---|---|---|
| span | 일반적인 작업 구간 | 시작과 끝이 있음 | 검색, 전처리, 도구 호출 같은 단계 |
| generation | 모델 호출 | 시작과 끝이 있음 | 모델 이름, 토큰 사용량, 비용을 함께 담음 |
| event | 한 시점의 사건 | 시점 하나 | 가드레일 발동, 캐시 적중 같은 표시 |
generation이 따로 있는 이유는 명확합니다. 비용과 토큰이라는 축이 여기에만 있기 때문입니다. 토큰과 비용 추적 문서에 따르면 사용량은 usage_details에, 비용은 cost_details에 담깁니다. 사용량 타입은 제공자마다 다른 임의의 문자열이며, 가장 단순하게는 입력과 출력 두 가지입니다. 그리고 사용량이 들어왔고 가격이 정의된 모델과 매칭되면 Langfuse가 수집 시점에 비용을 계산해 넣습니다.
실무적 함의는 분명합니다. 모델 호출을 span으로 남기면 비용 집계에서 사라집니다. 자체 호스팅 모델을 쓴다면 프로젝트 설정의 모델 정의에 정규식 패턴과 가격을 등록해야 그때부터 비용이 붙습니다.
중첩이 필요한 이유 — RAG 한 번의 실행이 남기는 트리
왜 평평한 로그가 아니라 트리여야 하는지는 실제 실행 하나를 그려 보면 바로 보입니다. 사용자 질문 하나가 RAG 파이프라인을 통과할 때 남는 구조는 대략 이렇습니다. 아래는 개념을 보여 주는 예시입니다.
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
이 트리가 답해 주는 질문은 평평한 로그가 답하지 못합니다. 전체 응답이 1.8초 걸렸을 때 그중 검색이 0.42초를 썼고 답변 생성이 1.09초를 썼다는 분해는 부모와 자식 관계가 있어야 계산됩니다. 실패도 마찬가지입니다. 어느 단계에서 예외가 났는지, 그 단계의 입력이 무엇이었는지는 그 단계가 독립된 observation일 때만 남습니다.
에이전트는 한 단계 더 나갑니다. 도구 호출이 몇 번 일어날지 실행 전에 알 수 없어 같은 이름의 형제 노드가 반복해 붙습니다. 루프를 세 바퀴 돈 실행과 열 바퀴 돈 실행이 같은 trace 이름으로 남고, 차이는 자식 observation의 개수와 깊이로만 드러납니다. "도구 호출이 다섯 번을 넘긴 실행"을 찾는 질의는 이 구조 위에서만 성립합니다.
trace 위에 얹히는 두 층 — session과 user
trace 하나로는 대화를 설명하지 못합니다. 여러 턴이 오가는 상호작용은 trace 여러 개로 남기 때문입니다. Langfuse는 이를 session으로 묶습니다. 문서는 관계를 하나의 session이 여러 trace를 가진다고 표기하며, session 식별자는 200자 미만의 US-ASCII 문자열이면 무엇이든 쓸 수 있다고 설명합니다.
사용자 축은 별도 속성으로 붙습니다. 데이터 모델 문서가 명시하는 trace 수준 속성은 네 가지입니다.
user_id— 최종 사용자 식별자session_id— 대화 또는 상호작용 묶음tags— 기능이나 워크플로 분류metadata— 임의의 키와 값
여기에 배포 맥락을 나누는 environment와 애플리케이션 버전을 표시하는 release, version이 더해집니다. 이 값들을 언제 정하느냐가 나중에 질의 가능한 축을 결정합니다. 뒤늦게 붙이면 과거 데이터에는 없습니다.
문서가 명시하는 중요한 동작이 하나 더 있습니다. trace 수준 속성은 그 trace에 속한 모든 observation으로 자동 전파됩니다. 이 문장이 다음 절과 3편의 저장 계층 이야기로 곧장 이어집니다.
score — 평가 결과가 붙는 자리
score는 네 가지 필드로 이루어집니다. 이름, 값, 데이터 타입, 그리고 선택적인 코멘트입니다. 데이터 타입은 문서 기준으로 네 종류입니다.
| 데이터 타입 | 값의 형태 | 쓰임 |
|---|---|---|
| NUMERIC | 실수 | 정확도, 관련성 같은 연속 측정 |
| CATEGORICAL | 미리 정한 문자열 | 알려진 선택지 중 하나로 분류 |
| BOOLEAN | 0 또는 1 | 통과와 실패 |
| TEXT | 1자에서 500자 자유 문자열 | 정성적 주석 |
TEXT 타입에는 제약이 있습니다. 문서는 TEXT score를 실험, LLM 심사 평가자, 분석에서 쓸 수 없다고 명시합니다. 자유 서술은 사람이 읽는 용도이지 집계 축이 아니라는 뜻입니다.
붙는 자리도 네 곳입니다. trace, observation, session, 그리고 데이터셋 실행입니다. 가장 흔한 것은 trace에 붙는 형태이고, 이것이 종단 평가에 해당합니다. v4에서 눈여겨볼 변화는 평가자가 trace 수준에서 observation 수준으로 내려왔다는 점입니다. 버전 정책 문서는 v3에서 v4로 넘어오며 일어난 변화 중 하나로 이를 명시합니다. 파이프라인 중간의 검색 단계 하나만 따로 평가하는 일이 가능해졌다는 의미입니다.
저장 형태를 알아야 질의가 어긋나지 않는다
앞에서 미뤄 둔 부분입니다. 데이터 모델 문서는 저장을 이렇게 설명합니다. 개념적으로 하나의 observation 테이블이 있고, 각 행은 observation 수준 데이터에 더해 trace 수준 속성의 사본을 함께 담습니다.
이 한 문장이 실무에서 세 가지를 결정합니다.
- 비정규화가 기본입니다.
user_id로 필터링하는 질의는 조인 없이 처리됩니다. 관계형 데이터베이스를 상상하고 조인을 설계하면 방향이 어긋납니다. - trace 속성은 사후 수정이 값싸지 않습니다. 사본이 observation 수마다 존재하기 때문입니다.
- 관측 데이터에 맞는 저장 엔진이 필요합니다. 넓고 반복이 많은 행을 대량으로 스캔하는 패턴은 컬럼 지향 저장이 잘하는 일입니다. Langfuse가 trace, observation, score를 ClickHouse에 두는 이유가 여기 있고, 3편에서 이 부분을 자세히 봅니다.
전송 방식도 알아 두면 좋습니다. 문서는 Langfuse가 trace를 로컬에서 배치로 모아 백그라운드로 보낸다고 설명합니다. 요청 경로를 동기적으로 막지 않기 위해서입니다. 그리고 SDK 전체가 OpenTelemetry 표준 위에 서 있습니다. 이 두 가지의 실제 동작은 2편에서 다룹니다.
마치며 — 먼저 정할 것과 나중에 고칠 것
데이터 모델에서도 나중에 고치기 쉬운 것과 어려운 것이 갈립니다. score를 추가하고 대시보드를 다시 그리고 태그를 늘리는 일은 언제든 할 수 있습니다. 반면 user_id와 session_id를 어떤 값으로 채울지, 어디까지를 하나의 trace로 볼지, 어떤 단계를 별도 observation으로 남길지는 초기에 정해야 합니다. 나중에 바꾸면 과거 데이터와의 연결이 끊깁니다.
지금 할 수 있는 점검은 간단합니다. 지난달에 받았던 질문 세 개를 적고, 지금 남기는 구조로 답할 수 있는지 확인해 보세요. 답할 수 없다면 빠진 것은 대시보드가 아니라 계측입니다.
도구 선택이 아직 끝나지 않았다면 LLM 프로덕션 모니터링 플랫폼 비교를 먼저 읽는 편이 좋습니다. 이 시리즈는 Langfuse를 쓰기로 정한 다음의 이야기입니다.
직접 해보기
- DuckDB 데이터 분석 놀이터 — 중첩된 트리를 평평한 테이블로 펴서 부모 자식 관계를 질의로 풀어 보세요.
- PostgreSQL 놀이터 — trace와 observation을 관계형으로 모델링해 보면 왜 비정규화가 선택되는지 체감할 수 있습니다.
시리즈
참고 자료
- Langfuse 데이터 모델: https://langfuse.com/docs/observability/data-model
- Langfuse Scores 개요: https://langfuse.com/docs/evaluation/scores/overview
- Langfuse Sessions: https://langfuse.com/docs/observability/features/sessions
- Langfuse 토큰과 비용 추적: https://langfuse.com/docs/observability/features/token-and-cost-tracking
- Langfuse 버전 정책: https://langfuse.com/self-hosting/versioning
The Langfuse Tracing Data Model — How Trace, Observation, and Score Hold One Execution
- 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