fix(otel): drop None attributes before they reach the OTLP encoder

The metric attribute filter now removes every attribute whose value is
None, and the content and inference-details events pass their attributes
through drop_none before emitting, so a call with no provider label or no
model name never hands the OTLP exporter a NoneType attribute. This closes
the gen_ai.request.model report on #36759 the same way the gen_ai.system
one was closed, and the regression tests cover both keys.
This commit is contained in:
mateo-berri 2026-09-16 12:52:14 -07:00
parent aa6d053a37
commit a2724e7f15
3 changed files with 53 additions and 31 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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)