diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index 110b0cb579f..bc82acccdf4 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -1142,8 +1142,7 @@ def deliverable_destinations( def operator_sink_scopes(*configs: OpenTelemetryV2Config) -> 'Mapping[_SinkKey, "OtelSpanScope"]': - """The accounts the operator's own exporters write to, in destination terms, and - how much of the tree each one receives. + """The accounts the operator's own exporters write to, in destination terms. Every v2 logger's config counts, since each logger exports through its own provider. An exporter with no endpoint of its own resolves one from the @@ -1151,20 +1150,23 @@ def operator_sink_scopes(*configs: OpenTelemetryV2Config) -> 'Mapping[_SinkKey, and so is one that never reaches the wire: a console kind ignores the endpoint, and a header-gated spec with no credentials is skipped when the provider is built. """ - return MappingProxyType( - { - key: _operator_scope(config, spec) - for config in configs - for spec in config.exporters - if _exports_to_the_wire(spec) and (key := _sink_key(spec.endpoint, parse_headers(spec.headers))) is not None - } + scoped: Final = tuple( + (key, _operator_scope(config, spec)) + for config in configs + for spec in config.exporters + if _exports_to_the_wire(spec) and (key := _sink_key(spec.endpoint, parse_headers(spec.headers))) is not None ) + return MappingProxyType({key: _widest(scope for other, scope in scoped if other == key) for key, _ in scoped}) def _operator_scope(config: OpenTelemetryV2Config, spec: ExporterSpec) -> "OtelSpanScope": return config.langfuse_span_scope if spec.owner is ExporterOwner.LANGFUSE_OTEL else "full" +def _widest(scopes: "Iterable[OtelSpanScope]") -> "OtelSpanScope": + return "full" if any(scope == "full" for scope in scopes) else "llm_only" + + def _exports_to_the_wire(spec: ExporterSpec) -> bool: """Whether ``build_tracer_provider`` gives ``spec`` an exporter that sends OTLP.""" return exporter_transport(spec.kind) != "headerless" and not (spec.requires_headers and not spec.headers) diff --git a/litellm/integrations/otel/presets/destinations.py b/litellm/integrations/otel/presets/destinations.py index 4b4396e41b7..63801e623af 100644 --- a/litellm/integrations/otel/presets/destinations.py +++ b/litellm/integrations/otel/presets/destinations.py @@ -112,7 +112,6 @@ _NO_ATTRS: Final[Mapping[str, str]] = MappingProxyType({}) def _span_scope(callback_name: str, params: StandardCallbackDynamicParams) -> OtelSpanScope: - """The export scope the tenant configured; only Langfuse offers one, every other backend gets the full tree.""" if callback_name != "langfuse_otel": return "full" return params.get("langfuse_span_scope") or "full" diff --git a/litellm/proxy/common_utils/callback_config_validation.py b/litellm/proxy/common_utils/callback_config_validation.py index 049b5ae67ef..0cc891acd94 100644 --- a/litellm/proxy/common_utils/callback_config_validation.py +++ b/litellm/proxy/common_utils/callback_config_validation.py @@ -48,8 +48,6 @@ def _langfuse_environment_error(callback_vars: Mapping[str, str]) -> str | None: def _langfuse_span_scope_error(callback_name: str | None, callback_vars: Mapping[str, str]) -> str | None: - """Only the OTel Langfuse callback reads the scope; on any other callback the - value would be stored and then ignored, with the full tree still exported.""" value: Final = callback_vars.get("langfuse_span_scope") if value is None: return None diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py index c83ae74cae8..c1de5cfc0c3 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py @@ -382,6 +382,26 @@ class TestRoutingMode: _sink_key("https://otlp.arize.com/v1/traces", {"space_id": "s", "api_key": "k"}): "full", } + @pytest.mark.parametrize("langfuse_first", [False, True]) + def test_two_operator_exporters_on_one_account_record_the_wider_scope(self, langfuse_first): + """A plain collector pointed at the Langfuse ingest with the same credentials as + the narrowed Langfuse exporter still sends the whole tree there. Recording + ``llm_only`` for that account would make additive hand a same-account team the + non-model spans a second time.""" + langfuse = ExporterSpec( + kind="otlp_http", + endpoint=self.OPERATOR_SINK[0], + headers="authorization=Basic op", + owner=ExporterOwner.LANGFUSE_OTEL, + ) + collector = ExporterSpec(kind="otlp_http", endpoint=self.OPERATOR_SINK[0], headers="authorization=Basic op") + config = OpenTelemetryV2Config( + langfuse_span_scope="llm_only", + exporters=(langfuse, collector) if langfuse_first else (collector, langfuse), + ) + + assert dict(operator_sink_scopes(config)) == {self.OPERATOR_SINK: "full"} + def test_a_team_pointing_at_a_credential_less_operator_exporter_still_gets_its_spans(self, monkeypatch): """Under additive the fan-out skips a destination the operator already writes to. An exporter the provider never built writes nothing, so skipping it would