Skip to content

Split View: Langfuse SDK 계측 — 무엇이 자동으로 잡히고 무엇을 손으로 넣나

✨ Learn with Quiz
|

Langfuse SDK 계측 — 무엇이 자동으로 잡히고 무엇을 손으로 넣나

들어가며 — 계측은 경계를 긋는 일이다

1편에서 데이터 모델을 봤습니다. trace 하나가 observation 트리를 담고, trace 수준 속성이 아래로 전파된다는 구조였습니다. 이제 그 트리를 실제로 만드는 쪽입니다.

계측에서 실수가 나는 지점은 늘 같습니다. 자동으로 잡히는 범위를 과대평가하거나 과소평가하는 것입니다. 과대평가하면 정작 필요한 중간 단계가 비어 있고, 과소평가하면 이미 잡히는 것을 손으로 다시 넣어 트리가 두 겹이 됩니다.

구성과 설정 이름은 2026-08-15에 공식 문서에서 확인했습니다. Langfuse는 버전에 따라 아키텍처가 달라지므로 사용 중인 버전의 문서를 다시 확인하세요. 이 글은 서버 v4와 파이썬 SDK v4를 기준으로 합니다. 버전 정책 문서는 서버 v4에서 파이썬 SDK v4와 JS/TS SDK v5가 정식 지원이라고 밝히고 있습니다.

시작점 — 키 세 개와 싱글턴 클라이언트

시작하기 문서가 모든 통합에 공통으로 요구하는 환경 변수는 세 개입니다.

pip install langfuse

export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_BASE_URL="https://cloud.langfuse.com"

LANGFUSE_BASE_URL의 기본값은 EU 리전입니다. 문서는 US, 일본, HIPAA 리전에 각각 별도 호스트가 있다고 명시합니다. 자체 호스팅이라면 여기에 자기 인스턴스 주소를 넣습니다.

클라이언트는 싱글턴입니다. 파이썬 SDK 개요는 애플리케이션 어디서든 get_client()로 접근할 수 있다고 설명합니다. 설정을 코드로 직접 넘기고 싶을 때만 Langfuse() 생성자를 씁니다.

방식 하나 — 데코레이터

가장 적은 코드로 시작하는 방법입니다. 계측 문서observe()가 함수의 입력, 출력, 소요 시간, 오류를 자동으로 잡는다고 설명합니다.

from langfuse import observe

@observe()
def my_data_processing_function(data, parameter):
    return {"processed_data": data, "status": "ok"}

@observe(name="llm-call", as_type="generation")
async def my_async_llm_call(prompt_text):
    return "LLM response"

인자는 네 가지가 문서에 나옵니다. name으로 observation 이름을 바꾸고, as_type으로 span인지 generation인지 정하고, capture_inputcapture_output으로 입출력 수집을 끕니다. 기본값은 둘 다 참입니다.

capture_input을 끄는 경우는 생각보다 자주 옵니다. 함수 인자에 원문 프롬프트나 사용자 개인정보가 들어가는 경로가 그렇습니다. 다만 이건 부분적인 대책이고, 제대로 된 마스킹은 5편에서 다룹니다.

방식 둘 — 컨텍스트 매니저

함수 단위가 아니라 블록 단위로 자르고 싶을 때 씁니다. 문서는 start_as_current_observation()을 활성 OpenTelemetry 컨텍스트를 갱신하면서 observation을 만드는 기본 방법이라고 설명합니다.

from langfuse import get_client, propagate_attributes

langfuse = get_client()

with langfuse.start_as_current_observation(
    as_type="span",
    name="user-request-pipeline",
    input={"user_query": "Tell me a joke"},
) as root_span:
    with propagate_attributes(user_id="user_123", session_id="session_abc"):
        with langfuse.start_as_current_observation(
            as_type="generation",
            name="joke-generation",
            model="gpt-4o",
        ) as generation:
            generation.update(output="Why did the span cross the road?")

    root_span.update(output={"final_joke": "..."})

중첩이 그대로 트리가 됩니다. with 블록의 들여쓰기가 1편에서 그린 부모 자식 관계와 일대일로 대응합니다. 여기서 나온 model="gpt-4o"가 중요합니다. generation으로 만들고 모델 이름을 주어야 비용 축이 생깁니다.

현재 활성 observation을 참조 없이 갱신하는 방법도 있습니다. 문서는 update_current_span()update_current_generation()을 이 용도로 안내합니다. 깊은 호출 스택 아래에서 값을 덧붙일 때 유용합니다.

방식 셋 — 컨텍스트를 바꾸지 않는 수동 생성

