fix(langfuse): emit otel trace version and release on the keys langfuse v4 reads (#36702)

* fix(langfuse): emit otel trace version and release on the keys langfuse v4 reads

The langfuse_otel exporter wrote version to langfuse.generation.version and
langfuse.trace.version, and release to langfuse.trace.release. Langfuse v4
recognizes neither, so both landed in the generic span attribute bag and every
trace reported version and release as null. v4 has a single langfuse.version
key, lifted to the trace when it sits on the root span, plus langfuse.release.

Also routes the otel v2 preset's per-request headers through the shared builder
so key-scoped and team-scoped exports carry x-langfuse-ingestion-version like
the other three exporter paths already do.

* fix(langfuse): give trace_version precedence over version on the shared v4 key

Matches the documented contract in docs/observability/langfuse_integration.md
and the legacy langfuse SDK callback, which both treat trace_version as the
authoritative trace version with version as its fallback.
This commit is contained in:
yucheng-berri 2026-08-12 20:39:58 -07:00 committed by GitHub
parent c7f5527870
commit d86336a7c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 72 additions and 14 deletions

View file

@ -90,7 +90,6 @@ class LangfuseOtelLogger(OpenTelemetry):
"generation_name": LangfuseSpanAttributes.GENERATION_NAME,
"generation_id": LangfuseSpanAttributes.GENERATION_ID,
"parent_observation_id": LangfuseSpanAttributes.PARENT_OBSERVATION_ID,
"version": LangfuseSpanAttributes.GENERATION_VERSION,
"mask_input": LangfuseSpanAttributes.MASK_INPUT,
"mask_output": LangfuseSpanAttributes.MASK_OUTPUT,
"trace_user_id": LangfuseSpanAttributes.TRACE_USER_ID,
@ -99,13 +98,18 @@ class LangfuseOtelLogger(OpenTelemetry):
"trace_name": LangfuseSpanAttributes.TRACE_NAME,
"trace_id": LangfuseSpanAttributes.TRACE_ID,
"trace_metadata": LangfuseSpanAttributes.TRACE_METADATA,
"trace_version": LangfuseSpanAttributes.TRACE_VERSION,
"trace_release": LangfuseSpanAttributes.TRACE_RELEASE,
"trace_release": LangfuseSpanAttributes.RELEASE,
"existing_trace_id": LangfuseSpanAttributes.EXISTING_TRACE_ID,
"update_trace_keys": LangfuseSpanAttributes.UPDATE_TRACE_KEYS,
"debug_langfuse": LangfuseSpanAttributes.DEBUG_LANGFUSE,
}
version: Final = (
metadata.get("trace_version") if metadata.get("trace_version") is not None else metadata.get("version")
)
if version is not None:
safe_set_attribute(span, LangfuseSpanAttributes.VERSION.value, version)
for key, enum_attr in mapping.items():
if key in metadata and metadata[key] is not None:
value = metadata[key]

View file

@ -42,9 +42,7 @@ def langfuse_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str,
public_key: Final = params.get("langfuse_public_key")
secret_key: Final = params.get("langfuse_secret_key")
if public_key and secret_key:
return {
"Authorization": _V1Langfuse._get_langfuse_authorization_header(
public_key=public_key, secret_key=secret_key
)
}
return _V1Langfuse._build_langfuse_otel_headers(
_V1Langfuse._get_langfuse_authorization_header(public_key=public_key, secret_key=secret_key)
)
return {}

View file

@ -16,12 +16,13 @@ class LangfuseOtelConfig(BaseModel):
class LangfuseSpanAttributes(str, Enum):
LANGFUSE_ENVIRONMENT = "langfuse.environment"
VERSION = "langfuse.version"
RELEASE = "langfuse.release"
# ---- Generation-level metadata ----
GENERATION_NAME = "langfuse.generation.name"
GENERATION_ID = "langfuse.generation.id"
PARENT_OBSERVATION_ID = "langfuse.generation.parent_observation_id"
GENERATION_VERSION = "langfuse.generation.version"
MASK_INPUT = "langfuse.generation.mask_input"
MASK_OUTPUT = "langfuse.generation.mask_output"
@ -36,8 +37,6 @@ class LangfuseSpanAttributes(str, Enum):
TRACE_NAME = "langfuse.trace.name"
TRACE_ID = "langfuse.trace.id"
TRACE_METADATA = "langfuse.trace.metadata"
TRACE_VERSION = "langfuse.trace.version"
TRACE_RELEASE = "langfuse.trace.release"
EXISTING_TRACE_ID = "langfuse.trace.existing_id"
UPDATE_TRACE_KEYS = "langfuse.trace.update_keys"

