mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +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,
|
OTELSemconvCategory,
|
||||||
parse_semconv_opt_in,
|
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.baggage import promoted_metadata
|
||||||
from litellm.integrations.otel.model.db_endpoint import db_span_attributes
|
from litellm.integrations.otel.model.db_endpoint import db_span_attributes
|
||||||
from litellm.integrations.otel.model.metadata import flatten_metadata
|
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]:
|
def _normalize_team_metadata_keys(value: str | Iterable[object] | None) -> list[str]:
|
||||||
"""Coerce a team-metadata allowlist from a list or comma-separated string.
|
"""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)
|
) = _resolve_metric_attribute_filter(attributes)
|
||||||
self._metric_attr_filter_resolved = True
|
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:
|
if not self._metric_attr_filter_resolved:
|
||||||
self._ensure_metric_attribute_filter()
|
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:
|
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:
|
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 key not in self._metric_attr_exclude
|
||||||
return attrs
|
return True
|
||||||
|
|
||||||
def _record_metrics(self, kwargs, response_obj, start_time, end_time):
|
def _record_metrics(self, kwargs, response_obj, start_time, end_time):
|
||||||
duration_s: Final = (end_time - start_time).total_seconds()
|
duration_s: Final = (end_time - start_time).total_seconds()
|
||||||
params: Final = kwargs.get("litellm_params") or {}
|
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 = {
|
common_attrs = {
|
||||||
"gen_ai.operation.name": (
|
"gen_ai.operation.name": (
|
||||||
|
|
@ -1872,7 +1890,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
||||||
otel_logger: Final = self._logger_provider.get_logger(LITELLM_LOGGER_NAME)
|
otel_logger: Final = self._logger_provider.get_logger(LITELLM_LOGGER_NAME)
|
||||||
|
|
||||||
parent_ctx: Final = span.get_span_context()
|
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:
|
if self._gen_ai_semconv_latest_experimental:
|
||||||
self._emit_inference_details_event(
|
self._emit_inference_details_event(
|
||||||
|
|
@ -1909,7 +1927,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
||||||
severity_number=SeverityNumber.INFO,
|
severity_number=SeverityNumber.INFO,
|
||||||
severity_text="INFO",
|
severity_text="INFO",
|
||||||
body=body,
|
body=body,
|
||||||
attributes=attrs,
|
attributes=drop_none(attrs),
|
||||||
)
|
)
|
||||||
otel_logger.emit(log_record)
|
otel_logger.emit(log_record)
|
||||||
|
|
||||||
|
|
@ -1941,7 +1959,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
||||||
severity_number=SeverityNumber.INFO,
|
severity_number=SeverityNumber.INFO,
|
||||||
severity_text="INFO",
|
severity_text="INFO",
|
||||||
body=body,
|
body=body,
|
||||||
attributes=attrs,
|
attributes=drop_none(attrs),
|
||||||
)
|
)
|
||||||
otel_logger.emit(log_record)
|
otel_logger.emit(log_record)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Any, Final
|
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
|
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -195,13 +196,16 @@ class OTELGenAISemconvMixin:
|
||||||
if value:
|
if value:
|
||||||
self.safe_set_attribute(span=span, key=semconv_key, value=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.
|
"""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.
|
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,
|
"event_name": _INFERENCE_DETAILS_EVENT_NAME,
|
||||||
"gen_ai.provider.name": provider,
|
"gen_ai.provider.name": provider,
|
||||||
"gen_ai.operation.name": self._gen_ai_operation_name(kwargs),
|
"gen_ai.operation.name": self._gen_ai_operation_name(kwargs),
|
||||||
|
|
@ -221,7 +225,7 @@ class OTELGenAISemconvMixin:
|
||||||
self,
|
self,
|
||||||
kwargs: dict,
|
kwargs: dict,
|
||||||
response_obj: dict,
|
response_obj: dict,
|
||||||
provider: str,
|
provider: str | None,
|
||||||
otel_logger,
|
otel_logger,
|
||||||
parent_ctx,
|
parent_ctx,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -239,6 +243,6 @@ class OTELGenAISemconvMixin:
|
||||||
severity_number=SeverityNumber.INFO,
|
severity_number=SeverityNumber.INFO,
|
||||||
severity_text="INFO",
|
severity_text="INFO",
|
||||||
body=None,
|
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)
|
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
|
# Adds the grandparent directory to sys.path to allow importing project modules
|
||||||
from opentelemetry import trace
|
from opentelemetry import trace
|
||||||
|
from opentelemetry.sdk._logs import LogData
|
||||||
from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider
|
from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider
|
||||||
from opentelemetry.sdk._logs.export import InMemoryLogExporter, SimpleLogRecordProcessor
|
from opentelemetry.sdk._logs.export import InMemoryLogExporter, SimpleLogRecordProcessor
|
||||||
from opentelemetry.sdk.metrics import MeterProvider
|
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 import TracerProvider
|
||||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
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):
|
def test_no_filter_keeps_every_attribute(self):
|
||||||
"""The no-config path is a hot-path no-op: it returns the same dict
|
"""The no-config path drops nothing: every attribute the caller set reaches the meter."""
|
||||||
object, so default emission pays zero copy cost. Locking identity makes
|
|
||||||
a future refactor that always copies/filters trip here."""
|
|
||||||
otel = OpenTelemetry(config=OpenTelemetryConfig(exporter="console"))
|
otel = OpenTelemetry(config=OpenTelemetryConfig(exporter="console"))
|
||||||
attrs = {"gen_ai.request.model": "m", "hidden_params": "{}"}
|
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):
|
def test_token_type_discriminator_rejected_from_either_list(self):
|
||||||
"""gen_ai.token.type is a structural discriminator stamped onto the
|
"""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")
|
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):
|
class TestDynamicTracerProviderCache(unittest.TestCase):
|
||||||
"""Every credential-scoped TracerProvider that owns its exporter also owns a
|
"""Every credential-scoped TracerProvider that owns its exporter also owns a
|
||||||
BatchSpanProcessor worker thread that only stops on shutdown, so the cache holding them
|
BatchSpanProcessor worker thread that only stops on shutdown, so the cache holding them
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue