Merge pull request #36815 from BerriAI/litellm_otel_gen_ai_system_none

fix(otel): drop None metric and event attributes before OTLP export
This commit is contained in:
Mateo Wang 2026-09-16 13:56:05 -07:00 committed by GitHub
commit b3898dfd85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 152 additions and 19 deletions

View file

@ -21,6 +21,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.baggage import promoted_metadata
from litellm.integrations.otel.model.db_endpoint import db_span_attributes
from litellm.integrations.otel.model.metadata import flatten_metadata
@ -208,6 +209,20 @@ def _resolve_metric_attribute_filter(
)
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.
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
return custom_llm_provider
def _normalize_team_metadata_keys(value: str | Iterable[object] | None) -> list[str]:
"""Coerce a team-metadata allowlist from a list or comma-separated string.
@ -1616,19 +1631,22 @@ 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()
params: Final = kwargs.get("litellm_params") or {}
provider: Final = params.get("custom_llm_provider", "Unknown")
provider: Final = _provider_label(params.get("custom_llm_provider"))
common_attrs = {
"gen_ai.operation.name": (
@ -1872,7 +1890,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
otel_logger: Final = self._logger_provider.get_logger(LITELLM_LOGGER_NAME)
parent_ctx: Final = span.get_span_context()
provider: Final = (kwargs.get("litellm_params") or {}).get("custom_llm_provider", "Unknown")
provider: Final = _provider_label((kwargs.get("litellm_params") or {}).get("custom_llm_provider"))
if self._gen_ai_semconv_latest_experimental:
self._emit_inference_details_event(
@ -1909,7 +1927,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)
@ -1941,7 +1959,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,13 +196,16 @@ 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) -> 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 provider/operation; input/output messages are added
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,
"gen_ai.operation.name": self._gen_ai_operation_name(kwargs),
@ -221,7 +225,7 @@ class OTELGenAISemconvMixin:
self,
kwargs: dict,
response_obj: dict,
provider: str,
provider: str | None,
otel_logger,
parent_ctx,
) -> None:
@ -239,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

@ -15,10 +15,11 @@ from unittest.mock import MagicMock, patch
# Adds the grandparent directory to sys.path to allow importing project modules
from opentelemetry import trace
from opentelemetry.sdk._logs import LogData
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.metrics.export import InMemoryMetricReader, MetricsData
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
@ -5921,13 +5922,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
@ -6068,6 +6067,118 @@ class TestOTELServiceTierAttributes(unittest.TestCase):
self.assertEqual(attributes[self.RESPONSE_KEY], "tier-added-by-provider-later")
class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase):
"""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
POLL_TIMEOUT = 2.0
def _providerless_kwargs(self) -> tuple[dict[str, object], dict[str, object]]:
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)
kwargs["litellm_params"]["custom_llm_provider"] = None
return kwargs, response_obj
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()
tracer_provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
otel = OpenTelemetry(
config=OpenTelemetryConfig(exporter="console", enable_metrics=True),
tracer_provider=tracer_provider,
meter_provider=meter_provider,
)
otel.tracer = tracer_provider.get_tracer(__name__)
start = datetime.utcnow()
otel._handle_success(kwargs, response_obj, start, start + timedelta(seconds=1))
deadline = time.time() + self.POLL_TIMEOUT
while time.time() < deadline:
data = metric_reader.get_metrics_data()
if data and getattr(data, "resource_metrics", None):
return data
time.sleep(self.POLL_INTERVAL)
return None
def _emitted_log_records(self, semconv_opt_in: str) -> tuple[LogData, ...]:
log_exporter = InMemoryLogExporter()
logger_provider = OTLoggerProvider()
logger_provider.add_log_record_processor(SimpleLogRecordProcessor(log_exporter))
with patch.dict(os.environ, {"OTEL_SEMCONV_STABILITY_OPT_IN": semconv_opt_in}):
handler = OpenTelemetry(
config=OpenTelemetryConfig(exporter="console", enable_events=True),
logger_provider=logger_provider,
)
handler.message_logging = True
kwargs, response_obj = self._providerless_kwargs()
span = handler.tracer.start_span("test")
with self.assertNoLogs("opentelemetry.attributes", level="WARNING"):
handler._emit_semantic_logs(kwargs, response_obj, span)
span.end()
handler._logger_provider.force_flush(2000)
return log_exporter.get_finished_logs()
def _assert_every_attribute_encodes(self, attrs: dict[str, object]) -> None:
from opentelemetry.exporter.otlp.proto.common._internal import _encode_attributes
self.assertEqual(len(_encode_attributes(attrs) or []), len(attrs))
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
for rm in data.resource_metrics
for sm in rm.scope_metrics
for m in sm.metrics
for dp in m.data.data_points
]
self.assertTrue(data_points, "no metric data points were recorded")
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):
logs = self._emitted_log_records("")
self.assertTrue(logs, "no content events were emitted")
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):
logs = self._emitted_log_records("gen_ai_latest_experimental")
self.assertEqual(len(logs), 1)
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)
class TestDynamicTracerProviderCache(unittest.TestCase):
"""Every credential-scoped TracerProvider that owns its exporter also owns a
BatchSpanProcessor worker thread that only stops on shutdown, so the cache holding them