View file

@ -1,5 +1,6 @@
"""Per-request multi-tenant credential routing (V1 parity)."""
import base64
import os
import sys
@ -42,6 +43,17 @@ def test_langfuse_dynamic_headers_need_both_keys():
assert headers is not None and "Authorization" in headers
def test_langfuse_dynamic_headers_carry_v4_ingestion_version():
headers = dynamic_otlp_headers(
"langfuse_otel", {"langfuse_public_key": "pk", "langfuse_secret_key": "sk"}
)
expected_auth = "Basic " + base64.b64encode(b"pk:sk").decode()
assert headers == {
"Authorization": expected_auth,
"x-langfuse-ingestion-version": "4",
}
def test_weave_dynamic_headers():
headers = dynamic_otlp_headers(
"weave_otel", {"wandb_api_key": "w", "weave_project_id": "p"}

View file

@ -211,7 +211,7 @@ class TestLangfuseOtelIntegration:
LangfuseSpanAttributes.GENERATION_NAME.value: "gen-name",
LangfuseSpanAttributes.GENERATION_ID.value: "gen-id",
LangfuseSpanAttributes.PARENT_OBSERVATION_ID.value: "parent-id",
LangfuseSpanAttributes.GENERATION_VERSION.value: "v1",
LangfuseSpanAttributes.VERSION.value: "t-ver",
LangfuseSpanAttributes.MASK_INPUT.value: True,
LangfuseSpanAttributes.MASK_OUTPUT.value: False,
LangfuseSpanAttributes.TRACE_USER_ID.value: "user-123",
@ -221,8 +221,7 @@ class TestLangfuseOtelIntegration:
LangfuseSpanAttributes.TRACE_NAME.value: "trace-name",
LangfuseSpanAttributes.TRACE_ID.value: "traceid", # stripped dashes
LangfuseSpanAttributes.TRACE_METADATA.value: json.dumps({"k": "v"}),
LangfuseSpanAttributes.TRACE_VERSION.value: "t-ver",
LangfuseSpanAttributes.TRACE_RELEASE.value: "rel-1",
LangfuseSpanAttributes.RELEASE.value: "rel-1",
LangfuseSpanAttributes.EXISTING_TRACE_ID.value: "existing-id",
LangfuseSpanAttributes.UPDATE_TRACE_KEYS.value: json.dumps(
["key1", "key2"]
@ -240,6 +239,52 @@ class TestLangfuseOtelIntegration:
actual == expected
), "Mismatch between expected and actual OTEL attribute mapping."
@pytest.mark.parametrize(
"metadata, expected_version",
[
(
{"version": "v-observation", "trace_version": "v-trace"},
"v-trace",
),
({"trace_version": "v-trace"}, "v-trace"),
({"version": "v-observation"}, "v-observation"),
({"version": "v-observation", "trace_version": ""}, ""),
({}, None),
],
ids=[
"trace-version-wins-as-documented",
"trace-only",
"observation-version-is-the-fallback",
"empty-trace-version-is-not-absent",
"neither-key-emits-nothing",
],
)
def test_version_emitted_on_langfuse_v4_key(self, metadata, expected_version):
kwargs = {"litellm_params": {"metadata": {"trace_release": "rel-9", **metadata}}}
with patch(
"litellm.integrations.arize._utils.safe_set_attribute"
) as mock_safe_set_attribute:
LangfuseOtelLogger._set_langfuse_specific_attributes(
MagicMock(), kwargs, None
)
emitted = {
call.args[1]: call.args[2] for call in mock_safe_set_attribute.call_args_list
}
if expected_version is None:
assert "langfuse.version" not in emitted
else:
assert emitted["langfuse.version"] == expected_version
assert emitted["langfuse.release"] == "rel-9"
for retired_key in (
"langfuse.generation.version",
"langfuse.trace.version",
"langfuse.trace.release",
):
assert retired_key not in emitted
def test_set_langfuse_specific_attributes_with_content(self):
"""Test that _set_langfuse_specific_attributes correctly sets observation.output with regular content response."""
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes