TutorialEvaluation & observability
Tracing an LLM app with Langfuse: setup, traces, costs and redaction
Add Langfuse tracing to a Python LLM app, choose cloud or self-hosted, read traces and token costs, and mask personal data before it leaves your app.
Langfuse tracing records every step of an LLM request (model calls, retrieval, tool calls) with its inputs, outputs, timing, token usage and cost. You add the Python SDK, wrap your functions with @observe() or swap in Langfuse's OpenAI client, and the traces appear in Langfuse Cloud or in a copy you host yourself. A masking hook lets you strip personal data before anything leaves your process.
The examples below follow the current Langfuse docs for Python SDK v4 (langfuse 4.16.0 on PyPI at the time of writing), which needs Python 3.10 or newer.
What does LLM tracing record?
A trace is one request or operation, such as a single chatbot turn from the user's question to the final answer. The Langfuse overview describes each trace as capturing every operation (LLM calls, retrieval steps, tool executions and custom logic) with timing, inputs, outputs and metadata.
Inside a trace sit observations, the individual steps. They nest, so a retrieval step and a model call can both sit under the request that triggered them. Traces can be grouped into sessions, which is how Langfuse represents a multi-turn conversation.
Each observation can carry attributes for filtering later: user_id, session_id, tags, metadata, environment (such as production or staging) and release version. For model calls, Langfuse also stores the model name, parameters, token usage and cost.
Langfuse cloud vs self-hosted
Langfuse (opens in a new tab) is open source. The repository is MIT licensed except for its ee folders, which carry a separate enterprise licence. Since January 2026 Langfuse has been part of ClickHouse, according to its README. The instrumentation code is the same for both deployment options; only the base URL and keys change.
| Langfuse Cloud | Self-hosted Langfuse | |
|---|---|---|
| Who runs it | The Langfuse team | You |
| Data location | EU, US, Japan or HIPAA region, picked by base URL | Your VPC or on-premises; internet access is optional |
| Getting started | Sign up, create a project, copy API keys | docker compose up from the cloned repo, then sign up at http://localhost:3000 |
| Production setup | Managed | Kubernetes with Helm (preferred), or Terraform templates for AWS, Azure and GCP |
| Components to operate | None | Web and worker containers, PostgreSQL, ClickHouse, Redis or Valkey, S3-compatible storage |
| Cost | Free tier without a credit card; paid plans on the pricing page | Free open-source licence; your infrastructure bill; some add-ons need an enterprise licence key |
| Telemetry to Langfuse | n/a | Basic usage statistics by default; set TELEMETRY_ENABLED=false to opt out |
The self-hosting docs (opens in a new tab) describe Docker Compose as fit for local use and testing on a single VM, without high availability, scaling or backups. For a VM install they recommend at least 4 cores and 16 GiB of memory. The compose file marks every secret with # CHANGEME, and the docs ask you to replace them with long random values. There is no default admin login: the first user signs up through the UI.
Self-hosting is the stronger choice when traces contain data that must not leave your network. Cloud is less work when your data can live in one of its regions.
How do you add Langfuse tracing to a Python app?
1. Install the SDK and set keys
pip install langfuse openai
Create API keys in the project settings, then set them as environment variables:
LANGFUSE_SECRET_KEY="sk-lf-..."
LANGFUSE_PUBLIC_KEY="pk-lf-..."
LANGFUSE_BASE_URL="https://cloud.langfuse.com" # EU region
# US: https://us.cloud.langfuse.com
# self-hosted: http://localhost:3000 or your own domain
2. Instrument the code
The Python SDK has three ways to create observations: the @observe() decorator, the start_as_current_observation() context manager, and manual observations. Langfuse also ships a drop-in OpenAI client that records each model call. This example combines the decorator with the OpenAI wrapper:
from langfuse import get_client, observe, propagate_attributes
from langfuse.openai import openai # drop-in replacement for the OpenAI SDK
langfuse = get_client()
@observe(as_type="retriever")
def retrieve(question: str) -> list[str]:
return search_index(question) # your own vector search
@observe()
def answer(question: str, user_id: str, session_id: str) -> str:
with propagate_attributes(user_id=user_id, session_id=session_id):
chunks = retrieve(question)
completion = openai.chat.completions.create(
name="answer",
model="gpt-4o",
messages=[
{"role": "system", "content": "Answer from the context only."},
{"role": "user", "content": f"{chunks}\n\nQuestion: {question}"},
],
)
return completion.choices[0].message.content
answer("How do I reset my password?", user_id="u_123", session_id="s_456")
langfuse.flush() # needed in scripts and other short-lived processes
@observe() captures the function's arguments, return value, timing and errors. propagate_attributes() copies user_id and session_id onto the current observation and every child created inside it. In SDK v4 that replaces the older update_current_trace() pattern.
3. Flush before exit
The SDK batches events and sends them in the background, so tracing does not block requests. Long-running servers flush on their own. Scripts, CLI tools, cron jobs and serverless functions must call langfuse.flush() before exiting, or the docs warn that buffered traces can be lost.
Traces, spans and generations
Langfuse calls every step an observation and gives each one a type. The three basic types are:
span: a unit of work with a duration, such as a request handler or a pipeline stage.generation: a call to an AI model, with prompt, completion, model parameters, token usage and cost.event: a single point in time with no duration.
Newer, more specific types (available from Python SDK 3.3.1) are agent, tool, chain, retriever, evaluator, embedding and guardrail. Framework integrations set them automatically; a LangChain @tool becomes a Langfuse tool observation. In your own code, pass as_type to @observe() or to start_as_current_observation().
For a manual model call, open a generation and record usage yourself:
with langfuse.start_as_current_observation(
as_type="generation", name="summarize", model="my-finetuned-model"
) as gen:
text, usage = call_my_model(prompt)
gen.update(
input=prompt,
output=text,
usage_details={"input": usage.input_tokens, "output": usage.output_tokens},
)
Tracking token usage and cost
Langfuse records usage and cost on generation and embedding observations, broken down by usage type (for example input, output, or provider-specific keys such as cached tokens). Each value is either ingested or inferred, according to the token and cost tracking docs (opens in a new tab):
- Ingested: your code or an integration sends usage and cost from the model response. The OpenAI wrapper, LangChain, LlamaIndex and LiteLLM integrations do this automatically.
- Inferred: Langfuse matches the generation's
modelname against a model definition with a price per usage type, then multiplies by the usage. If usage is missing and the model has a tokenizer configured, Langfuse counts the tokens itself.
Ingested values win when both exist. Langfuse ships definitions for popular OpenAI, Anthropic and Google models. For fine-tuned, self-hosted or privately priced models, add a definition under Project Settings > Models or through the /api/public/models API. Definitions match on a regular expression against the model name, and usage keys must match the price keys exactly.
Three details catch people out. Inferred costs are calculated at ingestion, so a price change applies only to new generations. Langfuse cannot infer cost for reasoning models when no token counts are sent, because it cannot see the reasoning tokens. And pricing tiers, such as higher rates above a context-length threshold, need tier conditions on the model definition.
The cost data feeds dashboards, spend alerts and the Metrics API, which can aggregate cost by user, tag or application.
Redacting personal data before it reaches the tracing tool
A trace holds whatever your app handled: user messages, retrieved documents, tool arguments, model output. Treat the tracing backend as another place that stores personal data.
Langfuse's masking docs (opens in a new tab) recommend the mask_otel_spans hook for new Python setups. It runs at export time, inside your process, on Langfuse spans and third-party instrumentation spans alike. It receives a batch of spans and returns patches that delete or replace attributes:
import re
from typing import Optional
from langfuse import Langfuse
from langfuse.types import MaskOtelSpansParams, MaskOtelSpansResult, OtelSpanPatch
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
def mask_otel_spans(*, params: MaskOtelSpansParams) -> Optional[MaskOtelSpansResult]:
patches = {}
for identifier, span in params.spans.items():
replacements = {
key: EMAIL.sub("[EMAIL]", value)
for key, value in span.attributes.items()
if isinstance(value, str) and EMAIL.search(value)
}
if replacements:
patches[identifier] = OtelSpanPatch(set_attributes=replacements)
return MaskOtelSpansResult(span_patches=patches)
langfuse = Langfuse(mask_otel_spans=mask_otel_spans)
Keep the function fast and deterministic. The docs state that if it raises an exception or returns an invalid result, Langfuse drops the whole export batch. It only affects spans exported by the Langfuse client, so a second OpenTelemetry exporter needs its own masking.
Redaction checklist
| Data in your traces | Example | What to do in Langfuse |
|---|---|---|
| Contact details in user input | emails, phone numbers | Regex replacement in mask_otel_spans |
| Secrets in tool calls | API keys, bearer tokens passed as arguments | Delete the attribute in mask_otel_spans, or @observe(capture_input=False) on that function |
| Large or sensitive documents | retrieved contracts, medical notes | capture_input=False / capture_output=False, or the LANGFUSE_OBSERVE_DECORATOR_IO_CAPTURE_ENABLED env var |
| Uploaded files | images, PDFs | Keep them out of traced inputs (for example with capture_input=False); the SDK uploads attachments straight to blob storage |
| User identifiers | account IDs used as user_id | Send a pseudonymous ID |
| Whole requests you do not need | health checks, internal traffic | sample_rate or should_export_span to skip them |
| Anything a client might miss | data from teams you do not control | Server-side ingestion masking (self-hosted, enterprise licence) as a second layer |
The self-hosted docs describe server-side masking as a safety net, not a replacement for masking in the client when data must never leave the application.
OpenTelemetry support
Langfuse is built on OpenTelemetry. The Python SDK sets up an OpenTelemetry tracer when you create the client, which means any OTel-based instrumentation library can feed it, and the same spans can also go to another backend.
SDK v4 applies a default span filter. It exports spans created by the Langfuse SDK, spans with gen_ai.* attributes, and spans from known LLM instrumentation scopes. HTTP, database and queue spans are dropped unless you pass a custom should_export_span function.
For other languages or an existing collector, Langfuse accepts OTLP over HTTP (JSON or protobuf) at /api/public/otel. gRPC is not supported. The OpenTelemetry integration page (opens in a new tab) gives this exporter configuration:
OTEL_EXPORTER_OTLP_ENDPOINT="https://cloud.langfuse.com/api/public/otel"
# self-hosted: http://localhost:3000/api/public/otel
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic ${AUTH_STRING},x-langfuse-ingestion-version=4"
AUTH_STRING is the base64 encoding of public_key:secret_key. Without the x-langfuse-ingestion-version=4 header, directly ingested OTel data can take up to 10 minutes to appear. To filter by user, session, tags or metadata, those attributes must be on every span in the trace; the docs recommend OpenTelemetry Baggage for that.
Frequently asked questions
Does Langfuse tracing slow down my application?
The SDK queues events locally and sends them in batches from a background exporter, so a traced request does not wait for Langfuse. Capturing very large inputs and outputs adds overhead, which is why the decorator lets you turn I/O capture off. The masking hook also runs on the export path, so a slow masking function usually delays exports rather than requests.
Can I trace only part of my traffic?
Yes. Set `sample_rate` on the client, or the `LANGFUSE_SAMPLE_RATE` environment variable, to a value between 0.0 and 1.0. When a trace is not sampled, none of its observations or scores are sent.
Can Langfuse trace apps written in languages other than Python and JavaScript?
Yes, through OpenTelemetry. Any language with an OpenTelemetry SDK can export spans to the `/api/public/otel` endpoint, and libraries such as OpenLLMetry and OpenLIT add LLM-specific instrumentation for Java, Go and frameworks like Semantic Kernel.
Do I lose anything by moving from Langfuse Cloud to self-hosting later?
The SDK code stays the same; you change `LANGFUSE_BASE_URL` and the API keys. Self-hosted instances run the same codebase as Langfuse Cloud, but some add-on features, such as server-side ingestion masking, require an enterprise licence key.
Sources
- 1.GitHub - langfuse/langfuse: 🪢 Open source agent evals & observability: Trace, evaluate, and improve LLM applications with one open platform. · GitHubRepositoryPrimarygithub.com
- 2.Open Source AI Engineering Platform - LangfuseOfficial docsPrimarylangfuse.com
- 3.Self-host Langfuse (Open Source LLM Observability) - LangfuseOfficial docsPrimarylangfuse.com
- 4.pypi.org/project/langfuseWeb
- 5.langfuse.com/docs/observability/sdk/overviewWeb
- 6.langfuse.com/docs/observability/overviewWeb
- 7.langfuse.com/docs/observability/data-modelWeb
- 8.langfuse.com/self-hosting/deployment/docker-composeWeb
- 9.langfuse.com/docs/observability/get-startedWeb
- 10.langfuse.com/docs/observability/sdk/instrumentationWeb
- 11.langfuse.com/docs/observability/sdk/upgrade-path/python-v3-to-v4Web
- 12.langfuse.com/docs/observability/features/observation-typesWeb
- 13.langfuse.com/docs/observability/features/token-and-cost-trackingWeb
- 14.langfuse.com/docs/observability/features/maskingWeb
- 15.langfuse.com/self-hosting/security/data-maskingWeb
- 16.langfuse.com/docs/observability/sdk/advanced-featuresWeb
- 17.langfuse.com/integrations/native/opentelemetryWeb
Spotted an error? See our corrections policy.
Enjoyed this? Get the weekly brief.
New guides, Project & Tool of the Week and the AI developments that matter. One email, no spam.
Read next
- TutorialEvaluation & observability
How to evaluate a RAG pipeline with Ragas: metrics, a test set and a first run
Set up RAG evaluation with Ragas: build a small test set, match each metric to retrieval or generation, run a first score and compare two versions.
7 min read
- TutorialEvaluation & observability
How to test prompts and LLM outputs with promptfoo, with a CI step
Set up LLM evaluation with promptfoo: write a YAML test suite with assertions, compare two models side by side and fail a CI build on regressions.
6 min read
- Weekly digest
The week on HuiaHub: sandboxed agents, RAG tooling and local models
41 new HuiaHub pages this week: sandboxing coding agents, RAG retrieval, local models on Macs and phones, Claude Haiku 5.5 and six how-to guides.
4 min read