Langfuse는 자체적인 데이터 수집 프로토콜을 고집하는 대신, CNCF의 표준인 OpenTelemetry(OTel)를 기본 수집 파이프라인으로 채택했습니다. 물론 Python이나 JS/TS SDK를 사용하면 대부분이 추상화되어 크게 이해할 필요가 없지만, 작동 방식으로 조금 더 소상히 이해하거나 다른 언어를 사용하여 Langfuse를 제어하고자 하는 경우에는 OTel과 추상을 어떻게 맞대고 있는지 파악해야 합니다.
Langfuse에서 모든 관측 데이터의 컨테이너 역할을 하는 최상위 객체는 트레이스(Trace)입니다.
<aside> 💡
Langfuse JAVA SDK
찾아보다 보면 Langfuse JAVA SDK를 발견할 수 있지만, Python이나 JS/TS SDK와 달리 트레이스(Traces), 스팬(Spans), 생성(Generations), 이벤트(Event)의 직접적인 기록을 지원하지 않습니다. REST API를 사용하거나, OTel 라이브러리를 사용하세요.
</aside>
Trace이외에도 Langfuse문서에서 등장하는 트레이스, 스팬같은 용어가 등장합니다. 이것들은 Otel이 정한 데이터 구조(Protocol)입니다. Langfuse가 만든 말이 아닙니다. 단, 생성(Generation)이나 이벤트(Event)는 Langfuse 용어입니다. Otel은 세상의 모든 작업을 그냥 스팬이라고 부릅니다.
<aside> 💡
트레이스
모든 관측 데이터의 컨테이너 역할을 하는 최상위 객체는 트레이스라고 했습니다. 이 또한 OTel과 Langfuse 공통입니다. 하지만 기술적으로 트레이스라는 껍데기를 먼저 만들고 그 안에 스팬을 넣는 것이 아닙니다. 부모가 없는 첫 번째 스팬이 생성되는 순간, 새로운 트레이스 ID가 발급되면서 자동으로 하나의 트레이스가 시작된다고 봅니다. 즉, 루트 스팬에 특별히 트레이스라는 이름을 붙여 주었다고 이해하는 편이 낫습니다.
</aside>
하지만 Langfuse는 LLM 앱을 분석하는 도구라서, 이 스팬을 좀 더 세분화해서 관리하고 싶어 합니다(ref1). 여기서부터 정말 헷갈립니다. OTel의 스팬 중에서 LLM이 답변을 생성한 스팬을 Langfuse에서는 특별히 생성이라고 부르고 예쁘게 보여줍니다. OTel의 스팬 중에서 시간의 흐름 없이 찰나에 발생한 로그의 성격을 가지는 스팬은 특별히 이벤트라고 부르고 예쁘게 보여줍니다. Langfuse는 생성, 이벤트, 스팬을 통틀어 관측(Observation)이라는 상위 개념을 만들었습니다. 어쨌든 관측(스팬, 생성, 이벤트)는 OTel 입장에서는 모두 스팬 타입이라는 것이 중요합니다.
<aside> 💡
이벤트
OTel에서 말하는 이벤트와 Langfuse에서 말하는 이벤트는 동일한 클래스가 아닙니다. 정확히 말하면, OTel에는 이벤트라는 단어를 쓰는 개념이 두 가지가 있습니다. 첫째는 스팬 이벤트이고(ref4), 둘째는 LogRecord 이벤트입니다(ref2). LogRecord 이벤트는 Loki 같은 로그 전용 시스템으로 데이터를 보낼 때의 맥락에서 사용되는 이벤트입니다(ref3). 한편, Langfuse는 트레이스 중심의 도구이기 때문에 두 번째 개념은 고려할 필요가 없습니다.
Langfuse 이벤트의 존재 목적은 스팬 이벤트의 성격을 가지지만, 스팬 이벤트로 구현되지는 않고 스팬으로 구현됩니다. OTel 표준 스팬 이벤트 추가는 span.add_event("new event")와 같은 방식으로 구현합니다. 하지만 Langfuse 관측 타입 중 하나인 이벤트는 OTel 관점에서 보면 langfuse.observation.type 속성에 이벤트를 명시하여 구현합니다. span.set_attribute("langfuse.observation.type", "event")
</aside>

↔ 모양은 모두 스팬, 빨간 바람개비는 생성, 점 모양은 이벤트입니다.
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("chat-message"):
with tracer.start_as_current_span("retrieval"):
with tracer.start_as_current_span("vector_store"):
with tracer.start_as_current_span("context-encoding"):
...
with tracer.start_as_current_span("llm-response") as span:
span.set_attribute("langfuse.observation.type", "generation")
...
with tracer.start_as_current_span("postprocessing"):
...
with tracer.start_as_current_span("response-sent") as span:
span.set_attribute("langfuse.observation.type", "event")
...
OTel 표준으로 Langfuse로 트레이스(최상위 객체)를 보낼 때, Langfuse에서 정확한 집계와 필터링이 수행되려면 다음 트레이스 레벨 속성들이 모든 스팬에 전파되어야 합니다. 사용자의 ID(langfuse.user.id 또는 user.id 사용), 세션의 ID(langfuse.session.id 또는 session.id 사용, 메타데이터(최상위 메타데이터 키의 경우 langfuse.trace.metadata.* 사용), 버전 정보(langfuse.version 사용), 릴리즈 정보(langfuse.release 사용), 태그(langfuse.trace.tags 사용)을 전파하세요.
<aside> 💡
langfuse.user.id vs user.id
Langfuse는 위 트레이스 레벨 속성들이 가령 langfuse.user.id 또는 user.id 중 어느 형태로 주어지더라도 모두 인식할 수 있습니다. 단, langfuse.*의 인식 우선순위가 높으므로, Langfuse에만 관련된 정보로 분리하고자 할 때 사용하면 됩니다.
</aside>
이러한 속성을 모든 스팬에 전파할 때 OpenTelemetry Baggage 사용을 권장합니다. Baggage는 컨텍스트 전파를 위한 OTel의 내장 메커니즘입니다. 지정된 키-값 쌍을 트레이스 컨텍스트 내의 모든 스팬에 자동으로 복사해 줍니다.
스팬을 Langfuse상에서 '생성(Generation)' 데이터로 표시하기 위해서는 Langfuse가 인식할 수 있는 특정 속성(Attribute)을 스팬에 포함시켜야 합니다.
gen_ai.*는 OTel 표준에 포함되는 규격입니다. Langfuse는 이 표준을 지원하므로, gen_ai.request.model이라고만 보내도 Langfuse가 알아서 "아, 이건 모델 이름이구나" 하고 인식해서 대시보드에 예쁘게 그려줍니다. 반드시 모델명(gen_ai.request.model)와 LLM 입력(gen_ai.prompt), LLM 출력(gen_ai.completion 또는 gen_ai.response.completion)를 제공해야 합니다. 이외에도 다음의 속성들을 설정하면 Langfuse가 이를 자동으로 파싱하여 대시보드에 매핑합니다.
gen_ai.usage.input_tokens: 프롬프트 토큰 수.
gen_ai.usage.output_tokens: 완성 토큰 수.
gen_ai.request.temperature: 모델의 온도 설정값.
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("chat-message") as root_span:
root_span.set_attribute("langfuse.user.id", "q2kj3b1kjggsaapvskjkn")
root_span.set_attribute("langfuse.session.id", "session_abc123")
with tracer.start_as_current_span("response-sent") as span:
# 필수
span.set_attribute("gen_ai.prompt", json.dumps(messages, ensure_ascii=False))
span.set_attribute("gen_ai.request.model", "gpt-4o")
response = client.chat.completions.create(model=model_name, messages=messages)
answer_text = response.choices[0].message.content
# 필수
span.set_attribute("gen_ai.completion", answer_text)
span.set_attribute("gen_ai.usage.input_tokens", response.usage.prompt_tokens)
span.set_attribute("gen_ai.usage.output_tokens", response.usage.completion_tokens)
<aside> 💡
관측 타입 명시 vs 자동 추론
gen_ai.request.model이 포함되면 langfuse.observation.type을 generation으로 설정하지 않아도 해당 스팬이 단순한 함수 호출이 아닌 LLM 생성 작업으로 인식되어 토큰 및 비용 차트가 활성화됩니다. 이벤트로 인식시키고 싶다면 langfuse.observation.type을 event로 설정하세요(ref5).
</aside>