From 8a059cd4b411af7aad3191dc87e85a4a953e9929 Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 16 Sep 2026 18:39:55 +0000 Subject: [PATCH] fix(otel): promote nested metadata keys under the caller's dotted path Strip only the proxy's requester_metadata. wrapper from an allowlisted key so requester_metadata.trace_id lands as litellm.metadata.trace_id while other dotted keys keep their full path and cannot collide on a shared leaf name Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/otel/model/baggage.py | 11 +++------- litellm/integrations/otel/model/config.py | 4 ++-- litellm/integrations/otel/model/metadata.py | 1 + .../integrations/otel/test_otel_v2_baggage.py | 22 ++++++++++++++----- .../integrations/otel/test_otel_v2_logger.py | 2 +- .../integrations/test_opentelemetry.py | 17 +++++++++----- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/litellm/integrations/otel/model/baggage.py b/litellm/integrations/otel/model/baggage.py index 0511eadaa8b..d380d868e90 100644 --- a/litellm/integrations/otel/model/baggage.py +++ b/litellm/integrations/otel/model/baggage.py @@ -18,7 +18,7 @@ from collections.abc import Callable, Mapping from types import MappingProxyType from typing import Final -from litellm.integrations.otel.model.metadata import RequestIdentity +from litellm.integrations.otel.model.metadata import REQUESTER_METADATA_PATH, RequestIdentity from litellm.integrations.otel.model.semconv import GenAI, LiteLLM # Attribute key -> value extractor over (identity, request_model, @@ -91,15 +91,10 @@ def promoted_baggage( def promoted_metadata(metadata: Mapping[str, str], metadata_keys: tuple[str, ...]) -> Mapping[str, str]: - """Allowlisted entries of a flattened metadata mapping under ``litellm.metadata.*``. - - A dotted key such as ``requester_metadata.trace_id`` reads the nested value and - is promoted under its last segment (``litellm.metadata.trace_id``), so the - caller-facing attribute name is independent of where the proxy stored it. - """ + """Allowlisted entries of a flattened metadata mapping under ``litellm.metadata.*``.""" return MappingProxyType( { - f"{LiteLLM.METADATA_PREFIX}{meta_key.rsplit('.', 1)[-1]}": value + f"{LiteLLM.METADATA_PREFIX}{meta_key.removeprefix(REQUESTER_METADATA_PATH)}": value for meta_key in metadata_keys if (value := metadata.get(meta_key)) } diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py index e5a8132dc71..5bda66ed618 100644 --- a/litellm/integrations/otel/model/config.py +++ b/litellm/integrations/otel/model/config.py @@ -211,8 +211,8 @@ class OpenTelemetryV2Config(BaseSettings): description=( "Metadata sub-keys promoted under the ``litellm.metadata.*`` " "namespace. A dotted path such as ``requester_metadata.trace_id`` " - "reads the caller's nested ``metadata.trace_id`` and is promoted under " - "its last segment (``litellm.metadata.trace_id``). " + "reads the caller's nested ``metadata.trace_id`` and is promoted as " + "``litellm.metadata.trace_id``; other dotted keys keep their full path. " "Configure via the ``LITELLM_OTEL_BAGGAGE_METADATA_KEYS`` " "env var (comma-separated) or " "``callback_settings.otel.baggage_metadata_keys`` in config.yaml." diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py index 9c2c214a45c..8b3a5fc3fd5 100644 --- a/litellm/integrations/otel/model/metadata.py +++ b/litellm/integrations/otel/model/metadata.py @@ -49,6 +49,7 @@ if TYPE_CHECKING: from litellm.types.utils import StandardLoggingPayload LANGFUSE_TRACE_NAME_HEADER: Final = "langfuse_trace_name" +REQUESTER_METADATA_PATH: Final = "requester_metadata." @dataclass(frozen=True) diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_baggage.py b/tests/test_litellm/integrations/otel/test_otel_v2_baggage.py index 78fdd251d18..930c01e524e 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_baggage.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_baggage.py @@ -168,31 +168,43 @@ def test_allowlisted_metadata_subkey_promoted_blob_excluded(): assert all("private_note" not in k for k in span.attributes) -def test_nested_metadata_key_promoted_under_leaf_name(): +def test_nested_metadata_key_promoted_under_caller_path(): """A dotted allowlist entry reads the nested caller metadata the proxy stores - under ``requester_metadata`` and lands on the LLM-call span as - ``litellm.metadata.``; unlisted siblings and the blob stay out.""" + under ``requester_metadata`` and lands on the LLM-call span under the caller's + own path (``litellm.metadata.trace_id``, ``litellm.metadata.nested.deep``); + a pre-existing flat dotted key keeps its full name, and unlisted siblings and + the blob stay out.""" engine, exporter = _engine_and_exporter() payload = _payload() + payload["metadata"]["a.b"] = "flat" payload["metadata"]["requester_metadata"] = { "trace_id": "abc", "attempt": 0, "empty": "", - "nested": {"deep": "x"}, + "nested": {"deep": "x", "skipped": "y"}, } data = LLMCallSpanData.from_standard_logging_payload(payload) bag = promoted_baggage( data.identity, data.request_model, BAGGAGE_PROMOTED_KEYS, - metadata_keys=("requester_metadata.trace_id", "requester_metadata.attempt", "requester_metadata.empty"), + metadata_keys=( + "requester_metadata.trace_id", + "requester_metadata.attempt", + "requester_metadata.empty", + "requester_metadata.nested.deep", + "a.b", + ), ) engine.emit(SpanRole.LLM_CALL, data, ctx_mod.set_request_baggage(bag)) (span,) = exporter.get_finished_spans() assert span.attributes[f"{LiteLLM.METADATA_PREFIX}trace_id"] == "abc" assert span.attributes[f"{LiteLLM.METADATA_PREFIX}attempt"] == "0" + assert span.attributes[f"{LiteLLM.METADATA_PREFIX}nested.deep"] == "x" + assert span.attributes[f"{LiteLLM.METADATA_PREFIX}a.b"] == "flat" assert f"{LiteLLM.METADATA_PREFIX}empty" not in span.attributes assert f"{LiteLLM.METADATA_PREFIX}deep" not in span.attributes + assert f"{LiteLLM.METADATA_PREFIX}nested.skipped" not in span.attributes assert not any(k.startswith(f"{LiteLLM.METADATA_PREFIX}requester_metadata") for k in span.attributes) diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py index aa78e3b7c4d..f9a61b689cb 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py @@ -1684,7 +1684,7 @@ def test_pre_call_hook_promotes_nested_request_metadata_key(): assert spans["redis set"].attributes[key] == "abc" assert data == {"model": "gpt-4o", "metadata": {"requester_metadata": {"trace_id": "abc", "nested": {"deep": "x"}}}} assert not any( - k.startswith(f"{LiteLLM.METADATA_PREFIX}requester_metadata") or k == f"{LiteLLM.METADATA_PREFIX}deep" + k.startswith(f"{LiteLLM.METADATA_PREFIX}requester_metadata") or k.endswith("deep") for s in spans.values() for k in s.attributes ) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index e25fb3964b8..7812590b3e7 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -5581,21 +5581,28 @@ class TestOpenTelemetryInferenceIdentityAttributes(unittest.TestCase): otel.set_attributes(span, kwargs, {"model": "azure/gpt-4o"}) assert "http.route" not in self._attr(span, exp) - def test_nested_metadata_key_promoted_under_leaf_name(self): + def test_nested_metadata_key_promoted_under_caller_path(self): """``baggage_metadata_keys: [requester_metadata.trace_id]`` stamps the - caller's nested metadata value as ``litellm.metadata.trace_id``; unlisted - siblings stay inside the ``metadata.requester_metadata`` blob.""" - otel = OpenTelemetry(config=OpenTelemetryConfig(baggage_metadata_keys=["requester_metadata.trace_id"])) + caller's nested metadata value as ``litellm.metadata.trace_id`` and a deeper + path keeps its dotted name; unlisted siblings stay inside the + ``metadata.requester_metadata`` blob.""" + otel = OpenTelemetry( + config=OpenTelemetryConfig( + baggage_metadata_keys=["requester_metadata.trace_id", "requester_metadata.nested.deep"] + ) + ) kwargs = self._kwargs() kwargs["standard_logging_object"]["metadata"]["requester_metadata"] = { "trace_id": "abc", - "nested": {"deep": "x"}, + "nested": {"deep": "x", "skipped": "y"}, } span, exp = self._span() otel.set_attributes(span, kwargs, {"model": "azure/gpt-4o"}) attrs = self._attr(span, exp) assert attrs["litellm.metadata.trace_id"] == "abc" + assert attrs["litellm.metadata.nested.deep"] == "x" assert "litellm.metadata.deep" not in attrs + assert "litellm.metadata.nested.skipped" not in attrs assert not any(k.startswith("litellm.metadata.requester_metadata") for k in attrs) def test_metadata_keys_default_to_none_promoted(self):