mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
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:
parent
aa6d053a37
commit
a2724e7f15
3 changed files with 53 additions and 31 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue