mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
Merge pull request #18738 from BerriAI/litellm_feat_allow_arize_project_name
[feat] allow configuring arize project name for OpenTelemetry service name
This commit is contained in:
commit
9dd9f9fc43
8 changed files with 91 additions and 67 deletions
|
|
@ -68,6 +68,7 @@ environment_variables:
|
|||
ARIZE_API_KEY: "141a****"
|
||||
ARIZE_ENDPOINT: "https://otlp.arize.com/v1" # OPTIONAL - your custom arize GRPC api endpoint
|
||||
ARIZE_HTTP_ENDPOINT: "https://otlp.arize.com/v1" # OPTIONAL - your custom arize HTTP api endpoint. Set either this or ARIZE_ENDPOINT or Neither (defaults to https://otlp.arize.com/v1 on grpc)
|
||||
ARIZE_PROJECT_NAME: "my-litellm-project" # OPTIONAL - sets the arize project name
|
||||
```
|
||||
|
||||
2. Start the proxy
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ class ArizeLogger(OpenTelemetry):
|
|||
space_id = os.environ.get("ARIZE_SPACE_ID")
|
||||
space_key = os.environ.get("ARIZE_SPACE_KEY")
|
||||
api_key = os.environ.get("ARIZE_API_KEY")
|
||||
project_name = os.environ.get("ARIZE_PROJECT_NAME")
|
||||
|
||||
grpc_endpoint = os.environ.get("ARIZE_ENDPOINT")
|
||||
http_endpoint = os.environ.get("ARIZE_HTTP_ENDPOINT")
|
||||
|
|
@ -74,6 +75,7 @@ class ArizeLogger(OpenTelemetry):
|
|||
api_key=api_key,
|
||||
protocol=protocol,
|
||||
endpoint=endpoint,
|
||||
project_name=project_name,
|
||||
)
|
||||
|
||||
async def async_service_success_hook(
|
||||
|
|
|
|||
|
|
@ -54,38 +54,6 @@ RAW_REQUEST_SPAN_NAME = "raw_gen_ai_request"
|
|||
LITELLM_REQUEST_SPAN_NAME = "litellm_request"
|
||||
|
||||
|
||||
def _get_litellm_resource():
|
||||
"""
|
||||
Create a proper OpenTelemetry Resource that respects OTEL_RESOURCE_ATTRIBUTES
|
||||
while maintaining backward compatibility with LiteLLM-specific environment variables.
|
||||
"""
|
||||
from opentelemetry.sdk.resources import OTELResourceDetector, Resource
|
||||
|
||||
# Create base resource attributes with LiteLLM-specific defaults
|
||||
# These will be overridden by OTEL_RESOURCE_ATTRIBUTES if present
|
||||
base_attributes: Dict[str, Optional[str]] = {
|
||||
"service.name": os.getenv("OTEL_SERVICE_NAME", "litellm"),
|
||||
"deployment.environment": os.getenv("OTEL_ENVIRONMENT_NAME", "production"),
|
||||
# Fix the model_id to use proper environment variable or default to service name
|
||||
"model_id": os.getenv(
|
||||
"OTEL_MODEL_ID", os.getenv("OTEL_SERVICE_NAME", "litellm")
|
||||
),
|
||||
}
|
||||
|
||||
# Create base resource with LiteLLM-specific defaults
|
||||
base_resource = Resource.create(base_attributes) # type: ignore
|
||||
|
||||
# Create resource from OTEL_RESOURCE_ATTRIBUTES using the detector
|
||||
otel_resource_detector = OTELResourceDetector()
|
||||
env_resource = otel_resource_detector.detect()
|
||||
|
||||
# Merge the resources: env_resource takes precedence over base_resource
|
||||
# This ensures OTEL_RESOURCE_ATTRIBUTES overrides LiteLLM defaults
|
||||
merged_resource = base_resource.merge(env_resource)
|
||||
|
||||
return merged_resource
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenTelemetryConfig:
|
||||
exporter: Union[str, SpanExporter] = "console"
|
||||
|
|
@ -93,6 +61,19 @@ class OpenTelemetryConfig:
|
|||
headers: Optional[str] = None
|
||||
enable_metrics: bool = False
|
||||
enable_events: bool = False
|
||||
service_name: Optional[str] = None
|
||||
deployment_environment: Optional[str] = None
|
||||
model_id: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.service_name:
|
||||
self.service_name = os.getenv("OTEL_SERVICE_NAME", "litellm")
|
||||
if not self.deployment_environment:
|
||||
self.deployment_environment = os.getenv(
|
||||
"OTEL_ENVIRONMENT_NAME", "production"
|
||||
)
|
||||
if not self.model_id:
|
||||
self.model_id = os.getenv("OTEL_MODEL_ID", self.service_name)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
|
|
@ -122,6 +103,9 @@ class OpenTelemetryConfig:
|
|||
os.getenv("LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS", "false").lower()
|
||||
== "true"
|
||||
)
|
||||
service_name = os.getenv("OTEL_SERVICE_NAME", "litellm")
|
||||
deployment_environment = os.getenv("OTEL_ENVIRONMENT_NAME", "production")
|
||||
model_id = os.getenv("OTEL_MODEL_ID", service_name)
|
||||
|
||||
if exporter == "in_memory":
|
||||
return cls(exporter=InMemorySpanExporter())
|
||||
|
|
@ -131,6 +115,9 @@ class OpenTelemetryConfig:
|
|||
headers=headers, # example: OTEL_HEADERS=x-honeycomb-team=B85YgLm96***"
|
||||
enable_metrics=enable_metrics,
|
||||
enable_events=enable_events,
|
||||
service_name=service_name,
|
||||
deployment_environment=deployment_environment,
|
||||
model_id=model_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -174,6 +161,22 @@ class OpenTelemetry(CustomLogger):
|
|||
self._init_logs(logger_provider)
|
||||
self._init_otel_logger_on_litellm_proxy()
|
||||
|
||||
@staticmethod
|
||||
def _get_litellm_resource(config: OpenTelemetryConfig):
|
||||
"""Create an OpenTelemetry Resource using config-driven defaults."""
|
||||
from opentelemetry.sdk.resources import OTELResourceDetector, Resource
|
||||
|
||||
base_attributes: Dict[str, Optional[str]] = {
|
||||
"service.name": config.service_name,
|
||||
"deployment.environment": config.deployment_environment,
|
||||
"model_id": config.model_id or config.service_name,
|
||||
}
|
||||
|
||||
base_resource = Resource.create(base_attributes) # type: ignore[arg-type]
|
||||
otel_resource_detector = OTELResourceDetector()
|
||||
env_resource = otel_resource_detector.detect()
|
||||
return base_resource.merge(env_resource)
|
||||
|
||||
def _init_otel_logger_on_litellm_proxy(self):
|
||||
"""
|
||||
Initializes OpenTelemetry for litellm proxy server
|
||||
|
|
@ -266,7 +269,7 @@ class OpenTelemetry(CustomLogger):
|
|||
from opentelemetry.trace import SpanKind
|
||||
|
||||
def create_tracer_provider():
|
||||
provider = TracerProvider(resource=_get_litellm_resource())
|
||||
provider = TracerProvider(resource=self._get_litellm_resource(self.config))
|
||||
provider.add_span_processor(self._get_span_processor())
|
||||
return provider
|
||||
|
||||
|
|
@ -300,7 +303,8 @@ class OpenTelemetry(CustomLogger):
|
|||
def create_meter_provider():
|
||||
metric_reader = self._get_metric_reader()
|
||||
return MeterProvider(
|
||||
metric_readers=[metric_reader], resource=_get_litellm_resource()
|
||||
metric_readers=[metric_reader],
|
||||
resource=self._get_litellm_resource(self.config),
|
||||
)
|
||||
|
||||
meter_provider = self._get_or_create_provider(
|
||||
|
|
@ -355,7 +359,9 @@ class OpenTelemetry(CustomLogger):
|
|||
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
||||
|
||||
def create_logger_provider():
|
||||
provider = OTLoggerProvider(resource=_get_litellm_resource())
|
||||
provider = OTLoggerProvider(
|
||||
resource=self._get_litellm_resource(self.config)
|
||||
)
|
||||
log_exporter = self._get_log_exporter()
|
||||
provider.add_log_record_processor(
|
||||
BatchLogRecordProcessor(log_exporter) # type: ignore[arg-type]
|
||||
|
|
@ -606,7 +612,7 @@ class OpenTelemetry(CustomLogger):
|
|||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
# Create a temporary tracer provider with dynamic headers
|
||||
temp_provider = TracerProvider(resource=_get_litellm_resource())
|
||||
temp_provider = TracerProvider(resource=self._get_litellm_resource(self.config))
|
||||
temp_provider.add_span_processor(
|
||||
self._get_span_processor(dynamic_headers=dynamic_headers)
|
||||
)
|
||||
|
|
@ -987,9 +993,9 @@ class OpenTelemetry(CustomLogger):
|
|||
|
||||
# Get the resource from the logger provider
|
||||
logger_provider = get_logger_provider()
|
||||
resource = (
|
||||
getattr(logger_provider, "_resource", None) or _get_litellm_resource()
|
||||
)
|
||||
resource = getattr(
|
||||
logger_provider, "_resource", None
|
||||
) or self._get_litellm_resource(self.config)
|
||||
|
||||
parent_ctx = span.get_span_context()
|
||||
provider = (kwargs.get("litellm_params") or {}).get(
|
||||
|
|
@ -1910,7 +1916,9 @@ class OpenTelemetry(CustomLogger):
|
|||
)
|
||||
|
||||
_split_otel_headers = OpenTelemetry._get_headers_dictionary(self.OTEL_HEADERS)
|
||||
normalized_endpoint = self._normalize_otel_endpoint(self.OTEL_ENDPOINT, "metrics")
|
||||
normalized_endpoint = self._normalize_otel_endpoint(
|
||||
self.OTEL_ENDPOINT, "metrics"
|
||||
)
|
||||
|
||||
if self.OTEL_EXPORTER == "console":
|
||||
exporter = ConsoleMetricExporter()
|
||||
|
|
|
|||
|
|
@ -3630,6 +3630,7 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
otel_config = OpenTelemetryConfig(
|
||||
exporter=arize_config.protocol,
|
||||
endpoint=arize_config.endpoint,
|
||||
service_name=arize_config.project_name,
|
||||
)
|
||||
|
||||
os.environ[
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@ class ArizeConfig(BaseModel):
|
|||
api_key: Optional[str] = None
|
||||
protocol: Protocol
|
||||
endpoint: str
|
||||
project_name: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ def test_get_arize_config(mock_env_vars):
|
|||
assert config.api_key == "test_api_key"
|
||||
assert config.endpoint == "https://otlp.arize.com/v1"
|
||||
assert config.protocol == "otlp_grpc"
|
||||
assert config.project_name is None
|
||||
|
||||
|
||||
def test_get_arize_config_with_endpoints(mock_env_vars, monkeypatch):
|
||||
|
|
@ -79,10 +80,12 @@ def test_get_arize_config_with_endpoints(mock_env_vars, monkeypatch):
|
|||
"""
|
||||
monkeypatch.setenv("ARIZE_ENDPOINT", "grpc://test.endpoint")
|
||||
monkeypatch.setenv("ARIZE_HTTP_ENDPOINT", "http://test.endpoint")
|
||||
monkeypatch.setenv("ARIZE_PROJECT_NAME", "custom-project")
|
||||
|
||||
config = ArizeLogger.get_arize_config()
|
||||
assert config.endpoint == "grpc://test.endpoint"
|
||||
assert config.protocol == "otlp_grpc"
|
||||
assert config.project_name == "custom-project"
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@ class TestArizeIntegrationWithProxy:
|
|||
with patch.dict(os.environ, {
|
||||
"ARIZE_SPACE_KEY": "test-space-123",
|
||||
"ARIZE_API_KEY": "test-api-456",
|
||||
"ARIZE_ENDPOINT": "https://custom.arize.com/v1"
|
||||
"ARIZE_ENDPOINT": "https://custom.arize.com/v1",
|
||||
"ARIZE_PROJECT_NAME": "custom-project",
|
||||
}):
|
||||
config = ArizeLogger.get_arize_config()
|
||||
|
||||
|
|
@ -131,13 +132,15 @@ class TestArizeIntegrationWithProxy:
|
|||
assert config.api_key == "test-api-456"
|
||||
assert config.endpoint == "https://custom.arize.com/v1"
|
||||
assert config.protocol == "otlp_grpc"
|
||||
assert config.project_name == "custom-project"
|
||||
|
||||
def test_arize_get_config_defaults(self):
|
||||
"""Test ArizeLogger.get_arize_config() with default endpoint."""
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"ARIZE_SPACE_KEY": "test-space-default",
|
||||
"ARIZE_API_KEY": "test-api-default"
|
||||
"ARIZE_API_KEY": "test-api-default",
|
||||
"ARIZE_PROJECT_NAME": "default-project",
|
||||
}, clear=True):
|
||||
config = ArizeLogger.get_arize_config()
|
||||
|
||||
|
|
@ -145,6 +148,7 @@ class TestArizeIntegrationWithProxy:
|
|||
assert config.api_key == "test-api-default"
|
||||
assert config.endpoint == "https://otlp.arize.com/v1" # Default endpoint
|
||||
assert config.protocol == "otlp_grpc" # Default protocol
|
||||
assert config.project_name == "default-project"
|
||||
|
||||
def test_arize_construct_dynamic_headers(self):
|
||||
"""Test dynamic OTEL headers construction for team/key logging."""
|
||||
|
|
@ -180,4 +184,4 @@ class TestArizeIntegrationWithProxy:
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
|
|||
|
|
@ -258,6 +258,22 @@ class TestOpenTelemetry(unittest.TestCase):
|
|||
MODEL = "arn:aws:bedrock:us-west-2:1234567890123:inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
HERE = os.path.dirname(__file__)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_open_telemetry_config_manual_defaults(self):
|
||||
"""Manual OpenTelemetryConfig creation should populate default identifiers."""
|
||||
config = OpenTelemetryConfig(exporter="console", endpoint="http://collector")
|
||||
self.assertEqual(config.service_name, "litellm")
|
||||
self.assertEqual(config.deployment_environment, "production")
|
||||
self.assertEqual(config.model_id, "litellm")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_open_telemetry_config_custom_service_name(self):
|
||||
"""Model ID should inherit provided service name when not explicitly set."""
|
||||
config = OpenTelemetryConfig(service_name="custom-service", exporter="console")
|
||||
self.assertEqual(config.service_name, "custom-service")
|
||||
self.assertEqual(config.deployment_environment, "production")
|
||||
self.assertEqual(config.model_id, "custom-service")
|
||||
|
||||
def wait_for_spans(self, exporter: InMemorySpanExporter, prefix: str):
|
||||
"""Poll until we see at least one span with an attribute key starting with `prefix`."""
|
||||
deadline = time.time() + self.POLL_TIMEOUT
|
||||
|
|
@ -504,8 +520,6 @@ class TestOpenTelemetry(unittest.TestCase):
|
|||
self, mock_detector_cls, mock_resource_create
|
||||
):
|
||||
"""Test _get_litellm_resource with default values when no environment variables are set."""
|
||||
from litellm.integrations.opentelemetry import _get_litellm_resource
|
||||
|
||||
# Mock the Resource.create method
|
||||
mock_base_resource = MagicMock()
|
||||
mock_resource_create.return_value = mock_base_resource
|
||||
|
|
@ -520,8 +534,8 @@ class TestOpenTelemetry(unittest.TestCase):
|
|||
mock_merged_resource = MagicMock()
|
||||
mock_base_resource.merge.return_value = mock_merged_resource
|
||||
|
||||
# Call the function
|
||||
result = _get_litellm_resource()
|
||||
config = OpenTelemetryConfig()
|
||||
result = OpenTelemetry._get_litellm_resource(config)
|
||||
|
||||
# Verify Resource.create was called with correct default attributes
|
||||
expected_attributes = {
|
||||
|
|
@ -549,8 +563,6 @@ class TestOpenTelemetry(unittest.TestCase):
|
|||
self, mock_detector_cls, mock_resource_create
|
||||
):
|
||||
"""Test _get_litellm_resource with LiteLLM-specific environment variables."""
|
||||
from litellm.integrations.opentelemetry import _get_litellm_resource
|
||||
|
||||
# Mock the Resource.create method
|
||||
mock_base_resource = MagicMock()
|
||||
mock_resource_create.return_value = mock_base_resource
|
||||
|
|
@ -565,8 +577,8 @@ class TestOpenTelemetry(unittest.TestCase):
|
|||
mock_merged_resource = MagicMock()
|
||||
mock_base_resource.merge.return_value = mock_merged_resource
|
||||
|
||||
# Call the function
|
||||
result = _get_litellm_resource()
|
||||
config = OpenTelemetryConfig.from_env()
|
||||
result = OpenTelemetry._get_litellm_resource(config)
|
||||
|
||||
# Verify Resource.create was called with environment variable values
|
||||
expected_attributes = {
|
||||
|
|
@ -593,8 +605,6 @@ class TestOpenTelemetry(unittest.TestCase):
|
|||
self, mock_detector_cls, mock_resource_create
|
||||
):
|
||||
"""Test _get_litellm_resource with OTEL_RESOURCE_ATTRIBUTES environment variable."""
|
||||
from litellm.integrations.opentelemetry import _get_litellm_resource
|
||||
|
||||
# Mock the Resource.create method to simulate the actual behavior
|
||||
# In reality, Resource.create() would parse OTEL_RESOURCE_ATTRIBUTES and merge it
|
||||
mock_base_resource = MagicMock()
|
||||
|
|
@ -610,8 +620,8 @@ class TestOpenTelemetry(unittest.TestCase):
|
|||
mock_merged_resource = MagicMock()
|
||||
mock_base_resource.merge.return_value = mock_merged_resource
|
||||
|
||||
# Call the function
|
||||
result = _get_litellm_resource()
|
||||
config = OpenTelemetryConfig.from_env()
|
||||
result = OpenTelemetry._get_litellm_resource(config)
|
||||
|
||||
# Verify Resource.create was called with the base attributes
|
||||
# The actual OTEL_RESOURCE_ATTRIBUTES parsing is handled by OpenTelemetry SDK
|
||||
|
|
@ -628,10 +638,8 @@ class TestOpenTelemetry(unittest.TestCase):
|
|||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_litellm_resource_integration_with_real_resource(self):
|
||||
"""Integration test to verify _get_litellm_resource works with actual OpenTelemetry Resource."""
|
||||
from litellm.integrations.opentelemetry import _get_litellm_resource
|
||||
|
||||
# This test uses the real OpenTelemetry Resource.create() method
|
||||
result = _get_litellm_resource()
|
||||
config = OpenTelemetryConfig()
|
||||
result = OpenTelemetry._get_litellm_resource(config)
|
||||
|
||||
# Verify the result is a Resource instance
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
|
@ -653,10 +661,8 @@ class TestOpenTelemetry(unittest.TestCase):
|
|||
)
|
||||
def test_get_litellm_resource_real_otel_resource_attributes(self):
|
||||
"""Integration test to verify OTEL_RESOURCE_ATTRIBUTES is properly handled."""
|
||||
from litellm.integrations.opentelemetry import _get_litellm_resource
|
||||
|
||||
# This test uses the real OpenTelemetry Resource.create() method
|
||||
result = _get_litellm_resource()
|
||||
config = OpenTelemetryConfig.from_env()
|
||||
result = OpenTelemetry._get_litellm_resource(config)
|
||||
|
||||
print("RESULT", result)
|
||||
|
||||
|
|
@ -683,10 +689,8 @@ class TestOpenTelemetry(unittest.TestCase):
|
|||
)
|
||||
def test_get_litellm_resource_precedence(self):
|
||||
"""Test that OTEL_SERVICE_NAME takes precedence over OTEL_RESOURCE_ATTRIBUTES according to OpenTelemetry spec."""
|
||||
from litellm.integrations.opentelemetry import _get_litellm_resource
|
||||
|
||||
# This test verifies the OpenTelemetry standard behavior
|
||||
result = _get_litellm_resource()
|
||||
config = OpenTelemetryConfig.from_env()
|
||||
result = OpenTelemetry._get_litellm_resource(config)
|
||||
|
||||
# Verify the result is a Resource instance
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue