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>
This commit is contained in:
yucheng 2026-09-24 08:21:42 +00:00
parent c6b3fccc6f
commit f3555a72be
7 changed files with 21 additions and 43 deletions

View file

@ -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
}
},

View file

@ -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"

View file

@ -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

View file

@ -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",

View file

@ -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] });

View file

@ -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",
},

View file

@ -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(<LoggingSettings value={initialValue} onChange={mockOnChange} />);
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",