diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 261cfe00946..afa6b795edd 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -20,6 +20,7 @@ from litellm.integrations.opentelemetry_utils.gen_ai_semconv import ( OTELSemconvCategory, parse_semconv_opt_in, ) +from litellm.integrations.otel.mappers.utils import drop_none from litellm.integrations.otel.model.db_endpoint import db_span_attributes from litellm.integrations.otel.model.semconv import Metric from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params @@ -209,10 +210,10 @@ def _provider_label(custom_llm_provider: object) -> str | None: """The provider label for one call's metrics and events, or None when the call carries no provider. - Callers omit the label entirely in that case: the OTLP encoder rejects a - None attribute value outright, and a placeholder would mint a permanent - metric series that no operator can act on. Mirrors the v2 integration's - ``_provider_attributes``. + Every attribute set drops None before export, so the label is simply absent + in that case: the OTLP encoder rejects a None attribute value outright, and a + placeholder would mint a permanent metric series that no operator can act + on. Mirrors the v2 integration's ``_provider_attributes``. """ if not isinstance(custom_llm_provider, str) or not custom_llm_provider: return None @@ -1615,14 +1616,17 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): ) = _resolve_metric_attribute_filter(attributes) self._metric_attr_filter_resolved = True - def _filter_metric_attributes(self, attrs: dict[str, str]) -> dict[str, str]: + def _filter_metric_attributes(self, attrs: Mapping[str, str | None]) -> dict[str, str]: if not self._metric_attr_filter_resolved: self._ensure_metric_attribute_filter() + return {k: v for k, v in attrs.items() if v is not None and self._metric_attribute_allowed(k)} + + def _metric_attribute_allowed(self, key: str) -> bool: if self._metric_attr_include is not None: - return {k: v for k, v in attrs.items() if k in self._metric_attr_include} + return key in self._metric_attr_include if self._metric_attr_exclude is not None: - return {k: v for k, v in attrs.items() if k not in self._metric_attr_exclude} - return attrs + return key not in self._metric_attr_exclude + return True def _record_metrics(self, kwargs, response_obj, start_time, end_time): duration_s: Final = (end_time - start_time).total_seconds() @@ -1633,7 +1637,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): "gen_ai.operation.name": ( self._gen_ai_operation_name(kwargs) if self._gen_ai_semconv_latest_experimental else "chat" ), - **({"gen_ai.system": provider} if provider else {}), + "gen_ai.system": provider, "gen_ai.request.model": kwargs.get("model"), "gen_ai.framework": "litellm", } @@ -1888,7 +1892,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): role = msg.get("role", "user") attrs = { "event_name": "gen_ai.content.prompt", - **({"gen_ai.system": provider} if provider else {}), + "gen_ai.system": provider, } if role == "tool" and msg.get("id"): attrs["id"] = msg["id"] @@ -1908,7 +1912,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): severity_number=SeverityNumber.INFO, severity_text="INFO", body=body, - attributes=attrs, + attributes=drop_none(attrs), ) otel_logger.emit(log_record) @@ -1916,7 +1920,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): for idx, choice in enumerate(response_obj.get("choices", [])): attrs = { "event_name": "gen_ai.content.completion", - **({"gen_ai.system": provider} if provider else {}), + "gen_ai.system": provider, "index": idx, "finish_reason": choice.get("finish_reason"), } @@ -1940,7 +1944,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): severity_number=SeverityNumber.INFO, severity_text="INFO", body=body, - attributes=attrs, + attributes=drop_none(attrs), ) otel_logger.emit(log_record) diff --git a/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py b/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py index 7a0de9f321f..81d9a947da7 100644 --- a/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py +++ b/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py @@ -33,6 +33,7 @@ from datetime import datetime from enum import Enum from typing import TYPE_CHECKING, Any, Final +from litellm.integrations.otel.mappers.utils import drop_none from litellm.litellm_core_utils.safe_json_dumps import safe_dumps if TYPE_CHECKING: @@ -195,16 +196,18 @@ class OTELGenAISemconvMixin: if value: self.safe_set_attribute(span=span, key=semconv_key, value=value) - def _build_inference_details_attrs(self, kwargs: dict, response_obj: dict, provider: str | None) -> dict[str, str]: + def _build_inference_details_attrs( + self, kwargs: dict, response_obj: dict, provider: str | None + ) -> dict[str, str | None]: """Build the attribute payload for the inference-details event. - Always includes operation, and provider when the call carries one; - input/output messages are added only when content capture is enabled - and non-empty. Mixin-internal. + Always includes operation and provider (None when the call carries none, + dropped before the event is emitted); input/output messages are added + only when content capture is enabled and non-empty. Mixin-internal. """ - attrs: Final[dict[str, str]] = { + attrs: Final[dict[str, str | None]] = { "event_name": _INFERENCE_DETAILS_EVENT_NAME, - **({"gen_ai.provider.name": provider} if provider else {}), + "gen_ai.provider.name": provider, "gen_ai.operation.name": self._gen_ai_operation_name(kwargs), } if not self._capture_in_event(): @@ -240,6 +243,6 @@ class OTELGenAISemconvMixin: severity_number=SeverityNumber.INFO, severity_text="INFO", body=None, - attributes=self._build_inference_details_attrs(kwargs, response_obj, provider), + attributes=drop_none(self._build_inference_details_attrs(kwargs, response_obj, provider)), ) otel_logger.emit(log_record) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 338f8c45956..8a8c076fe2e 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -5885,13 +5885,11 @@ class TestOpenTelemetryMetricAttributeFiltering(unittest.TestCase): } ) - def test_no_filter_returns_attrs_object_unchanged(self): - """The no-config path is a hot-path no-op: it returns the same dict - object, so default emission pays zero copy cost. Locking identity makes - a future refactor that always copies/filters trip here.""" + def test_no_filter_keeps_every_attribute(self): + """The no-config path drops nothing: every attribute the caller set reaches the meter.""" otel = OpenTelemetry(config=OpenTelemetryConfig(exporter="console")) attrs = {"gen_ai.request.model": "m", "hidden_params": "{}"} - self.assertIs(otel._filter_metric_attributes(attrs), attrs) + self.assertEqual(otel._filter_metric_attributes(attrs), attrs) def test_token_type_discriminator_rejected_from_either_list(self): """gen_ai.token.type is a structural discriminator stamped onto the @@ -6033,7 +6031,8 @@ class TestOTELServiceTierAttributes(unittest.TestCase): class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): - """Regression for the OTLP exporter rejecting gen_ai.system=None on every export cycle.""" + """Regression for the OTLP exporter rejecting a None gen_ai.system or gen_ai.request.model + attribute on every export cycle.""" HERE = os.path.dirname(__file__) POLL_INTERVAL = 0.05 @@ -6047,7 +6046,12 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): kwargs["litellm_params"]["custom_llm_provider"] = None return kwargs, response_obj - def _recorded_metrics(self) -> MetricsData | None: + def _modelless_kwargs(self) -> tuple[dict[str, object], dict[str, object]]: + kwargs, response_obj = self._providerless_kwargs() + kwargs["model"] = None + return kwargs, response_obj + + def _recorded_metrics(self, kwargs: dict[str, object], response_obj: dict[str, object]) -> MetricsData | None: metric_reader = InMemoryMetricReader() meter_provider = MeterProvider(metric_readers=[metric_reader]) tracer_provider = TracerProvider() @@ -6059,7 +6063,6 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): ) otel.tracer = tracer_provider.get_tracer(__name__) - kwargs, response_obj = self._providerless_kwargs() start = datetime.utcnow() otel._handle_success(kwargs, response_obj, start, start + timedelta(seconds=1)) @@ -6095,8 +6098,8 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): self.assertEqual(len(_encode_attributes(attrs) or []), len(attrs)) - def test_metrics_are_encodable_and_carry_no_provider_label(self): - data = self._recorded_metrics() + def _recorded_data_points(self, kwargs: dict[str, object], response_obj: dict[str, object]) -> list[object]: + data = self._recorded_metrics(kwargs, response_obj) self.assertIsNotNone(data, "no metrics were recorded") data_points = [ dp @@ -6106,8 +6109,18 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): for dp in m.data.data_points ] self.assertTrue(data_points, "no metric data points were recorded") - for dp in data_points: + return data_points + + def test_metrics_are_encodable_and_carry_no_provider_label(self): + kwargs, response_obj = self._providerless_kwargs() + for dp in self._recorded_data_points(kwargs, response_obj): self.assertNotIn("gen_ai.system", dp.attributes) + self.assertEqual(dp.attributes["gen_ai.request.model"], kwargs["model"]) + self._assert_every_attribute_encodes(dict(dp.attributes)) + + def test_metrics_are_encodable_and_carry_no_model_label_when_the_call_has_none(self): + for dp in self._recorded_data_points(*self._modelless_kwargs()): + self.assertNotIn("gen_ai.request.model", dp.attributes) self._assert_every_attribute_encodes(dict(dp.attributes)) def test_legacy_content_events_are_encodable_and_carry_no_provider_label(self): @@ -6116,6 +6129,7 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): for log in logs: attrs = dict(log.log_record.attributes or {}) self.assertNotIn("gen_ai.system", attrs) + self.assertNotIn(None, attrs.values()) self._assert_every_attribute_encodes(attrs) def test_inference_details_event_is_encodable_and_carries_no_provider_label(self): @@ -6124,6 +6138,7 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): attrs = dict(logs[0].log_record.attributes or {}) self.assertEqual(attrs["event_name"], "gen_ai.client.inference.operation.details") self.assertNotIn("gen_ai.provider.name", attrs) + self.assertNotIn(None, attrs.values()) self._assert_every_attribute_encodes(attrs)