diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 90383ddb60d..8da5dce7184 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2764,6 +2764,9 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): """ Safely sets an attribute on the span, ensuring the value is a primitive type. """ + is_recording: Final = getattr(span, "is_recording", None) + if callable(is_recording) and not is_recording(): + return primitive_value: Final = self._cast_as_primitive_value_type(value) span.set_attribute(key, primitive_value) diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 9eb9cb40caf..993590e9c9e 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -767,7 +767,8 @@ class OpenTelemetryV2(CustomLogger): # path's ``async_pre_call_hook``). Idempotent. set_request_root_span(server_span) for key, value in bag.items(): - server_span.set_attribute(key, value) + if is_recordable_span(server_span) and server_span.is_recording(): + server_span.set_attribute(key, value) except Exception: pass diff --git a/tests/unit/integrations/test_opentelemetry.py b/tests/unit/integrations/test_opentelemetry.py index 9ba09085ce8..87438893dff 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -11,7 +11,7 @@ import unittest import weakref from datetime import datetime, timedelta, timezone from pathlib import Path -from types import MappingProxyType +from types import MappingProxyType, SimpleNamespace from typing import Final from unittest.mock import MagicMock, patch @@ -46,6 +46,9 @@ from litellm.integrations.opentelemetry import ( RAW_REQUEST_SPAN_NAME, _normalize_team_metadata_keys, ) +from litellm.integrations.otel import logger as otel_logger +from litellm.integrations.otel.logger import OpenTelemetryV2 +from litellm.integrations.otel.plumbing.context import set_request_root_span from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy import proxy_server from litellm.proxy._types import SpanAttributes @@ -387,6 +390,47 @@ def test_v1_malformed_routing_metadata_preserves_inference_attributes(decision: assert not any(key.startswith("litellm.routing.") for key in finished.attributes) +def test_safe_set_attribute_skips_ended_and_non_recording_spans() -> None: + provider: Final = TracerProvider() + tracer: Final = provider.get_tracer(__name__) + otel: Final = OpenTelemetry(tracer_provider=provider) + + with tracer.start_as_current_span("recording") as recording_span: + otel.safe_set_attribute(recording_span, "litellm.test", "recorded") + assert recording_span.attributes["litellm.test"] == "recorded" + + ended_attributes: Final = dict(recording_span.attributes) + assert not recording_span.is_recording() + with tracer.start_as_current_span("non-recording") as non_recording_span: + pass + assert not non_recording_span.is_recording() + + otel.safe_set_attribute(recording_span, "litellm.test", "skipped") + otel.safe_set_attribute(non_recording_span, "litellm.test", "skipped") + + assert dict(recording_span.attributes) == ended_attributes + + +def test_seed_request_identity_preserves_recording_span_and_ignores_ended_span() -> None: + exporter: Final = InMemorySpanExporter() + provider: Final = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + logger: Final = OpenTelemetryV2(tracer_provider=provider) + auth = SimpleNamespace(team_id="qa-team", api_key="qa-key") + + with provider.get_tracer(__name__).start_as_current_span("server") as server_span: + set_request_root_span(server_span) + logger.seed_request_identity(auth, model="qa-model") + assert server_span.is_recording() + assert server_span.attributes["litellm.team.id"] == "qa-team" + + (finished,) = exporter.get_finished_spans() + attributes_before = dict(finished.attributes) + assert not server_span.is_recording() + logger.seed_request_identity(auth, model="qa-model") + assert dict(finished.attributes) == attributes_before + + class TestOpenTelemetryCostBreakdown(unittest.TestCase): def test_cost_breakdown_emitted_to_otel_span(self): """