문서는 활성 컨텍스트를 바꾸지 않고 직접 제어하고 싶을 때 start_observation()을 쓰라고 안내합니다.

from langfuse import get_client

langfuse = get_client()

span = langfuse.start_observation(name="manual-span")
span.update(input="Data for side task")

child = span.start_observation(name="child-span", as_type="generation")
child.end()
span.end()

이 방식이 필요한 곳은 비동기 작업, 백그라운드 태스크, 콜백처럼 시작과 끝이 같은 스코프에 있지 않은 코드입니다. 대신 end()를 부르는 책임이 온전히 사람에게 옵니다. 예외 경로에서 end()가 빠지면 그 observation은 끝나지 않은 채 남습니다.

trace 전체의 입출력을 루트 observation과 별개로 정하고 싶을 때는 set_trace_io() 또는 set_current_trace_io()를 씁니다. 루트가 내부 파이프라인 객체를 다루더라도 trace 목록에는 사용자가 실제로 물어본 문장이 보이게 하는 용도입니다.

속성을 아래로 흘려보내기 — propagate_attributes

1편에서 trace 수준 속성이 아래로 전파된다고 했습니다. 그 전파를 코드에서 켜는 지점이 propagate_attributes()입니다.

from langfuse import propagate_attributes

with propagate_attributes(
    user_id="user_123",
    session_id="session_abc",
    metadata={"experiment": "variant_a"},
    version="1.0",
    environment="staging",
    trace_name="user-workflow",
    as_baggage=True,
):
    run_pipeline()

문서에 나오는 인자는 위와 같습니다. as_baggage=True는 HTTP 헤더를 통한 서비스 간 전파를 켭니다. 게이트웨이와 워커가 분리된 구조에서 하나의 흐름을 이어 붙이려면 이 옵션이 필요합니다.

여기서 실무적으로 자주 틀리는 지점이 하나 있습니다. 이 값들은 블록 안에서 만들어지는 observation에 적용됩니다. 블록 밖에서 이미 만들어진 observation에는 붙지 않습니다. 그래서 propagate_attributes는 요청을 받은 가장 바깥쪽에서 열어야 합니다.

프레임워크 통합 — 자동으로 잡히는 범위

시작하기 문서가 나열하는 통합 경로는 OpenAI SDK, Vercel AI SDK, LangChain, 네이티브 SDK, OpenTelemetry, 그리고 LlamaIndex와 CrewAI, LiteLLM, AutoGen, Google ADK 같은 프레임워크들입니다. 설치 명령은 통합마다 다릅니다.

# 파이썬: OpenAI SDK 래퍼
pip install langfuse

# 파이썬: LangChain
pip install langfuse langchain-openai

# JS/TS: OpenAI 래퍼
npm install @langfuse/openai

# JS/TS: LangChain
npm install @langfuse/core @langfuse/langchain

# JS/TS: 네이티브 SDK
npm install @langfuse/tracing @langfuse/otel @opentelemetry/sdk-node

OpenAI 통합은 임포트를 바꾸는 것으로 끝납니다.

from langfuse.openai import openai

completion = openai.chat.completions.create(
    name="test-chat",
    model="gpt-4o",
    messages=[{"role": "user", "content": "1 + 1 = "}],
)
import OpenAI from "openai";
import { observeOpenAI } from "@langfuse/openai";

const openai = observeOpenAI(new OpenAI());

이런 통합이 자동으로 잡아 주는 것은 모델 호출 자체입니다. 모델 이름, 프롬프트, 응답, 토큰 사용량, 지연입니다. 잡아 주지 못하는 것은 그 호출들 사이에 있는 여러분의 코드입니다. 어떤 문서를 몇 개 가져왔는지, 리랭킹에서 순서가 어떻게 바뀌었는지, 어떤 분기를 탔는지는 손으로 넣어야 합니다.

경계를 정하는 기준은 단순합니다. 장애 회의에서 "그래서 어느 단계가 문제였나"라는 질문에 답해야 하는 단계는 전부 observation이어야 합니다.

OpenTelemetry와의 관계 — v4가 바꾼 것

파이썬 SDK 개요는 Langfuse SDK가 OpenTelemetry 위에 만들어졌다고 명시합니다. 그리고 버전 정책 문서는 v3에서 v4로 넘어오며 기존 배치 수집 방식을 OpenTelemetry로 교체했다고 밝힙니다. 이건 단순한 내부 구현 변경이 아니라 수집 경로 자체가 달라진 것이므로, v3 시절 자료를 보고 있다면 여기서 어긋납니다.

OpenTelemetry 통합 문서가 밝히는 수집 경로는 이렇습니다.

  • 엔드포인트 경로는 /api/public/otel입니다. 클라우드 리전마다 호스트가 다릅니다.
  • 프로토콜은 HTTP 위의 OTLP이며 HTTP/JSONHTTP/protobuf를 지원합니다. 문서는 gRPC를 아직 지원하지 않는다고 명시합니다.
  • 인증 헤더 두 개가 필요합니다.
# 예시: OTLP 익스포터에 넘기는 헤더
OTEL_EXPORTER_OTLP_ENDPOINT="https://cloud.langfuse.com/api/public/otel"
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic ${AUTH_STRING},x-langfuse-ingestion-version=4"

AUTH_STRING은 공개 키와 비밀 키를 base64로 인코딩한 값입니다. 두 번째 헤더인 x-langfuse-ingestion-version은 문서가 v4의 실시간 수집을 켜는 값으로 설명합니다.

속성 매핑 규칙도 문서에 있습니다. model 속성을 가진 스팬은 자동으로 generation 타입 observation이 됩니다. GenAI 시맨틱 규약 쪽에서는 gen_ai.system, gen_ai.request.model, gen_ai.response.model, gen_ai.prompt, gen_ai.completion, gen_ai.usage.*, gen_ai.usage.cost가 매핑됩니다. Langfuse 자체 네임스페이스로는 langfuse.observation.type, langfuse.observation.model.name, langfuse.observation.usage_details, langfuse.trace.metadata.*가 있습니다.

이 매핑을 알면 선택지가 하나 생깁니다. 이미 OpenTelemetry로 계측된 서비스라면 Langfuse SDK를 넣지 않고 익스포터 대상만 추가해도 트레이스가 들어옵니다. 다만 그 경우 스팬이 올바른 속성을 갖도록 직접 챙겨야 합니다.

내보내지 않을 것 걸러 내기

모든 스팬을 Langfuse로 보낼 필요는 없습니다. 이미 OpenTelemetry를 쓰는 애플리케이션이라면 HTTP 서버 스팬이나 데이터베이스 스팬까지 함께 흘러 들어옵니다. 고급 사용 문서should_export_span으로 내보낼 스팬을 고르라고 안내합니다.

from langfuse import Langfuse

langfuse = Langfuse(should_export_span=lambda span: True)

문서는 함께 조합할 수 있는 내장 판정 함수로 is_default_export_span, is_langfuse_span, is_genai_span을 소개합니다. 그리고 예전에 쓰던 blocked_instrumentation_scopes 인자는 폐기 예정이며, 거부 규칙은 should_export_span으로 표현하라고 명시합니다.

짧게 사는 프로세스에서 데이터가 사라지는 이유

1편에서 본 대로 Langfuse는 데이터를 로컬에서 배치로 모아 백그라운드로 보냅니다. 요청 경로를 막지 않는 대신, 프로세스가 배치를 비우기 전에 끝나면 그 데이터는 사라집니다.

배치 작업, 서버리스 함수, CLI 스크립트가 전부 여기에 해당합니다. 문서는 flush()를 버퍼에 있는 모든 observation과 score, 미디어 메타데이터를 지금 보내는 동작으로, shutdown()을 플러시에 더해 백그라운드 스레드가 정리될 때까지 기다리는 동작으로 구분합니다.

from langfuse import get_client

langfuse = get_client()

try:
    run_batch_job()
finally:
    langfuse.flush()      # 지금 즉시 전송
    langfuse.shutdown()   # 백그라운드 스레드 종료까지 대기

장기 실행 서버에서는 신경 쓸 일이 없습니다. 문제가 되는 것은 언제나 짧게 사는 프로세스입니다.

마치며 — 계측 순서

정리하면 순서는 이렇습니다. 먼저 프레임워크 통합으로 모델 호출을 자동으로 잡습니다. 그다음 가장 바깥에서 propagate_attributes를 열어 사용자와 세션, 환경을 붙입니다. 그리고 장애 회의에서 지목될 만한 중간 단계에만 수동 span을 넣습니다. 마지막으로 짧게 사는 실행 경로에 flush()를 답니다.

이 순서를 지키면 트리가 두 겹이 되지 않고, 나중에 답해야 할 질문에 필요한 축이 남습니다. 다음 글에서는 이렇게 만들어진 데이터가 실제로 어디에 어떤 형태로 저장되는지, Langfuse가 왜 ClickHouse를 쓰는지를 봅니다.

직접 해보기

시리즈

참고 자료

Langfuse SDK Instrumentation — What Is Captured Automatically and What You Add by Hand

Opening — Instrumentation Is Boundary Drawing

The first post covered the data model: one trace holds a tree of observations, and trace-level attributes propagate downward. This post is about actually building that tree.

