mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
commit
b3898dfd85
3 changed files with 152 additions and 19 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue