From f3555a72bea7cf6bb1fd6f11bbb4605257797254 Mon Sep 17 00:00:00 2001 From: yucheng Date: Thu, 24 Sep 2026 08:21:42 +0000 Subject: [PATCH] fix(otel v2): one span scope control per backend in the admin UI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/callback_configs.json | 34 +++---------------- litellm/integrations/otel/plumbing/context.py | 13 ++++--- .../team_callback_endpoints.py | 2 +- .../tests/integrationCritical/expected.json | 2 +- .../integrationCritical/otelSpanScope.spec.ts | 8 ++--- .../src/components/callback_info_helpers.tsx | 2 -- .../components/team/LoggingSettings.test.tsx | 3 +- 7 files changed, 21 insertions(+), 43 deletions(-) diff --git a/litellm/integrations/callback_configs.json b/litellm/integrations/callback_configs.json index cec32049251..41f1453394e 100644 --- a/litellm/integrations/callback_configs.json +++ b/litellm/integrations/callback_configs.json @@ -21,11 +21,7 @@ "type": "select", "ui_name": "Span Scope", "description": "full sends the whole request trace, no_internal drops the proxy's own auth, cache and database spans, llm_only sends just the model-call spans", - "options": [ - "full", - "no_internal", - "llm_only" - ], + "options": ["full", "no_internal", "llm_only"], "required": false } }, @@ -275,22 +271,7 @@ "type": "select", "ui_name": "Span Scope", "description": "full sends the whole request trace, no_internal drops the proxy's own auth, cache and database spans, llm_only sends just the model-call spans", - "options": [ - "full", - "no_internal", - "llm_only" - ], - "required": false - }, - "otel_span_scope": { - "type": "select", - "ui_name": "Span Scope", - "description": "full sends the whole request trace, no_internal drops the proxy's own auth, cache and database spans, llm_only sends just the model-call spans", - "options": [ - "full", - "no_internal", - "llm_only" - ], + "options": ["full", "no_internal", "llm_only"], "required": false } }, @@ -357,11 +338,7 @@ "type": "select", "ui_name": "Span Scope", "description": "full sends the whole request trace, no_internal drops the proxy's own auth, cache and database spans, llm_only sends just the model-call spans", - "options": [ - "full", - "no_internal", - "llm_only" - ], + "options": ["full", "no_internal", "llm_only"], "required": false } }, @@ -416,10 +393,7 @@ "type": "select", "ui_name": "Export Protocol", "description": "OTLP wire format for trace exports. Use http/json for collectors that cannot decode protobuf", - "options": [ - "http/protobuf", - "http/json" - ], + "options": ["http/protobuf", "http/json"], "required": false } }, diff --git a/litellm/integrations/otel/plumbing/context.py b/litellm/integrations/otel/plumbing/context.py index 50de22a5bc2..8dcfcf9b3b6 100644 --- a/litellm/integrations/otel/plumbing/context.py +++ b/litellm/integrations/otel/plumbing/context.py @@ -3,7 +3,7 @@ import os from collections.abc import Mapping from contextvars import ContextVar, Token -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, get_args from opentelemetry import baggage from opentelemetry.context import Context, get_current @@ -22,7 +22,7 @@ from opentelemetry.trace.propagation.tracecontext import ( from litellm.constants import OTEL_TENANT_SPAN_SCOPE_ENV from litellm.integrations.otel.model.semconv import HTTP -from litellm.types.utils import OTEL_SPAN_SCOPES, OtelSpanScope +from litellm.types.utils import OtelSpanScope if TYPE_CHECKING: from litellm.integrations.otel.model.destination import OtelDestination @@ -402,13 +402,18 @@ def tenant_destinations_are_additive() -> bool: return isinstance(configured, str) and configured.strip().lower() == ADDITIVE_DESTINATION_MODE +_SCOPES: Final[tuple[OtelSpanScope, ...]] = get_args(OtelSpanScope) + + def tenant_span_scope_default() -> OtelSpanScope: """The ``span_scope`` a tenant destination gets when its own callback vars name none.""" import litellm configured: Final = litellm.otel_tenant_span_scope or os.environ.get(OTEL_TENANT_SPAN_SCOPE_ENV) - if isinstance(configured, str) and configured.strip().lower() in OTEL_SPAN_SCOPES: - return configured.strip().lower() # pyright: ignore[reportReturnType] # membership check narrows to the literal + normalized: Final = configured.strip().lower() if isinstance(configured, str) else None + for scope in _SCOPES: + if scope == normalized: + return scope return "full" diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 1165c26b897..ce3c9a58d37 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -286,7 +286,7 @@ async def add_team_callbacks( - langfuse_host: The host for the Langfuse callback - langfuse_environment: The tracing environment for the Langfuse callback (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT) - langfuse_span_scope: For langfuse_otel, "full" (default) sends the whole request trace, "no_internal" holds back the proxy's own auth, cache and database spans, "llm_only" sends only the model-call spans - - otel_span_scope: For langfuse_otel, arize, weave_otel and newrelic, the same three scopes; a langfuse_otel entry may set either name, and otel_span_scope wins when they disagree; defaults to litellm_settings.otel_tenant_span_scope + - otel_span_scope: For langfuse_otel, arize, weave_otel and newrelic, the same three scopes; a langfuse_otel entry may set either name; different values on one entry are rejected; defaults to litellm_settings.otel_tenant_span_scope - gcs_bucket_name: The name of the GCS bucket - gcs_path_service_account: The path to the GCS service account - langsmith_api_key: The API key for the Langsmith callback diff --git a/tests/e2e/ui/tests/integrationCritical/expected.json b/tests/e2e/ui/tests/integrationCritical/expected.json index e9ce929f7e4..22fde221fd3 100644 --- a/tests/e2e/ui/tests/integrationCritical/expected.json +++ b/tests/e2e/ui/tests/integrationCritical/expected.json @@ -1,6 +1,6 @@ [ "tests/e2e/ui/tests/integrationCritical/projectDetachment.spec.ts::project creation and explicit detachment preserve saved scope and restore serving", - "tests/e2e/ui/tests/integrationCritical/otelSpanScope.spec.ts::team logging settings persist otel_span_scope no_internal through the callback editor", + "tests/e2e/ui/tests/integrationCritical/otelSpanScope.spec.ts::team logging settings persist langfuse_span_scope no_internal through the callback editor", "tests/e2e/ui/tests/integrationCritical/mcpUserEnvVars.spec.ts::per-user MCP env var stays updatable and clearable from the card after it is set", "tests/e2e/ui/tests/integrationCritical/mcpUserEnvVars.spec.ts::cancelling the clear confirmation keeps the stored value and sends no delete", "tests/e2e/ui/tests/integrationCritical/mcpUserEnvVars.spec.ts::pressing Enter on Update opens the credentials modal instead of the server editor", diff --git a/tests/e2e/ui/tests/integrationCritical/otelSpanScope.spec.ts b/tests/e2e/ui/tests/integrationCritical/otelSpanScope.spec.ts index fee862db9fe..e819c203a34 100644 --- a/tests/e2e/ui/tests/integrationCritical/otelSpanScope.spec.ts +++ b/tests/e2e/ui/tests/integrationCritical/otelSpanScope.spec.ts @@ -4,7 +4,7 @@ import { Page } from "../../fixtures/pages"; import { navigateToPage } from "../../helpers/navigation"; import { captureRequestBody, readBack } from "../../helpers/roundTrip"; -test("team logging settings persist otel_span_scope no_internal through the callback editor", async ({ +test("team logging settings persist langfuse_span_scope no_internal through the callback editor", async ({ page, request, }) => { @@ -40,7 +40,7 @@ test("team logging settings persist otel_span_scope no_internal through the call .getByRole("option", { name: /Langfuse OTEL/ }) .click(); const spanScope = page.getByRole("combobox", { - name: "otel span scope", + name: "langfuse span scope", }); await spanScope.click(); await expect( @@ -61,7 +61,7 @@ test("team logging settings persist otel_span_scope no_internal through the call { callback_name: "langfuse_otel", callback_type: "success", - callback_vars: { otel_span_scope: "no_internal" }, + callback_vars: { langfuse_span_scope: "no_internal" }, }, ]); await expect( @@ -71,7 +71,7 @@ test("team logging settings persist otel_span_scope no_internal through the call team_info: { metadata: { logging: any[] } }; }>(page, `/team/info?team_id=${team.team_id}`); expect( - info.team_info.metadata.logging[0].callback_vars.otel_span_scope, + info.team_info.metadata.logging[0].callback_vars.langfuse_span_scope, ).toBe("no_internal"); } finally { await post("/team/delete", { team_ids: [team.team_id] }); diff --git a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx index d8f41e76861..0e461be5a30 100644 --- a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx @@ -136,11 +136,9 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [ langfuse_host: "text", langfuse_environment: "text", langfuse_span_scope: "select", - otel_span_scope: "select", }, dynamic_param_options: { langfuse_span_scope: ["full", "no_internal", "llm_only"], - otel_span_scope: ["full", "no_internal", "llm_only"], }, description: "Langfuse v3 OTEL Logging Integration", }, diff --git a/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx b/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx index 929806f78c8..7481a577dc6 100644 --- a/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx @@ -216,7 +216,7 @@ describe("LoggingSettings", () => { expect(mockOnChange).toHaveBeenCalledWith([expect.objectContaining({ callback_type: "failure" })]); }); - it("offers the Langfuse OTEL span scope as a pick between full and llm_only rather than free text", async () => { + it("offers the Langfuse OTEL span scope as a pick between full, no_internal and llm_only rather than free text", async () => { const user = userEvent.setup({ delay: null }); const mockOnChange = vi.fn(); const initialValue = [ @@ -230,6 +230,7 @@ describe("LoggingSettings", () => { renderWithProviders(); expect(screen.queryByPlaceholderText("os.environ/LANGFUSE_SPAN_SCOPE")).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "otel span scope" })).not.toBeInTheDocument(); await user.click(screen.getByRole("combobox", { name: "langfuse span scope" })); expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual([ "full",