From 7d70d3c0450c05bc79849fe5f481455cc77927ce Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 13 Aug 2026 20:02:28 +0000 Subject: [PATCH 1/3] 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> --- litellm/integrations/opentelemetry.py | 24 +++- .../opentelemetry_utils/gen_ai_semconv.py | 11 +- .../integrations/test_opentelemetry.py | 108 ++++++++++++++++++ 3 files changed, 133 insertions(+), 10 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index c3461c849dc..3dfcee812c3 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -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"), } diff --git a/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py b/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py index 0e58cf67795..54fc49def5f 100644 --- a/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py +++ b/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py @@ -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: diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index b300c386326..af9f005d351 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -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 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) From ba971d69d6d3f1512b9d5691657602de53dd9847 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:51:28 -0700 Subject: [PATCH 2/3] test(otel): type the providerless-call helpers and inject the logger provider --- .../integrations/test_opentelemetry.py | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 0a038c822aa..338f8c45956 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -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 @@ -6032,19 +6033,13 @@ class TestOTELServiceTierAttributes(unittest.TestCase): 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 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.""" + """Regression for the OTLP exporter rejecting gen_ai.system=None on every export cycle.""" HERE = os.path.dirname(__file__) POLL_INTERVAL = 0.05 POLL_TIMEOUT = 2.0 - def _providerless_kwargs(self): + 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: @@ -6052,7 +6047,7 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): kwargs["litellm_params"]["custom_llm_provider"] = None return kwargs, response_obj - def _recorded_metrics(self): + def _recorded_metrics(self) -> MetricsData | None: metric_reader = InMemoryMetricReader() meter_provider = MeterProvider(metric_readers=[metric_reader]) tracer_provider = TracerProvider() @@ -6076,7 +6071,7 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): time.sleep(self.POLL_INTERVAL) return None - def _emitted_log_records(self, semconv_opt_in: str): + 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)) @@ -6089,17 +6084,13 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): 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.""" + 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)) From a2724e7f155cd3726b0c258713ca5da8ee2241f1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:52:14 -0700 Subject: [PATCH 3/3] fix(otel): drop None attributes before they reach the OTLP encoder The metric attribute filter now removes every attribute whose value is None, and the content and inference-details events pass their attributes through drop_none before emitting, so a call with no provider label or no model name never hands the OTLP exporter a NoneType attribute. This closes the gen_ai.request.model report on #36759 the same way the gen_ai.system one was closed, and the regression tests cover both keys. --- litellm/integrations/opentelemetry.py | 30 ++++++++------- .../opentelemetry_utils/gen_ai_semconv.py | 17 +++++---- .../integrations/test_opentelemetry.py | 37 +++++++++++++------ 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 261cfe00946..afa6b795edd 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -20,6 +20,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.db_endpoint import db_span_attributes from litellm.integrations.otel.model.semconv import Metric from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params @@ -209,10 +210,10 @@ 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``. + 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 @@ -1615,14 +1616,17 @@ 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() @@ -1633,7 +1637,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): "gen_ai.operation.name": ( self._gen_ai_operation_name(kwargs) if self._gen_ai_semconv_latest_experimental else "chat" ), - **({"gen_ai.system": provider} if provider else {}), + "gen_ai.system": provider, "gen_ai.request.model": kwargs.get("model"), "gen_ai.framework": "litellm", } @@ -1888,7 +1892,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): role = msg.get("role", "user") attrs = { "event_name": "gen_ai.content.prompt", - **({"gen_ai.system": provider} if provider else {}), + "gen_ai.system": provider, } if role == "tool" and msg.get("id"): attrs["id"] = msg["id"] @@ -1908,7 +1912,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) @@ -1916,7 +1920,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} if provider else {}), + "gen_ai.system": provider, "index": idx, "finish_reason": choice.get("finish_reason"), } @@ -1940,7 +1944,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) diff --git a/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py b/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py index 7a0de9f321f..81d9a947da7 100644 --- a/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py +++ b/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py @@ -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,16 +196,18 @@ 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 | None) -> 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 operation, and provider when the call carries one; - input/output messages are added only when content capture is enabled - and non-empty. Mixin-internal. + 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} if provider else {}), + "gen_ai.provider.name": provider, "gen_ai.operation.name": self._gen_ai_operation_name(kwargs), } if not self._capture_in_event(): @@ -240,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) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 338f8c45956..8a8c076fe2e 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -5885,13 +5885,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 @@ -6033,7 +6031,8 @@ class TestOTELServiceTierAttributes(unittest.TestCase): class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): - """Regression for the OTLP exporter rejecting gen_ai.system=None on every export cycle.""" + """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 @@ -6047,7 +6046,12 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): kwargs["litellm_params"]["custom_llm_provider"] = None return kwargs, response_obj - def _recorded_metrics(self) -> MetricsData | None: + 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() @@ -6059,7 +6063,6 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): ) 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)) @@ -6095,8 +6098,8 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): self.assertEqual(len(_encode_attributes(attrs) or []), len(attrs)) - def test_metrics_are_encodable_and_carry_no_provider_label(self): - data = self._recorded_metrics() + 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 @@ -6106,8 +6109,18 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): for dp in m.data.data_points ] self.assertTrue(data_points, "no metric data points were recorded") - for dp in data_points: + 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): @@ -6116,6 +6129,7 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): 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): @@ -6124,6 +6138,7 @@ class TestOpenTelemetryProviderlessCallAttributes(unittest.TestCase): 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)