mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(otel): stop sending gen_ai.system as None on metrics and content events
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
7fcca523aa
commit
7d70d3c045
3 changed files with 133 additions and 10 deletions
|
|
@ -193,6 +193,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.
|
||||
|
||||
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``.
|
||||
"""
|
||||
if not isinstance(custom_llm_provider, str) or not custom_llm_provider:
|
||||
return None
|
||||
return custom_llm_provider
|
||||
|
||||
|
||||
def _normalize_team_metadata_keys(value: Any) -> list[str]:
|
||||
"""Coerce a team-metadata allowlist from a list or comma-separated string.
|
||||
|
||||
|
|
@ -1488,13 +1502,13 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
|||
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": (
|
||||
self._gen_ai_operation_name(kwargs) if self._gen_ai_semconv_latest_experimental else "chat"
|
||||
),
|
||||
"gen_ai.system": provider,
|
||||
**({"gen_ai.system": provider} if provider else {}),
|
||||
"gen_ai.request.model": kwargs.get("model"),
|
||||
"gen_ai.framework": "litellm",
|
||||
}
|
||||
|
|
@ -1722,7 +1736,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(
|
||||
|
|
@ -1739,7 +1753,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
|||
role = msg.get("role", "user")
|
||||
attrs = {
|
||||
"event_name": "gen_ai.content.prompt",
|
||||
"gen_ai.system": provider,
|
||||
**({"gen_ai.system": provider} if provider else {}),
|
||||
}
|
||||
if role == "tool" and msg.get("id"):
|
||||
attrs["id"] = msg["id"]
|
||||
|
|
@ -1767,7 +1781,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,
|
||||
**({"gen_ai.system": provider} if provider else {}),
|
||||
"index": idx,
|
||||
"finish_reason": choice.get("finish_reason"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,15 +195,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, Any]:
|
||||
def _build_inference_details_attrs(self, kwargs: dict, response_obj: dict, provider: str | None) -> dict[str, Any]:
|
||||
"""Build the attribute payload for the inference-details event.
|
||||
|
||||
Always includes provider/operation; input/output messages are added
|
||||
only when content capture is enabled and non-empty. Mixin-internal.
|
||||
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.
|
||||
"""
|
||||
attrs: Final[dict[str, Any]] = {
|
||||
"event_name": _INFERENCE_DETAILS_EVENT_NAME,
|
||||
"gen_ai.provider.name": provider,
|
||||
**({"gen_ai.provider.name": provider} if provider else {}),
|
||||
"gen_ai.operation.name": self._gen_ai_operation_name(kwargs),
|
||||
}
|
||||
if not self._capture_in_event():
|
||||
|
|
@ -221,7 +222,7 @@ class OTELGenAISemconvMixin:
|
|||
self,
|
||||
kwargs: dict,
|
||||
response_obj: dict,
|
||||
provider: str,
|
||||
provider: str | None,
|
||||
otel_logger,
|
||||
parent_ctx,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -6007,3 +6007,111 @@ class TestOTELServiceTierAttributes(unittest.TestCase):
|
|||
response_obj,
|
||||
)
|
||||
self.assertEqual(attributes[self.RESPONSE_KEY], "tier-added-by-provider-later")
|
||||
|
||||
|
||||
class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase):
|
||||
"""A call whose litellm_params carry custom_llm_provider=None (routes like
|
||||
/v1/messages, /v1/responses, streaming chat and the passthrough endpoints
|
||||
all leave it unset) used to hand a None straight to the OTLP exporter,
|
||||
which rejects it per export with 'Invalid type <class NoneType> of value
|
||||
None' and keeps re-logging it forever because metric attribute sets are
|
||||
cumulative. These drive the real record/emit paths and then run the actual
|
||||
OTLP encoder over what came out, so they fail if the guard is reverted."""
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
POLL_INTERVAL = 0.05
|
||||
POLL_TIMEOUT = 2.0
|
||||
|
||||
def _providerless_kwargs(self):
|
||||
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 _recorded_metrics(self):
|
||||
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__)
|
||||
|
||||
kwargs, response_obj = self._providerless_kwargs()
|
||||
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):
|
||||
from opentelemetry import _logs
|
||||
from opentelemetry._logs._internal import ProxyLoggerProvider
|
||||
|
||||
log_exporter = InMemoryLogExporter()
|
||||
with (
|
||||
patch.dict(os.environ, {"OTEL_SEMCONV_STABILITY_OPT_IN": semconv_opt_in}),
|
||||
patch.object(_logs, "get_logger_provider", return_value=ProxyLoggerProvider()),
|
||||
patch.object(_logs, "set_logger_provider"),
|
||||
patch.object(OpenTelemetry, "_get_log_exporter", return_value=log_exporter),
|
||||
):
|
||||
handler = OpenTelemetry(config=OpenTelemetryConfig(exporter="console", enable_events=True))
|
||||
handler.message_logging = True
|
||||
|
||||
kwargs, response_obj = self._providerless_kwargs()
|
||||
span = handler.tracer.start_span("test")
|
||||
# The SDK drops an invalid attribute value and warns per record, so the
|
||||
# symptom on this path is unbounded warning volume, not a lost export.
|
||||
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):
|
||||
"""The exporter logs and drops any attribute it cannot encode, so a
|
||||
surviving None shows up as a missing key-value rather than a raise."""
|
||||
from opentelemetry.exporter.otlp.proto.common._internal import _encode_attributes
|
||||
|
||||
self.assertEqual(len(_encode_attributes(attrs) or []), len(attrs))
|
||||
|
||||
def test_metrics_are_encodable_and_carry_no_provider_label(self):
|
||||
data = self._recorded_metrics()
|
||||
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")
|
||||
for dp in data_points:
|
||||
self.assertNotIn("gen_ai.system", 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._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._assert_every_attribute_encodes(attrs)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue