Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docker/build_from_pip/Dockerfile.build_from_pip
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ RUN uv venv --python python && \
"anthropic[vertex]==0.84.0" \
"grpcio==1.78.0" \
"prometheus-client==0.20.0" \
"langfuse==2.59.7" \
"opentelemetry-api==1.28.0" \
"opentelemetry-sdk==1.28.0" \
"opentelemetry-exporter-otlp==1.28.0" \
"langfuse==4.7.0" \
"opentelemetry-api==1.33.1" \
"opentelemetry-sdk==1.33.1" \
"opentelemetry-exporter-otlp==1.33.1" \
"ddtrace==4.11.0" \
"sentry-sdk==2.21.0" \
"mangum==0.17.0" \
Expand Down
2 changes: 1 addition & 1 deletion litellm/integrations/SlackAlerting/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ async def _add_langfuse_trace_id_to_alert(
#########################################################
langfuse_object = litellm_logging_obj._get_callback_object(service_name="langfuse")
if langfuse_object is not None:
base_url = langfuse_object.Langfuse.base_url
base_url = langfuse_object.langfuse_host
return f"{base_url}/trace/{trace_id}"

return None
294 changes: 144 additions & 150 deletions litellm/integrations/langfuse/langfuse.py

Large diffs are not rendered by default.

42 changes: 39 additions & 3 deletions litellm/integrations/langfuse/langfuse_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@
LANGFUSE_CLOUD_US_ENDPOINT = "https://us.cloud.langfuse.com/api/public/otel"
LANGFUSE_INGESTION_VERSION_HEADER = "x-langfuse-ingestion-version"
LANGFUSE_INGESTION_VERSION = "4"
LANGFUSE_OTEL_INGESTION_VERSION_HEADER = LANGFUSE_INGESTION_VERSION_HEADER
LANGFUSE_OTEL_INGESTION_VERSION = LANGFUSE_INGESTION_VERSION


def build_langfuse_otel_headers(
public_key: str,
secret_key: str,
) -> dict[str, str]:
auth_string = f"{public_key}:{secret_key}"
auth_header = base64.b64encode(auth_string.encode()).decode()
return {
"Authorization": f"Basic {auth_header}",
LANGFUSE_INGESTION_VERSION_HEADER: LANGFUSE_INGESTION_VERSION,
}


class LangfuseOtelLogger(OpenTelemetry):
Expand Down Expand Up @@ -91,22 +105,44 @@ def _set_metadata_attributes(span: Span, metadata: dict):
"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,
"user_id": LangfuseSpanAttributes.TRACE_USER_ID,
"trace_user_id": LangfuseSpanAttributes.TRACE_USER_ID,
"session_id": LangfuseSpanAttributes.SESSION_ID,
"tags": LangfuseSpanAttributes.TAGS,
"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,
"existing_trace_id": LangfuseSpanAttributes.EXISTING_TRACE_ID,
"update_trace_keys": LangfuseSpanAttributes.UPDATE_TRACE_KEYS,
"debug_langfuse": LangfuseSpanAttributes.DEBUG_LANGFUSE,
}

trace_metadata = metadata.get("trace_metadata")
if isinstance(trace_metadata, dict):
for key, value in trace_metadata.items():
serialized_value = json.dumps(value) if isinstance(value, (list, dict)) else value
safe_set_attribute(
span,
f"{LangfuseSpanAttributes.TRACE_METADATA.value}.{key}",
serialized_value,
)
elif trace_metadata is not None:
safe_set_attribute(
span,
LangfuseSpanAttributes.TRACE_METADATA.value,
trace_metadata,
)

version = metadata.get("version") if metadata.get("version") is not None else metadata.get("trace_version")
if version is not None:
safe_set_attribute(
span,
LangfuseSpanAttributes.GENERATION_VERSION.value,
version,
)

for key, enum_attr in mapping.items():
if key in metadata and metadata[key] is not None:
value = metadata[key]
Expand Down
27 changes: 10 additions & 17 deletions litellm/integrations/langfuse/langfuse_prompt_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

import os
from functools import lru_cache
from importlib.metadata import version as package_version
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast

from packaging.version import Version
from typing_extensions import TypeAlias

from litellm.integrations.custom_logger import CustomLogger
Expand All @@ -25,7 +25,7 @@

if TYPE_CHECKING:
from langfuse import Langfuse
from langfuse.client import ChatPromptClient, TextPromptClient
from langfuse.model import ChatPromptClient, TextPromptClient

from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj

Expand Down Expand Up @@ -64,7 +64,6 @@ def langfuse_client_init(
Exception: If langfuse package is not installed
"""
try:
import langfuse
from langfuse import Langfuse
except Exception as e:
raise Exception(
Expand Down Expand Up @@ -95,20 +94,16 @@ def langfuse_client_init(
"flush_interval": LangFuseLogger._get_langfuse_flush_interval(flush_interval), # flush interval in seconds
}

if Version(langfuse.version.__version__) >= Version("2.6.0"):
parameters["sdk_integration"] = "litellm"
import httpx

if Version(langfuse.version.__version__) >= Version("2.7.3"):
import httpx
import litellm

import litellm
from ...llms.custom_httpx.http_handler import get_ssl_configuration

from ...llms.custom_httpx.http_handler import get_ssl_configuration

parameters["httpx_client"] = httpx.Client(
verify=get_ssl_configuration(),
cert=os.getenv("SSL_CERTIFICATE", litellm.ssl_certificate),
)
parameters["httpx_client"] = httpx.Client(
verify=get_ssl_configuration(),
cert=os.getenv("SSL_CERTIFICATE", litellm.ssl_certificate),
)

client = Langfuse(**parameters)

Expand All @@ -123,9 +118,7 @@ def __init__(
langfuse_host=None,
flush_interval=1,
):
import langfuse

self.langfuse_sdk_version = langfuse.version.__version__
self.langfuse_sdk_version = package_version("langfuse")
self.Langfuse = langfuse_client_init(
langfuse_public_key=langfuse_public_key,
langfuse_secret=langfuse_secret,
Expand Down
12 changes: 7 additions & 5 deletions litellm/integrations/otel/presets/langfuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from litellm.integrations.langfuse.langfuse_otel import (
LangfuseOtelLogger as _V1Langfuse,
)
from litellm.integrations.langfuse.langfuse_otel import (
build_langfuse_otel_headers,
)
from litellm.integrations.otel.model.config import (
ExporterOwner,
ExporterSpec,
Expand Down Expand Up @@ -40,9 +43,8 @@ def langfuse_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str,
public_key = params.get("langfuse_public_key")
secret_key = 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 build_langfuse_otel_headers(
public_key=public_key,
secret_key=secret_key,
)
return {}
6 changes: 3 additions & 3 deletions litellm/types/integrations/langfuse_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class LangfuseSpanAttributes(str, Enum):
GENERATION_NAME = "langfuse.generation.name"
GENERATION_ID = "langfuse.generation.id"
PARENT_OBSERVATION_ID = "langfuse.generation.parent_observation_id"
GENERATION_VERSION = "langfuse.generation.version"
GENERATION_VERSION = "langfuse.version"
MASK_INPUT = "langfuse.generation.mask_input"
MASK_OUTPUT = "langfuse.generation.mask_output"

Expand All @@ -36,8 +36,8 @@ 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"
TRACE_VERSION = "langfuse.version"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version attributes collide on OTel

Medium Severity

The GENERATION_VERSION and TRACE_VERSION attributes now map to the same langfuse.version key. This causes _set_metadata_attributes to overwrite one value when both are present, preventing distinct generation and trace versions from being recorded for Langfuse OTel spans. The SDK callback path still treats these fields as distinct.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aaeb263. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2c1dfdb4f0e. Langfuse v4 uses the single langfuse.version semantic attribute; observation version now takes precedence over propagated trace_version, matching SDK behavior, with trace version used as the fallback

TRACE_RELEASE = "langfuse.release"
EXISTING_TRACE_ID = "langfuse.trace.existing_id"
UPDATE_TRACE_KEYS = "langfuse.trace.update_keys"

Expand Down
30 changes: 15 additions & 15 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,11 @@ proxy-runtime = [
"anthropic[vertex]>=0.84.0,<1.0",
"grpcio==1.78.0",
"prometheus-client>=0.20.0,<1.0",
"langfuse>=2.59.7,<3.0",
"opentelemetry-api==1.28.0",
"opentelemetry-sdk==1.28.0",
"opentelemetry-exporter-otlp==1.28.0",
"opentelemetry-instrumentation-fastapi==0.49b0",
"langfuse>=4.7,<5.0",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hard breaking change for users on langfuse < 4.7

The minimum is raised from >=2.59.7,<3.0 to >=4.7,<5.0, skipping v3 entirely (which was never supported) and jumping two major versions. Any deployment that has langfuse pinned to v2.x or v3.x will encounter an import-time AttributeError or resolution conflict when they next update LiteLLM. Per the team's policy on backwards-incompatible changes, a feature flag or explicit opt-in mechanism should gate the v4 behaviour, giving users a migration window.

Rule Used: What: avoid backwards-incompatible changes without... (source)

"opentelemetry-api==1.33.1",
"opentelemetry-sdk==1.33.1",
"opentelemetry-exporter-otlp==1.33.1",
"opentelemetry-instrumentation-fastapi==0.54b1",
"ddtrace>=4.8.2,<5.0",
"sentry-sdk>=2.21.0,<3.0",
"mangum>=0.17.0,<1.0",
Expand Down Expand Up @@ -171,11 +171,11 @@ dev = [
"types-PyYAML==6.0.12.20250915",
"botocore-stubs==1.43.14",
"types-boto3[bedrock,bedrock-agent,bedrock-runtime,kms,s3,sagemaker-runtime,sts]==1.43.30",
"opentelemetry-api==1.28.0",
"opentelemetry-sdk==1.28.0",
"opentelemetry-exporter-otlp==1.28.0",
"opentelemetry-instrumentation-fastapi==0.49b0",
"langfuse==2.59.7",
"opentelemetry-api==1.33.1",
"opentelemetry-sdk==1.33.1",
"opentelemetry-exporter-otlp==1.33.1",
"opentelemetry-instrumentation-fastapi==0.54b1",
"langfuse==4.7.0",
"fastapi-offline==1.7.6",
"fakeredis==2.34.1",
"pytest-rerunfailures==15.1",
Expand All @@ -195,10 +195,10 @@ proxy-dev = [
"prisma==0.11.0",
"hypercorn==0.17.3",
"prometheus-client==0.20.0",
"opentelemetry-api==1.28.0",
"opentelemetry-sdk==1.28.0",
"opentelemetry-exporter-otlp==1.28.0",
"opentelemetry-instrumentation-fastapi==0.49b0",
"opentelemetry-api==1.33.1",
"opentelemetry-sdk==1.33.1",
"opentelemetry-exporter-otlp==1.33.1",
"opentelemetry-instrumentation-fastapi==0.54b1",
"azure-identity==1.25.2",
"a2a-sdk==1.1.0",
]
Expand All @@ -218,7 +218,7 @@ ci = [
"lunary==1.4.36; python_version == '3.10'",
"lunary==1.4.37; python_version >= '3.11'",
"logfire==4.6.0",
"traceloop-sdk==0.33.12",
"traceloop-sdk==0.34.0",
"detect-secrets==1.5.0",
"PyGithub==2.8.1",
"aiodynamo==24.7",
Expand Down
36 changes: 18 additions & 18 deletions tests/litellm_utils_tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ def test_logging_trace_id(langfuse_trace_id, langfuse_existing_trace_id):
- Unit test for `_get_trace_id` function in Logging obj
"""
from litellm.litellm_core_utils.litellm_logging import Logging
from langfuse import Langfuse

litellm.success_callback = ["langfuse"]
litellm_call_id = "my-unique-call-id"
Expand Down Expand Up @@ -961,24 +962,23 @@ def test_logging_trace_id(langfuse_trace_id, langfuse_existing_trace_id):
time.sleep(3)
assert litellm_logging_obj._get_trace_id(service_name="langfuse") is not None

## if existing_trace_id exists
if langfuse_existing_trace_id is not None:
assert (
litellm_logging_obj._get_trace_id(service_name="langfuse")
== langfuse_existing_trace_id
)
## if trace_id exists
elif langfuse_trace_id is not None:
assert (
litellm_logging_obj._get_trace_id(service_name="langfuse")
== langfuse_trace_id
)
## if no trace_id or existing_trace_id is provided, use litellm_trace_id
else:
assert (
litellm_logging_obj._get_trace_id(service_name="langfuse")
== litellm_logging_obj.litellm_trace_id
)
source_trace_id = (
langfuse_existing_trace_id
or langfuse_trace_id
or litellm_logging_obj.litellm_trace_id
)
normalized_trace_id = source_trace_id.lower().replace("-", "")
expected_trace_id = (
normalized_trace_id
if len(normalized_trace_id) == 32
and normalized_trace_id != "0" * 32
and all(character in "0123456789abcdef" for character in normalized_trace_id)
else Langfuse.create_trace_id(seed=source_trace_id)
)
assert (
litellm_logging_obj._get_trace_id(service_name="langfuse")
== expected_trace_id
)


def test_convert_model_response_object():
Expand Down
Loading
Loading