diff --git a/litellm/integrations/otel/emitter.py b/litellm/integrations/otel/emitter.py index 3784efcc2f2..87a432ee7ce 100644 --- a/litellm/integrations/otel/emitter.py +++ b/litellm/integrations/otel/emitter.py @@ -169,7 +169,6 @@ class SpanEmitter: self._mappers: list[AttributeMapper] = ( list(mappers) if mappers is not None else resolve_mappers(config.mapper_names) ) - # A key's or team's Langfuse destination reads the Langfuse vocabulary, which this logger may not write. self._langfuse: Final = ( None if any(isinstance(mapper, LangfuseMapper) for mapper in self._mappers) else LangfuseMapper() ) @@ -302,7 +301,12 @@ class SpanEmitter: ) configured: Final = {key: value for mapper in self._mappers for key, value in mapper.map(data).items()} for_langfuse: Final = ( - self._langfuse.map(data) + dict( + sorted( + self._langfuse.map(data).items(), + key=lambda item: item[0] not in LANGFUSE_REQUEST_METADATA_ATTRIBUTES, + ) + ) if self._langfuse is not None and ExporterOwner.LANGFUSE_OTEL in destination_backends() else _NO_ATTRIBUTES ) diff --git a/tests/unit/integrations/otel/test_otel_v2_destinations.py b/tests/unit/integrations/otel/test_otel_v2_destinations.py index c0b81c49a4f..572960726e2 100644 --- a/tests/unit/integrations/otel/test_otel_v2_destinations.py +++ b/tests/unit/integrations/otel/test_otel_v2_destinations.py @@ -35,6 +35,7 @@ from litellm.integrations.otel.logger import ( publish_global_otel_v2_provider, ) from litellm.integrations.otel.mappers import resolve_mappers +from litellm.integrations.otel.mappers.langfuse import LANGFUSE_REQUEST_METADATA_ATTRIBUTES from litellm.integrations.otel.model.config import ( CaptureMessageContent, ExporterOwner, @@ -3450,7 +3451,7 @@ class TestLangfuseDestinationFields: credential_less_proxy(monkeypatch) capture: Final = _ExporterCapture() for kind in ("otlp_grpc", "otlp_http", "console"): - monkeypatch.setitem(otel_providers._EXPORTER_FACTORIES, kind, capture.build) + monkeypatch.setitem(otel_providers._EXPORTER_FACTORIES, kind, capture.build) # pyright: ignore[reportPrivateUsage] # the factory table is the seam for capturing each exporter config: Final = OpenTelemetryV2Config() provider: Final = build_tracer_provider(config, tenant_overrides=True) attach_tenant_fan_out(provider, config) @@ -3480,24 +3481,43 @@ class TestLangfuseDestinationFields: assert "langfuse.observation.metadata" in to_langfuse assert "langfuse.observation.metadata" not in received[None] + @staticmethod + def _attributes( + destinations: tuple[OtelDestination, ...], limit: int | None, call: LLMCallSpanData + ) -> dict[str, object]: + exporter: Final = InMemorySpanExporter() + provider: Final = TracerProvider(span_limits=SpanLimits(max_span_attributes=limit)) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + emitter: Final = SpanEmitter(get_tracer(provider, "litellm"), OpenTelemetryV2Config()) + + def run() -> None: + set_request_destinations(destinations) + emitter.emit(SpanRole.LLM_CALL, call) + + in_fresh_context(run) + return dict(exporter.get_finished_spans()[0].attributes or {}) + def test_a_span_at_its_attribute_limit_sheds_the_destinations_langfuse_fields_first(self) -> None: - def attributes(destinations: tuple[OtelDestination, ...], limit: int | None) -> dict[str, object]: - exporter: Final = InMemorySpanExporter() - provider: Final = TracerProvider(span_limits=SpanLimits(max_span_attributes=limit)) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - emitter: Final = SpanEmitter(get_tracer(provider, "litellm"), OpenTelemetryV2Config()) - - def run() -> None: - set_request_destinations(destinations) - emitter.emit(SpanRole.LLM_CALL, _MODEL_CALL_WITH_CONTENT) - - in_fresh_context(run) - return dict(exporter.get_finished_spans()[0].attributes or {}) - - generic: Final = attributes((), None) + generic: Final = self._attributes((), None, _MODEL_CALL_WITH_CONTENT) assert "user.id" not in generic - assert attributes((LANGFUSE_DEST,), len(generic)) == generic, "the generic attributes all survive" + assert self._attributes((LANGFUSE_DEST,), len(generic), _MODEL_CALL_WITH_CONTENT) == generic, ( + "the generic attributes all survive" + ) + + def test_a_span_at_its_attribute_limit_sheds_the_destinations_request_metadata_before_its_other_fields( + self, + ) -> None: + call: Final = replace( + _MODEL_CALL_WITH_CONTENT, request_metadata=MappingProxyType({"user_api_key_alias": "prod-key"}) + ) + whole: Final = self._attributes((LANGFUSE_DEST,), None, call) + + squeezed: Final = self._attributes((LANGFUSE_DEST,), len(whole) - 1, call) + + assert len(squeezed) == len(whole) - 1 + assert whole.keys() - squeezed.keys() <= LANGFUSE_REQUEST_METADATA_ATTRIBUTES + assert squeezed["langfuse.observation.type"] == "generation" class TestContextIsolation: