Add Langfuse OpenTelemetry Integration (#11607)

* feat(langfuse_otel): add Langfuse OpenTelemetry integration for observability

- Introduced a new integration for Langfuse OpenTelemetry, allowing users to send LiteLLM traces and observability data.
- Updated sidebars to include documentation for the new integration.
- Added example usage and configuration details in the documentation.
- Implemented necessary classes and methods to handle OpenTelemetry attributes and configuration.
- Included tests to validate the integration functionality and environment variable handling.

Still WIP

* Remove example script for Langfuse OpenTelemetry integration with LiteLLM
This commit is contained in:
Cole McIntosh
2025-06-14 14:18:38 -07:00
committed by GitHub
parent f79737ad90
commit d1bd68155e
8 changed files with 401 additions and 0 deletions
@@ -0,0 +1,181 @@
# Langfuse OpenTelemetry Integration
The Langfuse OpenTelemetry integration allows you to send LiteLLM traces and observability data to Langfuse using the OpenTelemetry protocol. This provides a standardized way to collect and analyze your LLM usage data.
## Features
- Automatic trace collection for all LiteLLM requests
- Support for Langfuse Cloud (EU and US regions)
- Support for self-hosted Langfuse instances
- Custom endpoint configuration
- Secure authentication using Basic Auth
- Consistent attribute mapping with other OTEL integrations
## Prerequisites
1. **Langfuse Account**: Sign up at [Langfuse Cloud](https://cloud.langfuse.com) or set up a self-hosted instance
2. **API Keys**: Get your public and secret keys from your Langfuse project settings
3. **Dependencies**: Install required packages:
```bash
pip install litellm opentelemetry-api opentelemetry-sdk
```
## Configuration
### Environment Variables
| Variable | Required | Description | Example |
|----------|----------|-------------|---------|
| `LANGFUSE_PUBLIC_KEY` | Yes | Your Langfuse public key | `pk-lf-...` |
| `LANGFUSE_SECRET_KEY` | Yes | Your Langfuse secret key | `sk-lf-...` |
| `LANGFUSE_HOST` | No | Langfuse host URL | `https://us.cloud.langfuse.com` (default) |
### Endpoint Resolution
The integration automatically constructs the OTEL endpoint from the `LANGFUSE_HOST`:
- **Default (US)**: `https://us.cloud.langfuse.com/api/public/otel`
- **EU Region**: `https://cloud.langfuse.com/api/public/otel`
- **Self-hosted**: `{LANGFUSE_HOST}/api/public/otel`
## Usage
### Basic Setup
```python
import os
import litellm
# Set your Langfuse credentials
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-lf-..."
os.environ["LANGFUSE_SECRET_KEY"] = "sk-lf-..."
# Enable Langfuse OTEL integration
litellm.callbacks = ["langfuse_otel"]
# Make LLM requests as usual
response = litellm.completion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello!"}]
)
```
### Advanced Configuration
```python
import os
import litellm
# Set your Langfuse credentials
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-lf-..."
os.environ["LANGFUSE_SECRET_KEY"] = "sk-lf-..."
# Use EU region
os.environ["LANGFUSE_HOST"] = "https://cloud.langfuse.com" # EU region
# os.environ["LANGFUSE_HOST"] = "https://us.cloud.langfuse.com" # US region (default)
# Or use self-hosted instance
# os.environ["LANGFUSE_HOST"] = "https://my-langfuse.company.com"
litellm.callbacks = ["langfuse_otel"]
```
### Manual OTEL Configuration
If you need direct control over the OpenTelemetry configuration:
```python
import os
import base64
import litellm
# Get keys for your project from the project settings page: https://cloud.langfuse.com
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-lf-..."
os.environ["LANGFUSE_SECRET_KEY"] = "sk-lf-..."
os.environ["LANGFUSE_HOST"] = "https://cloud.langfuse.com" # EU region
# os.environ["LANGFUSE_HOST"] = "https://us.cloud.langfuse.com" # US region
LANGFUSE_AUTH = base64.b64encode(
f"{os.environ.get('LANGFUSE_PUBLIC_KEY')}:{os.environ.get('LANGFUSE_SECRET_KEY')}".encode()
).decode()
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = os.environ.get("LANGFUSE_HOST") + "/api/public/otel"
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"Authorization=Basic {LANGFUSE_AUTH}"
litellm.callbacks = ["langfuse_otel"]
```
### With LiteLLM Proxy
Add the integration to your proxy configuration:
```yaml
# config.yaml
litellm_settings:
callbacks: ["langfuse_otel"]
environment_variables:
LANGFUSE_PUBLIC_KEY: "pk-lf-..."
LANGFUSE_SECRET_KEY: "sk-lf-..."
LANGFUSE_HOST: "https://us.cloud.langfuse.com" # Default US region
```
## Data Collected
The integration automatically collects the following data:
- **Request Details**: Model, messages, parameters (temperature, max_tokens, etc.)
- **Response Details**: Generated content, token usage, finish reason
- **Timing Information**: Request duration, time to first token
- **Metadata**: User ID, session ID, custom tags (if provided)
- **Error Information**: Exception details and stack traces (if errors occur)
## Authentication
The integration uses HTTP Basic Authentication with your Langfuse public and secret keys:
```
Authorization: Basic <base64(public_key:secret_key)>
```
This is automatically handled by the integration - you just need to provide the keys via environment variables.
## Troubleshooting
### Common Issues
1. **Missing Credentials Error**
```
ValueError: LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be set
```
**Solution**: Ensure both environment variables are set with valid keys.
2. **Connection Issues**
- Check your internet connection
- Verify the endpoint URL is correct
- For self-hosted instances, ensure the `/api/public/otel` endpoint is accessible
3. **Authentication Errors**
- Verify your public and secret keys are correct
- Check that the keys belong to the same Langfuse project
- Ensure the keys have the necessary permissions
### Debug Mode
Enable verbose logging to see detailed information:
```python
import litellm
litellm.set_verbose = True
```
This will show:
- Endpoint resolution logic
- Authentication header creation
- OTEL trace submission details
## Related Links
- [Langfuse Documentation](https://langfuse.com/docs)
- [Langfuse OpenTelemetry Guide](https://langfuse.com/docs/integrations/opentelemetry)
- [OpenTelemetry Python SDK](https://opentelemetry.io/docs/languages/python/)
- [LiteLLM Observability](https://docs.litellm.ai/docs/observability/)
+1
View File
@@ -479,6 +479,7 @@ const sidebars = {
items: [
"observability/agentops_integration",
"observability/langfuse_integration",
"observability/langfuse_otel_integration",
"observability/lunary_integration",
"observability/deepeval_integration",
"observability/mlflow",
+1
View File
@@ -109,6 +109,7 @@ _custom_logger_compatible_callbacks_literal = Literal[
"argilla",
"mlflow",
"langfuse",
"langfuse_otel",
"pagerduty",
"humanloop",
"gcs_pubsub",
@@ -0,0 +1,89 @@
import base64
import os
from typing import TYPE_CHECKING, Any, Union
from litellm._logging import verbose_logger
from litellm.integrations.arize import _utils
from litellm.types.integrations.langfuse_otel import LangfuseOtelConfig
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
from litellm.types.integrations.arize import Protocol as _Protocol
from litellm.integrations.opentelemetry import OpenTelemetryConfig as _OpenTelemetryConfig
Protocol = _Protocol
OpenTelemetryConfig = _OpenTelemetryConfig
Span = Union[_Span, Any]
else:
Protocol = Any
OpenTelemetryConfig = Any
Span = Any
LANGFUSE_CLOUD_EU_ENDPOINT = "https://cloud.langfuse.com/api/public/otel"
LANGFUSE_CLOUD_US_ENDPOINT = "https://us.cloud.langfuse.com/api/public/otel"
class LangfuseOtelLogger:
@staticmethod
def set_langfuse_otel_attributes(span: Span, kwargs, response_obj):
"""
Sets OpenTelemetry span attributes for Langfuse observability.
Uses the same attribute setting logic as Arize Phoenix for consistency.
"""
_utils.set_attributes(span, kwargs, response_obj)
return
@staticmethod
def get_langfuse_otel_config() -> LangfuseOtelConfig:
"""
Retrieves the Langfuse OpenTelemetry configuration based on environment variables.
Environment Variables:
LANGFUSE_PUBLIC_KEY: Required. Langfuse public key for authentication.
LANGFUSE_SECRET_KEY: Required. Langfuse secret key for authentication.
LANGFUSE_HOST: Optional. Custom Langfuse host URL. Defaults to US cloud.
Returns:
LangfuseOtelConfig: A Pydantic model containing Langfuse OTEL configuration.
Raises:
ValueError: If required keys are missing.
"""
public_key = os.environ.get("LANGFUSE_PUBLIC_KEY", None)
secret_key = os.environ.get("LANGFUSE_SECRET_KEY", None)
if not public_key or not secret_key:
raise ValueError(
"LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be set for Langfuse OpenTelemetry integration."
)
# Determine endpoint - default to US cloud
langfuse_host = os.environ.get("LANGFUSE_HOST", None)
if langfuse_host:
# If LANGFUSE_HOST is provided, construct OTEL endpoint from it
if not langfuse_host.startswith("http"):
langfuse_host = "https://" + langfuse_host
endpoint = f"{langfuse_host.rstrip('/')}/api/public/otel"
verbose_logger.debug(f"Using Langfuse OTEL endpoint from host: {endpoint}")
else:
# Default to US cloud endpoint
endpoint = LANGFUSE_CLOUD_US_ENDPOINT
verbose_logger.debug(f"Using Langfuse US cloud endpoint: {endpoint}")
# Create Basic Auth header
auth_string = f"{public_key}:{secret_key}"
auth_header = base64.b64encode(auth_string.encode()).decode()
otlp_auth_headers = f"Authorization=Basic {auth_header}"
# Set standard OTEL environment variables
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = otlp_auth_headers
return LangfuseOtelConfig(
otlp_auth_headers=otlp_auth_headers,
protocol="otlp_http"
)
+7
View File
@@ -578,6 +578,13 @@ class OpenTelemetry(CustomLogger):
span, kwargs, response_obj
)
return
elif self.callback_name == "langfuse_otel":
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
LangfuseOtelLogger.set_langfuse_otel_attributes(
span, kwargs, response_obj
)
return
from litellm.proxy._types import SpanAttributes
optional_params = kwargs.get("optional_params", {})
@@ -125,6 +125,7 @@ from ..integrations.humanloop import HumanloopLogger
from ..integrations.lago import LagoLogger
from ..integrations.langfuse.langfuse import LangFuseLogger
from ..integrations.langfuse.langfuse_handler import LangFuseHandler
from ..integrations.langfuse.langfuse_otel import LangfuseOtelLogger
from ..integrations.langfuse.langfuse_prompt_management import LangfusePromptManagement
from ..integrations.langsmith import LangsmithLogger
from ..integrations.literal_ai import LiteralAILogger
@@ -3146,6 +3147,30 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
langfuse_logger = LangfusePromptManagement()
_in_memory_loggers.append(langfuse_logger)
return langfuse_logger # type: ignore
elif logging_integration == "langfuse_otel":
from litellm.integrations.opentelemetry import (
OpenTelemetry,
OpenTelemetryConfig,
)
langfuse_otel_config = LangfuseOtelLogger.get_langfuse_otel_config()
# The endpoint and headers are now set as environment variables by get_langfuse_otel_config()
otel_config = OpenTelemetryConfig(
exporter=langfuse_otel_config.protocol,
)
for callback in _in_memory_loggers:
if (
isinstance(callback, OpenTelemetry)
and callback.callback_name == "langfuse_otel"
):
return callback # type: ignore
_otel_logger = OpenTelemetry(
config=otel_config, callback_name="langfuse_otel"
)
_in_memory_loggers.append(_otel_logger)
return _otel_logger # type: ignore
elif logging_integration == "pagerduty":
for callback in _in_memory_loggers:
if isinstance(callback, PagerDutyAlerting):
@@ -0,0 +1,13 @@
from typing import TYPE_CHECKING, Literal, Optional, Any
from pydantic import BaseModel
if TYPE_CHECKING:
Protocol = Literal["otlp_grpc", "otlp_http"]
else:
Protocol = Any
class LangfuseOtelConfig(BaseModel):
otlp_auth_headers: Optional[str] = None
protocol: Protocol = "otlp_http"
@@ -0,0 +1,84 @@
import os
import pytest
from unittest.mock import patch, MagicMock
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
from litellm.types.integrations.langfuse_otel import LangfuseOtelConfig
class TestLangfuseOtelIntegration:
def test_get_langfuse_otel_config_with_required_env_vars(self):
"""Test that config is created correctly with required environment variables."""
# Clean environment of any Langfuse-related variables
env_vars_to_clean = ['LANGFUSE_HOST', 'OTEL_EXPORTER_OTLP_ENDPOINT', 'OTEL_EXPORTER_OTLP_HEADERS']
with patch.dict(os.environ, {
'LANGFUSE_PUBLIC_KEY': 'test_public_key',
'LANGFUSE_SECRET_KEY': 'test_secret_key'
}, clear=False):
# Remove any existing Langfuse variables
for var in env_vars_to_clean:
if var in os.environ:
del os.environ[var]
config = LangfuseOtelLogger.get_langfuse_otel_config()
assert isinstance(config, LangfuseOtelConfig)
assert config.protocol == "otlp_http"
assert "Authorization=Basic" in config.otlp_auth_headers
# Check that environment variables are set correctly (US default)
assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://us.cloud.langfuse.com/api/public/otel"
assert "Authorization=Basic" in os.environ.get("OTEL_EXPORTER_OTLP_HEADERS", "")
def test_get_langfuse_otel_config_missing_keys(self):
"""Test that ValueError is raised when required keys are missing."""
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(ValueError, match="LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be set"):
LangfuseOtelLogger.get_langfuse_otel_config()
def test_get_langfuse_otel_config_with_eu_host(self):
"""Test config with EU host."""
with patch.dict(os.environ, {
'LANGFUSE_PUBLIC_KEY': 'test_public_key',
'LANGFUSE_SECRET_KEY': 'test_secret_key',
'LANGFUSE_HOST': 'https://cloud.langfuse.com'
}, clear=False):
config = LangfuseOtelLogger.get_langfuse_otel_config()
assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://cloud.langfuse.com/api/public/otel"
def test_get_langfuse_otel_config_with_custom_host(self):
"""Test config with custom host."""
with patch.dict(os.environ, {
'LANGFUSE_PUBLIC_KEY': 'test_public_key',
'LANGFUSE_SECRET_KEY': 'test_secret_key',
'LANGFUSE_HOST': 'https://my-langfuse.com'
}, clear=False):
config = LangfuseOtelLogger.get_langfuse_otel_config()
assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://my-langfuse.com/api/public/otel"
def test_get_langfuse_otel_config_with_host_no_protocol(self):
"""Test config with custom host without protocol."""
with patch.dict(os.environ, {
'LANGFUSE_PUBLIC_KEY': 'test_public_key',
'LANGFUSE_SECRET_KEY': 'test_secret_key',
'LANGFUSE_HOST': 'my-langfuse.com'
}, clear=False):
config = LangfuseOtelLogger.get_langfuse_otel_config()
assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://my-langfuse.com/api/public/otel"
def test_set_langfuse_otel_attributes(self):
"""Test that set_langfuse_otel_attributes calls the Arize utils function."""
mock_span = MagicMock()
mock_kwargs = {"test": "kwargs"}
mock_response = {"test": "response"}
with patch('litellm.integrations.arize._utils.set_attributes') as mock_set_attributes:
LangfuseOtelLogger.set_langfuse_otel_attributes(mock_span, mock_kwargs, mock_response)
mock_set_attributes.assert_called_once_with(mock_span, mock_kwargs, mock_response)
if __name__ == "__main__":
pytest.main([__file__])