Instrumentation mistakes cluster in one place: over- or under-estimating what gets captured automatically. Overestimate and the intermediate step you actually needed is empty. Underestimate and you re-record by hand what was already captured, and the tree comes out doubled.

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 post is written against server v4 and Python SDK v4. The versioning documentation states that Python SDK v4 and JS/TS SDK v5 are the generally available SDKs on server v4.

Starting Point — Three Keys and a Singleton Client

The get started documentation requires the same three environment variables across every integration.

pip install langfuse

export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_BASE_URL="https://cloud.langfuse.com"

The default for LANGFUSE_BASE_URL is the EU region. The documentation lists separate hosts for the US, Japan, and HIPAA regions. Self-hosting means putting your own instance address here.

The client is a singleton. The Python SDK overview says it can be accessed anywhere in your application through get_client(). Use the Langfuse() constructor only when you want to pass configuration in code.

Approach One — The Decorator

The lowest-code way to start. The instrumentation documentation describes observe() as automatically capturing a function's inputs, outputs, timings, and errors.

from langfuse import observe

@observe()
def my_data_processing_function(data, parameter):
    return {"processed_data": data, "status": "ok"}

@observe(name="llm-call", as_type="generation")
async def my_async_llm_call(prompt_text):
    return "LLM response"

The documentation names four arguments. name changes the observation name, as_type chooses span or generation, and capture_input and capture_output turn off input and output collection. Both default to true.

Turning capture_input off comes up more often than you would expect, on paths where raw prompts or personal data arrive as function arguments. That is only a partial measure, though; proper masking is the fifth post.

Approach Two — The Context Manager

For cutting by block rather than by function. The documentation describes start_as_current_observation() as the primary way to create observations while keeping the active OpenTelemetry context updated.

from langfuse import get_client, propagate_attributes

langfuse = get_client()

with langfuse.start_as_current_observation(
    as_type="span",
    name="user-request-pipeline",
    input={"user_query": "Tell me a joke"},
) as root_span:
    with propagate_attributes(user_id="user_123", session_id="session_abc"):
        with langfuse.start_as_current_observation(
            as_type="generation",
            name="joke-generation",
            model="gpt-4o",
        ) as generation:
            generation.update(output="Why did the span cross the road?")

    root_span.update(output={"final_joke": "..."})

The nesting becomes the tree directly. Indentation of the with blocks maps one to one onto the parent-child relationships drawn in the first post. The model="gpt-4o" argument matters here: you need a generation with a model name before a cost axis exists at all.

There is also a way to update the currently active observation without holding a reference. The documentation points to update_current_span() and update_current_generation() for that, which is useful when you want to attach a value from deep inside a call stack.

Approach Three — Manual Creation That Does Not Move the Context

The documentation directs you to start_observation() when you want manual control without changing the active context.

from langfuse import get_client

langfuse = get_client()

span = langfuse.start_observation(name="manual-span")
span.update(input="Data for side task")

child = span.start_observation(name="child-span", as_type="generation")
child.end()
span.end()

This is what you need for async work, background tasks, and callbacks — code where the start and the end do not share a scope. In exchange, calling end() becomes entirely your responsibility. Miss it on an exception path and that observation stays unfinished.

When you want the trace-level input and output to differ from the root observation, use set_trace_io() or set_current_trace_io(). The point is that even if the root handles internal pipeline objects, the trace list shows the sentence the user actually typed.

Pushing Attributes Down — propagate_attributes

The first post said trace-level attributes propagate downward. propagate_attributes() is where you switch that propagation on in code.

from langfuse import propagate_attributes

with propagate_attributes(
    user_id="user_123",
    session_id="session_abc",
    metadata={"experiment": "variant_a"},
    version="1.0",
    environment="staging",
    trace_name="user-workflow",
    as_baggage=True,
):
    run_pipeline()

Those are the arguments the documentation lists. as_baggage=True enables propagation across services through HTTP headers, which you need when a gateway and a worker have to be stitched into one flow.

One thing trips people up here regularly. These values apply to observations created inside the block. They do not attach to observations that already exist outside it. So propagate_attributes belongs at the outermost point where the request arrives.

Framework Integrations — How Far Automatic Capture Reaches

The get started documentation lists the OpenAI SDK, the Vercel AI SDK, LangChain, the native SDKs, OpenTelemetry, and frameworks such as LlamaIndex, CrewAI, LiteLLM, AutoGen, and Google ADK. Install commands differ per integration.

# Python: OpenAI SDK wrapper
pip install langfuse

# Python: LangChain
pip install langfuse langchain-openai

# JS/TS: OpenAI wrapper
npm install @langfuse/openai

# JS/TS: LangChain
npm install @langfuse/core @langfuse/langchain

# JS/TS: native SDK
npm install @langfuse/tracing @langfuse/otel @opentelemetry/sdk-node

The OpenAI integration is done by changing the import.

from langfuse.openai import openai

completion = openai.chat.completions.create(
    name="test-chat",
    model="gpt-4o",
    messages=[{"role": "user", "content": "1 + 1 = "}],
)
import OpenAI from "openai";
import { observeOpenAI } from "@langfuse/openai";

const openai = observeOpenAI(new OpenAI());

What integrations like these capture automatically is the model call itself: model name, prompt, response, token usage, latency. What they cannot capture is your code between those calls. How many documents were retrieved, how reranking changed the order, which branch the run took — all of that you add by hand.

The rule for where to draw the boundary is simple. Every step that has to answer "so which stage was the problem?" in an incident review needs to be its own observation.

The OpenTelemetry Relationship — What v4 Changed

The Python SDK overview states plainly that the Langfuse SDKs are built on top of OpenTelemetry. And the versioning documentation says the move from v3 to v4 replaced the legacy batch ingestion with OpenTelemetry. That is not an internal implementation detail — the ingestion path itself changed, so material written for v3 diverges right here.

The OpenTelemetry integration documentation describes the ingestion path this way.

  • The endpoint path is /api/public/otel. Cloud regions have different hosts.
  • The protocol is OTLP over HTTP, supporting HTTP/JSON and HTTP/protobuf. The documentation states that gRPC is not supported yet.
  • Two authentication headers are required.
# Illustrative: headers passed to an OTLP exporter
OTEL_EXPORTER_OTLP_ENDPOINT="https://cloud.langfuse.com/api/public/otel"
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic ${AUTH_STRING},x-langfuse-ingestion-version=4"

AUTH_STRING is the base64 encoding of the public and secret keys. The second header, x-langfuse-ingestion-version, is described in the documentation as the value that enables real-time ingestion in v4.

The attribute mapping rules are documented as well. A span carrying a model attribute automatically becomes a generation-type observation. On the GenAI semantic convention side, gen_ai.system, gen_ai.request.model, gen_ai.response.model, gen_ai.prompt, gen_ai.completion, gen_ai.usage.*, and gen_ai.usage.cost are mapped. In the Langfuse namespace there are langfuse.observation.type, langfuse.observation.model.name, langfuse.observation.usage_details, and langfuse.trace.metadata.*.

Knowing this mapping opens an option. A service already instrumented with OpenTelemetry can send traces to Langfuse by adding an export target, without the Langfuse SDK at all. In that case you own the job of making sure the spans carry the right attributes.

Filtering Out What Should Not Be Exported

Not every span needs to go to Langfuse. An application already running OpenTelemetry will also produce HTTP server spans and database spans. The advanced usage documentation points to should_export_span for choosing what to export.

from langfuse import Langfuse

langfuse = Langfuse(should_export_span=lambda span: True)

The documentation introduces built-in predicates you can compose: is_default_export_span, is_langfuse_span, and is_genai_span. It also states that the older blocked_instrumentation_scopes parameter is deprecated and that deny rules should be expressed through should_export_span.

Why Data Disappears in Short-Lived Processes

As the first post noted, Langfuse batches data locally and sends it in the background. The upside is that the request path is never blocked; the downside is that if the process ends before the batch drains, that data is gone.

Batch jobs, serverless functions, and CLI scripts all live here. The documentation distinguishes flush(), which sends every buffered observation, score, and media metadata record now, from shutdown(), which flushes and then waits for the background threads to finish and terminate.

from langfuse import get_client

langfuse = get_client()

try:
    run_batch_job()
finally:
    langfuse.flush()      # send immediately
    langfuse.shutdown()   # wait for background threads to terminate

Long-running servers never have to think about this. It is always the short-lived processes that bite.

Closing — The Order of Instrumentation

To put it together: start with a framework integration so model calls are captured automatically. Then open propagate_attributes at the outermost point to attach user, session, and environment. Then add manual spans only for the intermediate stages likely to be named in an incident review. Finally, add flush() on short-lived execution paths.

Follow that order and the tree does not double up, and the axes you will need later survive. The next post looks at where all this data actually lands and in what shape, and why Langfuse uses ClickHouse.

Try It Yourself

  • HTTP Request Builder — building a request against the OTLP endpoint with its headers makes the auth shape concrete.
  • LLM API Cost Calculator — get a feel for what the token usage recorded on a generation is worth in actual money.

Series

References