mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-28 10:20:18 +00:00
The proxy SERVER span ("Received Proxy Server Request") only carried
http.response.status_code on failures (set in _record_exception_on_span),
so success traces had no 2xx bucket — error-ratio and status-breakdown
dashboards were missing their denominator and the span violated the HTTP
semconv (the attribute is required whenever a response is sent). Add a
set_response_status_code_attribute helper and call it from
async_post_call_success_hook with 200, symmetric with the failure path
and the existing route/preprocessing-duration SERVER-span attributes.
4943 lines
194 KiB
Python
4943 lines
194 KiB
Python
import asyncio
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
import unittest
|
||
from datetime import datetime, timedelta, timezone
|
||
from parameterized import parameterized
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
# Adds the grandparent directory to sys.path to allow importing project modules
|
||
sys.path.insert(0, os.path.abspath("../.."))
|
||
from opentelemetry import trace
|
||
from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider
|
||
from opentelemetry.sdk._logs.export import InMemoryLogExporter, SimpleLogRecordProcessor
|
||
from opentelemetry.sdk.metrics import MeterProvider
|
||
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
||
from opentelemetry.sdk.trace import TracerProvider
|
||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||
|
||
from litellm.integrations.opentelemetry import (
|
||
OpenTelemetry,
|
||
OpenTelemetryConfig,
|
||
OTELSemconvCategory,
|
||
)
|
||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||
|
||
|
||
class TestOpenTelemetryGuardrails(unittest.TestCase):
|
||
@patch("litellm.integrations.opentelemetry.datetime")
|
||
def test_create_guardrail_span_with_valid_info(self, mock_datetime):
|
||
# Setup
|
||
otel = OpenTelemetry()
|
||
otel.tracer = MagicMock()
|
||
mock_span = MagicMock()
|
||
otel.tracer.start_span.return_value = mock_span
|
||
|
||
# Create guardrail information
|
||
guardrail_info = {
|
||
"guardrail_name": "test_guardrail",
|
||
"guardrail_mode": "input",
|
||
"masked_entity_count": {"CREDIT_CARD": 2},
|
||
"guardrail_response": "filtered_content",
|
||
"start_time": 1609459200.0,
|
||
"end_time": 1609459201.0,
|
||
}
|
||
|
||
# Create a kwargs dict with standard_logging_object containing guardrail information
|
||
kwargs = {
|
||
"standard_logging_object": {"guardrail_information": [guardrail_info]}
|
||
}
|
||
|
||
# Call the method
|
||
otel._create_guardrail_span(kwargs=kwargs, context=None)
|
||
|
||
# Assertions
|
||
otel.tracer.start_span.assert_called_once()
|
||
|
||
# print all calls to mock_span.set_attribute
|
||
print("Calls to mock_span.set_attribute:")
|
||
for call in mock_span.set_attribute.call_args_list:
|
||
print(call)
|
||
|
||
# Check that the span has the correct attributes set
|
||
mock_span.set_attribute.assert_any_call("guardrail_name", "test_guardrail")
|
||
mock_span.set_attribute.assert_any_call("guardrail_mode", "input")
|
||
mock_span.set_attribute.assert_any_call(
|
||
"guardrail_response", "filtered_content"
|
||
)
|
||
mock_span.set_attribute.assert_any_call(
|
||
"masked_entity_count", safe_dumps({"CREDIT_CARD": 2})
|
||
)
|
||
|
||
# Verify that the span was ended
|
||
mock_span.end.assert_called_once()
|
||
|
||
def test_create_guardrail_span_with_no_info(self):
|
||
# Setup
|
||
otel = OpenTelemetry()
|
||
otel.tracer = MagicMock()
|
||
|
||
# Test with no guardrail information
|
||
kwargs = {"standard_logging_object": {}}
|
||
otel._create_guardrail_span(kwargs=kwargs, context=None)
|
||
|
||
# Verify that start_span was never called
|
||
otel.tracer.start_span.assert_not_called()
|
||
|
||
|
||
class TestOpenTelemetryCostBreakdown(unittest.TestCase):
|
||
def test_cost_breakdown_emitted_to_otel_span(self):
|
||
"""
|
||
Test that cost breakdown from StandardLoggingPayload is emitted to OpenTelemetry span attributes.
|
||
"""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
cost_breakdown = {
|
||
"input_cost": 0.001,
|
||
"output_cost": 0.002,
|
||
"total_cost": 0.003,
|
||
"tool_usage_cost": 0.0001,
|
||
"original_cost": 0.004,
|
||
"discount_percent": 0.25,
|
||
"discount_amount": 0.001,
|
||
}
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
"cost_breakdown": cost_breakdown,
|
||
},
|
||
}
|
||
|
||
response_obj = {
|
||
"id": "test-response-id",
|
||
"model": "gpt-4",
|
||
"choices": [],
|
||
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||
}
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
mock_span.set_attribute.assert_any_call("gen_ai.cost.input_cost", 0.001)
|
||
mock_span.set_attribute.assert_any_call("gen_ai.cost.output_cost", 0.002)
|
||
mock_span.set_attribute.assert_any_call("gen_ai.cost.total_cost", 0.003)
|
||
mock_span.set_attribute.assert_any_call("gen_ai.cost.tool_usage_cost", 0.0001)
|
||
mock_span.set_attribute.assert_any_call("gen_ai.cost.original_cost", 0.004)
|
||
mock_span.set_attribute.assert_any_call("gen_ai.cost.discount_percent", 0.25)
|
||
mock_span.set_attribute.assert_any_call("gen_ai.cost.discount_amount", 0.001)
|
||
|
||
def test_cost_breakdown_with_partial_fields(self):
|
||
"""
|
||
Test that cost breakdown works correctly when only some fields are present.
|
||
"""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
cost_breakdown = {
|
||
"input_cost": 0.001,
|
||
"output_cost": 0.002,
|
||
"total_cost": 0.003,
|
||
}
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
"cost_breakdown": cost_breakdown,
|
||
},
|
||
}
|
||
|
||
response_obj = {
|
||
"id": "test-response-id",
|
||
"model": "gpt-4",
|
||
"choices": [],
|
||
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||
}
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
mock_span.set_attribute.assert_any_call("gen_ai.cost.input_cost", 0.001)
|
||
mock_span.set_attribute.assert_any_call("gen_ai.cost.output_cost", 0.002)
|
||
mock_span.set_attribute.assert_any_call("gen_ai.cost.total_cost", 0.003)
|
||
|
||
call_args_list = [call[0] for call in mock_span.set_attribute.call_args_list]
|
||
assert ("gen_ai.cost.tool_usage_cost", 0.0001) not in call_args_list
|
||
assert ("gen_ai.cost.original_cost", 0.004) not in call_args_list
|
||
|
||
|
||
class TestOpenTelemetryProviderInitialization(unittest.TestCase):
|
||
"""Test suite for verifying provider initialization respects existing providers"""
|
||
|
||
def test_init_tracing_respects_existing_tracer_provider(self):
|
||
"""
|
||
Unit test: _init_tracing() should respect existing TracerProvider.
|
||
|
||
When a TracerProvider already exists (e.g., set by Langfuse SDK),
|
||
LiteLLM should use it instead of creating a new one.
|
||
"""
|
||
from opentelemetry import trace
|
||
from opentelemetry.sdk.trace import TracerProvider
|
||
|
||
# Setup: Create and set an existing TracerProvider
|
||
tracer_provider = TracerProvider()
|
||
trace.set_tracer_provider(tracer_provider)
|
||
existing_provider = trace.get_tracer_provider()
|
||
|
||
# Act: Initialize OpenTelemetry integration (should detect existing provider)
|
||
otel_integration = OpenTelemetry()
|
||
|
||
# Assert: The existing provider should still be active
|
||
current_provider = trace.get_tracer_provider()
|
||
assert (
|
||
current_provider is existing_provider
|
||
), "Existing TracerProvider should be respected and not overridden"
|
||
|
||
@patch.dict(
|
||
os.environ, {"LITELLM_OTEL_INTEGRATION_ENABLE_METRICS": "true"}, clear=True
|
||
)
|
||
def test_init_metrics_respects_existing_meter_provider(self):
|
||
"""
|
||
Unit test: _init_metrics() should respect existing MeterProvider.
|
||
|
||
When a MeterProvider already exists (e.g., set by Langfuse SDK),
|
||
LiteLLM should use it instead of creating a new one.
|
||
"""
|
||
from opentelemetry import metrics
|
||
from opentelemetry.sdk.metrics import MeterProvider
|
||
|
||
# Create and set an existing MeterProvider
|
||
meter_provider = MeterProvider()
|
||
metrics.set_meter_provider(meter_provider)
|
||
existing_provider = metrics.get_meter_provider()
|
||
|
||
# Act: Initialize OpenTelemetry integration (should detect existing provider)
|
||
config = OpenTelemetryConfig.from_env()
|
||
otel_integration = OpenTelemetry(config=config)
|
||
|
||
# Assert: The existing provider should still be active
|
||
current_provider = metrics.get_meter_provider()
|
||
assert (
|
||
current_provider is existing_provider
|
||
), "Existing MeterProvider should be respected and not overridden"
|
||
|
||
@patch.dict(
|
||
os.environ, {"LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS": "true"}, clear=True
|
||
)
|
||
def test_init_logs_respects_existing_logger_provider(self):
|
||
"""
|
||
Unit test: _init_logs() should respect existing LoggerProvider.
|
||
|
||
When a LoggerProvider already exists (e.g., set by Langfuse SDK),
|
||
LiteLLM should use it instead of creating a new one.
|
||
"""
|
||
from opentelemetry._logs import get_logger_provider, set_logger_provider
|
||
from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider
|
||
|
||
# Create and set an existing LoggerProvider
|
||
logger_provider = OTLoggerProvider()
|
||
set_logger_provider(logger_provider)
|
||
existing_provider = get_logger_provider()
|
||
|
||
# Act: Initialize OpenTelemetry integration (should detect existing provider)
|
||
config = OpenTelemetryConfig.from_env()
|
||
otel_integration = OpenTelemetry(config=config)
|
||
|
||
# Assert: The existing provider should still be active
|
||
current_provider = get_logger_provider()
|
||
assert (
|
||
current_provider is existing_provider
|
||
), "Existing LoggerProvider should be respected and not overridden"
|
||
|
||
|
||
class TestOpenTelemetryDualHandlerIsolation(unittest.TestCase):
|
||
"""Two OpenTelemetry handlers coexisting via skip_set_global=True
|
||
must each get their own provider for every signal (tracer/meter/logger)."""
|
||
|
||
@staticmethod
|
||
def _wire_span_processor(exporter):
|
||
"""Context manager: while active, the next OpenTelemetry instance
|
||
wires its TracerProvider to `exporter`."""
|
||
return patch.object(
|
||
OpenTelemetry,
|
||
"_get_span_processor",
|
||
lambda self, dynamic_headers=None: SimpleSpanProcessor(exporter),
|
||
)
|
||
|
||
def test_skip_set_global_creates_isolated_tracer_provider(self):
|
||
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
|
||
|
||
fake_existing = SDKTracerProvider()
|
||
own_exporter = InMemorySpanExporter()
|
||
cfg = OpenTelemetryConfig(
|
||
exporter="console", service_name="iso-test", skip_set_global=True
|
||
)
|
||
with (
|
||
patch.object(trace, "get_tracer_provider", return_value=fake_existing),
|
||
patch.object(trace, "set_tracer_provider") as mock_set,
|
||
self._wire_span_processor(own_exporter),
|
||
):
|
||
handler = OpenTelemetry(config=cfg)
|
||
|
||
self.assertIsNot(handler._tracer_provider, fake_existing)
|
||
mock_set.assert_not_called()
|
||
|
||
handler.tracer.start_span("isolation_check").end()
|
||
handler._tracer_provider.force_flush(2000)
|
||
self.assertEqual(
|
||
[s.name for s in own_exporter.get_finished_spans()],
|
||
["isolation_check"],
|
||
)
|
||
|
||
def test_skip_set_global_via_callback_name_back_compat(self):
|
||
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
|
||
|
||
fake_existing = SDKTracerProvider()
|
||
cfg = OpenTelemetryConfig(exporter="console", service_name="lf-back-compat")
|
||
with (
|
||
patch.object(trace, "get_tracer_provider", return_value=fake_existing),
|
||
patch.object(trace, "set_tracer_provider"),
|
||
self._wire_span_processor(InMemorySpanExporter()),
|
||
):
|
||
handler = OpenTelemetry(config=cfg, callback_name="langfuse_otel")
|
||
|
||
self.assertIsNot(handler._tracer_provider, fake_existing)
|
||
|
||
def test_default_behavior_reuses_existing_sdk_tracer_provider(self):
|
||
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
|
||
|
||
fake_existing = SDKTracerProvider()
|
||
with patch.object(trace, "get_tracer_provider", return_value=fake_existing):
|
||
handler = OpenTelemetry(config=OpenTelemetryConfig(service_name="shared"))
|
||
self.assertIs(handler._tracer_provider, fake_existing)
|
||
|
||
def test_skip_set_global_creates_isolated_meter_provider(self):
|
||
from opentelemetry import metrics
|
||
from opentelemetry.sdk.metrics import MeterProvider as SDKMeterProvider
|
||
|
||
fake_existing = SDKMeterProvider()
|
||
cfg = OpenTelemetryConfig(
|
||
exporter="console",
|
||
service_name="meter-iso-test",
|
||
enable_metrics=True,
|
||
skip_set_global=True,
|
||
)
|
||
with (
|
||
patch.object(metrics, "get_meter_provider", return_value=fake_existing),
|
||
patch.object(metrics, "set_meter_provider") as mock_set,
|
||
self._wire_span_processor(InMemorySpanExporter()),
|
||
):
|
||
handler = OpenTelemetry(config=cfg)
|
||
|
||
self.assertIsNot(handler._meter_provider, fake_existing)
|
||
mock_set.assert_not_called()
|
||
|
||
def test_skip_set_global_creates_isolated_logger_provider(self):
|
||
from opentelemetry import _logs
|
||
from opentelemetry.sdk._logs import LoggerProvider as SDKLoggerProvider
|
||
|
||
fake_existing = SDKLoggerProvider()
|
||
cfg = OpenTelemetryConfig(
|
||
exporter="console",
|
||
service_name="logger-iso-test",
|
||
enable_events=True,
|
||
skip_set_global=True,
|
||
)
|
||
with (
|
||
patch.object(_logs, "get_logger_provider", return_value=fake_existing),
|
||
patch.object(_logs, "set_logger_provider") as mock_set,
|
||
self._wire_span_processor(InMemorySpanExporter()),
|
||
):
|
||
handler = OpenTelemetry(config=cfg)
|
||
|
||
self.assertIsNot(handler._logger_provider, fake_existing)
|
||
mock_set.assert_not_called()
|
||
|
||
def test_emitted_logs_route_to_isolated_logger_provider(self):
|
||
# End-to-end: emitted logs land in the handler's private LoggerProvider,
|
||
# not the global one. Guards against get_logger() bypassing self._logger_provider.
|
||
from opentelemetry import _logs
|
||
from opentelemetry.sdk._logs import LoggerProvider as SDKLoggerProvider
|
||
|
||
global_exporter = InMemoryLogExporter()
|
||
fake_existing = SDKLoggerProvider()
|
||
fake_existing.add_log_record_processor(
|
||
SimpleLogRecordProcessor(global_exporter)
|
||
)
|
||
|
||
private_exporter = InMemoryLogExporter()
|
||
cfg = OpenTelemetryConfig(
|
||
exporter="console",
|
||
service_name="logger-emit-test",
|
||
enable_events=True,
|
||
skip_set_global=True,
|
||
)
|
||
with (
|
||
patch.object(_logs, "get_logger_provider", return_value=fake_existing),
|
||
patch.object(_logs, "set_logger_provider"),
|
||
patch.object(
|
||
OpenTelemetry, "_get_log_exporter", return_value=private_exporter
|
||
),
|
||
self._wire_span_processor(InMemorySpanExporter()),
|
||
):
|
||
handler = OpenTelemetry(config=cfg)
|
||
|
||
span = handler.tracer.start_span("emit-test")
|
||
handler._emit_semantic_logs(
|
||
kwargs={"messages": [{"role": "user", "content": "hi"}]},
|
||
response_obj={"choices": []},
|
||
span=span,
|
||
)
|
||
span.end()
|
||
handler._logger_provider.force_flush(2000)
|
||
|
||
self.assertGreater(len(private_exporter.get_finished_logs()), 0)
|
||
self.assertEqual(len(global_exporter.get_finished_logs()), 0)
|
||
|
||
def test_two_handlers_each_receive_their_own_spans(self):
|
||
# Handler A gets explicit injection (production-ish: claims the global).
|
||
exporter_a = InMemorySpanExporter()
|
||
provider_a = TracerProvider()
|
||
provider_a.add_span_processor(SimpleSpanProcessor(exporter_a))
|
||
handler_a = OpenTelemetry(
|
||
config=OpenTelemetryConfig(service_name="handler-a"),
|
||
tracer_provider=provider_a,
|
||
)
|
||
|
||
# Handler B comes along with the global appearing to be A's provider.
|
||
exporter_b = InMemorySpanExporter()
|
||
cfg_b = OpenTelemetryConfig(
|
||
exporter="console", service_name="handler-b", skip_set_global=True
|
||
)
|
||
with (
|
||
patch.object(trace, "get_tracer_provider", return_value=provider_a),
|
||
patch.object(trace, "set_tracer_provider"),
|
||
self._wire_span_processor(exporter_b),
|
||
):
|
||
handler_b = OpenTelemetry(config=cfg_b)
|
||
|
||
self.assertIsNot(handler_a._tracer_provider, handler_b._tracer_provider)
|
||
|
||
handler_a.tracer.start_span("from_handler_a").end()
|
||
handler_b.tracer.start_span("from_handler_b").end()
|
||
provider_a.force_flush(2000)
|
||
handler_b._tracer_provider.force_flush(2000)
|
||
|
||
self.assertEqual(
|
||
sorted(s.name for s in exporter_a.get_finished_spans()),
|
||
["from_handler_a"],
|
||
)
|
||
self.assertEqual(
|
||
sorted(s.name for s in exporter_b.get_finished_spans()),
|
||
["from_handler_b"],
|
||
)
|
||
|
||
|
||
class TestOpenTelemetryCaptureMessageContent(unittest.TestCase):
|
||
"""OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT and the
|
||
OpenTelemetryConfig.capture_message_content programmatic override
|
||
drive what the handler captures in spans vs events."""
|
||
|
||
@staticmethod
|
||
def _make(env=None, config_value=None, message_logging=True):
|
||
env_dict = (
|
||
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": env}
|
||
if env is not None
|
||
else {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}
|
||
)
|
||
with patch.dict(os.environ, env_dict):
|
||
handler = OpenTelemetry(
|
||
config=OpenTelemetryConfig(
|
||
exporter="console", capture_message_content=config_value
|
||
)
|
||
)
|
||
handler.message_logging = message_logging
|
||
return handler, handler._resolve_capture_mode()
|
||
|
||
def test_no_explicit_setting_falls_back_to_message_logging_true(self):
|
||
_, mode = self._make()
|
||
self.assertEqual(mode, "SPAN_AND_EVENT")
|
||
|
||
def test_no_explicit_setting_falls_back_to_message_logging_false(self):
|
||
_, mode = self._make(message_logging=False)
|
||
self.assertEqual(mode, "NO_CONTENT")
|
||
|
||
def test_env_var_no_content(self):
|
||
_, mode = self._make(env="NO_CONTENT")
|
||
self.assertEqual(mode, "NO_CONTENT")
|
||
|
||
def test_env_var_span_only(self):
|
||
_, mode = self._make(env="SPAN_ONLY")
|
||
self.assertEqual(mode, "SPAN_ONLY")
|
||
|
||
def test_env_var_event_only(self):
|
||
_, mode = self._make(env="EVENT_ONLY")
|
||
self.assertEqual(mode, "EVENT_ONLY")
|
||
|
||
def test_env_var_span_and_event(self):
|
||
_, mode = self._make(env="SPAN_AND_EVENT")
|
||
self.assertEqual(mode, "SPAN_AND_EVENT")
|
||
|
||
def test_env_var_legacy_true_maps_to_event_only(self):
|
||
_, mode = self._make(env="true")
|
||
self.assertEqual(mode, "EVENT_ONLY")
|
||
|
||
def test_env_var_legacy_false_maps_to_no_content(self):
|
||
for env in ("false", "0"):
|
||
with self.subTest(env=env):
|
||
_, mode = self._make(env=env)
|
||
self.assertEqual(mode, "NO_CONTENT")
|
||
|
||
def test_env_var_unknown_value_falls_through_to_legacy(self):
|
||
_, mode = self._make(env="garbage", message_logging=True)
|
||
self.assertEqual(mode, "SPAN_AND_EVENT")
|
||
|
||
def test_config_field_overrides_env(self):
|
||
_, mode = self._make(env="EVENT_ONLY", config_value="SPAN_ONLY")
|
||
self.assertEqual(mode, "SPAN_ONLY")
|
||
|
||
def test_turn_off_message_logging_forces_no_content(self):
|
||
with patch("litellm.turn_off_message_logging", True):
|
||
_, mode = self._make(env="SPAN_AND_EVENT", message_logging=True)
|
||
self.assertEqual(mode, "NO_CONTENT")
|
||
|
||
def test_capture_in_span_and_event_predicates(self):
|
||
cases = {
|
||
"NO_CONTENT": (False, False),
|
||
"SPAN_ONLY": (True, False),
|
||
"EVENT_ONLY": (False, True),
|
||
"SPAN_AND_EVENT": (True, True),
|
||
}
|
||
for mode, (in_span, in_event) in cases.items():
|
||
handler, _ = self._make(env=mode)
|
||
self.assertEqual(handler._capture_in_span(), in_span, msg=mode)
|
||
self.assertEqual(handler._capture_in_event(), in_event, msg=mode)
|
||
|
||
def test_two_handlers_can_have_different_modes(self):
|
||
# FIL's stated requirement: one handler strips content, the other keeps it.
|
||
with patch.dict(
|
||
os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}
|
||
):
|
||
stripped = OpenTelemetry(
|
||
config=OpenTelemetryConfig(
|
||
exporter="console", capture_message_content="NO_CONTENT"
|
||
)
|
||
)
|
||
kept = OpenTelemetry(
|
||
config=OpenTelemetryConfig(
|
||
exporter="console", capture_message_content="SPAN_AND_EVENT"
|
||
)
|
||
)
|
||
self.assertEqual(stripped._resolve_capture_mode(), "NO_CONTENT")
|
||
self.assertEqual(kept._resolve_capture_mode(), "SPAN_AND_EVENT")
|
||
self.assertFalse(stripped._capture_in_span())
|
||
self.assertFalse(stripped._capture_in_event())
|
||
self.assertTrue(kept._capture_in_span())
|
||
self.assertTrue(kept._capture_in_event())
|
||
|
||
|
||
class TestOpenTelemetrySemconvStability(unittest.TestCase):
|
||
"""OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental opts into
|
||
semconv-conformant span shape (name, kind, no raw_gen_ai_request child)."""
|
||
|
||
@staticmethod
|
||
def _make(env=None, config_value=None):
|
||
env_value = env if env is not None else ""
|
||
with patch.dict(os.environ, {"OTEL_SEMCONV_STABILITY_OPT_IN": env_value}):
|
||
return OpenTelemetry(
|
||
config=OpenTelemetryConfig(
|
||
exporter="console",
|
||
semconv_stability_opt_in=config_value or set(),
|
||
)
|
||
)
|
||
|
||
def test_default_unset_keeps_legacy_span_name(self):
|
||
h = self._make()
|
||
self.assertFalse(h._gen_ai_semconv_latest_experimental)
|
||
kwargs = {"model": "gpt-4", "call_type": "acompletion"}
|
||
self.assertEqual(h._get_span_name(kwargs), "litellm_request")
|
||
|
||
def test_opt_in_emits_semconv_span_name(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
self.assertTrue(h._gen_ai_semconv_latest_experimental)
|
||
kwargs = {"model": "gpt-4", "call_type": "acompletion"}
|
||
self.assertEqual(h._get_span_name(kwargs), "chat gpt-4")
|
||
|
||
def test_opt_in_supports_comma_separated_categories(self):
|
||
h = self._make(env="other_category,gen_ai_latest_experimental")
|
||
self.assertTrue(h._gen_ai_semconv_latest_experimental)
|
||
|
||
def test_opt_in_ignores_unrelated_category(self):
|
||
h = self._make(env="some_other_category")
|
||
self.assertFalse(h._gen_ai_semconv_latest_experimental)
|
||
|
||
def test_config_field_enables_without_env(self):
|
||
h = self._make(
|
||
env="", config_value={OTELSemconvCategory.GEN_AI_LATEST_EXPERIMENTAL}
|
||
)
|
||
self.assertTrue(h._gen_ai_semconv_latest_experimental)
|
||
|
||
def test_config_field_unions_with_env(self):
|
||
h = self._make(
|
||
env="gen_ai_latest_experimental",
|
||
config_value={OTELSemconvCategory.GEN_AI_LATEST_EXPERIMENTAL},
|
||
)
|
||
self.assertTrue(h._gen_ai_semconv_latest_experimental)
|
||
|
||
def test_operation_name_for_embeddings(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
kwargs = {
|
||
"model": "text-embedding-3-small",
|
||
"call_type": "aembedding",
|
||
}
|
||
self.assertEqual(h._get_span_name(kwargs), "embeddings text-embedding-3-small")
|
||
|
||
def test_operation_name_for_text_completion(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
kwargs = {"model": "babbage-002", "call_type": "atext_completion"}
|
||
self.assertEqual(h._get_span_name(kwargs), "text_completion babbage-002")
|
||
|
||
def test_operation_name_defaults_to_chat(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
kwargs = {"model": "claude-sonnet-4-5", "call_type": "unknown"}
|
||
self.assertEqual(h._get_span_name(kwargs), "chat claude-sonnet-4-5")
|
||
|
||
def test_generation_name_metadata_overrides_semconv_name(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"call_type": "acompletion",
|
||
"litellm_params": {"metadata": {"generation_name": "user-named-span"}},
|
||
}
|
||
self.assertEqual(h._get_span_name(kwargs), "user-named-span")
|
||
|
||
def test_opt_in_skips_raw_gen_ai_request_span(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
h._maybe_log_raw_request = OpenTelemetry._maybe_log_raw_request.__get__(h)
|
||
h.tracer = MagicMock()
|
||
h.set_raw_request_attributes = MagicMock()
|
||
kwargs = {"litellm_params": {"metadata": {}}}
|
||
h._maybe_log_raw_request(kwargs, {}, None, None, MagicMock())
|
||
h.tracer.start_span.assert_not_called()
|
||
|
||
def test_semconv_request_attributes_emit_when_present(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
span = MagicMock()
|
||
optional_params = {
|
||
"frequency_penalty": 0.5,
|
||
"presence_penalty": 0.2,
|
||
"top_k": 40,
|
||
"seed": 42,
|
||
"stop": ["\n\n"],
|
||
"stream": True,
|
||
"n": 3,
|
||
}
|
||
h._set_semconv_request_attributes(span, optional_params)
|
||
calls = {
|
||
c.args[0] if c.args else c.kwargs.get("key"): c
|
||
for c in span.set_attribute.call_args_list
|
||
}
|
||
self.assertIn("gen_ai.request.frequency_penalty", calls)
|
||
self.assertIn("gen_ai.request.presence_penalty", calls)
|
||
self.assertIn("gen_ai.request.top_k", calls)
|
||
self.assertIn("gen_ai.request.seed", calls)
|
||
self.assertIn("gen_ai.request.stop_sequences", calls)
|
||
self.assertIn("gen_ai.request.stream", calls)
|
||
self.assertIn("gen_ai.request.choice.count", calls)
|
||
|
||
def test_semconv_request_choice_count_omitted_when_one(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
span = MagicMock()
|
||
h._set_semconv_request_attributes(span, {"n": 1})
|
||
keys = {c.args[0] for c in span.set_attribute.call_args_list if c.args}
|
||
self.assertNotIn("gen_ai.request.choice.count", keys)
|
||
|
||
def test_semconv_request_choice_count_omitted_for_invalid_n(self):
|
||
# n must be a valid count (int > 1); 0/negative/non-int are suppressed.
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
for bad_n in (0, -1, "2", 2.0):
|
||
span = MagicMock()
|
||
h._set_semconv_request_attributes(span, {"n": bad_n})
|
||
keys = {c.args[0] for c in span.set_attribute.call_args_list if c.args}
|
||
self.assertNotIn(
|
||
"gen_ai.request.choice.count", keys, f"n={bad_n!r} should be omitted"
|
||
)
|
||
|
||
def _stream_calls(self, span):
|
||
return [
|
||
c
|
||
for c in span.set_attribute.call_args_list
|
||
if c.args and c.args[0] == "gen_ai.request.stream"
|
||
]
|
||
|
||
def test_semconv_request_stream_emitted_as_bool_when_streaming(self):
|
||
# Conditionally required per spec: present (as bool True) only when streaming.
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
span = MagicMock()
|
||
h._set_semconv_request_attributes(span, {"stream": True})
|
||
stream_calls = self._stream_calls(span)
|
||
self.assertEqual(len(stream_calls), 1)
|
||
self.assertIs(stream_calls[0].args[1], True)
|
||
|
||
def test_semconv_request_stream_omitted_when_not_streaming(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
span = MagicMock()
|
||
h._set_semconv_request_attributes(span, {"stream": False})
|
||
self.assertEqual(self._stream_calls(span), [])
|
||
|
||
def test_semconv_request_stop_sequences_normalizes_string_to_list(self):
|
||
# Spec types gen_ai.request.stop_sequences as string[]; a scalar stop
|
||
# is wrapped, and the value is a real list (not a JSON-encoded string).
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
span = MagicMock()
|
||
h._set_semconv_request_attributes(span, {"stop": "STOP_TOKEN"})
|
||
stop_calls = [
|
||
c
|
||
for c in span.set_attribute.call_args_list
|
||
if c.args and c.args[0] == "gen_ai.request.stop_sequences"
|
||
]
|
||
self.assertEqual(len(stop_calls), 1)
|
||
self.assertEqual(stop_calls[0].args[1], ["STOP_TOKEN"])
|
||
|
||
def test_semconv_cache_token_attributes(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
span = MagicMock()
|
||
std_log = {
|
||
"metadata": {
|
||
"usage_object": {
|
||
"cache_creation_input_tokens": 12,
|
||
"cache_read_input_tokens": 34,
|
||
}
|
||
}
|
||
}
|
||
h._set_semconv_cache_token_attributes(span, std_log)
|
||
keys = {
|
||
c.args[0]: c.args[1] for c in span.set_attribute.call_args_list if c.args
|
||
}
|
||
self.assertEqual(keys.get("gen_ai.usage.cache_creation.input_tokens"), 12)
|
||
self.assertEqual(keys.get("gen_ai.usage.cache_read.input_tokens"), 34)
|
||
|
||
def test_semconv_cache_token_attributes_handles_none_metadata(self):
|
||
# standard_logging_payload["metadata"] = None should not crash.
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
span = MagicMock()
|
||
h._set_semconv_cache_token_attributes(span, {"metadata": None})
|
||
span.set_attribute.assert_not_called()
|
||
|
||
def test_semconv_cache_token_attributes_omitted_when_zero(self):
|
||
h = self._make(env="gen_ai_latest_experimental")
|
||
span = MagicMock()
|
||
std_log = {
|
||
"metadata": {
|
||
"usage_object": {
|
||
"cache_creation_input_tokens": 0,
|
||
"cache_read_input_tokens": 0,
|
||
}
|
||
}
|
||
}
|
||
h._set_semconv_cache_token_attributes(span, std_log)
|
||
keys = {c.args[0] for c in span.set_attribute.call_args_list if c.args}
|
||
self.assertNotIn("gen_ai.usage.cache_creation.input_tokens", keys)
|
||
self.assertNotIn("gen_ai.usage.cache_read.input_tokens", keys)
|
||
|
||
def _set_attributes_keys(self, h):
|
||
"""Run set_attributes with a minimal chat payload; return {key: value}."""
|
||
span = MagicMock()
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "hi"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
response_obj = {"id": "r", "model": "gpt-4", "choices": []}
|
||
h.set_attributes(span=span, kwargs=kwargs, response_obj=response_obj)
|
||
return {
|
||
c.args[0]: c.args[1] for c in span.set_attribute.call_args_list if c.args
|
||
}
|
||
|
||
def test_semconv_mode_emits_provider_name_not_system(self):
|
||
# Latest-experimental semconv replaced gen_ai.system with
|
||
# gen_ai.provider.name; only the conformant key is emitted.
|
||
keys = self._set_attributes_keys(self._make(env="gen_ai_latest_experimental"))
|
||
self.assertEqual(keys.get("gen_ai.provider.name"), "openai")
|
||
self.assertNotIn("gen_ai.system", keys)
|
||
|
||
def test_legacy_mode_emits_system_not_provider_name(self):
|
||
keys = self._set_attributes_keys(self._make())
|
||
self.assertEqual(keys.get("gen_ai.system"), "openai")
|
||
self.assertNotIn("gen_ai.provider.name", keys)
|
||
|
||
def test_opt_in_emits_consolidated_inference_details_event(self):
|
||
from opentelemetry import _logs
|
||
from opentelemetry._logs._internal import ProxyLoggerProvider
|
||
|
||
log_exporter = InMemoryLogExporter()
|
||
# Make _init_logs see a non-SDK global (the proxy default) so it
|
||
# falls into the create_new branch and consults _get_log_exporter,
|
||
# which we patch to return our in-memory exporter.
|
||
with (
|
||
patch.dict(
|
||
os.environ,
|
||
{"OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental"},
|
||
),
|
||
patch.object(
|
||
_logs, "get_logger_provider", return_value=ProxyLoggerProvider()
|
||
),
|
||
patch.object(_logs, "set_logger_provider"),
|
||
patch.object(OpenTelemetry, "_get_log_exporter", return_value=log_exporter),
|
||
):
|
||
h = OpenTelemetry(
|
||
config=OpenTelemetryConfig(exporter="console", enable_events=True)
|
||
)
|
||
h.message_logging = True
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"call_type": "acompletion",
|
||
"messages": [{"role": "user", "content": "hi"}],
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
}
|
||
response_obj = {
|
||
"choices": [
|
||
{
|
||
"message": {"role": "assistant", "content": "hello"},
|
||
"finish_reason": "stop",
|
||
}
|
||
]
|
||
}
|
||
span = h.tracer.start_span("test")
|
||
h._emit_semantic_logs(kwargs, response_obj, span)
|
||
span.end()
|
||
h._logger_provider.force_flush(2000)
|
||
|
||
records = [r.log_record for r in log_exporter.get_finished_logs()]
|
||
# Exactly ONE inference details event, not the legacy per-message/choice pair.
|
||
self.assertEqual(len(records), 1)
|
||
attrs = dict(records[0].attributes or {})
|
||
self.assertEqual(
|
||
attrs["event_name"], "gen_ai.client.inference.operation.details"
|
||
)
|
||
self.assertEqual(attrs["gen_ai.provider.name"], "openai")
|
||
self.assertEqual(attrs["gen_ai.operation.name"], "chat")
|
||
self.assertIn("gen_ai.input.messages", attrs)
|
||
self.assertIn("gen_ai.output.messages", attrs)
|
||
|
||
def test_opt_in_inference_details_respects_content_kill_switch(self):
|
||
from opentelemetry import _logs
|
||
from opentelemetry._logs._internal import ProxyLoggerProvider
|
||
|
||
log_exporter = InMemoryLogExporter()
|
||
with (
|
||
patch.dict(
|
||
os.environ,
|
||
{"OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental"},
|
||
),
|
||
patch("litellm.turn_off_message_logging", True),
|
||
patch.object(
|
||
_logs, "get_logger_provider", return_value=ProxyLoggerProvider()
|
||
),
|
||
patch.object(_logs, "set_logger_provider"),
|
||
patch.object(OpenTelemetry, "_get_log_exporter", return_value=log_exporter),
|
||
):
|
||
h = OpenTelemetry(
|
||
config=OpenTelemetryConfig(exporter="console", enable_events=True)
|
||
)
|
||
h.message_logging = True
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"call_type": "acompletion",
|
||
"messages": [{"role": "user", "content": "private prompt"}],
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
}
|
||
response_obj = {
|
||
"choices": [
|
||
{
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": "private completion",
|
||
},
|
||
"finish_reason": "stop",
|
||
}
|
||
]
|
||
}
|
||
span = h.tracer.start_span("test")
|
||
h._emit_semantic_logs(kwargs, response_obj, span)
|
||
span.end()
|
||
h._logger_provider.force_flush(2000)
|
||
|
||
records = [r.log_record for r in log_exporter.get_finished_logs()]
|
||
self.assertEqual(len(records), 1)
|
||
attrs = dict(records[0].attributes or {})
|
||
self.assertNotIn("gen_ai.input.messages", attrs)
|
||
self.assertNotIn("gen_ai.output.messages", attrs)
|
||
|
||
|
||
class TestOpenTelemetry(unittest.TestCase):
|
||
POLL_INTERVAL = 0.05
|
||
POLL_TIMEOUT = 2.0
|
||
MODEL = "arn:aws:bedrock:us-west-2:1234567890123:inference-profile/us.anthropic.claude-sonnet-4-5-20250929-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")
|
||
|
||
@patch.dict(os.environ, {}, clear=True)
|
||
def test_open_telemetry_config_auto_infer_otlp_http_when_endpoint_set(self):
|
||
"""When endpoint is set but exporter is default 'console', auto-infer 'otlp_http'.
|
||
|
||
This fixes an issue where UI-configured OTEL settings would default to console
|
||
output instead of sending traces to the configured endpoint.
|
||
See: https://github.com/BerriAI/litellm/issues/XXXX
|
||
"""
|
||
# When endpoint is specified without explicit exporter, should auto-infer otlp_http
|
||
config = OpenTelemetryConfig(endpoint="https://otel-collector.example.com:443")
|
||
self.assertEqual(config.exporter, "otlp_http")
|
||
|
||
# When exporter is explicitly set to something other than console, should not override
|
||
config_grpc = OpenTelemetryConfig(
|
||
exporter="grpc", endpoint="https://otel-collector.example.com:443"
|
||
)
|
||
self.assertEqual(config_grpc.exporter, "grpc")
|
||
|
||
# When no endpoint is set, should keep console as default
|
||
config_no_endpoint = OpenTelemetryConfig()
|
||
self.assertEqual(config_no_endpoint.exporter, "console")
|
||
|
||
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
|
||
while time.time() < deadline:
|
||
spans = exporter.get_finished_spans()
|
||
matches = [
|
||
s
|
||
for s in spans
|
||
if s.attributes and any(str(k).startswith(prefix) for k in s.attributes)
|
||
]
|
||
if matches:
|
||
return matches
|
||
time.sleep(self.POLL_INTERVAL)
|
||
return []
|
||
|
||
def wait_for_metric(self, reader: InMemoryMetricReader, name: str):
|
||
"""Poll until we see a metric with the given name."""
|
||
deadline = time.time() + self.POLL_TIMEOUT
|
||
while time.time() < deadline:
|
||
data = reader.get_metrics_data()
|
||
# guard against None or missing attribute
|
||
if not data or not hasattr(data, "resource_metrics"):
|
||
time.sleep(self.POLL_INTERVAL)
|
||
continue
|
||
|
||
for rm in data.resource_metrics:
|
||
for sm in rm.scope_metrics:
|
||
for m in sm.metrics:
|
||
if m.name == name:
|
||
return m
|
||
|
||
time.sleep(self.POLL_INTERVAL)
|
||
return None
|
||
|
||
def wait_for_log(self, reader: InMemoryLogExporter, name: str):
|
||
"""Poll until we see a log with the given name."""
|
||
deadline = time.time() + self.POLL_TIMEOUT
|
||
while time.time() < deadline:
|
||
logs = reader.get_finished_logs()
|
||
if not logs:
|
||
time.sleep(self.POLL_INTERVAL)
|
||
continue
|
||
matches = [
|
||
log
|
||
for log in logs
|
||
# if log.attributes and any(str(k).startswith(prefix) for k in log.attributes)
|
||
]
|
||
if matches:
|
||
return matches
|
||
time.sleep(self.POLL_INTERVAL)
|
||
return []
|
||
|
||
@patch("litellm.integrations.opentelemetry.datetime")
|
||
def test_create_guardrail_span_with_valid_info(self, mock_datetime):
|
||
# Setup
|
||
otel = OpenTelemetry()
|
||
otel.tracer = MagicMock()
|
||
mock_span = MagicMock()
|
||
otel.tracer.start_span.return_value = mock_span
|
||
|
||
# Create guardrail information
|
||
guardrail_info = {
|
||
"guardrail_name": "test_guardrail",
|
||
"guardrail_mode": "input",
|
||
"masked_entity_count": {"CREDIT_CARD": 2},
|
||
"guardrail_response": "filtered_content",
|
||
"start_time": 1609459200.0,
|
||
"end_time": 1609459201.0,
|
||
}
|
||
|
||
# Create a kwargs dict with standard_logging_object containing guardrail information
|
||
kwargs = {
|
||
"standard_logging_object": {"guardrail_information": [guardrail_info]}
|
||
}
|
||
|
||
# Call the method
|
||
otel._create_guardrail_span(kwargs=kwargs, context=None)
|
||
|
||
# Assertions
|
||
otel.tracer.start_span.assert_called_once()
|
||
|
||
# print all calls to mock_span.set_attribute
|
||
print("Calls to mock_span.set_attribute:")
|
||
for call in mock_span.set_attribute.call_args_list:
|
||
print(call)
|
||
|
||
# Check that the span has the correct attributes set
|
||
mock_span.set_attribute.assert_any_call("guardrail_name", "test_guardrail")
|
||
mock_span.set_attribute.assert_any_call("guardrail_mode", "input")
|
||
mock_span.set_attribute.assert_any_call(
|
||
"guardrail_response", "filtered_content"
|
||
)
|
||
mock_span.set_attribute.assert_any_call(
|
||
"masked_entity_count", safe_dumps({"CREDIT_CARD": 2})
|
||
)
|
||
|
||
# Verify that the span was ended
|
||
mock_span.end.assert_called_once()
|
||
|
||
def test_create_guardrail_span_with_no_info(self):
|
||
# Setup
|
||
otel = OpenTelemetry()
|
||
otel.tracer = MagicMock()
|
||
|
||
# Test with no guardrail information
|
||
kwargs = {"standard_logging_object": {}}
|
||
otel._create_guardrail_span(kwargs=kwargs, context=None)
|
||
|
||
# Verify that start_span was never called
|
||
otel.tracer.start_span.assert_not_called()
|
||
|
||
def test_get_tracer_to_use_for_request_with_dynamic_headers(self):
|
||
"""Test that get_tracer_to_use_for_request returns a dynamic tracer when dynamic headers are present."""
|
||
# Setup
|
||
otel = OpenTelemetry()
|
||
otel.tracer = MagicMock()
|
||
|
||
# Mock the dynamic header extraction and tracer creation
|
||
with (
|
||
patch.object(
|
||
otel, "_get_dynamic_otel_headers_from_kwargs"
|
||
) as mock_get_headers,
|
||
patch.object(otel, "_get_tracer_with_dynamic_headers") as mock_get_tracer,
|
||
):
|
||
|
||
# Test case 1: With dynamic headers
|
||
mock_get_headers.return_value = {
|
||
"arize-space-id": "test-space",
|
||
"api_key": "test-key",
|
||
}
|
||
mock_dynamic_tracer = MagicMock()
|
||
mock_get_tracer.return_value = mock_dynamic_tracer
|
||
|
||
kwargs = {
|
||
"standard_callback_dynamic_params": {"arize_space_key": "test-space"}
|
||
}
|
||
result = otel.get_tracer_to_use_for_request(kwargs)
|
||
|
||
# Assertions
|
||
mock_get_headers.assert_called_once_with(kwargs)
|
||
mock_get_tracer.assert_called_once_with(
|
||
{"arize-space-id": "test-space", "api_key": "test-key"}
|
||
)
|
||
self.assertEqual(result, mock_dynamic_tracer)
|
||
|
||
def test_get_tracer_to_use_for_request_without_dynamic_headers(self):
|
||
"""Test that get_tracer_to_use_for_request returns the default tracer when no dynamic headers are present."""
|
||
# Setup
|
||
otel = OpenTelemetry()
|
||
otel.tracer = MagicMock()
|
||
|
||
# Mock the dynamic header extraction to return None
|
||
with patch.object(
|
||
otel, "_get_dynamic_otel_headers_from_kwargs"
|
||
) as mock_get_headers:
|
||
mock_get_headers.return_value = None
|
||
|
||
kwargs = {}
|
||
result = otel.get_tracer_to_use_for_request(kwargs)
|
||
|
||
# Assertions
|
||
mock_get_headers.assert_called_once_with(kwargs)
|
||
self.assertEqual(result, otel.tracer)
|
||
|
||
def test_get_dynamic_otel_headers_from_kwargs(self):
|
||
"""Test that _get_dynamic_otel_headers_from_kwargs correctly extracts dynamic headers from kwargs."""
|
||
# Setup
|
||
otel = OpenTelemetry()
|
||
|
||
# Mock the construct_dynamic_otel_headers method
|
||
with patch.object(otel, "construct_dynamic_otel_headers") as mock_construct:
|
||
# Test case 1: With standard_callback_dynamic_params
|
||
mock_construct.return_value = {
|
||
"arize-space-id": "test-space",
|
||
"api_key": "test-key",
|
||
}
|
||
|
||
standard_params = {
|
||
"arize_space_key": "test-space",
|
||
"arize_api_key": "test-key",
|
||
}
|
||
kwargs = {"standard_callback_dynamic_params": standard_params}
|
||
|
||
result = otel._get_dynamic_otel_headers_from_kwargs(kwargs)
|
||
|
||
# Assertions
|
||
mock_construct.assert_called_once_with(
|
||
standard_callback_dynamic_params=standard_params
|
||
)
|
||
self.assertEqual(
|
||
result, {"arize-space-id": "test-space", "api_key": "test-key"}
|
||
)
|
||
|
||
# Test case 2: Without standard_callback_dynamic_params
|
||
kwargs_empty = {}
|
||
result_empty = otel._get_dynamic_otel_headers_from_kwargs(kwargs_empty)
|
||
|
||
# Should return None when no dynamic params
|
||
self.assertIsNone(result_empty)
|
||
|
||
# Test case 3: With empty construct result
|
||
mock_construct.return_value = {}
|
||
result_empty_construct = otel._get_dynamic_otel_headers_from_kwargs(kwargs)
|
||
|
||
# Should return None when construct returns empty dict
|
||
self.assertIsNone(result_empty_construct)
|
||
|
||
@patch("opentelemetry.sdk.trace.TracerProvider")
|
||
@patch("opentelemetry.sdk.resources.Resource")
|
||
def test_get_tracer_with_dynamic_headers(self, mock_resource, mock_tracer_provider):
|
||
"""Test that _get_tracer_with_dynamic_headers creates a temporary tracer with dynamic headers."""
|
||
# Setup
|
||
otel = OpenTelemetry()
|
||
|
||
# Mock the span processor creation
|
||
with patch.object(otel, "_get_span_processor") as mock_get_span_processor:
|
||
mock_span_processor = MagicMock()
|
||
mock_get_span_processor.return_value = mock_span_processor
|
||
|
||
# Mock the tracer provider and its methods
|
||
mock_provider_instance = MagicMock()
|
||
mock_tracer_provider.return_value = mock_provider_instance
|
||
mock_tracer = MagicMock()
|
||
mock_provider_instance.get_tracer.return_value = mock_tracer
|
||
|
||
# Mock the resource
|
||
mock_resource_instance = MagicMock()
|
||
mock_resource.return_value = mock_resource_instance
|
||
|
||
# Test
|
||
dynamic_headers = {"arize-space-id": "test-space", "api_key": "test-key"}
|
||
result = otel._get_tracer_with_dynamic_headers(dynamic_headers)
|
||
|
||
# Assertions
|
||
mock_get_span_processor.assert_called_once_with(
|
||
dynamic_headers=dynamic_headers
|
||
)
|
||
mock_provider_instance.add_span_processor.assert_called_once_with(
|
||
mock_span_processor
|
||
)
|
||
mock_provider_instance.get_tracer.assert_called_once_with("litellm")
|
||
self.assertEqual(result, mock_tracer)
|
||
|
||
@patch.dict(os.environ, {}, clear=True)
|
||
@patch("opentelemetry.sdk.resources.Resource.create")
|
||
@patch("opentelemetry.sdk.resources.OTELResourceDetector")
|
||
def test_get_litellm_resource_with_defaults(
|
||
self, mock_detector_cls, mock_resource_create
|
||
):
|
||
"""Test _get_litellm_resource with default values when no environment variables are set."""
|
||
# Mock the Resource.create method
|
||
mock_base_resource = MagicMock()
|
||
mock_resource_create.return_value = mock_base_resource
|
||
|
||
# Mock the OTELResourceDetector
|
||
mock_detector = MagicMock()
|
||
mock_detector_cls.return_value = mock_detector
|
||
mock_env_resource = MagicMock()
|
||
mock_detector.detect.return_value = mock_env_resource
|
||
|
||
# Mock the merged resource
|
||
mock_merged_resource = MagicMock()
|
||
mock_base_resource.merge.return_value = mock_merged_resource
|
||
|
||
config = OpenTelemetryConfig()
|
||
result = OpenTelemetry._get_litellm_resource(config)
|
||
|
||
# Verify Resource.create was called with correct default attributes
|
||
expected_attributes = {
|
||
"service.name": "litellm",
|
||
"deployment.environment": "production",
|
||
"model_id": "litellm",
|
||
}
|
||
mock_resource_create.assert_called_once_with(expected_attributes)
|
||
mock_detector.detect.assert_called_once()
|
||
mock_base_resource.merge.assert_called_once_with(mock_env_resource)
|
||
self.assertEqual(result, mock_merged_resource)
|
||
|
||
@patch.dict(
|
||
os.environ,
|
||
{
|
||
"OTEL_SERVICE_NAME": "test-service",
|
||
"OTEL_ENVIRONMENT_NAME": "staging",
|
||
"OTEL_MODEL_ID": "test-model",
|
||
},
|
||
clear=True,
|
||
)
|
||
@patch("opentelemetry.sdk.resources.Resource.create")
|
||
@patch("opentelemetry.sdk.resources.OTELResourceDetector")
|
||
def test_get_litellm_resource_with_litellm_env_vars(
|
||
self, mock_detector_cls, mock_resource_create
|
||
):
|
||
"""Test _get_litellm_resource with LiteLLM-specific environment variables."""
|
||
# Mock the Resource.create method
|
||
mock_base_resource = MagicMock()
|
||
mock_resource_create.return_value = mock_base_resource
|
||
|
||
# Mock the OTELResourceDetector
|
||
mock_detector = MagicMock()
|
||
mock_detector_cls.return_value = mock_detector
|
||
mock_env_resource = MagicMock()
|
||
mock_detector.detect.return_value = mock_env_resource
|
||
|
||
# Mock the merged resource
|
||
mock_merged_resource = MagicMock()
|
||
mock_base_resource.merge.return_value = mock_merged_resource
|
||
|
||
config = OpenTelemetryConfig.from_env()
|
||
result = OpenTelemetry._get_litellm_resource(config)
|
||
|
||
# Verify Resource.create was called with environment variable values
|
||
expected_attributes = {
|
||
"service.name": "test-service",
|
||
"deployment.environment": "staging",
|
||
"model_id": "test-model",
|
||
}
|
||
mock_resource_create.assert_called_once_with(expected_attributes)
|
||
mock_detector.detect.assert_called_once()
|
||
mock_base_resource.merge.assert_called_once_with(mock_env_resource)
|
||
self.assertEqual(result, mock_merged_resource)
|
||
|
||
@patch.dict(
|
||
os.environ,
|
||
{
|
||
"OTEL_RESOURCE_ATTRIBUTES": "service.name=otel-service,deployment.environment=production,custom.attr=value",
|
||
"OTEL_SERVICE_NAME": "should-be-overridden",
|
||
},
|
||
clear=True,
|
||
)
|
||
@patch("opentelemetry.sdk.resources.Resource.create")
|
||
@patch("opentelemetry.sdk.resources.OTELResourceDetector")
|
||
def test_get_litellm_resource_with_otel_resource_attributes(
|
||
self, mock_detector_cls, mock_resource_create
|
||
):
|
||
"""Test _get_litellm_resource with OTEL_RESOURCE_ATTRIBUTES environment variable."""
|
||
# 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()
|
||
mock_resource_create.return_value = mock_base_resource
|
||
|
||
# Mock the OTELResourceDetector
|
||
mock_detector = MagicMock()
|
||
mock_detector_cls.return_value = mock_detector
|
||
mock_env_resource = MagicMock()
|
||
mock_detector.detect.return_value = mock_env_resource
|
||
|
||
# Mock the merged resource
|
||
mock_merged_resource = MagicMock()
|
||
mock_base_resource.merge.return_value = mock_merged_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
|
||
expected_attributes = {
|
||
"service.name": "should-be-overridden",
|
||
"deployment.environment": "production",
|
||
"model_id": "should-be-overridden",
|
||
}
|
||
mock_resource_create.assert_called_once_with(expected_attributes)
|
||
mock_detector.detect.assert_called_once()
|
||
mock_base_resource.merge.assert_called_once_with(mock_env_resource)
|
||
self.assertEqual(result, mock_merged_resource)
|
||
|
||
@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."""
|
||
config = OpenTelemetryConfig()
|
||
result = OpenTelemetry._get_litellm_resource(config)
|
||
|
||
# Verify the result is a Resource instance
|
||
from opentelemetry.sdk.resources import Resource
|
||
|
||
self.assertIsInstance(result, Resource)
|
||
|
||
# Verify the resource has the expected default attributes
|
||
attributes = result.attributes
|
||
self.assertEqual(attributes.get("service.name"), "litellm")
|
||
self.assertEqual(attributes.get("deployment.environment"), "production")
|
||
self.assertEqual(attributes.get("model_id"), "litellm")
|
||
|
||
@patch.dict(
|
||
os.environ,
|
||
{
|
||
"OTEL_RESOURCE_ATTRIBUTES": "service.name=from-env,custom.attribute=test-value,deployment.environment=test-env"
|
||
},
|
||
clear=True,
|
||
)
|
||
def test_get_litellm_resource_real_otel_resource_attributes(self):
|
||
"""Integration test to verify OTEL_RESOURCE_ATTRIBUTES is properly handled."""
|
||
config = OpenTelemetryConfig.from_env()
|
||
result = OpenTelemetry._get_litellm_resource(config)
|
||
|
||
print("RESULT", result)
|
||
|
||
# Verify the result is a Resource instance
|
||
from opentelemetry.sdk.resources import Resource
|
||
|
||
self.assertIsInstance(result, Resource)
|
||
|
||
# Verify that OTEL_RESOURCE_ATTRIBUTES values override the defaults
|
||
attributes = result.attributes
|
||
self.assertEqual(attributes.get("service.name"), "from-env")
|
||
self.assertEqual(attributes.get("deployment.environment"), "test-env")
|
||
self.assertEqual(attributes.get("custom.attribute"), "test-value")
|
||
# model_id should still be set from the base attributes since it wasn't in OTEL_RESOURCE_ATTRIBUTES
|
||
self.assertEqual(attributes.get("model_id"), "litellm")
|
||
|
||
@patch.dict(
|
||
os.environ,
|
||
{
|
||
"OTEL_SERVICE_NAME": "litellm-service",
|
||
"OTEL_RESOURCE_ATTRIBUTES": "service.name=otel-override,extra.attr=extra-value",
|
||
},
|
||
clear=True,
|
||
)
|
||
def test_get_litellm_resource_precedence(self):
|
||
"""Test that OTEL_SERVICE_NAME takes precedence over OTEL_RESOURCE_ATTRIBUTES according to OpenTelemetry spec."""
|
||
config = OpenTelemetryConfig.from_env()
|
||
result = OpenTelemetry._get_litellm_resource(config)
|
||
|
||
# Verify the result is a Resource instance
|
||
from opentelemetry.sdk.resources import Resource
|
||
|
||
self.assertIsInstance(result, Resource)
|
||
|
||
# According to OpenTelemetry spec, OTEL_SERVICE_NAME takes precedence over service.name in OTEL_RESOURCE_ATTRIBUTES
|
||
attributes = result.attributes
|
||
self.assertEqual(attributes.get("service.name"), "litellm-service")
|
||
# But other attributes from OTEL_RESOURCE_ATTRIBUTES should still be present
|
||
self.assertEqual(attributes.get("extra.attr"), "extra-value")
|
||
|
||
def test_handle_success_spans_only(self):
|
||
# make sure neither events nor metrics is on
|
||
os.environ.pop("LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS", None)
|
||
os.environ.pop("LITELLM_OTEL_INTEGRATION_ENABLE_METRICS", None)
|
||
|
||
# ─── build in‐memory OTEL providers/exporters ─────────────────────────────
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
# no logs / no metrics
|
||
log_exporter = InMemoryLogExporter()
|
||
logger_provider = OTLoggerProvider()
|
||
logger_provider.add_log_record_processor(SimpleLogRecordProcessor(log_exporter))
|
||
metric_reader = InMemoryMetricReader()
|
||
meter_provider = MeterProvider(metric_readers=[metric_reader])
|
||
|
||
# ─── instantiate our OpenTelemetry logger with test providers ───────────
|
||
otel = OpenTelemetry(
|
||
tracer_provider=tracer_provider,
|
||
meter_provider=meter_provider,
|
||
logger_provider=logger_provider, # pass even if events disabled (safe)
|
||
)
|
||
# bind our tracer to the test tracer provider (global registration is a no-op after the first time)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
# ─── minimal input / output for a chat call ──────────────────────────────
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
with open(
|
||
os.path.join(self.HERE, "open_telemetry", "data", "captured_kwargs.json")
|
||
) as f:
|
||
kwargs = json.load(f)
|
||
with open(
|
||
os.path.join(self.HERE, "open_telemetry", "data", "captured_response.json")
|
||
) as f:
|
||
response_obj = json.load(f)
|
||
|
||
# ─── exercise the hook ───────────────────────────────────────────────────
|
||
otel._handle_success(kwargs, response_obj, start, end)
|
||
|
||
# ─── assert spans only ───────────────────────────────────────────────────
|
||
spans = span_exporter.get_finished_spans()
|
||
self.assertTrue(spans, "Expected at least one span")
|
||
# must have the top‐level litellm_request span
|
||
# self.assertIn(
|
||
# LITELLM_REQUEST_SPAN_NAME,
|
||
# [s.name for s in spans],
|
||
# "litellm_request span missing",
|
||
# )
|
||
# model attribute should be on that span
|
||
found = any(
|
||
s.attributes and s.attributes.get("gen_ai.request.model") == self.MODEL
|
||
for s in spans
|
||
)
|
||
self.assertTrue(found, "expected gen_ai.request.model on span attributes")
|
||
|
||
# no metrics recorded
|
||
self.assertIsNone(
|
||
self.wait_for_metric(metric_reader, "gen_ai.client.operation.duration"),
|
||
"Did not expect any metrics",
|
||
)
|
||
# no logs emitted
|
||
logs = log_exporter.get_finished_logs()
|
||
self.assertFalse(logs, "Did not expect any logs")
|
||
|
||
@patch.dict(
|
||
os.environ, {"LITELLM_OTEL_INTEGRATION_ENABLE_METRICS": "true"}, clear=True
|
||
)
|
||
def test_handle_success_spans_and_metrics(self):
|
||
# ─── build in‐memory OTEL providers/exporters ─────────────────────────────
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
log_exporter = InMemoryLogExporter()
|
||
logger_provider = OTLoggerProvider()
|
||
logger_provider.add_log_record_processor(SimpleLogRecordProcessor(log_exporter))
|
||
metric_reader = InMemoryMetricReader()
|
||
meter_provider = MeterProvider(metric_readers=[metric_reader])
|
||
|
||
# ─── instantiate our OpenTelemetry logger with test providers ───────────
|
||
otel = OpenTelemetry(
|
||
tracer_provider=tracer_provider,
|
||
meter_provider=meter_provider,
|
||
logger_provider=logger_provider, # needed if events were enabled
|
||
)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
# ─── minimal input / output for a chat call ──────────────────────────────
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
with open(
|
||
os.path.join(self.HERE, "open_telemetry", "data", "captured_kwargs.json")
|
||
) as f:
|
||
kwargs = json.load(f)
|
||
with open(
|
||
os.path.join(self.HERE, "open_telemetry", "data", "captured_response.json")
|
||
) as f:
|
||
response_obj = json.load(f)
|
||
|
||
# ─── exercise the hook ───────────────────────────────────────────────────
|
||
otel._handle_success(kwargs, response_obj, start, end)
|
||
|
||
# ─── assert spans ────────────────────────────────────────────────────────
|
||
spans = span_exporter.get_finished_spans()
|
||
self.assertTrue(spans, "Expected at least one span")
|
||
|
||
# ─── assert metrics ──────────────────────────────────────────────────────
|
||
duration_metric = self.wait_for_metric(
|
||
metric_reader, "gen_ai.client.operation.duration"
|
||
)
|
||
self.assertIsNotNone(duration_metric, "duration histogram was not recorded")
|
||
# model attribute should be present on a data point
|
||
found_dp = False
|
||
if (
|
||
duration_metric
|
||
and hasattr(duration_metric, "data")
|
||
and hasattr(duration_metric.data, "data_points")
|
||
):
|
||
found_dp = any(
|
||
dp.attributes.get("gen_ai.request.model") == self.MODEL
|
||
for dp in duration_metric.data.data_points
|
||
)
|
||
self.assertTrue(
|
||
found_dp, "expected gen_ai.request.model attribute on a data point"
|
||
)
|
||
|
||
# ─── no events when only metrics enabled ─────────────────────────────────
|
||
logs = log_exporter.get_finished_logs()
|
||
self.assertFalse(logs, "Did not expect any logs")
|
||
|
||
def test_get_span_name_with_generation_name(self):
|
||
"""Test _get_span_name returns generation_name when present"""
|
||
otel = OpenTelemetry()
|
||
kwargs = {"litellm_params": {"metadata": {"generation_name": "custom_span"}}}
|
||
result = otel._get_span_name(kwargs)
|
||
self.assertEqual(result, "custom_span")
|
||
|
||
def test_get_span_name_without_generation_name(self):
|
||
"""Test _get_span_name returns default when generation_name missing"""
|
||
from litellm.integrations.opentelemetry import LITELLM_REQUEST_SPAN_NAME
|
||
|
||
otel = OpenTelemetry()
|
||
kwargs = {"litellm_params": {"metadata": {}}}
|
||
result = otel._get_span_name(kwargs)
|
||
self.assertEqual(result, LITELLM_REQUEST_SPAN_NAME)
|
||
|
||
@patch.dict(os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""})
|
||
@patch("litellm.turn_off_message_logging", False)
|
||
def test_maybe_log_raw_request_creates_span(self):
|
||
"""Test _maybe_log_raw_request creates span when logging enabled"""
|
||
from litellm.integrations.opentelemetry import RAW_REQUEST_SPAN_NAME
|
||
|
||
otel = OpenTelemetry()
|
||
otel.message_logging = True
|
||
|
||
mock_tracer = MagicMock()
|
||
mock_span = MagicMock()
|
||
mock_tracer.start_span.return_value = mock_span
|
||
otel.get_tracer_to_use_for_request = MagicMock(return_value=mock_tracer)
|
||
otel.set_raw_request_attributes = MagicMock()
|
||
otel._to_ns = MagicMock(return_value=1234567890)
|
||
|
||
kwargs = {"litellm_params": {"metadata": {}}}
|
||
otel._maybe_log_raw_request(
|
||
kwargs, {}, datetime.now(), datetime.now(), MagicMock()
|
||
)
|
||
|
||
mock_tracer.start_span.assert_called_once()
|
||
self.assertEqual(
|
||
mock_tracer.start_span.call_args[1]["name"], RAW_REQUEST_SPAN_NAME
|
||
)
|
||
|
||
@patch("litellm.turn_off_message_logging", True)
|
||
def test_maybe_log_raw_request_skips_when_logging_disabled(self):
|
||
"""Test _maybe_log_raw_request skips when logging disabled"""
|
||
otel = OpenTelemetry()
|
||
mock_tracer = MagicMock()
|
||
otel.get_tracer_to_use_for_request = MagicMock(return_value=mock_tracer)
|
||
|
||
kwargs = {"litellm_params": {"metadata": {}}}
|
||
otel._maybe_log_raw_request(
|
||
kwargs, {}, datetime.now(), datetime.now(), MagicMock()
|
||
)
|
||
|
||
mock_tracer.start_span.assert_not_called()
|
||
|
||
|
||
class TestOpenTelemetryHeaderSplitting(unittest.TestCase):
|
||
"""Test suite for _get_headers_dictionary method"""
|
||
|
||
def test_split_multiple_headers_comma_separated(self):
|
||
"""Test splitting multiple headers separated by commas"""
|
||
otel = OpenTelemetry()
|
||
headers = "api-key=key,other-config-value=value"
|
||
result = otel._get_headers_dictionary(headers)
|
||
self.assertEqual(result, {"api-key": "key", "other-config-value": "value"})
|
||
|
||
def test_split_headers_with_equals_in_values(self):
|
||
"""Test splitting headers where values contain equals signs (split only on first '=')"""
|
||
otel = OpenTelemetry()
|
||
headers = "api-key=value1=part2,config=setting=enabled"
|
||
result = otel._get_headers_dictionary(headers)
|
||
self.assertEqual(
|
||
result, {"api-key": "value1=part2", "config": "setting=enabled"}
|
||
)
|
||
|
||
|
||
class TestOpenTelemetryEndpointNormalization(unittest.TestCase):
|
||
"""Test suite for the unified _normalize_otel_endpoint method"""
|
||
|
||
def test_normalize_traces_endpoint_from_logs_path(self):
|
||
"""Test normalizing endpoint with /v1/logs to /v1/traces"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(
|
||
"http://collector:4318/v1/logs", "traces"
|
||
)
|
||
self.assertEqual(result, "http://collector:4318/v1/traces")
|
||
|
||
def test_normalize_traces_endpoint_from_metrics_path(self):
|
||
"""Test normalizing endpoint with /v1/metrics to /v1/traces"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(
|
||
"http://collector:4318/v1/metrics", "traces"
|
||
)
|
||
self.assertEqual(result, "http://collector:4318/v1/traces")
|
||
|
||
def test_normalize_traces_endpoint_from_base_url(self):
|
||
"""Test adding /v1/traces to base URL"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint("http://collector:4318", "traces")
|
||
self.assertEqual(result, "http://collector:4318/v1/traces")
|
||
|
||
def test_normalize_traces_endpoint_from_v1_path(self):
|
||
"""Test adding traces to /v1 path"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint("http://collector:4318/v1", "traces")
|
||
self.assertEqual(result, "http://collector:4318/v1/traces")
|
||
|
||
def test_normalize_traces_endpoint_already_correct(self):
|
||
"""Test endpoint already ending with /v1/traces remains unchanged"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(
|
||
"http://collector:4318/v1/traces", "traces"
|
||
)
|
||
self.assertEqual(result, "http://collector:4318/v1/traces")
|
||
|
||
def test_normalize_metrics_endpoint_from_traces_path(self):
|
||
"""Test normalizing endpoint with /v1/traces to /v1/metrics"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(
|
||
"http://collector:4318/v1/traces", "metrics"
|
||
)
|
||
self.assertEqual(result, "http://collector:4318/v1/metrics")
|
||
|
||
def test_normalize_metrics_endpoint_from_logs_path(self):
|
||
"""Test normalizing endpoint with /v1/logs to /v1/metrics"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(
|
||
"http://collector:4318/v1/logs", "metrics"
|
||
)
|
||
self.assertEqual(result, "http://collector:4318/v1/metrics")
|
||
|
||
def test_normalize_metrics_endpoint_from_base_url(self):
|
||
"""Test adding /v1/metrics to base URL"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint("http://collector:4318", "metrics")
|
||
self.assertEqual(result, "http://collector:4318/v1/metrics")
|
||
|
||
def test_normalize_metrics_endpoint_already_correct(self):
|
||
"""Test endpoint already ending with /v1/metrics remains unchanged"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(
|
||
"http://collector:4318/v1/metrics", "metrics"
|
||
)
|
||
self.assertEqual(result, "http://collector:4318/v1/metrics")
|
||
|
||
def test_normalize_logs_endpoint_from_traces_path(self):
|
||
"""Test normalizing endpoint with /v1/traces to /v1/logs"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(
|
||
"http://collector:4318/v1/traces", "logs"
|
||
)
|
||
self.assertEqual(result, "http://collector:4318/v1/logs")
|
||
|
||
def test_normalize_logs_endpoint_from_metrics_path(self):
|
||
"""Test normalizing endpoint with /v1/metrics to /v1/logs"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(
|
||
"http://collector:4318/v1/metrics", "logs"
|
||
)
|
||
self.assertEqual(result, "http://collector:4318/v1/logs")
|
||
|
||
def test_normalize_logs_endpoint_from_base_url(self):
|
||
"""Test adding /v1/logs to base URL"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint("http://collector:4318", "logs")
|
||
self.assertEqual(result, "http://collector:4318/v1/logs")
|
||
|
||
def test_normalize_logs_endpoint_already_correct(self):
|
||
"""Test endpoint already ending with /v1/logs remains unchanged"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint("http://collector:4318/v1/logs", "logs")
|
||
self.assertEqual(result, "http://collector:4318/v1/logs")
|
||
|
||
def test_normalize_endpoint_with_trailing_slash(self):
|
||
"""Test that trailing slashes are properly handled"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint("http://collector:4318/", "traces")
|
||
self.assertEqual(result, "http://collector:4318/v1/traces")
|
||
|
||
@parameterized.expand(
|
||
[
|
||
(
|
||
"https://ingest.eu1.observability.splunkcloud.com/v2/trace/otlp",
|
||
"https://ingest.eu1.observability.splunkcloud.com/v2/trace/otlp",
|
||
),
|
||
(
|
||
"https://ingest.us0.observability.splunkcloud.com/v2/trace/otlp/",
|
||
"https://ingest.us0.observability.splunkcloud.com/v2/trace/otlp",
|
||
),
|
||
(
|
||
"https://ingest.eu0.signalfx.com/v2/trace/otlp",
|
||
"https://ingest.eu0.signalfx.com/v2/trace/otlp",
|
||
),
|
||
(
|
||
"https://example.com/prefix/v2/trace/otlp",
|
||
"https://example.com/prefix/v2/trace/otlp",
|
||
),
|
||
]
|
||
)
|
||
def test_normalize_traces_nonstandard_otlp_ingest_urls_unchanged(
|
||
self, input_url: str, expected: str
|
||
) -> None:
|
||
"""Splunk-style /v2/trace/otlp endpoints must not get /v1/traces appended."""
|
||
otel = OpenTelemetry()
|
||
self.assertEqual(
|
||
otel._normalize_otel_endpoint(input_url, "traces"),
|
||
expected,
|
||
)
|
||
|
||
def test_normalize_endpoint_none(self):
|
||
"""Test that None endpoint returns None"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(None, "traces")
|
||
self.assertIsNone(result)
|
||
|
||
def test_normalize_endpoint_empty_string(self):
|
||
"""Test that empty string returns empty string"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint("", "traces")
|
||
self.assertEqual(result, "")
|
||
|
||
def test_normalize_endpoint_invalid_signal_type(self):
|
||
"""Test that invalid signal type returns endpoint unchanged with warning"""
|
||
otel = OpenTelemetry()
|
||
endpoint = "http://collector:4318/v1/traces"
|
||
|
||
with patch("litellm._logging.verbose_logger.warning") as mock_warning:
|
||
result = otel._normalize_otel_endpoint(endpoint, "invalid")
|
||
|
||
# Should return endpoint unchanged
|
||
self.assertEqual(result, endpoint)
|
||
|
||
# Should log a warning
|
||
mock_warning.assert_called_once()
|
||
# Check the warning was called with the expected format string and parameters
|
||
call_args = mock_warning.call_args[0]
|
||
self.assertIn("Invalid signal_type", call_args[0])
|
||
self.assertEqual(call_args[1], "invalid") # signal_type parameter
|
||
self.assertEqual(
|
||
call_args[2], {"traces", "metrics", "logs"}
|
||
) # valid_signals parameter
|
||
|
||
def test_normalize_endpoint_https(self):
|
||
"""Test normalization works with https URLs"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(
|
||
"https://collector.example.com:4318", "logs"
|
||
)
|
||
self.assertEqual(result, "https://collector.example.com:4318/v1/logs")
|
||
|
||
def test_normalize_endpoint_with_path_prefix(self):
|
||
"""Test normalization works with URLs that have path prefixes"""
|
||
otel = OpenTelemetry()
|
||
result = otel._normalize_otel_endpoint(
|
||
"http://collector:4318/otel/v1/traces", "logs"
|
||
)
|
||
# Should replace the final /traces with /logs
|
||
self.assertEqual(result, "http://collector:4318/otel/v1/logs")
|
||
|
||
def test_normalize_endpoint_consistency_across_signals(self):
|
||
"""Test that normalization is consistent for all signal types from the same base"""
|
||
otel = OpenTelemetry()
|
||
base = "http://collector:4318"
|
||
|
||
traces_result = otel._normalize_otel_endpoint(base, "traces")
|
||
metrics_result = otel._normalize_otel_endpoint(base, "metrics")
|
||
logs_result = otel._normalize_otel_endpoint(base, "logs")
|
||
|
||
# All should have the same base with different signal paths
|
||
self.assertEqual(traces_result, "http://collector:4318/v1/traces")
|
||
self.assertEqual(metrics_result, "http://collector:4318/v1/metrics")
|
||
self.assertEqual(logs_result, "http://collector:4318/v1/logs")
|
||
|
||
def test_normalize_endpoint_signal_switching(self):
|
||
"""Test switching between different signal types on the same endpoint"""
|
||
otel = OpenTelemetry()
|
||
|
||
# Start with traces
|
||
endpoint = "http://collector:4318/v1/traces"
|
||
|
||
# Switch to metrics
|
||
metrics = otel._normalize_otel_endpoint(endpoint, "metrics")
|
||
self.assertEqual(metrics, "http://collector:4318/v1/metrics")
|
||
|
||
# Switch to logs
|
||
logs = otel._normalize_otel_endpoint(metrics, "logs")
|
||
self.assertEqual(logs, "http://collector:4318/v1/logs")
|
||
|
||
# Switch back to traces
|
||
traces = otel._normalize_otel_endpoint(logs, "traces")
|
||
self.assertEqual(traces, "http://collector:4318/v1/traces")
|
||
|
||
|
||
class TestOpenTelemetryProtocolSelection(unittest.TestCase):
|
||
"""Test suite for verifying correct exporter selection based on protocol"""
|
||
|
||
def test_get_span_processor_uses_http_exporter_for_otlp_http(self):
|
||
"""Test that otlp_http protocol uses OTLPSpanExporterHTTP"""
|
||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||
OTLPSpanExporter as OTLPSpanExporterHTTP,
|
||
)
|
||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||
|
||
config = OpenTelemetryConfig(
|
||
exporter="otlp_http", endpoint="http://collector:4318"
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
processor = otel._get_span_processor()
|
||
|
||
# Verify it's a BatchSpanProcessor
|
||
self.assertIsInstance(processor, BatchSpanProcessor)
|
||
|
||
# Verify the exporter is the HTTP variant
|
||
self.assertIsInstance(processor.span_exporter, OTLPSpanExporterHTTP)
|
||
|
||
def test_get_span_processor_uses_grpc_exporter_for_otlp_grpc(self):
|
||
"""Test that otlp_grpc protocol uses OTLPSpanExporterGRPC"""
|
||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
||
OTLPSpanExporter as OTLPSpanExporterGRPC,
|
||
)
|
||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||
|
||
config = OpenTelemetryConfig(
|
||
exporter="otlp_grpc", endpoint="http://collector:4317"
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
processor = otel._get_span_processor()
|
||
|
||
# Verify it's a BatchSpanProcessor
|
||
self.assertIsInstance(processor, BatchSpanProcessor)
|
||
|
||
# Verify the exporter is the gRPC variant
|
||
self.assertIsInstance(processor.span_exporter, OTLPSpanExporterGRPC)
|
||
|
||
def test_get_span_processor_uses_grpc_exporter_for_grpc_alias(self):
|
||
"""Test that 'grpc' protocol alias uses OTLPSpanExporterGRPC"""
|
||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
||
OTLPSpanExporter as OTLPSpanExporterGRPC,
|
||
)
|
||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||
|
||
config = OpenTelemetryConfig(exporter="grpc", endpoint="http://collector:4317")
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
processor = otel._get_span_processor()
|
||
|
||
# Verify it's a BatchSpanProcessor
|
||
self.assertIsInstance(processor, BatchSpanProcessor)
|
||
|
||
# Verify the exporter is the gRPC variant
|
||
self.assertIsInstance(processor.span_exporter, OTLPSpanExporterGRPC)
|
||
|
||
def test_get_span_processor_uses_http_exporter_for_http_protobuf(self):
|
||
"""Test that http/protobuf protocol uses OTLPSpanExporterHTTP"""
|
||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||
OTLPSpanExporter as OTLPSpanExporterHTTP,
|
||
)
|
||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||
|
||
config = OpenTelemetryConfig(
|
||
exporter="http/protobuf", endpoint="http://collector:4318"
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
processor = otel._get_span_processor()
|
||
|
||
# Verify it's a BatchSpanProcessor
|
||
self.assertIsInstance(processor, BatchSpanProcessor)
|
||
|
||
# Verify the exporter is the HTTP variant
|
||
self.assertIsInstance(processor.span_exporter, OTLPSpanExporterHTTP)
|
||
|
||
def test_get_span_processor_uses_console_exporter_for_console(self):
|
||
"""Test that console protocol uses ConsoleSpanExporter"""
|
||
from opentelemetry.sdk.trace.export import (
|
||
BatchSpanProcessor,
|
||
ConsoleSpanExporter,
|
||
)
|
||
|
||
config = OpenTelemetryConfig(exporter="console")
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
processor = otel._get_span_processor()
|
||
|
||
# Verify it's a BatchSpanProcessor
|
||
self.assertIsInstance(processor, BatchSpanProcessor)
|
||
|
||
# Verify the exporter is the console variant
|
||
self.assertIsInstance(processor.span_exporter, ConsoleSpanExporter)
|
||
|
||
def test_get_log_exporter_uses_http_exporter_for_otlp_http(self):
|
||
"""Test that otlp_http protocol uses HTTP OTLPLogExporter"""
|
||
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
|
||
|
||
config = OpenTelemetryConfig(
|
||
exporter="otlp_http", endpoint="http://collector:4318", enable_events=True
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
exporter = otel._get_log_exporter()
|
||
|
||
# Verify the exporter is the HTTP variant
|
||
self.assertIsInstance(exporter, OTLPLogExporter)
|
||
|
||
# Check that it's from the http module by checking the module name
|
||
self.assertIn("http", exporter.__class__.__module__)
|
||
|
||
def test_get_log_exporter_uses_grpc_exporter_for_otlp_grpc(self):
|
||
"""Test that otlp_grpc protocol uses gRPC OTLPLogExporter"""
|
||
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||
|
||
config = OpenTelemetryConfig(
|
||
exporter="otlp_grpc", endpoint="http://collector:4317", enable_events=True
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
exporter = otel._get_log_exporter()
|
||
|
||
# Verify the exporter is the gRPC variant
|
||
self.assertIsInstance(exporter, OTLPLogExporter)
|
||
|
||
# Check that it's from the grpc module by checking the module name
|
||
self.assertIn("grpc", exporter.__class__.__module__)
|
||
|
||
def test_get_log_exporter_uses_grpc_exporter_for_grpc_alias(self):
|
||
"""Test that 'grpc' protocol alias uses gRPC OTLPLogExporter"""
|
||
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||
|
||
config = OpenTelemetryConfig(
|
||
exporter="grpc", endpoint="http://collector:4317", enable_events=True
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
exporter = otel._get_log_exporter()
|
||
|
||
# Verify the exporter is the gRPC variant
|
||
self.assertIsInstance(exporter, OTLPLogExporter)
|
||
|
||
# Check that it's from the grpc module by checking the module name
|
||
self.assertIn("grpc", exporter.__class__.__module__)
|
||
|
||
def test_get_log_exporter_uses_console_exporter_for_console(self):
|
||
"""Test that console protocol uses ConsoleLogExporter"""
|
||
from opentelemetry.sdk._logs.export import ConsoleLogExporter
|
||
|
||
config = OpenTelemetryConfig(exporter="console", enable_events=True)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
exporter = otel._get_log_exporter()
|
||
|
||
# Verify the exporter is the console variant
|
||
self.assertIsInstance(exporter, ConsoleLogExporter)
|
||
|
||
def test_get_log_exporter_defaults_to_console_for_unknown_protocol(self):
|
||
"""Test that unknown protocol defaults to ConsoleLogExporter with warning"""
|
||
from opentelemetry.sdk._logs.export import ConsoleLogExporter
|
||
|
||
config = OpenTelemetryConfig(exporter="unknown_protocol", enable_events=True)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
with patch("litellm._logging.verbose_logger.warning") as mock_warning:
|
||
exporter = otel._get_log_exporter()
|
||
|
||
# Verify the exporter defaults to console
|
||
self.assertIsInstance(exporter, ConsoleLogExporter)
|
||
|
||
# Verify a warning was logged
|
||
mock_warning.assert_called_once()
|
||
args = mock_warning.call_args[0]
|
||
self.assertIn("Unknown log exporter", args[0])
|
||
self.assertIn("unknown_protocol", args[1])
|
||
|
||
@patch.dict(
|
||
os.environ,
|
||
{
|
||
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
|
||
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
|
||
},
|
||
clear=False,
|
||
)
|
||
def test_protocol_selection_from_environment_http(self):
|
||
"""Test that protocol selection works correctly from environment variables for HTTP"""
|
||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||
OTLPSpanExporter as OTLPSpanExporterHTTP,
|
||
)
|
||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||
|
||
config = OpenTelemetryConfig.from_env()
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
processor = otel._get_span_processor()
|
||
|
||
# Verify the HTTP exporter is used
|
||
self.assertIsInstance(processor, BatchSpanProcessor)
|
||
self.assertIsInstance(processor.span_exporter, OTLPSpanExporterHTTP)
|
||
|
||
@patch.dict(
|
||
os.environ,
|
||
{
|
||
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4317",
|
||
},
|
||
clear=False,
|
||
)
|
||
def test_protocol_selection_from_environment_grpc(self):
|
||
"""Test that protocol selection works correctly from environment variables for gRPC"""
|
||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
||
OTLPSpanExporter as OTLPSpanExporterGRPC,
|
||
)
|
||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||
|
||
config = OpenTelemetryConfig.from_env()
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
processor = otel._get_span_processor()
|
||
|
||
# Verify the gRPC exporter is used
|
||
self.assertIsInstance(processor, BatchSpanProcessor)
|
||
self.assertIsInstance(processor.span_exporter, OTLPSpanExporterGRPC)
|
||
|
||
@patch.dict(
|
||
os.environ,
|
||
{
|
||
"OTEL_EXPORTER": "otlp_http",
|
||
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
|
||
},
|
||
clear=False,
|
||
)
|
||
def test_protocol_selection_from_otel_exporter_fallback_http(self):
|
||
"""OTEL_EXPORTER drives protocol when OTEL_EXPORTER_OTLP_PROTOCOL is unset."""
|
||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||
OTLPSpanExporter as OTLPSpanExporterHTTP,
|
||
)
|
||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||
|
||
popped_protocol = os.environ.pop("OTEL_EXPORTER_OTLP_PROTOCOL", None)
|
||
try:
|
||
config = OpenTelemetryConfig.from_env()
|
||
self.assertEqual(config.exporter, "otlp_http")
|
||
otel = OpenTelemetry(config=config)
|
||
processor = otel._get_span_processor()
|
||
self.assertIsInstance(processor, BatchSpanProcessor)
|
||
self.assertIsInstance(processor.span_exporter, OTLPSpanExporterHTTP)
|
||
finally:
|
||
if popped_protocol is not None:
|
||
os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = popped_protocol
|
||
|
||
@patch.dict(
|
||
os.environ,
|
||
{
|
||
"OTEL_EXPORTER": "otlp_grpc",
|
||
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4317",
|
||
},
|
||
clear=False,
|
||
)
|
||
def test_protocol_selection_from_otel_exporter_fallback_grpc(self):
|
||
"""OTEL_EXPORTER drives protocol when OTEL_EXPORTER_OTLP_PROTOCOL is unset."""
|
||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
||
OTLPSpanExporter as OTLPSpanExporterGRPC,
|
||
)
|
||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||
|
||
popped_protocol = os.environ.pop("OTEL_EXPORTER_OTLP_PROTOCOL", None)
|
||
try:
|
||
config = OpenTelemetryConfig.from_env()
|
||
self.assertEqual(config.exporter, "otlp_grpc")
|
||
otel = OpenTelemetry(config=config)
|
||
processor = otel._get_span_processor()
|
||
self.assertIsInstance(processor, BatchSpanProcessor)
|
||
self.assertIsInstance(processor.span_exporter, OTLPSpanExporterGRPC)
|
||
finally:
|
||
if popped_protocol is not None:
|
||
os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = popped_protocol
|
||
|
||
def test_http_exporter_endpoint_normalization_for_traces(self):
|
||
"""Test that HTTP trace exporter gets properly normalized endpoint"""
|
||
config = OpenTelemetryConfig(
|
||
exporter="otlp_http", endpoint="http://collector:4318"
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
processor = otel._get_span_processor()
|
||
|
||
# Verify the endpoint was normalized to include /v1/traces
|
||
# Access the private _endpoint attribute if available
|
||
if hasattr(processor.span_exporter, "_endpoint"):
|
||
self.assertEqual(processor.span_exporter._endpoint, "http://collector:4318/v1/traces") # type: ignore[attr-defined]
|
||
|
||
def test_grpc_exporter_endpoint_normalization_for_traces(self):
|
||
"""Test that gRPC trace exporter gets properly normalized endpoint"""
|
||
config = OpenTelemetryConfig(
|
||
exporter="otlp_grpc", endpoint="http://collector:4317"
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
processor = otel._get_span_processor()
|
||
|
||
# Verify the endpoint was normalized to include /v1/traces
|
||
# Note: gRPC exporters strip the http:// prefix, so we check for the normalized path
|
||
if hasattr(processor.span_exporter, "_endpoint"):
|
||
# gRPC exporter strips http:// prefix
|
||
self.assertIn("collector:4317", processor.span_exporter._endpoint) # type: ignore[attr-defined]
|
||
# The endpoint should have been normalized with /v1/traces before being passed to gRPC exporter
|
||
# We verify this by checking the normalization function was called correctly
|
||
normalized = otel._normalize_otel_endpoint(
|
||
"http://collector:4317", "traces"
|
||
)
|
||
self.assertEqual(normalized, "http://collector:4317/v1/traces")
|
||
|
||
def test_http_log_exporter_endpoint_normalization_for_logs(self):
|
||
"""Test that HTTP log exporter gets properly normalized endpoint"""
|
||
config = OpenTelemetryConfig(
|
||
exporter="otlp_http",
|
||
endpoint="http://collector:4318/v1/traces",
|
||
enable_events=True,
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
exporter = otel._get_log_exporter()
|
||
|
||
# Verify the endpoint was normalized to /v1/logs (not /v1/traces)
|
||
# Access the private _endpoint attribute if available
|
||
if hasattr(exporter, "_endpoint"):
|
||
self.assertEqual(exporter._endpoint, "http://collector:4318/v1/logs") # type: ignore[attr-defined]
|
||
|
||
def test_grpc_log_exporter_endpoint_normalization_for_logs(self):
|
||
"""Test that gRPC log exporter gets properly normalized endpoint"""
|
||
config = OpenTelemetryConfig(
|
||
exporter="otlp_grpc",
|
||
endpoint="http://collector:4317/v1/traces",
|
||
enable_events=True,
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
exporter = otel._get_log_exporter()
|
||
|
||
# Verify the endpoint was normalized to /v1/logs (not /v1/traces)
|
||
# Note: gRPC exporters strip the http:// prefix, so we check for the normalized path
|
||
if hasattr(exporter, "_endpoint"):
|
||
# gRPC exporter strips http:// prefix
|
||
self.assertIn("collector:4317", exporter._endpoint) # type: ignore[attr-defined]
|
||
# The endpoint should have been normalized with /v1/logs before being passed to gRPC exporter
|
||
# We verify this by checking the normalization function was called correctly
|
||
normalized = otel._normalize_otel_endpoint(
|
||
"http://collector:4317/v1/traces", "logs"
|
||
)
|
||
self.assertEqual(normalized, "http://collector:4317/v1/logs")
|
||
|
||
def test_get_metric_reader_uses_http_exporter_for_http_protobuf(self):
|
||
"""Test that http/protobuf protocol uses OTLPMetricExporterHTTP"""
|
||
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
|
||
OTLPMetricExporter,
|
||
)
|
||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||
|
||
config = OpenTelemetryConfig(
|
||
exporter="http/protobuf", endpoint="http://collector:4318"
|
||
)
|
||
otel = OpenTelemetry(config=config)
|
||
|
||
reader = otel._get_metric_reader()
|
||
|
||
self.assertIsInstance(reader, PeriodicExportingMetricReader)
|
||
self.assertIsInstance(reader._exporter, OTLPMetricExporter)
|
||
|
||
|
||
class TestOpenTelemetryExternalSpan(unittest.TestCase):
|
||
"""
|
||
Test suite for external span handling in OpenTelemetry integration.
|
||
|
||
These tests verify that LiteLLM correctly handles spans created outside
|
||
of LiteLLM (e.g., by Langfuse SDK, user application code, or global context)
|
||
without closing them prematurely.
|
||
|
||
Background:
|
||
- External spans can come from: Langfuse SDK, user code, HTTP traceparent headers, global context
|
||
- LiteLLM should NEVER close spans it did not create
|
||
- Bug: LiteLLM was reusing and closing external spans in _start_primary_span
|
||
"""
|
||
|
||
HERE = os.path.dirname(__file__)
|
||
|
||
def setUp(self):
|
||
"""Set up common test fixtures"""
|
||
self.span_exporter = InMemorySpanExporter()
|
||
self.tracer_provider = TracerProvider()
|
||
self.tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter))
|
||
|
||
# Don't set global tracer provider - instead, get tracers directly from our provider
|
||
# This avoids "Overriding of current TracerProvider is not allowed" warnings
|
||
|
||
# Clear any existing spans
|
||
self.span_exporter.clear()
|
||
|
||
def _create_test_kwargs_and_response(self):
|
||
"""Load test data from JSON files"""
|
||
with open(
|
||
os.path.join(self.HERE, "open_telemetry", "data", "captured_kwargs.json")
|
||
) as f:
|
||
kwargs = json.load(f)
|
||
|
||
with open(
|
||
os.path.join(self.HERE, "open_telemetry", "data", "captured_response.json")
|
||
) as f:
|
||
response_obj = json.load(f)
|
||
|
||
return kwargs, response_obj
|
||
|
||
def _get_spans_by_name(self, name):
|
||
"""Get all spans with the given name"""
|
||
spans = self.span_exporter.get_finished_spans()
|
||
return [s for s in spans if s.name == name]
|
||
|
||
@patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}, clear=False)
|
||
def test_external_span_not_closed_with_use_otel_litellm_request_span_false(self):
|
||
"""
|
||
Test that external spans are not closed when USE_OTEL_LITELLM_REQUEST_SPAN=false (default).
|
||
|
||
Expected behavior:
|
||
- External span remains open (is_recording = True)
|
||
- raw_gen_ai_request spans are direct children of external span (shallow hierarchy)
|
||
- No litellm_request span is created
|
||
- Multiple completions work correctly
|
||
"""
|
||
# Initialize OpenTelemetry
|
||
otel = OpenTelemetry(tracer_provider=self.tracer_provider)
|
||
|
||
# Load test data
|
||
kwargs, response_obj = self._create_test_kwargs_and_response()
|
||
|
||
# Create external parent span using our test TracerProvider
|
||
tracer = self.tracer_provider.get_tracer(__name__)
|
||
|
||
with tracer.start_as_current_span("external_parent_span") as parent_span:
|
||
parent_ctx = parent_span.get_span_context()
|
||
parent_trace_id = parent_ctx.trace_id
|
||
parent_span_id = parent_ctx.span_id
|
||
|
||
self.assertTrue(
|
||
parent_span.is_recording(),
|
||
"External span should be recording before completion calls",
|
||
)
|
||
|
||
# First completion call
|
||
start_time = datetime.utcnow()
|
||
end_time = start_time + timedelta(seconds=1)
|
||
otel._handle_success(kwargs, response_obj, start_time, end_time)
|
||
|
||
# Verify parent span is still recording
|
||
self.assertTrue(
|
||
parent_span.is_recording(),
|
||
"External span should still be recording after first completion",
|
||
)
|
||
|
||
# Second completion call
|
||
start_time2 = end_time
|
||
end_time2 = start_time2 + timedelta(seconds=1)
|
||
otel._handle_success(kwargs, response_obj, start_time2, end_time2)
|
||
|
||
# Verify parent span is still recording
|
||
self.assertTrue(
|
||
parent_span.is_recording(),
|
||
"External span should still be recording after second completion",
|
||
)
|
||
|
||
# After exiting context, verify spans
|
||
spans = self.span_exporter.get_finished_spans()
|
||
|
||
# All spans should have the same trace_id
|
||
for span in spans:
|
||
self.assertEqual(
|
||
span.context.trace_id,
|
||
parent_trace_id,
|
||
f"Span {span.name} should have same trace_id as parent",
|
||
)
|
||
|
||
# Should have external_parent_span
|
||
parent_spans = self._get_spans_by_name("external_parent_span")
|
||
self.assertEqual(
|
||
len(parent_spans), 1, "Should have exactly one external_parent_span"
|
||
)
|
||
|
||
# Verify LiteLLM set attributes on external parent span
|
||
parent_span_finished = parent_spans[0]
|
||
self.assertIsNotNone(
|
||
parent_span_finished.attributes,
|
||
"Parent span should have attributes set by LiteLLM",
|
||
)
|
||
self.assertIn(
|
||
"gen_ai.request.model",
|
||
parent_span_finished.attributes,
|
||
"Parent span should have model attribute from LiteLLM",
|
||
)
|
||
|
||
# Should have raw_gen_ai_request spans (if message_logging is on)
|
||
raw_spans = self._get_spans_by_name("raw_gen_ai_request")
|
||
# Note: May be 0 if message_logging is off, or 2 if on
|
||
|
||
# Should NOT have litellm_request spans (USE_OTEL_LITELLM_REQUEST_SPAN=false)
|
||
litellm_spans = self._get_spans_by_name("litellm_request")
|
||
self.assertEqual(
|
||
len(litellm_spans),
|
||
0,
|
||
"Should NOT have litellm_request spans when USE_OTEL_LITELLM_REQUEST_SPAN=false",
|
||
)
|
||
|
||
# Verify raw_gen_ai_request spans are direct children of external span
|
||
for raw_span in raw_spans:
|
||
self.assertEqual(
|
||
raw_span.parent.span_id if raw_span.parent else None,
|
||
parent_span_id,
|
||
"raw_gen_ai_request should be direct child of external_parent_span",
|
||
)
|
||
|
||
@patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}, clear=False)
|
||
def test_external_span_not_closed_with_use_otel_litellm_request_span_true(self):
|
||
"""
|
||
Test that external spans are not closed when USE_OTEL_LITELLM_REQUEST_SPAN=true.
|
||
|
||
Expected behavior:
|
||
- External span remains open (is_recording = True)
|
||
- litellm_request spans are created as children of external span
|
||
- raw_gen_ai_request spans are children of litellm_request spans
|
||
- Correct hierarchy: external_parent → litellm_request → raw_gen_ai_request
|
||
"""
|
||
import copy
|
||
|
||
# Initialize OpenTelemetry
|
||
otel = OpenTelemetry(tracer_provider=self.tracer_provider)
|
||
|
||
kwargs1, response_obj = self._create_test_kwargs_and_response()
|
||
kwargs2 = copy.deepcopy(kwargs1)
|
||
|
||
# Create external parent span using our test TracerProvider
|
||
tracer = self.tracer_provider.get_tracer(__name__)
|
||
|
||
with tracer.start_as_current_span("external_parent_span") as parent_span:
|
||
parent_ctx = parent_span.get_span_context()
|
||
parent_trace_id = parent_ctx.trace_id
|
||
parent_span_id = parent_ctx.span_id
|
||
|
||
# First completion call
|
||
start_time = datetime.utcnow()
|
||
end_time = start_time + timedelta(seconds=1)
|
||
otel._handle_success(kwargs1, response_obj, start_time, end_time)
|
||
|
||
# Verify parent span is still recording
|
||
self.assertTrue(
|
||
parent_span.is_recording(),
|
||
"External span should still be recording after first completion",
|
||
)
|
||
|
||
# Second completion call
|
||
start_time2 = end_time
|
||
end_time2 = start_time2 + timedelta(seconds=1)
|
||
otel._handle_success(kwargs2, response_obj, start_time2, end_time2)
|
||
|
||
# Verify parent span is still recording
|
||
self.assertTrue(
|
||
parent_span.is_recording(),
|
||
"External span should still be recording after second completion",
|
||
)
|
||
|
||
# After exiting context, verify spans
|
||
spans = self.span_exporter.get_finished_spans()
|
||
|
||
# All spans should have the same trace_id
|
||
for span in spans:
|
||
self.assertEqual(
|
||
span.context.trace_id,
|
||
parent_trace_id,
|
||
f"Span {span.name} should have same trace_id as parent",
|
||
)
|
||
|
||
# Should have litellm_request spans (USE_OTEL_LITELLM_REQUEST_SPAN=true)
|
||
litellm_spans = self._get_spans_by_name("litellm_request")
|
||
self.assertEqual(
|
||
len(litellm_spans),
|
||
2,
|
||
"Should have 2 litellm_request spans when USE_OTEL_LITELLM_REQUEST_SPAN=true",
|
||
)
|
||
|
||
# Verify litellm_request spans are children of external span
|
||
for litellm_span in litellm_spans:
|
||
self.assertEqual(
|
||
litellm_span.parent.span_id if litellm_span.parent else None,
|
||
parent_span_id,
|
||
"litellm_request should be child of external_parent_span",
|
||
)
|
||
|
||
# Verify raw_gen_ai_request spans (if present) are children of litellm_request
|
||
raw_spans = self._get_spans_by_name("raw_gen_ai_request")
|
||
if raw_spans:
|
||
litellm_span_ids = {s.context.span_id for s in litellm_spans}
|
||
for raw_span in raw_spans:
|
||
self.assertIn(
|
||
raw_span.parent.span_id if raw_span.parent else None,
|
||
litellm_span_ids,
|
||
"raw_gen_ai_request should be child of litellm_request",
|
||
)
|
||
|
||
@patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}, clear=False)
|
||
def test_external_span_with_multiple_completions(self):
|
||
"""
|
||
Test that multiple completion calls work correctly within external span context.
|
||
|
||
Expected behavior:
|
||
- Both completion calls succeed
|
||
- All spans belong to the same trace
|
||
- External span remains open throughout
|
||
- No errors or warnings about "ended span"
|
||
"""
|
||
# Initialize OpenTelemetry
|
||
otel = OpenTelemetry(tracer_provider=self.tracer_provider)
|
||
|
||
# Load test data
|
||
kwargs, response_obj = self._create_test_kwargs_and_response()
|
||
|
||
# Create external parent span using our test TracerProvider
|
||
tracer = self.tracer_provider.get_tracer(__name__)
|
||
|
||
with tracer.start_as_current_span("external_parent_span") as parent_span:
|
||
parent_ctx = parent_span.get_span_context()
|
||
parent_trace_id = parent_ctx.trace_id
|
||
|
||
# Make multiple completion calls
|
||
for i in range(3):
|
||
start_time = datetime.utcnow()
|
||
end_time = start_time + timedelta(seconds=1)
|
||
|
||
# This should not raise any exceptions
|
||
otel._handle_success(kwargs, response_obj, start_time, end_time)
|
||
|
||
# Verify parent span is still recording after each call
|
||
self.assertTrue(
|
||
parent_span.is_recording(),
|
||
f"External span should still be recording after completion #{i+1}",
|
||
)
|
||
|
||
# Verify all spans have the same trace_id
|
||
spans = self.span_exporter.get_finished_spans()
|
||
for span in spans:
|
||
self.assertEqual(
|
||
span.context.trace_id,
|
||
parent_trace_id,
|
||
"All spans should belong to the same trace",
|
||
)
|
||
|
||
# Should have the external parent span
|
||
parent_spans = self._get_spans_by_name("external_parent_span")
|
||
self.assertEqual(
|
||
len(parent_spans), 1, "Should have exactly one external_parent_span"
|
||
)
|
||
|
||
# Verify LiteLLM set attributes on external parent span
|
||
parent_span_finished = parent_spans[0]
|
||
self.assertIn(
|
||
"gen_ai.request.model",
|
||
parent_span_finished.attributes,
|
||
"Parent span should have model attribute from LiteLLM",
|
||
)
|
||
|
||
@patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}, clear=False)
|
||
def test_external_span_from_global_context(self):
|
||
"""
|
||
Test external span detection from global context (Priority 3 in _get_span_context).
|
||
|
||
This simulates the case where a span is set in the global context
|
||
(e.g., by user code or Langfuse SDK) and LiteLLM detects it via
|
||
trace.get_current_span().
|
||
|
||
Expected behavior:
|
||
- LiteLLM detects the span from global context
|
||
- External span is not closed
|
||
- Correct parent-child relationship
|
||
"""
|
||
# Initialize OpenTelemetry
|
||
otel = OpenTelemetry(tracer_provider=self.tracer_provider)
|
||
|
||
# Load test data
|
||
kwargs, response_obj = self._create_test_kwargs_and_response()
|
||
|
||
# Create external parent span and set it as current using our test TracerProvider
|
||
tracer = self.tracer_provider.get_tracer(__name__)
|
||
|
||
with tracer.start_as_current_span("external_global_span") as parent_span:
|
||
parent_ctx = parent_span.get_span_context()
|
||
parent_trace_id = parent_ctx.trace_id
|
||
|
||
# Verify the span is in global context
|
||
current_span = trace.get_current_span()
|
||
self.assertEqual(
|
||
current_span, parent_span, "Span should be in global context"
|
||
)
|
||
|
||
# Make completion call
|
||
start_time = datetime.utcnow()
|
||
end_time = start_time + timedelta(seconds=1)
|
||
otel._handle_success(kwargs, response_obj, start_time, end_time)
|
||
|
||
# Verify parent span is still recording
|
||
self.assertTrue(
|
||
parent_span.is_recording(),
|
||
"External span from global context should not be closed",
|
||
)
|
||
|
||
# Verify trace structure
|
||
spans = self.span_exporter.get_finished_spans()
|
||
for span in spans:
|
||
self.assertEqual(
|
||
span.context.trace_id,
|
||
parent_trace_id,
|
||
"All spans should have the same trace_id",
|
||
)
|
||
|
||
@patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}, clear=False)
|
||
def test_external_span_hierarchy_preserved(self):
|
||
"""
|
||
Test that span hierarchy is correctly preserved with external parent.
|
||
|
||
Expected behavior:
|
||
- Parent span IDs are correct
|
||
- Trace structure matches expected hierarchy
|
||
- Span names are correct
|
||
"""
|
||
# Initialize OpenTelemetry
|
||
otel = OpenTelemetry(tracer_provider=self.tracer_provider)
|
||
otel.message_logging = (
|
||
True # Enable message logging to get raw_gen_ai_request spans
|
||
)
|
||
|
||
# Load test data
|
||
kwargs, response_obj = self._create_test_kwargs_and_response()
|
||
|
||
# Create external parent span using our test TracerProvider
|
||
tracer = self.tracer_provider.get_tracer(__name__)
|
||
|
||
with tracer.start_as_current_span("external_parent_span") as parent_span:
|
||
parent_span_id = parent_span.get_span_context().span_id
|
||
|
||
# Make completion call
|
||
start_time = datetime.utcnow()
|
||
end_time = start_time + timedelta(seconds=1)
|
||
otel._handle_success(kwargs, response_obj, start_time, end_time)
|
||
|
||
# Verify hierarchy
|
||
spans = self.span_exporter.get_finished_spans()
|
||
|
||
# Get spans by name
|
||
parent_spans = self._get_spans_by_name("external_parent_span")
|
||
raw_spans = self._get_spans_by_name("raw_gen_ai_request")
|
||
|
||
self.assertEqual(len(parent_spans), 1, "Should have one parent span")
|
||
|
||
# Verify parent-child relationship
|
||
if raw_spans: # If message_logging is on
|
||
for raw_span in raw_spans:
|
||
self.assertEqual(
|
||
raw_span.parent.span_id if raw_span.parent else None,
|
||
parent_span_id,
|
||
"raw_gen_ai_request should be child of external_parent_span",
|
||
)
|
||
|
||
@patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}, clear=False)
|
||
def test_external_span_not_ended_on_failure(self):
|
||
"""
|
||
Test that external spans are not closed even on failure.
|
||
|
||
Expected behavior:
|
||
- When _handle_failure is called with external span context
|
||
- External span remains open (is_recording = True)
|
||
- Error span is created correctly
|
||
- External span status is NOT changed by LiteLLM
|
||
"""
|
||
# Initialize OpenTelemetry
|
||
otel = OpenTelemetry(tracer_provider=self.tracer_provider)
|
||
|
||
# Load test data
|
||
kwargs, response_obj = self._create_test_kwargs_and_response()
|
||
|
||
# Create external parent span using our test TracerProvider
|
||
tracer = self.tracer_provider.get_tracer(__name__)
|
||
|
||
with tracer.start_as_current_span("external_parent_span") as parent_span:
|
||
parent_ctx = parent_span.get_span_context()
|
||
parent_trace_id = parent_ctx.trace_id
|
||
|
||
# Simulate failure
|
||
start_time = datetime.utcnow()
|
||
end_time = start_time + timedelta(seconds=1)
|
||
|
||
# Create error response object
|
||
error_response = {"error": "Test error"}
|
||
|
||
# Call _handle_failure
|
||
otel._handle_failure(kwargs, error_response, start_time, end_time)
|
||
|
||
# Verify parent span is still recording
|
||
self.assertTrue(
|
||
parent_span.is_recording(),
|
||
"External span should still be recording even after failure",
|
||
)
|
||
|
||
# Verify trace structure
|
||
spans = self.span_exporter.get_finished_spans()
|
||
|
||
# All spans should have the same trace_id
|
||
for span in spans:
|
||
self.assertEqual(
|
||
span.context.trace_id,
|
||
parent_trace_id,
|
||
"All spans should have the same trace_id even on failure",
|
||
)
|
||
|
||
# Should have external_parent_span
|
||
parent_spans = self._get_spans_by_name("external_parent_span")
|
||
self.assertEqual(
|
||
len(parent_spans), 1, "Should have exactly one external_parent_span"
|
||
)
|
||
|
||
# Verify LiteLLM set attributes on external parent span even on failure
|
||
parent_span_finished = parent_spans[0]
|
||
self.assertIn(
|
||
"gen_ai.request.model",
|
||
parent_span_finished.attributes,
|
||
"Parent span should have model attribute from LiteLLM even on failure",
|
||
)
|
||
|
||
|
||
class TestOpenTelemetrySemanticConventions138(unittest.TestCase):
|
||
"""
|
||
Test suite for OpenTelemetry 1.38 Semantic Conventions compliance.
|
||
|
||
These tests verify that LiteLLM emits span attributes following the
|
||
OpenTelemetry GenAI semantic conventions v1.38, including:
|
||
- gen_ai.input.messages (JSON string with parts array)
|
||
- gen_ai.output.messages (JSON string with parts array)
|
||
- gen_ai.usage.input_tokens / output_tokens (new naming)
|
||
- gen_ai.response.finish_reasons (JSON array)
|
||
|
||
See: https://github.com/BerriAI/litellm/issues/17794
|
||
"""
|
||
|
||
def setUp(self):
|
||
# Insulate from a shell-set OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT
|
||
# so these tests exercise the legacy default path (message_logging=True).
|
||
self._prev = os.environ.pop(
|
||
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", None
|
||
)
|
||
|
||
def tearDown(self):
|
||
if self._prev is not None:
|
||
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = (
|
||
self._prev
|
||
)
|
||
|
||
def test_input_messages_uses_parts_structure(self):
|
||
"""
|
||
Test that gen_ai.input.messages uses the OTEL 1.38 parts array structure.
|
||
|
||
Expected format:
|
||
[{"role": "user", "parts": [{"type": "text", "content": "Hello"}]}]
|
||
"""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello world"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
|
||
response_obj = {
|
||
"id": "test-response-id",
|
||
"model": "gpt-4",
|
||
"choices": [
|
||
{
|
||
"finish_reason": "stop",
|
||
"message": {"role": "assistant", "content": "Hi there!"},
|
||
}
|
||
],
|
||
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||
}
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
# Find the call that set gen_ai.input.messages
|
||
input_messages_calls = [
|
||
call
|
||
for call in mock_span.set_attribute.call_args_list
|
||
if call[0][0] == "gen_ai.input.messages"
|
||
]
|
||
self.assertEqual(
|
||
len(input_messages_calls),
|
||
1,
|
||
"Should have exactly one gen_ai.input.messages attribute",
|
||
)
|
||
|
||
input_messages_value = input_messages_calls[0][0][1]
|
||
parsed = json.loads(input_messages_value)
|
||
|
||
# Verify structure
|
||
self.assertIsInstance(parsed, list)
|
||
self.assertEqual(len(parsed), 1)
|
||
self.assertEqual(parsed[0]["role"], "user")
|
||
self.assertIn("parts", parsed[0])
|
||
self.assertEqual(parsed[0]["parts"][0]["type"], "text")
|
||
self.assertEqual(parsed[0]["parts"][0]["content"], "Hello world")
|
||
|
||
def test_output_messages_uses_parts_structure(self):
|
||
"""
|
||
Test that gen_ai.output.messages uses the OTEL 1.38 parts array structure.
|
||
|
||
Expected format:
|
||
[{"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}], "finish_reason": "stop"}]
|
||
"""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
|
||
response_obj = {
|
||
"id": "test-response-id",
|
||
"model": "gpt-4",
|
||
"choices": [
|
||
{
|
||
"finish_reason": "stop",
|
||
"message": {"role": "assistant", "content": "Hello back!"},
|
||
}
|
||
],
|
||
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||
}
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
# Find the call that set gen_ai.output.messages
|
||
output_messages_calls = [
|
||
call
|
||
for call in mock_span.set_attribute.call_args_list
|
||
if call[0][0] == "gen_ai.output.messages"
|
||
]
|
||
self.assertEqual(
|
||
len(output_messages_calls),
|
||
1,
|
||
"Should have exactly one gen_ai.output.messages attribute",
|
||
)
|
||
|
||
output_messages_value = output_messages_calls[0][0][1]
|
||
parsed = json.loads(output_messages_value)
|
||
|
||
# Verify structure
|
||
self.assertIsInstance(parsed, list)
|
||
self.assertEqual(len(parsed), 1)
|
||
self.assertEqual(parsed[0]["role"], "assistant")
|
||
self.assertIn("parts", parsed[0])
|
||
self.assertEqual(parsed[0]["parts"][0]["type"], "text")
|
||
self.assertEqual(parsed[0]["parts"][0]["content"], "Hello back!")
|
||
self.assertEqual(parsed[0]["finish_reason"], "stop")
|
||
|
||
def test_usage_tokens_use_new_naming_convention(self):
|
||
"""
|
||
Test that token usage uses the OTEL 1.38 naming convention:
|
||
- gen_ai.usage.input_tokens (not prompt_tokens)
|
||
- gen_ai.usage.output_tokens (not completion_tokens)
|
||
"""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
|
||
response_obj = {
|
||
"id": "test-response-id",
|
||
"model": "gpt-4",
|
||
"choices": [],
|
||
"usage": {
|
||
"prompt_tokens": 100,
|
||
"completion_tokens": 50,
|
||
"total_tokens": 150,
|
||
},
|
||
}
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
# Verify new naming convention is used
|
||
mock_span.set_attribute.assert_any_call("gen_ai.usage.input_tokens", 100)
|
||
mock_span.set_attribute.assert_any_call("gen_ai.usage.output_tokens", 50)
|
||
mock_span.set_attribute.assert_any_call("gen_ai.usage.total_tokens", 150)
|
||
|
||
def test_finish_reasons_is_json_array(self):
|
||
"""
|
||
Test that gen_ai.response.finish_reasons is a proper JSON array.
|
||
|
||
Expected: '["stop"]' (not "['stop']")
|
||
"""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
|
||
response_obj = {
|
||
"id": "test-response-id",
|
||
"model": "gpt-4",
|
||
"choices": [
|
||
{
|
||
"finish_reason": "stop",
|
||
"message": {"role": "assistant", "content": "Hi"},
|
||
},
|
||
],
|
||
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||
}
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
# Find the call that set gen_ai.response.finish_reasons
|
||
finish_reasons_calls = [
|
||
call
|
||
for call in mock_span.set_attribute.call_args_list
|
||
if call[0][0] == "gen_ai.response.finish_reasons"
|
||
]
|
||
self.assertEqual(
|
||
len(finish_reasons_calls),
|
||
1,
|
||
"Should have exactly one gen_ai.response.finish_reasons attribute",
|
||
)
|
||
|
||
finish_reasons_value = finish_reasons_calls[0][0][1]
|
||
|
||
# Verify it's valid JSON (not Python repr)
|
||
parsed = json.loads(finish_reasons_value)
|
||
self.assertEqual(parsed, ["stop"])
|
||
|
||
def test_operation_name_is_chat_for_completion(self):
|
||
"""
|
||
Test that gen_ai.operation.name is 'chat' for completion calls.
|
||
"""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
|
||
response_obj = {
|
||
"id": "test-response-id",
|
||
"model": "gpt-4",
|
||
"choices": [],
|
||
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||
}
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
mock_span.set_attribute.assert_any_call("gen_ai.operation.name", "chat")
|
||
|
||
@parameterized.expand([("_handle_success",), ("_handle_failure",)])
|
||
def test_handle_success_failure_nulls_parent_span_if_ignore_context_propagation(
|
||
self, handle_method: str
|
||
):
|
||
"""
|
||
If ignore_context_propagation is True, _handle_success should ignore any parent span
|
||
and create a root-level span. This could be useful for langfuse_otel where
|
||
_handle_success may ignore parent spans from other providers and create a root-level
|
||
span (symmetric with _handle_failure).
|
||
"""
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(
|
||
config=OpenTelemetryConfig(ignore_context_propagation=True),
|
||
tracer_provider=tracer_provider,
|
||
)
|
||
otel.tracer = tracer_provider.get_tracer("litellm")
|
||
|
||
other_tracer = tracer_provider.get_tracer("other_provider")
|
||
other_span = other_tracer.start_span("parent_span")
|
||
|
||
start = datetime.now(timezone.utc)
|
||
end = start + timedelta(seconds=1)
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {
|
||
"custom_llm_provider": "openai",
|
||
"metadata": {"litellm_parent_otel_span": other_span},
|
||
},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
"exception": Exception("test error"),
|
||
}
|
||
|
||
with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}):
|
||
if handle_method == "_handle_success":
|
||
otel._handle_success(kwargs, None, start, end)
|
||
elif handle_method == "_handle_failure":
|
||
otel._handle_failure(kwargs, None, start, end)
|
||
else:
|
||
self.fail(f"Invalid handle_method: {handle_method}")
|
||
|
||
other_span.end()
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
child_spans = [s for s in spans if s.name != "parent_span"]
|
||
child_span_ids = {s.context.span_id for s in child_spans if s.context}
|
||
|
||
self.assertTrue(child_spans, "Expected at least one child span")
|
||
for span in child_spans:
|
||
assert (
|
||
span.parent is None or span.parent.span_id in child_span_ids
|
||
), f"if ignore_context_propagation is True, span should not have parent from other providers, but got parent: {span.parent}"
|
||
|
||
@parameterized.expand([("_handle_success",), ("_handle_failure",)])
|
||
def test_handle_success_failure_default_preserves_parent_span(
|
||
self, handle_method: str
|
||
):
|
||
"""
|
||
For default otel callbacks, _handle_success should use parent spans normally.
|
||
(symmetric with _handle_failure)
|
||
"""
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer("litellm")
|
||
|
||
parent_span = otel.tracer.start_span("parent_span")
|
||
|
||
start = datetime.now(timezone.utc)
|
||
end = start + timedelta(seconds=1)
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {
|
||
"custom_llm_provider": "openai",
|
||
"metadata": {"litellm_parent_otel_span": parent_span},
|
||
},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
"exception": Exception("test error"),
|
||
}
|
||
|
||
with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}):
|
||
if handle_method == "_handle_success":
|
||
otel._handle_success(kwargs, None, start, end)
|
||
elif handle_method == "_handle_failure":
|
||
otel._handle_failure(kwargs, None, start, end)
|
||
else:
|
||
self.fail(f"Invalid handle_method: {handle_method}")
|
||
|
||
parent_span.end()
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
child_spans = [s for s in spans if s.name != "parent_span"]
|
||
|
||
self.assertTrue(child_spans, "Expected at least one child span")
|
||
for span in child_spans:
|
||
assert (
|
||
span.parent is not None
|
||
), f"By default parent span should be preserved, but got None parent for span: {span.name}"
|
||
|
||
@parameterized.expand([("_handle_success",), ("_handle_failure",)])
|
||
def test_handle_success_failure_with_context_propagation_preserves_parent_span(
|
||
self, handle_method: str
|
||
):
|
||
"""
|
||
For otel callbacks with context propagation enabled, _handle_success should
|
||
use parent spans normally. (symmetric with _handle_failure)
|
||
"""
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(
|
||
config=OpenTelemetryConfig(ignore_context_propagation=False),
|
||
tracer_provider=tracer_provider,
|
||
)
|
||
otel.tracer = tracer_provider.get_tracer("litellm")
|
||
|
||
parent_span = otel.tracer.start_span("parent_span")
|
||
|
||
start = datetime.now(timezone.utc)
|
||
end = start + timedelta(seconds=1)
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {
|
||
"custom_llm_provider": "openai",
|
||
"metadata": {"litellm_parent_otel_span": parent_span},
|
||
},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
"exception": Exception("test error"),
|
||
}
|
||
|
||
with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}):
|
||
if handle_method == "_handle_success":
|
||
otel._handle_success(kwargs, None, start, end)
|
||
elif handle_method == "_handle_failure":
|
||
otel._handle_failure(kwargs, None, start, end)
|
||
else:
|
||
self.fail(f"Invalid handle_method: {handle_method}")
|
||
|
||
parent_span.end()
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
child_spans = [s for s in spans if s.name != "parent_span"]
|
||
|
||
self.assertTrue(child_spans, "Expected at least one child span")
|
||
for span in child_spans:
|
||
assert (
|
||
span.parent is not None
|
||
), f"If ignore_context_propagation is False, parent span should be preserved, but got None parent for span: {span.name}"
|
||
|
||
def test_handle_failure_hasattr_guard_on_parent_name(self):
|
||
"""
|
||
_handle_failure should not raise AttributeError when parent_otel_span
|
||
lacks a 'name' attribute (e.g., NonRecordingSpan).
|
||
"""
|
||
otel = OpenTelemetry()
|
||
otel.tracer = MagicMock()
|
||
mock_span = MagicMock()
|
||
otel.tracer.start_span.return_value = mock_span
|
||
parent_without_name = MagicMock()
|
||
del parent_without_name.name
|
||
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {
|
||
"custom_llm_provider": "openai",
|
||
"metadata": {"litellm_parent_otel_span": parent_without_name},
|
||
},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
|
||
try:
|
||
otel._handle_failure(kwargs, None, start, end)
|
||
except AttributeError as e:
|
||
self.fail(
|
||
f"_handle_failure raised AttributeError on parent span without 'name': {e}"
|
||
)
|
||
|
||
def test_handle_failure_creates_error_span(self):
|
||
"""
|
||
_handle_failure should create a span with ERROR status.
|
||
"""
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer("litellm")
|
||
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
"exception": Exception("test error"),
|
||
}
|
||
|
||
otel._handle_failure(kwargs, None, start, end)
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
self.assertTrue(spans, "Expected at least one span")
|
||
|
||
from opentelemetry.trace import StatusCode
|
||
|
||
error_spans = [s for s in spans if s.status.status_code == StatusCode.ERROR]
|
||
self.assertTrue(error_spans, "Expected at least one span with ERROR status")
|
||
|
||
|
||
class TestRawSpanAttributeIsolation(unittest.TestCase):
|
||
"""Issue #3: raw_gen_ai_request span should only contain provider-specific
|
||
llm.{provider}.* attributes, not the duplicated gen_ai.* / metadata.* attrs."""
|
||
|
||
@patch("litellm.turn_off_message_logging", False)
|
||
def test_raw_span_does_not_duplicate_parent_attributes(self):
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.message_logging = True
|
||
|
||
mock_tracer = tracer_provider.get_tracer(__name__)
|
||
otel.get_tracer_to_use_for_request = MagicMock(return_value=mock_tracer)
|
||
|
||
raw_span = mock_tracer.start_span("raw_gen_ai_request")
|
||
|
||
kwargs = {
|
||
"litellm_params": {"custom_llm_provider": "vertex_ai"},
|
||
"optional_params": {"temperature": 0.7},
|
||
"original_response": '{"predictions": [1,2,3]}',
|
||
"additional_args": {
|
||
"complete_input_dict": {"instances": [{"content": "hello"}]}
|
||
},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "embedding",
|
||
"metadata": {"user_api_key_hash": "abc123"},
|
||
"hidden_params": {},
|
||
},
|
||
}
|
||
response_obj = {"model": "text-embedding-004", "usage": {"total_tokens": 5}}
|
||
|
||
otel.set_raw_request_attributes(raw_span, kwargs, response_obj)
|
||
raw_span.end()
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
raw = [s for s in spans if s.name == "raw_gen_ai_request"][0]
|
||
attr_keys = set(raw.attributes.keys()) if raw.attributes else set()
|
||
|
||
# Provider-specific attributes SHOULD be present
|
||
self.assertTrue(
|
||
any(k.startswith("llm.vertex_ai.") for k in attr_keys),
|
||
f"Expected llm.vertex_ai.* attributes, got: {attr_keys}",
|
||
)
|
||
# Standard gen_ai / metadata attributes should NOT be present
|
||
self.assertFalse(
|
||
any(k.startswith("gen_ai.") for k in attr_keys),
|
||
f"raw span should not contain gen_ai.* attributes, got: {attr_keys}",
|
||
)
|
||
self.assertFalse(
|
||
any(k.startswith("metadata.") for k in attr_keys),
|
||
f"raw span should not contain metadata.* attributes, got: {attr_keys}",
|
||
)
|
||
|
||
|
||
class TestNoParentSpanDuplication(unittest.TestCase):
|
||
"""Issue #4: When litellm_request child span exists, the parent
|
||
litellm_proxy_request span should NOT get set_attributes() called."""
|
||
|
||
HERE = os.path.dirname(__file__)
|
||
|
||
@patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}, clear=False)
|
||
def test_parent_proxy_span_not_duplicated(self):
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
|
||
with open(
|
||
os.path.join(self.HERE, "open_telemetry", "data", "captured_kwargs.json")
|
||
) as f:
|
||
kwargs = json.load(f)
|
||
with open(
|
||
os.path.join(self.HERE, "open_telemetry", "data", "captured_response.json")
|
||
) as f:
|
||
response_obj = json.load(f)
|
||
|
||
# Simulate proxy flow: create a parent proxy span
|
||
tracer = tracer_provider.get_tracer(__name__)
|
||
from litellm.integrations.opentelemetry import LITELLM_PROXY_REQUEST_SPAN_NAME
|
||
|
||
parent_span = tracer.start_span(name=LITELLM_PROXY_REQUEST_SPAN_NAME)
|
||
# Inject parent span into kwargs so _get_span_context finds it
|
||
kwargs["litellm_params"]["metadata"]["litellm_parent_otel_span"] = parent_span
|
||
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
otel._handle_success(kwargs, response_obj, start, end)
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
proxy_spans = [s for s in spans if s.name == LITELLM_PROXY_REQUEST_SPAN_NAME]
|
||
self.assertEqual(len(proxy_spans), 1, "Should have exactly one proxy span")
|
||
|
||
proxy_attrs = proxy_spans[0].attributes or {}
|
||
# The parent proxy span should NOT have gen_ai.request.model set
|
||
self.assertNotIn(
|
||
"gen_ai.request.model",
|
||
proxy_attrs,
|
||
"Parent proxy span should NOT duplicate gen_ai.request.model (Issue #4)",
|
||
)
|
||
|
||
|
||
class TestGuardrailSpanParenting(unittest.TestCase):
|
||
"""Issue #5: Guardrail spans must not be orphaned — they should always
|
||
be children of the litellm_request span (or parent span)."""
|
||
|
||
def test_guardrail_span_is_child_of_litellm_request(self):
|
||
"""When no parent proxy span exists, guardrail spans should be
|
||
children of the litellm_request span, not orphaned root spans."""
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
guardrail_info = {
|
||
"guardrail_name": "pii_filter",
|
||
"guardrail_mode": "pre_call",
|
||
"guardrail_response": "ok",
|
||
"start_time": time.time(),
|
||
"end_time": time.time() + 0.1,
|
||
}
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai", "metadata": {}},
|
||
"standard_logging_object": {
|
||
"id": "test-guardrail-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
"hidden_params": {},
|
||
"guardrail_information": [guardrail_info],
|
||
},
|
||
}
|
||
response_obj = {
|
||
"id": "chatcmpl-test",
|
||
"choices": [
|
||
{
|
||
"finish_reason": "stop",
|
||
"index": 0,
|
||
"message": {"content": "Hi!", "role": "assistant"},
|
||
}
|
||
],
|
||
"usage": {
|
||
"prompt_tokens": 5,
|
||
"completion_tokens": 2,
|
||
"total_tokens": 7,
|
||
},
|
||
}
|
||
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
otel._handle_success(kwargs, response_obj, start, end)
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
guardrail_spans = [s for s in spans if s.name == "guardrail"]
|
||
litellm_spans = [s for s in spans if s.name == "litellm_request"]
|
||
|
||
self.assertTrue(guardrail_spans, "Expected at least one guardrail span")
|
||
self.assertTrue(litellm_spans, "Expected a litellm_request span")
|
||
|
||
litellm_span = litellm_spans[0]
|
||
for gs in guardrail_spans:
|
||
# All spans should share the same trace_id (not orphaned)
|
||
self.assertEqual(
|
||
gs.context.trace_id,
|
||
litellm_span.context.trace_id,
|
||
"Guardrail span should share trace_id with litellm_request (not orphaned)",
|
||
)
|
||
# Guardrail should be a child of the litellm_request span
|
||
self.assertIsNotNone(
|
||
gs.parent,
|
||
"Guardrail span should have a parent (not be a root span)",
|
||
)
|
||
self.assertEqual(
|
||
gs.parent.span_id,
|
||
litellm_span.context.span_id,
|
||
"Guardrail span should be a child of litellm_request",
|
||
)
|
||
|
||
def test_guardrail_span_parented_on_failure(self):
|
||
"""Guardrail spans should also be properly parented in the failure path."""
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
guardrail_info = {
|
||
"guardrail_name": "content_filter",
|
||
"guardrail_mode": "pre_call",
|
||
"guardrail_response": "blocked",
|
||
"start_time": time.time(),
|
||
"end_time": time.time() + 0.05,
|
||
}
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai", "metadata": {}},
|
||
"standard_logging_object": {
|
||
"id": "test-fail-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
"hidden_params": {},
|
||
"guardrail_information": [guardrail_info],
|
||
},
|
||
"exception": Exception("test error"),
|
||
}
|
||
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
otel._handle_failure(kwargs, None, start, end)
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
guardrail_spans = [s for s in spans if s.name == "guardrail"]
|
||
|
||
self.assertTrue(guardrail_spans, "Expected at least one guardrail span")
|
||
for gs in guardrail_spans:
|
||
self.assertIsNotNone(
|
||
gs.parent,
|
||
"Guardrail span should have a parent on failure path too",
|
||
)
|
||
|
||
|
||
class TestResponseIdFallback(unittest.TestCase):
|
||
"""Issue #8: gen_ai.response.id should be set for embeddings and image gen
|
||
using standard_logging_payload['id'] as fallback."""
|
||
|
||
def test_response_id_from_response_obj(self):
|
||
"""When response_obj has an id, it should be used."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "litellm-call-id-123",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
response_obj = {
|
||
"id": "chatcmpl-provider-id-456",
|
||
"choices": [
|
||
{
|
||
"finish_reason": "stop",
|
||
"index": 0,
|
||
"message": {"content": "Hi", "role": "assistant"},
|
||
}
|
||
],
|
||
"usage": {
|
||
"prompt_tokens": 5,
|
||
"completion_tokens": 2,
|
||
"total_tokens": 7,
|
||
},
|
||
}
|
||
|
||
otel.set_attributes(mock_span, kwargs, response_obj)
|
||
|
||
# Should use provider response ID, not litellm call ID
|
||
mock_span.set_attribute.assert_any_call(
|
||
"gen_ai.response.id", "chatcmpl-provider-id-456"
|
||
)
|
||
|
||
def test_response_id_fallback_for_embeddings(self):
|
||
"""When response_obj has no id (embeddings), fallback to
|
||
standard_logging_payload['id']."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = {
|
||
"model": "text-embedding-ada-002",
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "litellm-embed-call-789",
|
||
"call_type": "embedding",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
# Embedding response has no "id" field
|
||
response_obj = {
|
||
"object": "list",
|
||
"data": [{"embedding": [0.1, 0.2], "index": 0}],
|
||
"model": "text-embedding-ada-002",
|
||
"usage": {"prompt_tokens": 5, "total_tokens": 5},
|
||
}
|
||
|
||
otel.set_attributes(mock_span, kwargs, response_obj)
|
||
|
||
# Should fallback to litellm call ID
|
||
mock_span.set_attribute.assert_any_call(
|
||
"gen_ai.response.id", "litellm-embed-call-789"
|
||
)
|
||
|
||
def test_response_id_fallback_for_image_gen(self):
|
||
"""When response_obj has no id (image gen), fallback to
|
||
standard_logging_payload['id']."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = {
|
||
"model": "dall-e-3",
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "litellm-img-call-101",
|
||
"call_type": "image_generation",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
# Image response has no "id" field
|
||
response_obj = {
|
||
"created": 1234567890,
|
||
"data": [{"url": "https://example.com/img.png"}],
|
||
}
|
||
|
||
otel.set_attributes(mock_span, kwargs, response_obj)
|
||
|
||
# Should fallback to litellm call ID
|
||
mock_span.set_attribute.assert_any_call(
|
||
"gen_ai.response.id", "litellm-img-call-101"
|
||
)
|
||
|
||
def test_litellm_call_id_emitted_as_span_attribute(self):
|
||
"""litellm.call_id must be set on the span from standard_logging_payload."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
call_id = "my-litellm-call-uuid-456"
|
||
kwargs = {
|
||
"model": "gpt-4o",
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "chatcmpl-provider-id",
|
||
"litellm_call_id": call_id,
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
response_obj = {"id": "chatcmpl-provider-id", "model": "gpt-4o"}
|
||
|
||
otel.set_attributes(mock_span, kwargs, response_obj)
|
||
|
||
mock_span.set_attribute.assert_any_call("litellm.call_id", call_id)
|
||
|
||
|
||
class TestOpenTelemetryResponsesAPI(unittest.TestCase):
|
||
"""
|
||
Tests for Responses API (/v1/responses) OTel span attributes.
|
||
|
||
The Responses API uses ``output`` (list of output items) instead of
|
||
``choices``, ``instructions`` instead of ``system_instructions``, and
|
||
``status`` instead of per-choice ``finish_reason``.
|
||
|
||
See: https://github.com/BerriAI/litellm/issues/25840
|
||
"""
|
||
|
||
def _base_kwargs(self, **overrides):
|
||
"""Return minimal kwargs for set_attributes with Responses API defaults."""
|
||
kwargs = {
|
||
"model": "gpt-4o",
|
||
"messages": [{"role": "user", "content": "What is 2+2?"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "resp_abc123",
|
||
"call_type": "responses",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
kwargs.update(overrides)
|
||
return kwargs
|
||
|
||
def _responses_api_response_obj(self, text="The answer is 4.", status="completed"):
|
||
"""Return a dict mimicking ResponsesAPIResponse with a message output."""
|
||
return {
|
||
"id": "resp_abc123",
|
||
"model": "gpt-4o",
|
||
"status": status,
|
||
"output": [
|
||
{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [
|
||
{
|
||
"type": "output_text",
|
||
"text": text,
|
||
}
|
||
],
|
||
}
|
||
],
|
||
"usage": {
|
||
"prompt_tokens": 10,
|
||
"completion_tokens": 20,
|
||
"total_tokens": 30,
|
||
},
|
||
}
|
||
|
||
def _get_attr(self, mock_span, attr_name):
|
||
"""Extract the value set for a specific attribute name, or None."""
|
||
calls = [
|
||
call
|
||
for call in mock_span.set_attribute.call_args_list
|
||
if call[0][0] == attr_name
|
||
]
|
||
if not calls:
|
||
return None
|
||
return calls[0][0][1]
|
||
|
||
# ------------------------------------------------------------------
|
||
# gen_ai.output.messages
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_output_messages_populated_for_responses_api(self):
|
||
"""gen_ai.output.messages must be set when response has output items."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = self._base_kwargs()
|
||
response_obj = self._responses_api_response_obj(text="The answer is 4.")
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
raw = self._get_attr(mock_span, "gen_ai.output.messages")
|
||
self.assertIsNotNone(raw, "gen_ai.output.messages should be set")
|
||
|
||
parsed = json.loads(raw)
|
||
self.assertIsInstance(parsed, list)
|
||
self.assertEqual(len(parsed), 1)
|
||
self.assertEqual(parsed[0]["role"], "assistant")
|
||
self.assertIn("parts", parsed[0])
|
||
self.assertEqual(parsed[0]["parts"][0]["type"], "text")
|
||
self.assertEqual(parsed[0]["parts"][0]["content"], "The answer is 4.")
|
||
|
||
def test_output_messages_with_multiple_content_items(self):
|
||
"""Multiple output_text items in a single message should all appear as parts."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
response_obj = {
|
||
"id": "resp_multi",
|
||
"model": "gpt-4o",
|
||
"status": "completed",
|
||
"output": [
|
||
{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "output_text", "text": "First paragraph."},
|
||
{"type": "output_text", "text": "Second paragraph."},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
|
||
otel.set_attributes(
|
||
span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj
|
||
)
|
||
|
||
raw = self._get_attr(mock_span, "gen_ai.output.messages")
|
||
parsed = json.loads(raw)
|
||
self.assertEqual(len(parsed[0]["parts"]), 2)
|
||
self.assertEqual(parsed[0]["parts"][0]["content"], "First paragraph.")
|
||
self.assertEqual(parsed[0]["parts"][1]["content"], "Second paragraph.")
|
||
|
||
def test_output_messages_with_function_call(self):
|
||
"""function_call output items should appear as tool_call parts."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
response_obj = {
|
||
"id": "resp_fc",
|
||
"model": "gpt-4o",
|
||
"status": "completed",
|
||
"output": [
|
||
{
|
||
"type": "function_call",
|
||
"name": "get_weather",
|
||
"call_id": "call_abc",
|
||
"arguments": '{"location": "SF"}',
|
||
}
|
||
],
|
||
}
|
||
|
||
otel.set_attributes(
|
||
span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj
|
||
)
|
||
|
||
raw = self._get_attr(mock_span, "gen_ai.output.messages")
|
||
parsed = json.loads(raw)
|
||
self.assertEqual(len(parsed), 1)
|
||
self.assertEqual(parsed[0]["role"], "assistant")
|
||
self.assertEqual(parsed[0]["parts"][0]["type"], "tool_call")
|
||
self.assertEqual(parsed[0]["parts"][0]["name"], "get_weather")
|
||
self.assertEqual(parsed[0]["parts"][0]["arguments"], '{"location": "SF"}')
|
||
self.assertEqual(parsed[0]["parts"][0]["id"], "call_abc")
|
||
|
||
def test_output_messages_mixed_message_and_function_call(self):
|
||
"""Mixed output with both message and function_call items."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
response_obj = {
|
||
"id": "resp_mixed",
|
||
"model": "gpt-4o",
|
||
"status": "completed",
|
||
"output": [
|
||
{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "output_text", "text": "Let me check the weather."},
|
||
],
|
||
},
|
||
{
|
||
"type": "function_call",
|
||
"name": "get_weather",
|
||
"call_id": "call_xyz",
|
||
"arguments": "{}",
|
||
},
|
||
],
|
||
}
|
||
|
||
otel.set_attributes(
|
||
span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj
|
||
)
|
||
|
||
raw = self._get_attr(mock_span, "gen_ai.output.messages")
|
||
parsed = json.loads(raw)
|
||
self.assertEqual(len(parsed), 2)
|
||
self.assertEqual(parsed[0]["role"], "assistant")
|
||
self.assertEqual(parsed[0]["parts"][0]["content"], "Let me check the weather.")
|
||
self.assertEqual(parsed[1]["parts"][0]["type"], "tool_call")
|
||
|
||
def test_output_messages_empty_text_skipped(self):
|
||
"""Output items with empty text should not produce parts."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
response_obj = {
|
||
"id": "resp_empty",
|
||
"model": "gpt-4o",
|
||
"status": "completed",
|
||
"output": [
|
||
{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "output_text", "text": ""}],
|
||
}
|
||
],
|
||
}
|
||
|
||
otel.set_attributes(
|
||
span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj
|
||
)
|
||
|
||
# No output messages should be set since the text is empty
|
||
raw = self._get_attr(mock_span, "gen_ai.output.messages")
|
||
self.assertIsNone(
|
||
raw, "Empty output text should not produce gen_ai.output.messages"
|
||
)
|
||
|
||
def test_choices_still_work(self):
|
||
"""Existing choices-based responses must still work (no regression)."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
|
||
response_obj = {
|
||
"id": "chatcmpl-123",
|
||
"model": "gpt-4",
|
||
"choices": [
|
||
{
|
||
"finish_reason": "stop",
|
||
"message": {"role": "assistant", "content": "Hi there!"},
|
||
}
|
||
],
|
||
"usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15},
|
||
}
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
raw = self._get_attr(mock_span, "gen_ai.output.messages")
|
||
parsed = json.loads(raw)
|
||
self.assertEqual(parsed[0]["parts"][0]["content"], "Hi there!")
|
||
self.assertEqual(parsed[0]["finish_reason"], "stop")
|
||
|
||
# ------------------------------------------------------------------
|
||
# gen_ai.response.finish_reasons
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_finish_reasons_from_status(self):
|
||
"""gen_ai.response.finish_reasons should use ResponsesAPIResponse.status."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
otel.set_attributes(
|
||
span=mock_span,
|
||
kwargs=self._base_kwargs(),
|
||
response_obj=self._responses_api_response_obj(status="completed"),
|
||
)
|
||
|
||
raw = self._get_attr(mock_span, "gen_ai.response.finish_reasons")
|
||
self.assertIsNotNone(raw)
|
||
parsed = json.loads(raw)
|
||
self.assertEqual(parsed, ["completed"])
|
||
|
||
def test_finish_reasons_incomplete_status(self):
|
||
"""Non-completed status values should still be captured."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
otel.set_attributes(
|
||
span=mock_span,
|
||
kwargs=self._base_kwargs(),
|
||
response_obj=self._responses_api_response_obj(status="incomplete"),
|
||
)
|
||
|
||
raw = self._get_attr(mock_span, "gen_ai.response.finish_reasons")
|
||
parsed = json.loads(raw)
|
||
self.assertEqual(parsed, ["incomplete"])
|
||
|
||
# ------------------------------------------------------------------
|
||
# gen_ai.system_instructions
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_system_instructions_from_instructions_kwarg(self):
|
||
"""Responses API passes system prompt as kwargs['instructions']."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = self._base_kwargs(instructions="You are a math tutor.")
|
||
response_obj = self._responses_api_response_obj()
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
value = self._get_attr(mock_span, "gen_ai.system_instructions")
|
||
self.assertEqual(value, "You are a math tutor.")
|
||
|
||
def test_system_instructions_from_system_kwarg(self):
|
||
"""Anthropic Messages API passes system prompt as kwargs['system']."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = self._base_kwargs(system="You are a helpful assistant.")
|
||
response_obj = self._responses_api_response_obj()
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
value = self._get_attr(mock_span, "gen_ai.system_instructions")
|
||
self.assertEqual(value, "You are a helpful assistant.")
|
||
|
||
def test_system_instructions_from_system_instructions_kwarg(self):
|
||
"""Vertex AI Gemini path uses kwargs['system_instructions'] (existing behavior)."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = self._base_kwargs(
|
||
system_instructions=[{"role": "system", "content": "Be concise."}]
|
||
)
|
||
response_obj = self._responses_api_response_obj()
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
raw = self._get_attr(mock_span, "gen_ai.system_instructions")
|
||
self.assertIsNotNone(raw)
|
||
parsed = json.loads(raw)
|
||
self.assertEqual(parsed[0]["role"], "system")
|
||
self.assertIn("parts", parsed[0])
|
||
|
||
def test_system_instructions_precedence(self):
|
||
"""system_instructions takes precedence over instructions and system."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = self._base_kwargs(
|
||
system_instructions="From Gemini",
|
||
instructions="From Responses API",
|
||
system="From Anthropic",
|
||
)
|
||
response_obj = self._responses_api_response_obj()
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
# system_instructions (string) should win — it's checked first
|
||
value = self._get_attr(mock_span, "gen_ai.system_instructions")
|
||
self.assertEqual(value, "From Gemini")
|
||
|
||
def test_no_system_instructions_when_absent(self):
|
||
"""No gen_ai.system_instructions attr when none of the kwargs are set."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = self._base_kwargs()
|
||
response_obj = self._responses_api_response_obj()
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
value = self._get_attr(mock_span, "gen_ai.system_instructions")
|
||
self.assertIsNone(value)
|
||
|
||
|
||
class TestTransformResponsesAPIOutput(unittest.TestCase):
|
||
"""
|
||
Unit tests for _transform_responses_api_output_to_otel.
|
||
"""
|
||
|
||
def test_message_with_output_text(self):
|
||
otel = OpenTelemetry()
|
||
output = [
|
||
{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "output_text", "text": "Hello!"}],
|
||
}
|
||
]
|
||
result = otel._transform_responses_api_output_to_otel(output)
|
||
self.assertEqual(len(result), 1)
|
||
self.assertEqual(result[0]["role"], "assistant")
|
||
self.assertEqual(result[0]["parts"], [{"type": "text", "content": "Hello!"}])
|
||
|
||
def test_function_call_item(self):
|
||
otel = OpenTelemetry()
|
||
output = [
|
||
{
|
||
"type": "function_call",
|
||
"name": "search",
|
||
"call_id": "call_1",
|
||
"arguments": '{"q": "test"}',
|
||
}
|
||
]
|
||
result = otel._transform_responses_api_output_to_otel(output)
|
||
self.assertEqual(len(result), 1)
|
||
self.assertEqual(result[0]["role"], "assistant")
|
||
self.assertEqual(result[0]["parts"][0]["type"], "tool_call")
|
||
self.assertEqual(result[0]["parts"][0]["name"], "search")
|
||
self.assertEqual(result[0]["parts"][0]["id"], "call_1")
|
||
|
||
def test_function_call_without_call_id(self):
|
||
otel = OpenTelemetry()
|
||
output = [
|
||
{
|
||
"type": "function_call",
|
||
"name": "search",
|
||
"arguments": "{}",
|
||
}
|
||
]
|
||
result = otel._transform_responses_api_output_to_otel(output)
|
||
self.assertNotIn("id", result[0]["parts"][0])
|
||
|
||
def test_unknown_type_ignored(self):
|
||
otel = OpenTelemetry()
|
||
output = [{"type": "reasoning", "content": "thinking..."}]
|
||
result = otel._transform_responses_api_output_to_otel(output)
|
||
self.assertEqual(result, [])
|
||
|
||
def test_non_dict_items_ignored(self):
|
||
otel = OpenTelemetry()
|
||
output = ["not a dict", 42, None]
|
||
result = otel._transform_responses_api_output_to_otel(output)
|
||
self.assertEqual(result, [])
|
||
|
||
def test_empty_output(self):
|
||
otel = OpenTelemetry()
|
||
result = otel._transform_responses_api_output_to_otel([])
|
||
self.assertEqual(result, [])
|
||
|
||
def test_message_with_empty_text_skipped(self):
|
||
otel = OpenTelemetry()
|
||
output = [
|
||
{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "output_text", "text": ""}],
|
||
}
|
||
]
|
||
result = otel._transform_responses_api_output_to_otel(output)
|
||
self.assertEqual(result, [])
|
||
|
||
def test_message_default_role(self):
|
||
"""Messages without explicit role should default to assistant."""
|
||
otel = OpenTelemetry()
|
||
output = [
|
||
{
|
||
"type": "message",
|
||
"content": [{"type": "output_text", "text": "Hi"}],
|
||
}
|
||
]
|
||
result = otel._transform_responses_api_output_to_otel(output)
|
||
self.assertEqual(result[0]["role"], "assistant")
|
||
|
||
def test_pydantic_like_objects_accepted(self):
|
||
"""Items with .get() but not isinstance(dict) should be accepted."""
|
||
|
||
class FakeOutputItem:
|
||
"""Mimics BaseLiteLLMOpenAIResponseObject duck-typing."""
|
||
|
||
def __init__(self, data):
|
||
self._data = data
|
||
|
||
def get(self, key, default=None):
|
||
return self._data.get(key, default)
|
||
|
||
class FakeContent:
|
||
def __init__(self, data):
|
||
self._data = data
|
||
|
||
def get(self, key, default=None):
|
||
return self._data.get(key, default)
|
||
|
||
otel = OpenTelemetry()
|
||
output = [
|
||
FakeOutputItem(
|
||
{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [
|
||
FakeContent({"type": "output_text", "text": "Pydantic works!"}),
|
||
],
|
||
}
|
||
)
|
||
]
|
||
result = otel._transform_responses_api_output_to_otel(output)
|
||
self.assertEqual(len(result), 1)
|
||
self.assertEqual(result[0]["parts"][0]["content"], "Pydantic works!")
|
||
|
||
|
||
class TestSystemInstructionsPrecedence(unittest.TestCase):
|
||
"""Tests for the is-not-None precedence in system_instructions coalescing."""
|
||
|
||
def _get_attr(self, mock_span, attr_name):
|
||
calls = [
|
||
call
|
||
for call in mock_span.set_attribute.call_args_list
|
||
if call[0][0] == attr_name
|
||
]
|
||
if not calls:
|
||
return None
|
||
return calls[0][0][1]
|
||
|
||
def _base_kwargs(self, **overrides):
|
||
kwargs = {
|
||
"model": "gpt-4o",
|
||
"messages": [{"role": "user", "content": "Hi"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "responses",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
kwargs.update(overrides)
|
||
return kwargs
|
||
|
||
def test_empty_list_system_instructions_does_not_fallthrough(self):
|
||
"""An empty list for system_instructions should NOT fall through to instructions."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
kwargs = self._base_kwargs(
|
||
system_instructions=[],
|
||
instructions="Should not be used",
|
||
)
|
||
response_obj = {"id": "r1", "model": "gpt-4o"}
|
||
|
||
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
|
||
|
||
# system_instructions is [] (falsy but not None), so it wins.
|
||
# Since it's an empty list, no attribute should be set (nothing to transform).
|
||
value = self._get_attr(mock_span, "gen_ai.system_instructions")
|
||
# The empty list is truthy for `is not None` but produces empty
|
||
# transformed output — the attribute should NOT contain "Should not be used".
|
||
if value is not None:
|
||
self.assertNotIn("Should not be used", str(value))
|
||
|
||
|
||
class TestResponsesAPIToolCallSpanAttributes(unittest.TestCase):
|
||
"""Tests for per-tool-call span attributes on Responses API function_call items."""
|
||
|
||
def _base_kwargs(self):
|
||
return {
|
||
"model": "gpt-4o",
|
||
"messages": [{"role": "user", "content": "What is the weather?"}],
|
||
"optional_params": {},
|
||
"litellm_params": {"custom_llm_provider": "openai"},
|
||
"standard_logging_object": {
|
||
"id": "resp_tc",
|
||
"call_type": "responses",
|
||
"metadata": {},
|
||
},
|
||
}
|
||
|
||
def test_per_tool_call_attributes_emitted(self):
|
||
"""function_call output items should produce per-tool-call span attributes."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
response_obj = {
|
||
"id": "resp_tc",
|
||
"model": "gpt-4o",
|
||
"status": "completed",
|
||
"output": [
|
||
{
|
||
"type": "function_call",
|
||
"name": "get_weather",
|
||
"call_id": "call_abc",
|
||
"arguments": '{"location": "SF"}',
|
||
}
|
||
],
|
||
}
|
||
|
||
otel.set_attributes(
|
||
span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj
|
||
)
|
||
|
||
# Verify per-tool-call attributes were set (same format as choices branch)
|
||
attr_names = [call[0][0] for call in mock_span.set_attribute.call_args_list]
|
||
tool_call_attrs = [a for a in attr_names if "function_call" in a]
|
||
self.assertTrue(
|
||
len(tool_call_attrs) > 0, "Per-tool-call span attributes should be emitted"
|
||
)
|
||
|
||
# Verify the name attribute specifically
|
||
mock_span.set_attribute.assert_any_call(
|
||
"gen_ai.completion.0.function_call.name", "get_weather"
|
||
)
|
||
mock_span.set_attribute.assert_any_call(
|
||
"gen_ai.completion.0.function_call.arguments", '{"location": "SF"}'
|
||
)
|
||
|
||
def test_multiple_tool_calls_indexed(self):
|
||
"""Multiple function_call items should be indexed correctly."""
|
||
otel = OpenTelemetry()
|
||
mock_span = MagicMock()
|
||
|
||
response_obj = {
|
||
"id": "resp_tc2",
|
||
"model": "gpt-4o",
|
||
"status": "completed",
|
||
"output": [
|
||
{
|
||
"type": "function_call",
|
||
"name": "get_weather",
|
||
"call_id": "call_1",
|
||
"arguments": "{}",
|
||
},
|
||
{
|
||
"type": "function_call",
|
||
"name": "get_time",
|
||
"call_id": "call_2",
|
||
"arguments": "{}",
|
||
},
|
||
],
|
||
}
|
||
|
||
otel.set_attributes(
|
||
span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj
|
||
)
|
||
|
||
mock_span.set_attribute.assert_any_call(
|
||
"gen_ai.completion.0.function_call.name", "get_weather"
|
||
)
|
||
mock_span.set_attribute.assert_any_call(
|
||
"gen_ai.completion.1.function_call.name", "get_time"
|
||
)
|
||
|
||
|
||
class TestOpenTelemetryProxyParentSpanChildEmission(unittest.TestCase):
|
||
"""When metadata includes litellm_parent_otel_span (the proxy
|
||
span), the primary litellm_request span must still be created as a child
|
||
so the trace hierarchy is complete."""
|
||
|
||
def _build_kwargs(self, parent_span):
|
||
return {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {
|
||
"custom_llm_provider": "openai",
|
||
"metadata": {"litellm_parent_otel_span": parent_span},
|
||
},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
"hidden_params": {},
|
||
},
|
||
}
|
||
|
||
def test_get_span_context_returns_none_parent_for_metadata_span(self):
|
||
"""_get_span_context Priority 1 must return (ctx, None) — never the
|
||
parent span object — so callers always create litellm_request as a
|
||
child of ctx."""
|
||
tracer_provider = TracerProvider()
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
parent_span = otel.tracer.start_span("some_external_parent")
|
||
kwargs = self._build_kwargs(parent_span)
|
||
|
||
ctx, returned_parent = otel._get_span_context(kwargs)
|
||
|
||
self.assertIsNotNone(ctx, "ctx should carry the parent for child spans")
|
||
self.assertIsNone(
|
||
returned_parent,
|
||
"parent_span return slot must be None so callers create litellm_request",
|
||
)
|
||
parent_span.end()
|
||
|
||
def test_litellm_request_emitted_as_child_of_proxy_parent_span(self):
|
||
"""End-to-end: proxy span in metadata should yield exactly one
|
||
litellm_request span parented to it, with no extra root span."""
|
||
from litellm.integrations.opentelemetry import (
|
||
LITELLM_PROXY_REQUEST_SPAN_NAME,
|
||
LITELLM_REQUEST_SPAN_NAME,
|
||
)
|
||
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
proxy_span = otel.tracer.start_span(LITELLM_PROXY_REQUEST_SPAN_NAME)
|
||
kwargs = self._build_kwargs(proxy_span)
|
||
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
otel._handle_success(kwargs, response_obj=None, start_time=start, end_time=end)
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
litellm_spans = [s for s in spans if s.name == LITELLM_REQUEST_SPAN_NAME]
|
||
proxy_spans = [s for s in spans if s.name == LITELLM_PROXY_REQUEST_SPAN_NAME]
|
||
|
||
self.assertEqual(
|
||
len(litellm_spans), 1, "Exactly one litellm_request span must be emitted"
|
||
)
|
||
self.assertEqual(
|
||
len(proxy_spans), 1, "Proxy span should be closed exactly once"
|
||
)
|
||
|
||
litellm_span = litellm_spans[0]
|
||
self.assertIsNotNone(
|
||
litellm_span.parent, "litellm_request must have a parent (not root)"
|
||
)
|
||
self.assertEqual(
|
||
litellm_span.parent.span_id,
|
||
proxy_spans[0].context.span_id,
|
||
"litellm_request must be a child of the proxy span",
|
||
)
|
||
|
||
def test_end_proxy_span_from_kwargs_closes_recording_proxy_span(self):
|
||
from litellm.integrations.opentelemetry import LITELLM_PROXY_REQUEST_SPAN_NAME
|
||
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
proxy_span = otel.tracer.start_span(LITELLM_PROXY_REQUEST_SPAN_NAME)
|
||
self.assertTrue(proxy_span.is_recording())
|
||
|
||
kwargs = {
|
||
"litellm_params": {
|
||
"metadata": {"litellm_parent_otel_span": proxy_span},
|
||
}
|
||
}
|
||
otel._end_proxy_span_from_kwargs(kwargs, end_time=datetime.utcnow())
|
||
|
||
self.assertFalse(
|
||
proxy_span.is_recording(), "Proxy span should be closed by helper"
|
||
)
|
||
|
||
def test_end_proxy_span_from_kwargs_does_not_close_external_span(self):
|
||
"""Spans not named LITELLM_PROXY_REQUEST_SPAN_NAME must not be closed —
|
||
they may belong to external owners (Langfuse SDK, user code, etc.)."""
|
||
tracer_provider = TracerProvider()
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
external = otel.tracer.start_span("external_caller_span")
|
||
kwargs = {
|
||
"litellm_params": {
|
||
"metadata": {"litellm_parent_otel_span": external},
|
||
}
|
||
}
|
||
otel._end_proxy_span_from_kwargs(kwargs, end_time=datetime.utcnow())
|
||
|
||
self.assertTrue(
|
||
external.is_recording(),
|
||
"External (non-proxy) parent span must not be closed by LiteLLM",
|
||
)
|
||
external.end()
|
||
|
||
|
||
class TestOpenTelemetryProxyLoggerFirstRegisteredWins(unittest.TestCase):
|
||
"""open_telemetry_logger ownership must not be silently
|
||
overwritten by later handlers. First-registered wins."""
|
||
|
||
def _install_fake_proxy_server(self):
|
||
"""Install a stub ``litellm.proxy.proxy_server`` so the test does
|
||
not depend on optional proxy dependencies (websockets, etc.).
|
||
Returns (fake_module, cleanup_fn)."""
|
||
import importlib
|
||
import types
|
||
|
||
proxy_pkg_name = "litellm.proxy"
|
||
proxy_server_name = "litellm.proxy.proxy_server"
|
||
|
||
previous_pkg = sys.modules.get(proxy_pkg_name)
|
||
previous_mod = sys.modules.get(proxy_server_name)
|
||
|
||
# Ensure litellm.proxy package object exists
|
||
if previous_pkg is None:
|
||
try:
|
||
pkg = importlib.import_module(proxy_pkg_name)
|
||
except Exception:
|
||
pkg = types.ModuleType(proxy_pkg_name)
|
||
sys.modules[proxy_pkg_name] = pkg
|
||
else:
|
||
pkg = previous_pkg
|
||
|
||
fake = types.ModuleType(proxy_server_name)
|
||
fake.open_telemetry_logger = None
|
||
sys.modules[proxy_server_name] = fake
|
||
setattr(pkg, "proxy_server", fake)
|
||
|
||
def cleanup():
|
||
if previous_mod is not None:
|
||
sys.modules[proxy_server_name] = previous_mod
|
||
setattr(pkg, "proxy_server", previous_mod)
|
||
else:
|
||
sys.modules.pop(proxy_server_name, None)
|
||
if hasattr(pkg, "proxy_server"):
|
||
try:
|
||
delattr(pkg, "proxy_server")
|
||
except AttributeError:
|
||
pass
|
||
if previous_pkg is None and proxy_pkg_name in sys.modules:
|
||
if sys.modules[proxy_pkg_name] is pkg:
|
||
# Leave it in place — removing it would break later imports
|
||
pass
|
||
|
||
return fake, cleanup
|
||
|
||
def test_first_registered_handler_keeps_ownership(self):
|
||
fake_proxy_server, cleanup = self._install_fake_proxy_server()
|
||
try:
|
||
first = OpenTelemetry()
|
||
self.assertIs(
|
||
fake_proxy_server.open_telemetry_logger,
|
||
first,
|
||
"First registered handler must own the proxy logger slot",
|
||
)
|
||
|
||
second = OpenTelemetry()
|
||
self.assertIs(
|
||
fake_proxy_server.open_telemetry_logger,
|
||
first,
|
||
"Second handler must NOT overwrite the first-registered logger",
|
||
)
|
||
self.assertIsNot(
|
||
fake_proxy_server.open_telemetry_logger,
|
||
second,
|
||
"Proxy logger must remain pointed at the first handler",
|
||
)
|
||
finally:
|
||
cleanup()
|
||
|
||
def test_assignment_happens_when_slot_is_unset(self):
|
||
fake_proxy_server, cleanup = self._install_fake_proxy_server()
|
||
try:
|
||
handler = OpenTelemetry()
|
||
self.assertIs(fake_proxy_server.open_telemetry_logger, handler)
|
||
finally:
|
||
cleanup()
|
||
|
||
def test_existing_non_none_logger_is_preserved(self):
|
||
"""If ``proxy_server.open_telemetry_logger`` is already set to any
|
||
non-None value, a new handler must not overwrite it — even if the
|
||
existing value is not an OpenTelemetry instance."""
|
||
fake_proxy_server, cleanup = self._install_fake_proxy_server()
|
||
try:
|
||
sentinel = object()
|
||
fake_proxy_server.open_telemetry_logger = sentinel
|
||
OpenTelemetry()
|
||
self.assertIs(
|
||
fake_proxy_server.open_telemetry_logger,
|
||
sentinel,
|
||
"Existing non-None logger must not be overwritten",
|
||
)
|
||
finally:
|
||
cleanup()
|
||
|
||
|
||
class TestOpenTelemetrySpanDedupe(unittest.TestCase):
|
||
"""``_emit_once`` is a per-request, per-handler idempotency guard that
|
||
prevents duplicate span emission across two distinct dual-fire patterns:
|
||
|
||
1. Handler-level: streaming triggers both sync and async success/failure
|
||
callbacks for one request — the second call would otherwise produce a
|
||
duplicate ``litellm_request`` span.
|
||
2. Payload-driven entry-level: ``_create_guardrail_span`` is invoked
|
||
from three lifecycle points (post-call hook, success, failure) and
|
||
re-reads a mutating list — the same logical guardrail invocation
|
||
would otherwise be emitted up to three times.
|
||
"""
|
||
|
||
def _build_kwargs(self, *, exception: bool = False):
|
||
kwargs = {
|
||
"model": "gpt-4",
|
||
"messages": [{"role": "user", "content": "Hello"}],
|
||
"optional_params": {},
|
||
"litellm_params": {
|
||
"custom_llm_provider": "openai",
|
||
"metadata": {},
|
||
},
|
||
"standard_logging_object": {
|
||
"id": "test-id",
|
||
"call_type": "completion",
|
||
"metadata": {},
|
||
"hidden_params": {},
|
||
},
|
||
}
|
||
if exception:
|
||
kwargs["exception"] = Exception("test error")
|
||
return kwargs
|
||
|
||
def test_emit_once_first_call_returns_true_then_false(self):
|
||
otel = OpenTelemetry()
|
||
kwargs = self._build_kwargs()
|
||
self.assertTrue(otel._emit_once(kwargs, "success"))
|
||
self.assertFalse(
|
||
otel._emit_once(kwargs, "success"),
|
||
"Repeat call for same handler+scope+kwargs must be deduped",
|
||
)
|
||
|
||
def test_emit_once_distinct_scopes_dont_collide(self):
|
||
"""Different scopes on the same handler+kwargs must each emit once."""
|
||
otel = OpenTelemetry()
|
||
kwargs = self._build_kwargs()
|
||
self.assertTrue(otel._emit_once(kwargs, "success"))
|
||
self.assertTrue(
|
||
otel._emit_once(kwargs, "failure"),
|
||
"Failure scope must be independent of success scope",
|
||
)
|
||
self.assertTrue(
|
||
otel._emit_once(kwargs, "guardrail", "block-code", 1.0, "pre_call"),
|
||
"Guardrail entry scope must be independent of success/failure scopes",
|
||
)
|
||
self.assertFalse(otel._emit_once(kwargs, "success"))
|
||
self.assertFalse(otel._emit_once(kwargs, "failure"))
|
||
self.assertFalse(
|
||
otel._emit_once(kwargs, "guardrail", "block-code", 1.0, "pre_call")
|
||
)
|
||
|
||
def test_emit_once_separate_handlers_each_emit(self):
|
||
"""Two distinct handler instances must each emit exactly once for the
|
||
same scope."""
|
||
otel_a = OpenTelemetry()
|
||
otel_b = OpenTelemetry()
|
||
kwargs = self._build_kwargs()
|
||
self.assertTrue(otel_a._emit_once(kwargs, "success"))
|
||
self.assertTrue(
|
||
otel_b._emit_once(kwargs, "success"),
|
||
"Different handler instance must not share the first handler's marker",
|
||
)
|
||
self.assertFalse(otel_a._emit_once(kwargs, "success"))
|
||
self.assertFalse(otel_b._emit_once(kwargs, "success"))
|
||
|
||
def test_emit_once_handles_missing_metadata(self):
|
||
otel = OpenTelemetry()
|
||
kwargs = {"litellm_params": {}}
|
||
self.assertTrue(otel._emit_once(kwargs, "success"))
|
||
self.assertFalse(otel._emit_once(kwargs, "success"))
|
||
|
||
def test_emit_once_handles_missing_litellm_params(self):
|
||
otel = OpenTelemetry()
|
||
kwargs = {}
|
||
self.assertTrue(otel._emit_once(kwargs, "success"))
|
||
self.assertFalse(otel._emit_once(kwargs, "success"))
|
||
|
||
def test_handle_success_emits_single_litellm_request_span_on_double_call(self):
|
||
"""Sync + async callback paths firing for the same kwargs must
|
||
result in exactly one litellm_request span."""
|
||
from litellm.integrations.opentelemetry import LITELLM_REQUEST_SPAN_NAME
|
||
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
kwargs = self._build_kwargs()
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
|
||
otel._handle_success(kwargs, response_obj=None, start_time=start, end_time=end)
|
||
otel._handle_success(kwargs, response_obj=None, start_time=start, end_time=end)
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
litellm_spans = [s for s in spans if s.name == LITELLM_REQUEST_SPAN_NAME]
|
||
self.assertEqual(
|
||
len(litellm_spans),
|
||
1,
|
||
f"Exactly one litellm_request span expected, got {len(litellm_spans)}",
|
||
)
|
||
|
||
def test_handle_success_dedupe_skip_still_closes_proxy_span(self):
|
||
"""When the success path is short-circuited as a duplicate, the
|
||
proxy span must still be closed so traces don't leak."""
|
||
from litellm.integrations.opentelemetry import LITELLM_PROXY_REQUEST_SPAN_NAME
|
||
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
proxy_span = otel.tracer.start_span(LITELLM_PROXY_REQUEST_SPAN_NAME)
|
||
kwargs = self._build_kwargs()
|
||
kwargs["litellm_params"]["metadata"]["litellm_parent_otel_span"] = proxy_span
|
||
|
||
otel._emit_once(kwargs, "success") # pre-mark to force dedupe-skip branch
|
||
self.assertTrue(proxy_span.is_recording())
|
||
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
otel._handle_success(kwargs, response_obj=None, start_time=start, end_time=end)
|
||
|
||
self.assertFalse(
|
||
proxy_span.is_recording(),
|
||
"Dedupe-skip path must still close the proxy span via _end_proxy_span_from_kwargs",
|
||
)
|
||
|
||
def test_handle_failure_emits_single_error_span_on_double_call(self):
|
||
"""Sync + async failure callback paths firing for the same kwargs
|
||
must result in exactly one ERROR litellm_request span."""
|
||
from opentelemetry.trace import StatusCode
|
||
|
||
from litellm.integrations.opentelemetry import LITELLM_REQUEST_SPAN_NAME
|
||
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
kwargs = self._build_kwargs(exception=True)
|
||
start = datetime.utcnow()
|
||
end = start + timedelta(seconds=1)
|
||
|
||
otel._handle_failure(kwargs, response_obj=None, start_time=start, end_time=end)
|
||
otel._handle_failure(kwargs, response_obj=None, start_time=start, end_time=end)
|
||
|
||
spans = span_exporter.get_finished_spans()
|
||
litellm_spans = [s for s in spans if s.name == LITELLM_REQUEST_SPAN_NAME]
|
||
self.assertEqual(
|
||
len(litellm_spans),
|
||
1,
|
||
f"Exactly one litellm_request ERROR span expected, got {len(litellm_spans)}",
|
||
)
|
||
self.assertEqual(litellm_spans[0].status.status_code, StatusCode.ERROR)
|
||
|
||
def test_create_guardrail_span_dedupes_across_lifecycle_entrypoints(self):
|
||
"""``_create_guardrail_span`` is called from post-call-success hook,
|
||
``_handle_success``, and ``_handle_failure``. A single guardrail
|
||
invocation (identified by ``(name, start_time, mode)``) must produce
|
||
exactly one span per handler even when the underlying entry is
|
||
mutated between calls (e.g. proxy enriches ``guardrail_response``)."""
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
kwargs = self._build_kwargs()
|
||
guardrail_entry = {
|
||
"guardrail_name": "block-code",
|
||
"guardrail_mode": "pre_call",
|
||
"guardrail_response": "allow",
|
||
"start_time": 1.0,
|
||
"end_time": 2.0,
|
||
}
|
||
kwargs["standard_logging_object"]["guardrail_information"] = [guardrail_entry]
|
||
|
||
otel._create_guardrail_span(kwargs=kwargs, context=None)
|
||
# Mutate the entry between calls — proxy enriches the response.
|
||
guardrail_entry["guardrail_response"] = [
|
||
{"type": "code_block", "action_taken": "block"}
|
||
]
|
||
guardrail_entry["end_time"] = 3.0
|
||
otel._create_guardrail_span(kwargs=kwargs, context=None)
|
||
otel._create_guardrail_span(kwargs=kwargs, context=None)
|
||
|
||
guardrail_spans = [
|
||
s for s in span_exporter.get_finished_spans() if s.name == "guardrail"
|
||
]
|
||
self.assertEqual(
|
||
len(guardrail_spans),
|
||
1,
|
||
f"Exactly one guardrail span expected per logical invocation, got {len(guardrail_spans)}",
|
||
)
|
||
|
||
def test_create_guardrail_span_emits_distinct_entries(self):
|
||
"""Two real guardrail invocations (different ``start_time``) must
|
||
each emit a span — entry-level dedupe must not collapse them."""
|
||
span_exporter = InMemorySpanExporter()
|
||
tracer_provider = TracerProvider()
|
||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||
|
||
otel = OpenTelemetry(tracer_provider=tracer_provider)
|
||
otel.tracer = tracer_provider.get_tracer(__name__)
|
||
|
||
kwargs = self._build_kwargs()
|
||
kwargs["standard_logging_object"]["guardrail_information"] = [
|
||
{
|
||
"guardrail_name": "block-code",
|
||
"guardrail_mode": "pre_call",
|
||
"guardrail_response": "allow",
|
||
"start_time": 1.0,
|
||
"end_time": 2.0,
|
||
},
|
||
{
|
||
"guardrail_name": "block-code",
|
||
"guardrail_mode": "post_call",
|
||
"guardrail_response": "allow",
|
||
"start_time": 5.0,
|
||
"end_time": 6.0,
|
||
},
|
||
]
|
||
|
||
otel._create_guardrail_span(kwargs=kwargs, context=None)
|
||
otel._create_guardrail_span(kwargs=kwargs, context=None)
|
||
|
||
guardrail_spans = [
|
||
s for s in span_exporter.get_finished_spans() if s.name == "guardrail"
|
||
]
|
||
self.assertEqual(
|
||
len(guardrail_spans),
|
||
2,
|
||
f"Two distinct guardrail invocations expected, got {len(guardrail_spans)}",
|
||
)
|
||
|
||
|
||
class TestOpenTelemetryHttpStatusCodeAttribute(unittest.TestCase):
|
||
"""PR 1: the failure recorder also exposes the HTTP status under the
|
||
OTel-standard ``http.response.status_code`` (as an int), while keeping the
|
||
legacy ``error.code`` for back-compat and leaving span status untouched.
|
||
"""
|
||
|
||
def _record(self, error_information):
|
||
exporter = InMemorySpanExporter()
|
||
provider = TracerProvider()
|
||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||
tracer = provider.get_tracer(__name__)
|
||
|
||
otel = OpenTelemetry()
|
||
span = tracer.start_span("Received Proxy Server Request")
|
||
kwargs = {
|
||
"exception": ValueError("boom"),
|
||
"standard_logging_object": {"error_information": error_information},
|
||
}
|
||
otel._record_exception_on_span(span=span, kwargs=kwargs)
|
||
span.end()
|
||
|
||
finished = exporter.get_finished_spans()
|
||
assert len(finished) == 1
|
||
return finished[0]
|
||
|
||
def test_401_sets_int_status_code_and_error_type(self):
|
||
span = self._record({"error_code": "401", "error_class": "AuthenticationError"})
|
||
assert span.attributes["http.response.status_code"] == 401
|
||
assert isinstance(span.attributes["http.response.status_code"], int)
|
||
assert span.attributes["error.type"] == "AuthenticationError"
|
||
|
||
def test_429_terminal(self):
|
||
span = self._record({"error_code": "429"})
|
||
assert span.attributes["http.response.status_code"] == 429
|
||
|
||
def test_500_sets_status_code_and_records_exception_event(self):
|
||
span = self._record({"error_code": "500"})
|
||
assert span.attributes["http.response.status_code"] == 500
|
||
assert any(e.name == "exception" for e in span.events)
|
||
|
||
def test_legacy_error_code_still_present_no_regression(self):
|
||
span = self._record({"error_code": "401"})
|
||
assert span.attributes["error.code"] == "401"
|
||
|
||
def test_non_numeric_error_code_omits_status_code(self):
|
||
span = self._record({"error_code": "ContextWindowExceeded"})
|
||
assert "http.response.status_code" not in span.attributes
|
||
# legacy attribute still set so existing dashboards don't regress
|
||
assert span.attributes["error.code"] == "ContextWindowExceeded"
|
||
|
||
def test_empty_error_code_omits_status_code(self):
|
||
span = self._record({"error_code": ""})
|
||
assert "http.response.status_code" not in span.attributes
|
||
|
||
def test_recorder_does_not_touch_span_status(self):
|
||
span = self._record({"error_code": "401"})
|
||
assert span.status.status_code == trace.StatusCode.UNSET
|
||
|
||
|
||
class TestOpenTelemetryFailureHookStampsServerSpan(unittest.TestCase):
|
||
"""Error attributes must land on the SERVER span dashboards query.
|
||
``_handle_failure`` records on the litellm_request child span, so
|
||
``async_post_call_failure_hook`` — which holds the SERVER span via
|
||
``user_api_key_dict.parent_otel_span`` — is where it gets stamped.
|
||
"""
|
||
|
||
def _run_hook(self, exception):
|
||
exporter = InMemorySpanExporter()
|
||
provider = TracerProvider()
|
||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||
tracer = provider.get_tracer(__name__)
|
||
|
||
otel = OpenTelemetry()
|
||
otel.tracer = tracer
|
||
server_span = tracer.start_span("Received Proxy Server Request")
|
||
|
||
user_api_key_dict = MagicMock()
|
||
user_api_key_dict.parent_otel_span = server_span
|
||
|
||
asyncio.run(
|
||
otel.async_post_call_failure_hook(
|
||
request_data={},
|
||
original_exception=exception,
|
||
user_api_key_dict=user_api_key_dict,
|
||
traceback_str="trace",
|
||
)
|
||
)
|
||
|
||
finished = {s.name: s for s in exporter.get_finished_spans()}
|
||
assert "Received Proxy Server Request" in finished
|
||
return finished["Received Proxy Server Request"]
|
||
|
||
def test_server_span_gets_int_status_code_and_error_type(self):
|
||
class _Boom(Exception):
|
||
status_code = 500
|
||
|
||
span = self._run_hook(_Boom("upstream blew up"))
|
||
assert span.attributes["http.response.status_code"] == 500
|
||
assert isinstance(span.attributes["http.response.status_code"], int)
|
||
assert span.attributes["error.type"] == "_Boom"
|
||
assert span.attributes["error.code"] == "500" # legacy, string
|
||
assert span.status.status_code == trace.StatusCode.ERROR
|
||
|
||
def test_non_numeric_code_omits_status_code_no_crash(self):
|
||
class _Boom(Exception):
|
||
code = "ContextWindowExceeded"
|
||
|
||
span = self._run_hook(_Boom("bad"))
|
||
assert "http.response.status_code" not in span.attributes
|
||
assert span.attributes["error.code"] == "ContextWindowExceeded"
|
||
|
||
def test_no_parent_span_is_noop(self):
|
||
otel = OpenTelemetry()
|
||
otel.tracer = MagicMock()
|
||
user_api_key_dict = MagicMock()
|
||
user_api_key_dict.parent_otel_span = None
|
||
# Must not raise when there is no SERVER span (e.g. pre-auth 401).
|
||
asyncio.run(
|
||
otel.async_post_call_failure_hook(
|
||
request_data={},
|
||
original_exception=ValueError("x"),
|
||
user_api_key_dict=user_api_key_dict,
|
||
traceback_str=None,
|
||
)
|
||
)
|
||
|
||
|
||
class TestOpenTelemetrySetProxyRequestRouteAttributes(unittest.TestCase):
|
||
"""http.route (template) + url.path (literal) must land on the SERVER
|
||
span. The logging handlers write the litellm_request child span, so
|
||
this is set from the auth path on the freshly-created SERVER span.
|
||
"""
|
||
|
||
def _set(self, **kwargs):
|
||
exporter = InMemorySpanExporter()
|
||
provider = TracerProvider()
|
||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||
tracer = provider.get_tracer(__name__)
|
||
|
||
otel = OpenTelemetry()
|
||
span = tracer.start_span("Received Proxy Server Request")
|
||
otel.set_proxy_request_route_attributes(span, **kwargs)
|
||
span.end()
|
||
return exporter.get_finished_spans()[0]
|
||
|
||
def test_sets_named_template_and_literal(self):
|
||
span = self._set(
|
||
url_path="/v1/threads/abc123/runs",
|
||
http_route="/v1/threads/{thread_id}/runs",
|
||
)
|
||
# Exact OTel-standard names — NOT metadata.* (naming regression guard).
|
||
assert span.attributes["url.path"] == "/v1/threads/abc123/runs"
|
||
assert span.attributes["http.route"] == "/v1/threads/{thread_id}/runs"
|
||
assert span.attributes["http.route"] != span.attributes["url.path"]
|
||
assert "metadata.http_route" not in span.attributes
|
||
|
||
def test_flat_route_template_equals_literal(self):
|
||
span = self._set(
|
||
url_path="/v1/chat/completions",
|
||
http_route="/v1/chat/completions",
|
||
)
|
||
assert span.attributes["http.route"] == "/v1/chat/completions"
|
||
assert span.attributes["url.path"] == "/v1/chat/completions"
|
||
|
||
def test_missing_http_route_omits_only_that_attribute(self):
|
||
span = self._set(url_path="/v1/chat/completions", http_route=None)
|
||
assert span.attributes["url.path"] == "/v1/chat/completions"
|
||
assert "http.route" not in span.attributes
|
||
|
||
def test_missing_both_sets_nothing(self):
|
||
span = self._set(url_path=None, http_route=None)
|
||
assert "url.path" not in span.attributes
|
||
assert "http.route" not in span.attributes
|
||
|
||
def test_none_span_is_noop(self):
|
||
otel = OpenTelemetry()
|
||
# Mirrors the Langfuse-override path (create span returns None).
|
||
otel.set_proxy_request_route_attributes(None, url_path="/x", http_route="/x")
|
||
|
||
|
||
class TestOpenTelemetrySetResponseStatusCodeAttribute(unittest.TestCase):
|
||
"""http.response.status_code must land on the SERVER span on the
|
||
success path too (failure path sets it in _record_exception_on_span).
|
||
Without this the attribute is failure-only, so error-ratio /
|
||
status-breakdown dashboards have no 2xx bucket.
|
||
"""
|
||
|
||
def _set(self, status_code):
|
||
exporter = InMemorySpanExporter()
|
||
provider = TracerProvider()
|
||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||
tracer = provider.get_tracer(__name__)
|
||
|
||
otel = OpenTelemetry()
|
||
span = tracer.start_span("Received Proxy Server Request")
|
||
otel.set_response_status_code_attribute(span, status_code)
|
||
span.end()
|
||
return exporter.get_finished_spans()[0]
|
||
|
||
def test_success_sets_int_200(self):
|
||
span = self._set(200)
|
||
# Exact OTel-standard name, stored as int (regression guard).
|
||
assert span.attributes["http.response.status_code"] == 200
|
||
assert isinstance(span.attributes["http.response.status_code"], int)
|
||
|
||
def test_none_status_code_omits_attribute(self):
|
||
span = self._set(None)
|
||
assert "http.response.status_code" not in span.attributes
|
||
|
||
def test_none_span_is_noop(self):
|
||
otel = OpenTelemetry()
|
||
# Mirrors the Langfuse-override path (create span returns None).
|
||
otel.set_response_status_code_attribute(None, 200)
|
||
|
||
|
||
class TestOpenTelemetryPreprocessingDuration(unittest.TestCase):
|
||
"""litellm.preprocessing.duration_ms (proxy-receive -> first provider
|
||
handoff) on the SERVER span. Read from container metadata so the
|
||
success (model_call_details) and failure (request_data) paths work
|
||
uniformly. Excludes retries via the set-once first_api_call_start_time.
|
||
"""
|
||
|
||
def _span(self):
|
||
exporter = InMemorySpanExporter()
|
||
provider = TracerProvider()
|
||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||
tracer = provider.get_tracer(__name__)
|
||
return tracer.start_span("Received Proxy Server Request"), exporter
|
||
|
||
def _attr(self, span, exporter):
|
||
span.end()
|
||
return exporter.get_finished_spans()[0].attributes
|
||
|
||
def test_success_shape_model_call_details(self):
|
||
# success path: first_api_call_start_time top-level,
|
||
# received-at under litellm_params.metadata
|
||
received = datetime(2026, 1, 1, 0, 0, 0)
|
||
handoff = datetime(2026, 1, 1, 0, 0, 0, 250000) # +250ms
|
||
otel = OpenTelemetry()
|
||
span, exp = self._span()
|
||
otel.set_preprocessing_duration_attribute(
|
||
span,
|
||
{
|
||
"first_api_call_start_time": handoff,
|
||
"litellm_params": {"metadata": {"litellm_received_at": received}},
|
||
},
|
||
)
|
||
attrs = self._attr(span, exp)
|
||
self.assertAlmostEqual(
|
||
attrs["litellm.preprocessing.duration_ms"], 250.0, places=1
|
||
)
|
||
|
||
def test_failure_shape_request_data(self):
|
||
# failure path: request_data with first_api_call_start_time lifted
|
||
# to the TOP LEVEL by the proxy (off the logging object, before it
|
||
# is popped) and received-at riding the metadata variable. The
|
||
# user metadata sub-dict is never used for the handoff anchor.
|
||
received = datetime(2026, 1, 1, 0, 0, 0)
|
||
handoff = datetime(2026, 1, 1, 0, 0, 0, 30000) # +30ms
|
||
otel = OpenTelemetry()
|
||
span, exp = self._span()
|
||
otel.set_preprocessing_duration_attribute(
|
||
span,
|
||
{
|
||
"first_api_call_start_time": handoff,
|
||
"metadata": {"litellm_received_at": received},
|
||
},
|
||
)
|
||
attrs = self._attr(span, exp)
|
||
self.assertAlmostEqual(
|
||
attrs["litellm.preprocessing.duration_ms"], 30.0, places=1
|
||
)
|
||
|
||
def test_missing_received_at_omits(self):
|
||
otel = OpenTelemetry()
|
||
span, exp = self._span()
|
||
otel.set_preprocessing_duration_attribute(
|
||
span, {"first_api_call_start_time": datetime(2026, 1, 1)}
|
||
)
|
||
assert "litellm.preprocessing.duration_ms" not in self._attr(span, exp)
|
||
|
||
def test_missing_handoff_omits(self):
|
||
otel = OpenTelemetry()
|
||
span, exp = self._span()
|
||
otel.set_preprocessing_duration_attribute(
|
||
span, {"metadata": {"litellm_received_at": datetime(2026, 1, 1)}}
|
||
)
|
||
assert "litellm.preprocessing.duration_ms" not in self._attr(span, exp)
|
||
|
||
def test_negative_duration_omitted(self):
|
||
# clock skew: handoff before receive -> omit, not a negative value
|
||
otel = OpenTelemetry()
|
||
span, exp = self._span()
|
||
otel.set_preprocessing_duration_attribute(
|
||
span,
|
||
{
|
||
"first_api_call_start_time": datetime(2026, 1, 1, 0, 0, 0),
|
||
"metadata": {"litellm_received_at": datetime(2026, 1, 1, 0, 0, 5)},
|
||
},
|
||
)
|
||
assert "litellm.preprocessing.duration_ms" not in self._attr(span, exp)
|
||
|
||
def test_none_span_is_noop(self):
|
||
OpenTelemetry().set_preprocessing_duration_attribute(
|
||
None, {"first_api_call_start_time": datetime(2026, 1, 1)}
|
||
)
|
||
|
||
def test_non_dict_container_is_noop(self):
|
||
otel = OpenTelemetry()
|
||
span, exp = self._span()
|
||
otel.set_preprocessing_duration_attribute(span, None)
|
||
assert "litellm.preprocessing.duration_ms" not in self._attr(span, exp)
|