Skip to main content

Migrate the Langfuse SDK callback to OpenTelemetry v2

Turn it on​

Read What changes first, then:

  1. Replace langfuse with langfuse_otel in your callbacks.
  2. Set LITELLM_OTEL_V2=true.
  3. Set the scope to either Path A (LLM only logging) or Path B (full tracing). If no scope is set the proxy runs at full.
  4. Restart the proxy.

The flag also moves your other OTel callbacks (apart from logfire) to V2. See the general V2 migration guide.

What changes​

Ingestion volume and scope​

Ingestion costs

The default full scope can boost Langfuse ingestion by an order of magnitude or more at the same request rate.

Replace langfuse with langfuse_otel and set LITELLM_OTEL_V2=true and that's what Langfuse will be receiving. The default scope is full not LLM only. It will capture the HTTP root, auth, Postgres, Redis, guardrails, MCP tools, model calls and spend-write spans as they occur. Admin requests will also generate traces. This can see Langfuse ingestion go up by an order of magnitude or more. Don't assume that the same request rate will result in the same Langfuse bill.

Use llm_only Since v1.103.0 for the closest volume match to the legacy callback. A single request with a single provider call will result in one generation being sent, however a request with multiple provider calls can result in multiple generations being sent. This is not a guarantee of identical billing. Guardrail blocks prior to a model call and MCP only activity will result in no observations being recorded in that destination under llm_only.

Server compatibility and HTTP 404​

Self-hosted Langfuse OSS v2 servers do not expose the /api/public/otel/v1/traces endpoint and will return an HTTP 404 if you attempt an OTLP export. The SDK callback has used the same endpoint since LiteLLM v1.104.0, so this only affects you if you are migrating from an older release. You must upgrade the server prior to cutover. The Langfuse integration docs state that server 3.63.0 or newer is required for SDK v4. That doesn't necessarily mean that every server prior to that version will support the OTel preset. You must confirm OTLP ingestion works on your target server prior to a production cutover.

Content capture is off by default​

V2 defaults to no_content so your Langfuse input/output will be blank unless you turn on span content capture via OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only or turn it on for a tenant destination Since v1.108.0. Turning capture on will send sensitive content. Check redaction first. The variable is global, so it also turns capture on for every other V2 OTel callback on the proxy.

Names and metadata are not drop-in compatible​

The V2 Langfuse mapper has no equivalent to the legacy generation_name, custom generation IDs, existing_trace_id, trace mutation, version/release fields and prompt linking. Any dashboards or integrations that you've saved which are reliant on any of those fields will need to be updated. See How legacy fields map.

Key and team destinations​

The stored key/team callback langfuse is still the SDK callback. Change it to langfuse_otel, same credentials, explicitly set its scope and content policy. V2 reads only langfuse_secret_key, so an entry saved with the older langfuse_secret name logs to your project with no error. The global scope variable does not restrict tenant destinations. V2 defaults to override routing so a tenant's Langfuse destination will take over your global Langfuse exporter for that request. Under full the database reads that resolve the key during auth still go to your exporter in both modes. additive will keep both. A custom langfuse_host will require its key pair and an entry in general_settings.provider_url_destination_allowed_hosts. An unreachable tenant destination will not fall back to your project. A destination missing its key pair sends that request to your project with no error. A tenant host missing from the allowlist is skipped with a warning, so the model call goes to your Langfuse host as its own trace signed with the tenant's keys and the rest of the request goes to your project. All of this needs your own LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY on the proxy, since without them langfuse_otel falls back to the older OTel integration and V2 tenant routing does not apply. See tenant routing.

Duplicate callbacks​

Don't leave both callbacks enabled for the same traffic and project. langfuse and langfuse_otel are separate integrations and both will log the request which will lead to duplicate observations and extra ingestion. Turning on LITELLM_OTEL_V2 does not in any way turn the SDK callback into full tree tracing. The SDK callback will still generate one generation for its logged call. You'll need to remove it from the global success/failure callbacks and from the stored key/team callbacks if you're replacing it.

Redaction and capture​

Redaction and capture are separate controls. A successful call passes through LiteLLM's shared turn_off_message_logging redaction prior to V2 even having a look at the logging payload. Under llm_only a capture opt-in in no way undoes the redaction that has already occurred in that first stage. Under the default full scope it does, because the request root observation (POST /v1/chat/completions) still carries the raw prompt and response with turn_off_message_logging on, while the generation shows redacted-by-litellm. The root also carries them for no-log requests, which the SDK callback skipped, and full sends MCP tool arguments and results and guardrail responses without message redaction. If you redact messages and turn capture on, use llm_only on the operator and on every key and team destination, since a destination without langfuse_span_scope runs at full. Don't assume that this proves redaction on the failure paths. You need to test redaction on the failure paths separately and you must use no_content on any destination where it is absolutely critical that the message content doesn't get to that destination. Under full, no_content still sends guardrail responses, which can hold the checked text, so use llm_only there too. The request metadata that V2 exports for the user_api_key_* fields is using the same redact_user_api_key_info helper as the SDK callback. It doesn't redact the litellm.metadata.* identity attributes V2 puts on every span after auth (user_api_key_alias, user_api_key_user_id, user_api_key_org_id and user_api_key_end_user_id, plus requester_ip_address on MCP spans), team identity, the key hash or the client address and user agent on the full root span, and it's not a general purpose anonymisation switch. V2 ignores the per-request mask_input and mask_output metadata and the langfuse_masking_function parameter, so a caller that relies on them sends its prompts once capture is on. You need to test your actual redaction policy with capture enabled prior to rollout.

Choose a migration path​

The Langfuse integration recommendation is to use the OpenTelemetry v2 langfuse_otel preset. The V2 scope guidance is to use llm_only unless you specifically need infrastructure spans in Langfuse. full is the implementation default but it's not the recommended scope for every deployment. Path A is following that scope recommendation. Path B is an explicit opt-in to full trace collection for those teams that need it.

Path A, LLM-only logging with fewer breaking changes​

Keep your model config and replace the legacy logging callback with this proxy config. Remove any instances of success_callback: ["langfuse"] or failure_callback: ["langfuse"] (rather than just appending on the new callback).

litellm_settings:
callbacks: ["langfuse_otel"]

Set the environment and restart the proxy. Use the host for your Langfuse region or for your self hosted deployment. A misspelled scope value breaks every V2 callback, not only Langfuse. The presets fall back to the older OTel integration, which also takes over key and team langfuse_otel entries without their scope, and the generic otel callback stops, so use llm_only exactly.

export LITELLM_OTEL_V2=true
export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_HOST="https://cloud.langfuse.com"
export LITELLM_OTEL_LANGFUSE_SPAN_SCOPE=llm_only
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only

Set key and team destinations explicitly​

Register the replacement team callback with both scope and capture policy Since v1.108.0. Save the old langfuse entry for rollback and then remove it. The operator scope variable only applies to the exporter built from the global environment credentials.

curl -X POST 'http://localhost:4000/team/<team-id>/callback' \
-H "Authorization: Bearer $LITELLM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"callback_name": "langfuse_otel",
"callback_type": "success",
"callback_vars": {
"langfuse_public_key": "pk-lf-...",
"langfuse_secret_key": "sk-lf-...",
"langfuse_span_scope": "llm_only",
"capture_message_content": "span_only"
}
}'

For a key, put the same callback object in metadata.logging on /key/generate or /key/update. A success entry also receives failed requests, because V2 picks the destination before the request has an outcome. A failure only entry gets no destination and cannot take capture_message_content, but its keys still send every model call, failed or not, to your Langfuse host as its own trace signed with them. See key and team destination setup. Tenant capture can override the global setting so you do need to explicitly set no_content for any destinations which are absolutely not to receive prompts. An omitted capture policy inherits your own Langfuse exporter's policy, or the generic otel callback's policy when you have no Langfuse exporter of your own.

Stage and verify the cutover​

Save the YAML, the environment and the stored key/team logging entries. Start with staging and have a separate Langfuse project so that you don't double your production observation traffic. Just send a simple successful request, a streaming request, a failed provider request and a guardrail blocked request (if your deployment has guardrails). Include the metadata and redaction settings that your apps actually send.

In Langfuse, open up each test trace and look at the observation tree. Count the observations in the tree not the rows in the trace list. For the simple one provider call test under llm_only you'll see one generation and no HTTP, no auth, no database, no Redis and no spend write observations. The input and output will have the expected permitted content, token usage and cost (where available) and the trace_name, session, user and tags will all match your test request. A pre-model guardrail block will have no generation to export under that scope. A tenant test will end up in the tenant project and will follow your chosen override/additive policy.

Compare Langfuse ingestion usage over a fixed request sample against the SDK baseline prior to any traffic going live. Update the dashboards to be able to filter on generation names, metadata, trace IDs and cost/usage fields. Roll it out in stages and keep a close eye out for exporter failures (in particular HTTP 404s and auth errors) as well as ingestion usage.

Roll back to the SDK callback​

Restore the saved langfuse callback config and the stored key/team entries, remove langfuse_otel, restore the prior env and restart. If V2 was only ever enabled for the purposes of this migration then also unset LITELLM_OTEL_V2. Turning the flag off whilst still having langfuse_otel configured will result in the older OTel integration (not the SDK callback) being selected. Ensure you have a LiteLLM/SDK version that is compatible with your Langfuse server when you're restoring a v2 era setup. Confirm one generation goes through the restored SDK callback before you return all traffic.

Path B, explicitly opt into the full request trace​

Use the same preset, same credentials and same content capture configuration as Path A. Set the operator scope to full. For each key or team destination that requires the full tree also set callback_vars.langfuse_span_scope to full. Leaving the scope unset has the same effect, just setting it explicitly makes the ingestion decision unambiguous.

export LITELLM_OTEL_LANGFUSE_SPAN_SCOPE=full

This keeps the recommended V2 integration but with a broader volume scope. It's more expensive in terms of Langfuse ingestion than simply generation only. The number of spans will be dependent on the operations you're doing so you should measure your workload rather than just go on the basis of one observation per request.

Langfuse now shows the HTTP request root and child observations for auth, guardrails, db/cache activity, routing and model calls. Spend writes and background database polls arrive as separate traces. Langfuse types the request root and the route <model> observation as generations too, and with capture on it infers usage and cost for the root from its raw input and output. The dashboards which previously just counted all observations are no longer counting the LLM request count. Filter on the model call observations, named <operation> <model> such as chat gpt-4o, to get at model usage and cost analysis and use the full tree for latency and failure analysis. Admin traces and requests which are blocked prior to inference will appear without a generation. The same staged rollout and rollback as Path A applies and you must explicitly approve the measured ingestion increase prior to production cutover.

How legacy fields map​

These mappings are for the V2 langfuse_otel preset, not for the older callback that would be selected if V2 is disabled. Controls are metadata fields unless otherwise specified. Unsupported fields are not automatically mapped.

Trace and generation names​

metadata.trace_name exports as langfuse.trace.name. The langfuse_trace_name proxy header takes precedence. Full request roots default to litellm-{call_type} Since v1.109.0 when no traceparent comes in. Under llm_only the generation name becomes the trace name if no custom name is supplied.

Legacy generation_name has no custom name equivalent. The V2 model span uses {operation} {model}. Legacy defaults to litellm:{key_alias} if present, otherwise litellm-{call_type}. Update the name filters.

User, session and tags​

metadata.trace_user_id maps to user.id with the langfuse_trace_user_id header taking precedence. V2 doesn't make use of the old fallback to the end user ID. metadata.session_id maps to session.id with the langfuse_session_id header taking precedence.

metadata.tags maps to langfuse.trace.tags and retains non-empty strings. The tags are taken from the request metadata, which also holds the key and team tags and the x-litellm-tags header, rather than a langfuse_tags header.

Trace IDs and parent observations​

Use standard OTel trace context propagation to link up the distributed traces rather than trace_id/existing_trace_id metadata. V2 doesn't implement the SDK's arbitrary-ID normalisation or the existing trace mutation semantics.

generation_id and parent_observation_id make use of the OTel span identity and parent context, with no equivalent caller override in the V2 mapper. langfuse.observation.id has the LiteLLM call ID, which a caller can set with the x-litellm-call-id header.

Metadata and identity fields​

trace_metadata and arbitrary generation metadata have no wholesale passthrough equivalent. V2 does emit the identity fields in langfuse.observation.metadata and langfuse.trace.metadata.* Since v1.109.0. Custom metadata requires an explicit baggage allowlist (which is not SDK trace metadata).

Fields user_api_key_alias, user_api_key_user_id, user_api_key_end_user_id, user_api_key_team_id, user_api_key_team_alias and user_api_key_org_id are in the observation metadata and the trace metadata attributes. They must be non-empty strings. redact_user_api_key_info removes them from the observation and trace metadata, except the trace metadata team_id and team_alias, but user_api_key_alias, user_api_key_user_id, user_api_key_end_user_id and user_api_key_org_id still go out as litellm.metadata.* attributes on every span. There are also team ID and team alias identity mappings.

Model, usage and cost​

Model and model parameters map to langfuse.observation.model.name and .model.parameters. V2 includes temperature, top_p, max_tokens, frequency/presence penalties and seed. The model name is the request model or model group (gen_ai.request.model). The deployment is in litellm.provider.model and the model the provider reports is in gen_ai.response.model.

Token usage maps to langfuse.observation.usage_details, broken down into uncached input, cached input, cache creation, non-reasoning output and reasoning output. It also carries total, the total token count. The keys are input, input_cached_tokens, input_cache_creation, output, output_reasoning_tokens and total, while the SDK callback sent cache_read_input_tokens and cache_creation_input_tokens and counted reasoning tokens in output.

Response cost maps to langfuse.observation.cost_details with total. It uses the response cost calculated by LiteLLM where that's available. Cost is only omitted when LiteLLM could not look up a price Since v1.109.0, and a failed request exports a cost of 0 unless a stream recovered usage first or a guardrail added cost.

Content and redaction​

Prompt and response logging requires OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only (default is no content). Tenant capture_message_content can override the global policy. Shared turn_off_message_logging and redact_user_api_key_info controls still apply, except that under full the request root observation, MCP tool spans and guardrail responses are not message redacted, and the litellm.metadata.* identity attributes are never removed. Test message redaction and identity exposure separately as per Redaction and capture. Content capture is not an anonymisation policy.

Credentials​

Global LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_HOST are still the preset environment credentials. V2 is exporting via Langfuse's OTLP endpoint rather than creating the generations via the SDK callback. With no LANGFUSE_HOST the preset sends to the US cloud (https://us.cloud.langfuse.com), while the SDK callback defaulted to the EU cloud (https://cloud.langfuse.com). LANGFUSE_OTEL_HOST takes precedence over LANGFUSE_HOST. V2 does not read LANGFUSE_BASE_URL, which the SDK callback used when LANGFUSE_HOST was unset, so move that value to LANGFUSE_HOST.

Per-key and per-team credentials use langfuse_public_key, langfuse_secret_key and langfuse_host on a langfuse_otel destination in the key or team metadata.logging. They route to an OTel destination rather than an SDK client. The proxy rejects dynamic per-request langfuse_public_key and langfuse_secret_key with a 401 unless general_settings.allow_client_side_credentials is on. When they are allowed they only send the model call observation to that project, as its own trace linked to the request trace, while the rest of the tree stays in your project, and a per-request langfuse_host is ignored. Re-register the stored callbacks and test the routing. Set langfuse_span_scope and capture policy separately for each destination.

Fields with no equivalent​

update_trace_keys has no equivalent SDK style update of a pre-existing Langfuse trace. The V2 Langfuse mapper likewise has no equivalent control for version, trace_version, trace_release or debug_langfuse. Don't rely on these SDK specific fields after cutover.

Per-request mask_input, mask_output and langfuse_masking_function have no V2 equivalent. V2 reads only the langfuse_trace_name, langfuse_trace_user_id and langfuse_session_id headers, not every langfuse_* header.

Langfuse prompt association via generation parameters has no prompt-link mapping in V2. You'll need to check prompt related workflows individually prior to migration.

For the full V2 config see OpenTelemetry v2. The general V2 migration guide is the one that covers migration from OTel v1 rather than from the Langfuse SDK callback.