From d1bd68155e9d8699c62b3323fdc2877c8da4e595 Mon Sep 17 00:00:00 2001 From: Cole McIntosh <82463175+colesmcintosh@users.noreply.github.com> Date: Sat, 14 Jun 2025 15:18:38 -0600 Subject: [PATCH] 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 --- .../langfuse_otel_integration.md | 181 ++++++++++++++++++ docs/my-website/sidebars.js | 1 + litellm/__init__.py | 1 + .../integrations/langfuse/langfuse_otel.py | 89 +++++++++ litellm/integrations/opentelemetry.py | 7 + litellm/litellm_core_utils/litellm_logging.py | 25 +++ litellm/types/integrations/langfuse_otel.py | 13 ++ .../integrations/test_langfuse_otel.py | 84 ++++++++ 8 files changed, 401 insertions(+) create mode 100644 docs/my-website/docs/observability/langfuse_otel_integration.md create mode 100644 litellm/integrations/langfuse/langfuse_otel.py create mode 100644 litellm/types/integrations/langfuse_otel.py create mode 100644 tests/litellm/integrations/test_langfuse_otel.py diff --git a/docs/my-website/docs/observability/langfuse_otel_integration.md b/docs/my-website/docs/observability/langfuse_otel_integration.md new file mode 100644 index 0000000000..267738c300 --- /dev/null +++ b/docs/my-website/docs/observability/langfuse_otel_integration.md @@ -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 +``` + +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/) \ No newline at end of file diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index d68143c8b1..48abfdd916 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -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", diff --git a/litellm/__init__.py b/litellm/__init__.py index 27ed078fc5..2dd7745f3e 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -109,6 +109,7 @@ _custom_logger_compatible_callbacks_literal = Literal[ "argilla", "mlflow", "langfuse", + "langfuse_otel", "pagerduty", "humanloop", "gcs_pubsub", diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py new file mode 100644 index 0000000000..6d7f927c3e --- /dev/null +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -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" + ) \ No newline at end of file diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 452d44c76a..c51447c116 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -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", {}) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index d6e37bba6f..4bd46d3440 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -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): diff --git a/litellm/types/integrations/langfuse_otel.py b/litellm/types/integrations/langfuse_otel.py new file mode 100644 index 0000000000..389b013501 --- /dev/null +++ b/litellm/types/integrations/langfuse_otel.py @@ -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" \ No newline at end of file diff --git a/tests/litellm/integrations/test_langfuse_otel.py b/tests/litellm/integrations/test_langfuse_otel.py new file mode 100644 index 0000000000..65d622e1c8 --- /dev/null +++ b/tests/litellm/integrations/test_langfuse_otel.py @@ -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__]) \ No newline at end of file