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:
mateo 2026-08-13 20:02:28 +00:00
parent 7fcca523aa
commit 7d70d3c045
3 changed files with 133 additions and 10 deletions

View file

@ -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"),
}

View file

@ -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:

View file

@ -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)