feat(otel v2): fold otel_internal_spans into a tri-state otel_span_scope

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-24 07:58:55 +00:00
parent 683a049ca8
commit c6b3fccc6f
23 changed files with 264 additions and 277 deletions

View file

@ -328,9 +328,9 @@ provider_url_destination_allowed_hosts: List[str] = []
#: "override" (default) or "additive": whether a key or team destination replaces
#: the operator's exporter for that backend or exports alongside it.
otel_tenant_destination_mode: str | None = None
#: "include" (default) or "exclude": whether a key or team destination receives the
#: proxy's own SERVICE and DB_CALL spans (auth, Redis, Postgres, spend writes).
otel_tenant_internal_spans: str | None = None
#: "full" (default), "no_internal" or "llm_only": the default span scope a key or
#: team destination gets when its own callback vars name none.
otel_tenant_span_scope: str | None = None
ssl_ecdh_curve: Optional[str] = None # Set to 'X25519' to disable PQC and improve performance
disable_streaming_logging: bool = False
disable_token_counter: bool = False

View file

@ -1677,8 +1677,7 @@ LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL: Final = "litellm_no_upstream_llm_call"
# the key's values after the team metadata merge so a key outranks its team).
OTEL_SERVICE_NAME_METADATA_KEYS: Final = ("otel_service_name_override", "otel_service_name")
OTEL_EXCLUDE_INTERNAL_SPANS: Final = "exclude"
OTEL_TENANT_INTERNAL_SPANS_ENV: Final = "LITELLM_OTEL_TENANT_INTERNAL_SPANS"
OTEL_TENANT_SPAN_SCOPE_ENV: Final = "LITELLM_OTEL_TENANT_SPAN_SCOPE"
# Key Rotation Constants
LITELLM_KEY_ROTATION_ENABLED: Final = os.getenv("LITELLM_KEY_ROTATION_ENABLED", "false")

View file

@ -17,11 +17,15 @@
"description": "Arize Space ID to identify your workspace",
"required": true
},
"otel_internal_spans": {
"otel_span_scope": {
"type": "select",
"ui_name": "Internal Spans",
"description": "include sends the proxy's own SERVICE and DB_CALL spans (auth, Redis, Postgres) with the trace, exclude holds them back",
"options": ["include", "exclude"],
"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
}
},
@ -270,15 +274,23 @@
"langfuse_span_scope": {
"type": "select",
"ui_name": "Span Scope",
"description": "full sends the whole request trace, llm_only sends just the model-call spans",
"options": ["full", "llm_only"],
"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_internal_spans": {
"otel_span_scope": {
"type": "select",
"ui_name": "Internal Spans",
"description": "include sends the proxy's own SERVICE and DB_CALL spans (auth, Redis, Postgres) with the trace, exclude holds them back",
"options": ["include", "exclude"],
"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
}
},
@ -341,11 +353,15 @@
"description": "Data center region for this team's account. Defaults to us.",
"required": false
},
"otel_internal_spans": {
"otel_span_scope": {
"type": "select",
"ui_name": "Internal Spans",
"description": "include sends the proxy's own SERVICE and DB_CALL spans (auth, Redis, Postgres) with the trace, exclude holds them back",
"options": ["include", "exclude"],
"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
}
},
@ -400,7 +416,10 @@
"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

@ -168,6 +168,7 @@ class OpenTelemetryV2Config(BaseSettings):
default="full",
validation_alias=AliasChoices("langfuse_span_scope", "LITELLM_OTEL_LANGFUSE_SPAN_SCOPE"),
description=(
"``no_internal`` holds back the proxy's own auth, cache and database spans while "
"``llm_only`` keeps just the model-call spans on the operator's own Langfuse "
"exporter (the spec whose owner is ``langfuse_otel``). Other exporters and "
"key/team destinations are not affected."

View file

@ -10,7 +10,7 @@ from urllib.parse import quote
from pydantic import BaseModel, ConfigDict, Field
from litellm.types.utils import OtelInternalSpans, OtelSpanScope
from litellm.types.utils import OtelSpanScope
class OtelDestination(BaseModel):
@ -29,13 +29,10 @@ class OtelDestination(BaseModel):
)
span_scope: OtelSpanScope = Field(
default="full",
description="``llm_only`` keeps just the model-call spans; the rest of the request tree is not forwarded.",
)
internal_spans: OtelInternalSpans = Field(
default="include",
description=(
"``exclude`` holds back the proxy's own SERVICE and DB_CALL spans (auth, Redis, Postgres, "
"spend writes); the request root and the tenant's own model, guardrail and MCP spans still go."
"``no_internal`` holds back the proxy's own SERVICE and DB_CALL spans (auth, Redis, "
"Postgres, spend writes) while ``llm_only`` keeps just the model-call spans; the request "
"root and the tenant's own model, guardrail and MCP spans still go under ``no_internal``."
),
)
@ -52,9 +49,9 @@ class OtelDestination(BaseModel):
def cache_key(self) -> tuple[str, tuple[tuple[str, str], ...], tuple[tuple[str, str], ...], str | None]:
"""Identity for processor reuse, so one destination means one exporter.
``span_scope`` and ``internal_spans`` are left out on purpose: they decide which
spans reach the processor, not how the processor exports them, so a full and an
``llm_only`` view of the same account share one exporter.
``span_scope`` is left out on purpose: it decides which spans reach the
processor, not how the processor exports them, so a full and an ``llm_only``
view of the same account share one exporter.
"""
return (
self.endpoint,

View file

@ -20,9 +20,9 @@ from opentelemetry.trace.propagation.tracecontext import (
TraceContextTextMapPropagator,
)
from litellm.constants import OTEL_EXCLUDE_INTERNAL_SPANS, OTEL_TENANT_INTERNAL_SPANS_ENV
from litellm.constants import OTEL_TENANT_SPAN_SCOPE_ENV
from litellm.integrations.otel.model.semconv import HTTP
from litellm.types.utils import OtelInternalSpans
from litellm.types.utils import OTEL_SPAN_SCOPES, OtelSpanScope
if TYPE_CHECKING:
from litellm.integrations.otel.model.destination import OtelDestination
@ -402,14 +402,14 @@ def tenant_destinations_are_additive() -> bool:
return isinstance(configured, str) and configured.strip().lower() == ADDITIVE_DESTINATION_MODE
def tenant_internal_spans_default() -> OtelInternalSpans:
"""The ``internal_spans`` a tenant destination gets when its own callback vars name none."""
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_internal_spans or os.environ.get(OTEL_TENANT_INTERNAL_SPANS_ENV)
if isinstance(configured, str) and configured.strip().lower() == OTEL_EXCLUDE_INTERNAL_SPANS:
return "exclude"
return "include"
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
return "full"
def destination_backends() -> frozenset[str]:

View file

@ -6,7 +6,7 @@ import time
from collections import OrderedDict
from collections.abc import Callable, Iterable, Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal
from typing import TYPE_CHECKING, Any, Final, Literal, assert_never
from opentelemetry import _logs, baggage, metrics, trace
from opentelemetry._logs import Logger, LoggerProvider, NoOpLoggerProvider
@ -435,25 +435,20 @@ def is_llm_call_span(span: ReadableSpan) -> bool:
def _in_scope(span: ReadableSpan, scope: "OtelSpanScope") -> bool:
return scope == "full" or is_llm_call_span(span)
def _is_internal_span(span: ReadableSpan) -> bool:
"""A SERVICE or DB_CALL span: neither the request root nor a tenant-owned model, MCP or guardrail span."""
return span.kind is not SpanKind.SERVER and not _is_tenant_owned_span(span.attributes or _NO_ATTRIBUTES)
def _forwarded(span: ReadableSpan, destination: "OtelDestination") -> bool:
if not _in_scope(span, destination.span_scope):
return False
return destination.internal_spans == "include" or not _is_internal_span(span)
if scope == "full":
return True
if scope == "no_internal":
return span.kind is SpanKind.SERVER or _is_tenant_owned_span(span.attributes or _NO_ATTRIBUTES)
if scope == "llm_only":
return is_llm_call_span(span)
return assert_never(scope)
def _scoped(span: ReadableSpan, scope: "OtelSpanScope") -> ReadableSpan:
"""Under ``llm_only`` the model call is the only span the exporter gets, so it goes out as the
trace's root (its parent is the request span that is held back) and, unless the caller named the
trace, its own name doubles as ``langfuse.trace.name`` so Langfuse does not show "Unnamed trace"."""
if scope == "full":
if scope != "llm_only":
return span
attributes: Final = span.attributes or _NO_ATTRIBUTES
named: Final = (
@ -579,7 +574,9 @@ class TenantFanOutSpanProcessor(SpanProcessor):
def on_end(self, span: ReadableSpan) -> None:
suppressed: Final = suppressed_backends()
for destination in request_destinations():
if self._operator_already_writes(span, destination, suppressed) or not _forwarded(span, destination):
if self._operator_already_writes(span, destination, suppressed) or not _in_scope(
span, destination.span_scope
):
continue
processor = self._acquire(destination) # rebind-ok: loop variable; pyright forbids Final in a loop
if processor is None:
@ -1225,8 +1222,11 @@ def _operator_scope(config: OpenTelemetryV2Config, spec: ExporterSpec) -> "OtelS
return config.langfuse_span_scope if spec.owner is ExporterOwner.LANGFUSE_OTEL else "full"
_SCOPE_RANK: Final[tuple["OtelSpanScope", ...]] = ("full", "no_internal", "llm_only")
def _widest(scopes: "Iterable[OtelSpanScope]") -> "OtelSpanScope":
return "full" if any(scope == "full" for scope in scopes) else "llm_only"
return min(scopes, key=_SCOPE_RANK.index)
def _exports_to_the_wire(spec: ExporterSpec) -> bool:

View file

@ -14,9 +14,9 @@ from typing import Final
import litellm
from litellm._logging import verbose_logger
from litellm.integrations.otel.model.destination import OtelDestination
from litellm.integrations.otel.plumbing.context import tenant_internal_spans_default
from litellm.integrations.otel.plumbing.context import tenant_span_scope_default
from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host
from litellm.types.utils import OtelInternalSpans, OtelSpanScope, StandardCallbackDynamicParams
from litellm.types.utils import OtelSpanScope, StandardCallbackDynamicParams
#: An endpoint plus the OTLP transport to reach it with, or ``None`` when the backend
#: names no destination. The transport is ``None`` where the backend has only one.
@ -113,14 +113,10 @@ _NO_ATTRS: Final[Mapping[str, str]] = MappingProxyType({})
def _span_scope(callback_name: str, params: StandardCallbackDynamicParams) -> OtelSpanScope:
if callback_name != "langfuse_otel":
return "full"
return params.get("langfuse_span_scope") or "full"
def _internal_spans(params: StandardCallbackDynamicParams) -> OtelInternalSpans:
configured: Final = params.get("otel_internal_spans")
return tenant_internal_spans_default() if configured is None else configured
configured: Final = params.get("otel_span_scope") or (
params.get("langfuse_span_scope") if callback_name == "langfuse_otel" else None
)
return tenant_span_scope_default() if configured is None else configured
def destination_capable_backends() -> frozenset[str]:
@ -162,5 +158,4 @@ def destination_for(
callback_name=callback_name,
protocol=protocol,
span_scope=_span_scope(callback_name, params),
internal_spans=_internal_spans(params),
)

View file

@ -5,7 +5,6 @@ from contextvars import ContextVar
from typing import Any, Final
from litellm.types.utils import (
OTEL_INTERNAL_SPAN_CHOICES,
OTEL_SPAN_SCOPES,
TRUSTED_CALLBACK_VARS_FIELD,
StandardCallbackDynamicParams,
@ -86,9 +85,9 @@ def validate_langfuse_span_scope_value(value: str) -> None:
raise ValueError(f"Invalid langfuse_span_scope {value!r}: must be one of {sorted(OTEL_SPAN_SCOPES)}")
def validate_otel_internal_spans_value(value: str) -> None:
if value not in OTEL_INTERNAL_SPAN_CHOICES:
raise ValueError(f"Invalid otel_internal_spans {value!r}: must be one of {sorted(OTEL_INTERNAL_SPAN_CHOICES)}")
def validate_otel_span_scope_value(value: str) -> None:
if value not in OTEL_SPAN_SCOPES:
raise ValueError(f"Invalid otel_span_scope {value!r}: must be one of {sorted(OTEL_SPAN_SCOPES)}")
# Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict

View file

@ -26,7 +26,7 @@ from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
validate_langfuse_environment_value,
validate_langfuse_span_scope_value,
validate_no_callback_env_reference,
validate_otel_internal_spans_value,
validate_otel_span_scope_value,
)
from litellm.types.agents import AgentCaller
from litellm.types.integrations.compression_interception import (
@ -2265,8 +2265,8 @@ class AddTeamCallback(LiteLLMPydanticObjectBase):
validate_langfuse_environment_value(callback_vars[key])
if key == "langfuse_span_scope":
validate_langfuse_span_scope_value(callback_vars[key])
if key == "otel_internal_spans":
validate_otel_internal_spans_value(callback_vars[key])
if key == "otel_span_scope":
validate_otel_span_scope_value(callback_vars[key])
return values

View file

@ -16,7 +16,7 @@ _NEWRELIC_CALLBACK: Final = "newrelic"
_NEWRELIC_VAR_PREFIX: Final = "newrelic_"
_LANGFUSE_OTEL_CALLBACK: Final = "langfuse_otel"
_LANGFUSE_SPAN_SCOPE_VAR: Final = "langfuse_span_scope"
_OTEL_INTERNAL_SPANS_VAR: Final = "otel_internal_spans"
_OTEL_SPAN_SCOPE_VAR: Final = "otel_span_scope"
_ARIZE_CALLBACK: Final = "arize"
_ARIZE_SAMPLING_RATE_VARS: Final[frozenset[str]] = frozenset(
{"arize_success_sampling_rate", "arize_error_sampling_rate"}
@ -34,9 +34,12 @@ def callback_config_error(callback_name: str | None, callback_vars: Mapping[str,
)
if langfuse_error is not None:
return langfuse_error
internal_spans_error: Final = _otel_internal_spans_error(callback_name, callback_vars)
if internal_spans_error is not None:
return internal_spans_error
otel_scope_error: Final = _otel_span_scope_error(callback_name, callback_vars)
if otel_scope_error is not None:
return otel_scope_error
alias_error: Final = _alias_conflict_error(callback_vars)
if alias_error is not None:
return alias_error
if callback_name != _NEWRELIC_CALLBACK:
return None
return _newrelic_config_error(callback_vars)
@ -81,27 +84,43 @@ def _langfuse_span_scope_error(callback_name: str | None, callback_vars: Mapping
return None
def _otel_internal_spans_error(callback_name: str | None, callback_vars: Mapping[str, str]) -> str | None:
value: Final = callback_vars.get(_OTEL_INTERNAL_SPANS_VAR)
def _otel_span_scope_error(callback_name: str | None, callback_vars: Mapping[str, str]) -> str | None:
value: Final = callback_vars.get(_OTEL_SPAN_SCOPE_VAR)
if value is None:
return None
from litellm.integrations.otel.presets.destinations import destination_capable_backends
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
validate_otel_internal_spans_value,
validate_otel_span_scope_value,
)
if callback_name not in destination_capable_backends():
return (
f"{_OTEL_INTERNAL_SPANS_VAR} applies to the OTEL destination callbacks only "
f"{_OTEL_SPAN_SCOPE_VAR} applies to the OTEL destination callbacks only "
f"({', '.join(sorted(destination_capable_backends()))}), not {callback_name!r}"
)
try:
validate_otel_internal_spans_value(value)
validate_otel_span_scope_value(value)
except ValueError as e:
return str(e)
return None
def _alias_conflict_error(callback_vars: Mapping[str, str]) -> str | None:
"""Reject one entry that names both scope aliases with different values.
``otel_span_scope`` outranks ``langfuse_span_scope`` when they disagree, so
storing both would export the one and silently drop the other.
"""
langfuse_scope: Final = callback_vars.get(_LANGFUSE_SPAN_SCOPE_VAR)
otel_scope: Final = callback_vars.get(_OTEL_SPAN_SCOPE_VAR)
if langfuse_scope is None or otel_scope is None or langfuse_scope == otel_scope:
return None
return (
f"{_LANGFUSE_SPAN_SCOPE_VAR} and {_OTEL_SPAN_SCOPE_VAR} name different scopes "
f"({langfuse_scope!r} vs {otel_scope!r}); set one of them"
)
# Which credential family a dynamic variable belongs to. The families are the
# integrations that share one account: every langfuse_* variable configures the
# same Langfuse project whether it rides the classic callback or the OTel one,
@ -199,13 +218,13 @@ def conflicting_span_scope_error(
return _conflicting_var_error(_LANGFUSE_SPAN_SCOPE_VAR, callback_vars, stored_vars_by_entry)
def conflicting_internal_spans_error(
def conflicting_otel_span_scope_error(
callback_vars: Mapping[str, str] | None,
stored_vars_by_entry: Sequence[Mapping[str, str]],
) -> str | None:
"""``stored_vars_by_entry`` must hold only the entries of the same callback: the
request merges the var per backend, so two backends may legitimately disagree."""
return _conflicting_var_error(_OTEL_INTERNAL_SPANS_VAR, callback_vars, stored_vars_by_entry)
return _conflicting_var_error(_OTEL_SPAN_SCOPE_VAR, callback_vars, stored_vars_by_entry)
def _conflicting_var_error(
@ -243,7 +262,7 @@ def logging_metadata_config_error(metadata: Mapping[str, object] | None) -> str
*(_logging_entry_error(entry) for entry in entries),
*(conflicting_span_scope_error(entry_vars[i], entry_vars[:i]) for i in range(len(entry_vars))),
*(
conflicting_internal_spans_error(
conflicting_otel_span_scope_error(
entry_vars[i],
tuple(vars_ for vars_, name in zip(entry_vars[:i], entry_names[:i]) if name == entry_names[i]),
)

View file

@ -31,7 +31,7 @@ from litellm.proxy._types import (
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.callback_config_validation import (
callback_config_error,
conflicting_internal_spans_error,
conflicting_otel_span_scope_error,
conflicting_span_scope_error,
cross_entry_family_error,
)
@ -285,8 +285,8 @@ async def add_team_callbacks(
- langfuse_secret: The secret for the Langfuse callback
- 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, "llm_only" sends only the model-call spans
- otel_internal_spans: For langfuse_otel, arize, weave_otel and newrelic, "include" sends the proxy's own SERVICE and DB_CALL spans (auth, Redis, Postgres) with the trace, "exclude" holds them back; defaults to litellm_settings.otel_tenant_internal_spans
- 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
- 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
@ -357,7 +357,7 @@ async def add_team_callbacks(
scope_error: Final = conflicting_span_scope_error(data.callback_vars, stored_entry_vars)
if scope_error is not None:
raise _callback_config_error(scope_error)
internal_spans_error: Final = conflicting_internal_spans_error(
otel_scope_error: Final = conflicting_otel_span_scope_error(
data.callback_vars,
tuple(
entry_vars
@ -365,8 +365,8 @@ async def add_team_callbacks(
if entry.get("callback_name") == data.callback_name
),
)
if internal_spans_error is not None:
raise _callback_config_error(internal_spans_error)
if otel_scope_error is not None:
raise _callback_config_error(otel_scope_error)
# One entry has to own a credential family end to end. The entries are
# flattened into one dict before a request reads them, so an entry
# naming only a destination would pair with a key written on another

View file

@ -3611,10 +3611,8 @@ OPENAI_RESPONSE_HEADERS: Final = [
]
OtelSpanScope = Literal["full", "llm_only"]
OtelSpanScope = Literal["full", "no_internal", "llm_only"]
OTEL_SPAN_SCOPES: Final[frozenset[str]] = frozenset(get_args(OtelSpanScope))
OtelInternalSpans = Literal["include", "exclude"]
OTEL_INTERNAL_SPAN_CHOICES: Final[frozenset[str]] = frozenset(get_args(OtelInternalSpans))
class StandardCallbackDynamicParams(TypedDict, total=False):
@ -3625,7 +3623,7 @@ class StandardCallbackDynamicParams(TypedDict, total=False):
langfuse_host: str | None
langfuse_environment: ReadOnly[str | None]
langfuse_span_scope: ReadOnly[OtelSpanScope | None]
otel_internal_spans: ReadOnly[OtelInternalSpans | None]
otel_span_scope: ReadOnly[OtelSpanScope | None]
# Langfuse prompt version
langfuse_prompt_version: int | None

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/otelInternalSpans.spec.ts::team logging settings persist otel_internal_spans exclude through the callback editor",
"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/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

@ -1,79 +0,0 @@
import { test, expect } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { Page } from "../../fixtures/pages";
import { navigateToPage } from "../../helpers/navigation";
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
test("team logging settings persist otel_internal_spans exclude through the callback editor", async ({
page,
request,
}) => {
const master = process.env.LITELLM_MASTER_KEY ?? "sk-integration-master";
const headers = { Authorization: `Bearer ${master}` };
const prefix = `integration-browser-${randomUUID()}`;
const post = async (url: string, data: object) => {
const response = await request.post(url, { headers, data });
expect(response.ok(), `${url}: ${await response.text()}`).toBe(true);
return response.json();
};
const team = await post("/team/new", { team_alias: prefix });
try {
await page.goto("/ui/login");
await page.getByPlaceholder("Enter your username").fill("admin");
await page.getByPlaceholder("Enter your password").fill(master);
await page.getByRole("button", { name: "Login", exact: true }).click();
await expect(page).toHaveURL(
(url) => url.pathname.startsWith("/ui") && !url.pathname.includes("login"),
);
await navigateToPage(page, Page.Teams);
await page.getByRole("button", { name: new RegExp(`^${prefix}`) }).click();
await page.getByRole("tab", { name: "Settings" }).click();
await page.getByRole("button", { name: "Edit Settings" }).click();
await page
.getByRole("button", { name: "Add Integration", exact: true })
.click();
await page
.getByRole("combobox")
.filter({ hasText: "Select integration" })
.click();
await page
.getByRole("option", { name: /Langfuse OTEL/ })
.click();
const internalSpans = page.getByRole("combobox", {
name: "otel internal spans",
});
await internalSpans.click();
await expect(
page.getByRole("option", { name: "include", exact: true }),
).toBeVisible();
await page
.getByRole("option", { name: "exclude", exact: true })
.click();
const update = await captureRequestBody(
page,
{ method: "POST", urlIncludes: "/team/update" },
async () => {
await page.getByRole("button", { name: "Save Changes" }).click();
},
);
const logging = (update.metadata as { logging: any[] }).logging;
expect(logging).toEqual([
{
callback_name: "langfuse_otel",
callback_type: "success",
callback_vars: { otel_internal_spans: "exclude" },
},
]);
await expect(
page.getByText("Team settings updated successfully"),
).toBeVisible();
const info = await readBack<{
team_info: { metadata: { logging: any[] } };
}>(page, `/team/info?team_id=${team.team_id}`);
expect(
info.team_info.metadata.logging[0].callback_vars.otel_internal_spans,
).toBe("exclude");
} finally {
await post("/team/delete", { team_ids: [team.team_id] });
}
});

View file

@ -1884,7 +1884,7 @@ class TestSpanScope:
@pytest.mark.parametrize("scope", ["everything", "LLM_ONLY", ""])
def test_an_unknown_scope_is_rejected_when_the_callback_is_saved(self, scope):
with pytest.raises(ValueError, match=r"Invalid langfuse_span_scope .*must be one of \['full', 'llm_only'\]"):
with pytest.raises(ValueError, match=r"Invalid langfuse_span_scope .*must be one of \['full', 'llm_only', 'no_internal'\]"):
AddTeamCallback(
callback_name="langfuse_otel",
callback_type="success",
@ -1904,7 +1904,7 @@ class TestSpanScope:
#: The proxy's own work inside the request: auth, the datastore calls, the spend write.
INTERNAL_SPANS = frozenset({"auth /v1/chat/completions", "postgres SELECT", "redis GET", "cost_tracking"})
TENANT_TREE = REQUEST_TREE - INTERNAL_SPANS
EXCLUDING_DEST = LANGFUSE_DEST.model_copy(update={"internal_spans": "exclude"})
NO_INTERNAL_DEST = LANGFUSE_DEST.model_copy(update={"span_scope": "no_internal"})
LANGFUSE_TEAM_CREDS = MappingProxyType(
{"langfuse_public_key": "pk-team", "langfuse_secret_key": "sk-team", "langfuse_host": "http://team.local"}
)
@ -1918,7 +1918,7 @@ COMPLETE_CREDENTIALS = MappingProxyType(
)
class TestInternalSpans:
class TestNoInternalScope:
@staticmethod
def _run(provider, destinations):
TestSpanScope._run(provider, destinations)
@ -1939,16 +1939,16 @@ class TestInternalSpans:
"callback_vars": {**LANGFUSE_TEAM_CREDS, **extra_vars},
}
def test_an_excluding_tenant_gets_the_request_root_and_its_own_calls_but_not_the_proxys_work(self, monkeypatch):
def test_a_no_internal_tenant_gets_the_request_root_and_its_own_calls_but_not_the_proxys_work(self, monkeypatch):
TestSpanScope._additive(monkeypatch)
operator, tenant = InMemorySpanExporter(), InMemorySpanExporter()
self._run(TestSpanScope._operator_provider(operator, tenant), (EXCLUDING_DEST,))
self._run(TestSpanScope._operator_provider(operator, tenant), (NO_INTERNAL_DEST,))
assert names(tenant) == TENANT_TREE
assert names(operator) == REQUEST_TREE, "the tenant's choice must not thin the operator's exporter"
def test_an_including_tenant_still_gets_the_whole_tree(self, monkeypatch):
def test_a_full_tenant_still_gets_the_whole_tree(self, monkeypatch):
TestSpanScope._additive(monkeypatch)
operator, tenant = InMemorySpanExporter(), InMemorySpanExporter()
@ -1959,7 +1959,7 @@ class TestInternalSpans:
def test_the_kept_spans_stay_parented_to_the_request_root(self):
tenant = InMemorySpanExporter()
self._run(self._fan_out({"langfuse_otel": tenant}), (EXCLUDING_DEST,))
self._run(self._fan_out({"langfuse_otel": tenant}), (NO_INTERNAL_DEST,))
by_name = {span.name: span for span in tenant.get_finished_spans()}
root = by_name["POST /v1/chat/completions"]
@ -1971,20 +1971,11 @@ class TestInternalSpans:
by_backend = {"langfuse_otel": InMemorySpanExporter(), "arize": InMemorySpanExporter()}
arize = OtelDestination(endpoint="https://otlp.arize.com", headers={"api_key": "k"}, callback_name="arize")
self._run(self._fan_out(by_backend), (EXCLUDING_DEST, arize))
self._run(self._fan_out(by_backend), (NO_INTERNAL_DEST, arize))
assert names(by_backend["langfuse_otel"]) == TENANT_TREE
assert names(by_backend["arize"]) == REQUEST_TREE
def test_excluding_composes_with_llm_only(self):
tenant = InMemorySpanExporter()
excluding_llm_only = LLM_ONLY_DEST.model_copy(update={"internal_spans": "exclude"})
self._run(self._fan_out({"langfuse_otel": tenant}), (excluding_llm_only,))
assert names(tenant) == LLM_SPANS
def test_two_views_of_one_account_share_the_exporter(self):
built, tenant = [], InMemorySpanExporter()
provider = TracerProvider()
@ -1995,35 +1986,35 @@ class TestInternalSpans:
provider.add_span_processor(TenantFanOutSpanProcessor(processor_factory=factory))
self._run(provider, (EXCLUDING_DEST,))
self._run(provider, (NO_INTERNAL_DEST,))
assert names(tenant) == TENANT_TREE
tenant.clear()
self._run(provider, (LANGFUSE_DEST,))
assert names(tenant) == REQUEST_TREE
assert len(built) == 1, "the same account must not get a second exporter for a second internal_spans"
assert len(built) == 1, "the same account must not get a second exporter for a second span_scope"
def test_a_team_callback_var_becomes_the_destinations_internal_spans(self, monkeypatch, allow_test_hosts):
def test_a_team_callback_var_becomes_the_destinations_span_scope(self, monkeypatch, allow_test_hosts):
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
auth = UserAPIKeyAuth(team_metadata={"logging": [self._team_entry(otel_internal_spans="exclude")]})
auth = UserAPIKeyAuth(team_metadata={"logging": [self._team_entry(otel_span_scope="no_internal")]})
assert [d.internal_spans for d in resolve_tenant_otel_destinations(auth)] == ["exclude"]
assert [d.span_scope for d in resolve_tenant_otel_destinations(auth)] == ["no_internal"]
def test_the_key_wins_over_the_team(self, monkeypatch, allow_test_hosts):
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
auth = UserAPIKeyAuth(
metadata={"logging": [self._team_entry(otel_internal_spans="include")]},
team_metadata={"logging": [self._team_entry(otel_internal_spans="exclude")]},
metadata={"logging": [self._team_entry(otel_span_scope="full")]},
team_metadata={"logging": [self._team_entry(otel_span_scope="no_internal")]},
)
assert [d.internal_spans for d in resolve_tenant_otel_destinations(auth)] == ["include"]
assert [d.span_scope for d in resolve_tenant_otel_destinations(auth)] == ["full"]
def test_resolving_leaves_the_stored_callback_vars_alone(self, monkeypatch, allow_test_hosts):
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
entry = self._team_entry(otel_internal_spans="exclude")
entry = self._team_entry(otel_span_scope="no_internal")
before = dict(entry["callback_vars"])
auth = UserAPIKeyAuth(team_metadata={"logging": [entry]})
@ -2034,56 +2025,77 @@ class TestInternalSpans:
@pytest.mark.parametrize("callback_name", sorted(COMPLETE_CREDENTIALS))
def test_every_destination_backend_honours_the_var(self, callback_name, monkeypatch, allow_test_hosts):
assert callback_name in destination_capable_backends()
monkeypatch.setattr(litellm, "otel_tenant_internal_spans", None)
monkeypatch.setattr(litellm, "otel_tenant_span_scope", None)
destination = destination_for(
callback_name, {**COMPLETE_CREDENTIALS[callback_name], "otel_internal_spans": "exclude"}
callback_name, {**COMPLETE_CREDENTIALS[callback_name], "otel_span_scope": "no_internal"}
)
assert destination is not None and destination.internal_spans == "exclude"
assert destination is not None and destination.span_scope == "no_internal"
def test_a_team_that_named_nothing_gets_the_system_default(self, monkeypatch, allow_test_hosts):
monkeypatch.setattr(litellm, "otel_tenant_internal_spans", "exclude")
monkeypatch.setattr(litellm, "otel_tenant_span_scope", "no_internal")
assert destination_for("langfuse_otel", LANGFUSE_TEAM_CREDS).internal_spans == "exclude"
assert destination_for("langfuse_otel", LANGFUSE_TEAM_CREDS).span_scope == "no_internal"
def test_the_env_var_sets_the_system_default(self, monkeypatch, allow_test_hosts):
monkeypatch.setattr(litellm, "otel_tenant_internal_spans", None)
monkeypatch.setenv("LITELLM_OTEL_TENANT_INTERNAL_SPANS", " Exclude\n")
monkeypatch.setattr(litellm, "otel_tenant_span_scope", None)
monkeypatch.setenv("LITELLM_OTEL_TENANT_SPAN_SCOPE", " No_Internal\n")
assert destination_for("langfuse_otel", LANGFUSE_TEAM_CREDS).internal_spans == "exclude"
assert destination_for("langfuse_otel", LANGFUSE_TEAM_CREDS).span_scope == "no_internal"
def test_a_team_asking_for_include_keeps_it_under_a_system_exclude(self, monkeypatch, allow_test_hosts):
monkeypatch.setattr(litellm, "otel_tenant_internal_spans", "exclude")
def test_a_team_asking_for_full_keeps_it_under_a_system_no_internal(self, monkeypatch, allow_test_hosts):
monkeypatch.setattr(litellm, "otel_tenant_span_scope", "no_internal")
assert (
destination_for("langfuse_otel", {**LANGFUSE_TEAM_CREDS, "otel_internal_spans": "include"}).internal_spans
== "include"
destination_for("langfuse_otel", {**LANGFUSE_TEAM_CREDS, "otel_span_scope": "full"}).span_scope
== "full"
)
@pytest.mark.parametrize("configured", [None, "", "everything"])
def test_anything_but_exclude_leaves_the_default_on_include(self, monkeypatch, allow_test_hosts, configured):
monkeypatch.setattr(litellm, "otel_tenant_internal_spans", configured)
monkeypatch.delenv("LITELLM_OTEL_TENANT_INTERNAL_SPANS", raising=False)
def test_anything_but_a_known_scope_leaves_the_default_on_full(self, monkeypatch, allow_test_hosts, configured):
monkeypatch.setattr(litellm, "otel_tenant_span_scope", configured)
monkeypatch.delenv("LITELLM_OTEL_TENANT_SPAN_SCOPE", raising=False)
assert destination_for("langfuse_otel", LANGFUSE_TEAM_CREDS).internal_spans == "include"
assert destination_for("langfuse_otel", LANGFUSE_TEAM_CREDS).span_scope == "full"
@pytest.mark.parametrize("value", ["everything", "EXCLUDE", ""])
@pytest.mark.parametrize("value", ["everything", "NO_INTERNAL", "exclude", ""])
def test_an_unknown_value_is_rejected_when_the_callback_is_saved(self, value):
with pytest.raises(ValueError, match=r"Invalid otel_internal_spans .*must be one of \['exclude', 'include'\]"):
with pytest.raises(ValueError, match=r"Invalid otel_span_scope .*must be one of \['full', 'llm_only', 'no_internal'\]"):
AddTeamCallback(
callback_name="langfuse_otel",
callback_type="success",
callback_vars={**LANGFUSE_TEAM_CREDS, "otel_internal_spans": value},
callback_vars={**LANGFUSE_TEAM_CREDS, "otel_span_scope": value},
)
def test_a_known_value_is_accepted_when_the_callback_is_saved(self):
saved = AddTeamCallback(
callback_name="arize",
callback_type="success",
callback_vars={"arize_api_key": "k", "arize_space_id": "s", "otel_internal_spans": "exclude"},
callback_vars={"arize_api_key": "k", "arize_space_id": "s", "otel_span_scope": "no_internal"},
)
assert saved.callback_vars["otel_internal_spans"] == "exclude"
assert saved.callback_vars["otel_span_scope"] == "no_internal"
def test_llm_only_on_newrelic_resolves_to_a_llm_only_destination(self, monkeypatch, allow_test_hosts):
monkeypatch.setattr(litellm, "otel_tenant_span_scope", None)
destination = destination_for(
"newrelic", {**COMPLETE_CREDENTIALS["newrelic"], "otel_span_scope": "llm_only"}
)
assert destination is not None and destination.span_scope == "llm_only"
@pytest.mark.parametrize(
"scopes, expected",
[
(("llm_only", "no_internal", "full"), "full"),
(("llm_only", "no_internal"), "no_internal"),
(("llm_only",), "llm_only"),
],
)
def test_the_widest_scope_wins(self, scopes, expected):
from litellm.integrations.otel.plumbing.providers import _widest
assert _widest(scopes) == expected
#: Anything that makes ``OpenTelemetryV2Config`` synthesize a real operator destination.

View file

@ -2,7 +2,7 @@ import pytest
from litellm.proxy.common_utils.callback_config_validation import (
callback_config_error,
conflicting_internal_spans_error,
conflicting_otel_span_scope_error,
conflicting_span_scope_error,
cross_entry_family_error,
logging_metadata_config_error,
@ -103,51 +103,70 @@ def test_key_logging_entries_may_not_disagree_on_the_span_scope():
@pytest.mark.parametrize("callback_name", ["langfuse_otel", "arize", "weave_otel", "newrelic"])
def test_otel_internal_spans_is_accepted_on_every_destination_backend(callback_name):
assert callback_config_error(callback_name, {"otel_internal_spans": "exclude"}) is None
assert callback_config_error(callback_name, {"otel_internal_spans": "include"}) is None
def test_otel_span_scope_is_accepted_on_every_destination_backend(callback_name):
for scope in ("full", "no_internal", "llm_only"):
assert callback_config_error(callback_name, {"otel_span_scope": scope}) is None
@pytest.mark.parametrize("bad", ["everything", "EXCLUDE", "no", ""])
def test_callback_config_error_rejects_an_unknown_otel_internal_spans(bad):
error = callback_config_error("arize", {"otel_internal_spans": bad})
assert error is not None and "otel_internal_spans" in error and "exclude" in error
@pytest.mark.parametrize("bad", ["everything", "NO_INTERNAL", "no", "exclude", ""])
def test_callback_config_error_rejects_an_unknown_otel_span_scope(bad):
error = callback_config_error("arize", {"otel_span_scope": bad})
assert error is not None and "otel_span_scope" in error and "no_internal" in error
@pytest.mark.parametrize("callback_name", ["langfuse", "datadog", "otel", "arize_phoenix", None])
def test_otel_internal_spans_on_a_callback_that_has_no_destination_is_rejected(callback_name):
error = callback_config_error(callback_name, {"otel_internal_spans": "exclude"})
assert error is not None and "otel_internal_spans" in error and "langfuse_otel" in error
def test_otel_span_scope_on_a_callback_that_has_no_destination_is_rejected(callback_name):
error = callback_config_error(callback_name, {"otel_span_scope": "no_internal"})
assert error is not None and "otel_span_scope" in error and "langfuse_otel" in error
def test_key_logging_entries_of_one_backend_may_not_disagree_on_internal_spans():
def test_langfuse_span_scope_and_otel_span_scope_disagreeing_on_one_entry_is_rejected():
error = callback_config_error(
"langfuse_otel", {"langfuse_span_scope": "llm_only", "otel_span_scope": "no_internal"}
)
assert error is not None and "langfuse_span_scope" in error and "otel_span_scope" in error
assert (
callback_config_error(
"langfuse_otel", {"langfuse_span_scope": "llm_only", "otel_span_scope": "llm_only"}
)
is None
)
assert callback_config_error("langfuse_otel", {"otel_span_scope": "no_internal"}) is None
def test_otel_span_scope_llm_only_is_accepted_on_newrelic():
assert callback_config_error("newrelic", {"otel_span_scope": "llm_only"}) is None
def test_key_logging_entries_of_one_backend_may_not_disagree_on_span_scope():
def entry(callback_name, callback_type, value):
return {
"callback_name": callback_name,
"callback_type": callback_type,
"callback_vars": {"otel_internal_spans": value},
"callback_vars": {"otel_span_scope": value},
}
disagreeing = {"logging": [entry("arize", "success", "exclude"), entry("arize", "failure", "include")]}
disagreeing = {"logging": [entry("arize", "success", "no_internal"), entry("arize", "failure", "full")]}
error = logging_metadata_config_error(disagreeing)
assert error is not None and "otel_internal_spans" in error and "'exclude'" in error
assert error is not None and "otel_span_scope" in error and "'no_internal'" in error
two_backends = {"logging": [entry("arize", "success", "exclude"), entry("langfuse_otel", "success", "include")]}
two_backends = {"logging": [entry("arize", "success", "no_internal"), entry("langfuse_otel", "success", "full")]}
assert logging_metadata_config_error(two_backends) is None
@pytest.mark.parametrize(
"new_vars, stored, rejected",
[
({"otel_internal_spans": "exclude"}, [{"otel_internal_spans": "include"}], True),
({"otel_internal_spans": "exclude"}, [{"otel_internal_spans": "exclude"}], False),
({"otel_internal_spans": "exclude"}, [{"arize_api_key": "k"}], False),
({"arize_api_key": "k"}, [{"otel_internal_spans": "exclude"}], False),
(None, [{"otel_internal_spans": "exclude"}], False),
({"otel_span_scope": "no_internal"}, [{"otel_span_scope": "full"}], True),
({"otel_span_scope": "no_internal"}, [{"otel_span_scope": "no_internal"}], False),
({"otel_span_scope": "no_internal"}, [{"arize_api_key": "k"}], False),
({"arize_api_key": "k"}, [{"otel_span_scope": "no_internal"}], False),
(None, [{"otel_span_scope": "no_internal"}], False),
],
)
def test_one_internal_spans_value_per_backend(new_vars, stored, rejected):
error = conflicting_internal_spans_error(new_vars, stored)
def test_one_span_scope_value_per_backend(new_vars, stored, rejected):
error = conflicting_otel_span_scope_error(new_vars, stored)
assert (error is not None) is rejected

View file

@ -299,17 +299,17 @@ class TestLangfuseOtelCallbackConfig:
assert scope["required"] is False
@pytest.mark.parametrize("callback_id", ["langfuse_otel", "arize", "newrelic"])
def test_internal_spans_is_a_select_over_exactly_the_choices_the_validator_accepts(self, callback_id):
from litellm.types.utils import OTEL_INTERNAL_SPAN_CHOICES
def test_otel_span_scope_is_a_select_over_exactly_the_scopes_the_validator_accepts(self, callback_id):
from litellm.types.utils import OTEL_SPAN_SCOPES
client = TestClient(app)
response = client.get("/callbacks/configs", headers={"Authorization": "Bearer sk-1234"})
assert response.status_code == 200
config = next(config for config in response.json() if config.get("id") == callback_id)
internal_spans = config["dynamic_params"]["otel_internal_spans"]
assert internal_spans["type"] == "select"
assert frozenset(internal_spans["options"]) == OTEL_INTERNAL_SPAN_CHOICES
assert internal_spans["required"] is False
span_scope = config["dynamic_params"]["otel_span_scope"]
assert span_scope["type"] == "select"
assert frozenset(span_scope["options"]) == OTEL_SPAN_SCOPES
assert span_scope["required"] is False
class TestNewRelicTeamCallbackValidation:

View file

@ -1675,10 +1675,10 @@ async def test_a_second_entry_may_not_flip_the_span_scope(patched_prisma, caller
@pytest.mark.asyncio
async def test_a_second_entry_may_not_flip_internal_spans_but_another_backend_may(patched_prisma):
"""The var merges per backend, so a second langfuse_otel entry saying include next to
a stored exclude would export whichever is stored last and is refused, while an arize
entry saying include is a different destination and is stored as written."""
async def test_a_second_entry_may_not_flip_span_scope_but_another_backend_may(patched_prisma):
"""The var merges per backend, so a second langfuse_otel entry saying full next to
a stored no_internal would export whichever is stored last and is refused, while an
arize entry saying full is a different destination and is stored as written."""
patched_prisma.get_data = AsyncMock(
return_value=_team_row(
metadata={
@ -1689,7 +1689,7 @@ async def test_a_second_entry_may_not_flip_internal_spans_but_another_backend_ma
"callback_vars": {
"langfuse_public_key": "pk",
"langfuse_secret_key": "sk",
"otel_internal_spans": "exclude",
"otel_span_scope": "no_internal",
},
}
]
@ -1704,7 +1704,7 @@ async def test_a_second_entry_may_not_flip_internal_spans_but_another_backend_ma
callback_vars={
"langfuse_public_key": "pk",
"langfuse_secret_key": "sk",
"otel_internal_spans": "include",
"otel_span_scope": "full",
},
),
http_request=Mock(spec=Request),
@ -1712,14 +1712,14 @@ async def test_a_second_entry_may_not_flip_internal_spans_but_another_backend_ma
user_api_key_dict=_admin_auth(),
)
assert exc.value.status_code == 400
assert "otel_internal_spans" in str(exc.value.detail) and "'exclude'" in str(exc.value.detail)
assert "otel_span_scope" in str(exc.value.detail) and "'no_internal'" in str(exc.value.detail)
patched_prisma.db.litellm_teamtable.update.assert_not_called()
await add_team_callbacks(
data=AddTeamCallback(
callback_name="arize",
callback_type="success",
callback_vars={"arize_api_key": "k", "arize_space_id": "s", "otel_internal_spans": "include"},
callback_vars={"arize_api_key": "k", "arize_space_id": "s", "otel_span_scope": "full"},
),
http_request=Mock(spec=Request),
team_id="team-victim",
@ -1727,7 +1727,7 @@ async def test_a_second_entry_may_not_flip_internal_spans_but_another_backend_ma
)
patched_prisma.db.litellm_teamtable.update.assert_awaited_once()
saved = json.loads(patched_prisma.db.litellm_teamtable.update.await_args.kwargs["data"]["metadata"])
assert [entry["callback_vars"]["otel_internal_spans"] for entry in saved["logging"]] == ["exclude", "include"]
assert [entry["callback_vars"]["otel_span_scope"] for entry in saved["logging"]] == ["no_internal", "full"]
@pytest.mark.asyncio

View file

@ -646,7 +646,7 @@ async def test_team_endpoints_reject_bogus_logging_metadata(endpoint, mock_db_cl
metadata: Final = {
"logging": [
{"callback_name": "langfuse_otel", "callback_vars": {"otel_internal_spans": "sometimes"}}
{"callback_name": "langfuse_otel", "callback_vars": {"otel_span_scope": "sometimes"}}
]
}
data: Final = (
@ -664,7 +664,7 @@ async def test_team_endpoints_reject_bogus_logging_metadata(endpoint, mock_db_cl
)
assert str(exc_info.value.code) == "400"
assert "otel_internal_spans" in str(exc_info.value.message)
assert "otel_span_scope" in str(exc_info.value.message)
mock_db_client.db.litellm_teamtable.create.assert_not_awaited()
mock_db_client.db.litellm_teamtable.update.assert_not_awaited()

View file

@ -32,10 +32,10 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
arize_space_id: "password",
arize_success_sampling_rate: "number",
arize_error_sampling_rate: "number",
otel_internal_spans: "select",
otel_span_scope: "select",
},
dynamic_param_options: {
otel_internal_spans: ["include", "exclude"],
otel_span_scope: ["full", "no_internal", "llm_only"],
},
description: "Arize Logging Integration",
},
@ -94,10 +94,10 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
dynamic_params: {
newrelic_api_key: "password",
newrelic_region: "text",
otel_internal_spans: "select",
otel_span_scope: "select",
},
dynamic_param_options: {
otel_internal_spans: ["include", "exclude"],
otel_span_scope: ["full", "no_internal", "llm_only"],
},
description: "New Relic Logging Integration",
},
@ -136,11 +136,11 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
langfuse_host: "text",
langfuse_environment: "text",
langfuse_span_scope: "select",
otel_internal_spans: "select",
otel_span_scope: "select",
},
dynamic_param_options: {
langfuse_span_scope: ["full", "llm_only"],
otel_internal_spans: ["include", "exclude"],
langfuse_span_scope: ["full", "no_internal", "llm_only"],
otel_span_scope: ["full", "no_internal", "llm_only"],
},
description: "Langfuse v3 OTEL Logging Integration",
},

View file

@ -231,7 +231,11 @@ describe("LoggingSettings", () => {
expect(screen.queryByPlaceholderText("os.environ/LANGFUSE_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", "llm_only"]);
expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual([
"full",
"no_internal",
"llm_only",
]);
await user.click(screen.getByRole("option", { name: "llm_only" }));
expect(mockOnChange).toHaveBeenCalledWith([
@ -239,7 +243,7 @@ describe("LoggingSettings", () => {
]);
});
it("offers the OTEL destination internal spans as a pick between include and exclude", async () => {
it("offers the OTEL destination span scope as a pick between full, no_internal and llm_only", async () => {
const user = userEvent.setup({ delay: null });
const mockOnChange = vi.fn();
const initialValue = [
@ -252,13 +256,17 @@ describe("LoggingSettings", () => {
renderWithProviders(<LoggingSettings value={initialValue} onChange={mockOnChange} />);
expect(screen.queryByPlaceholderText("os.environ/OTEL_INTERNAL_SPANS")).not.toBeInTheDocument();
await user.click(screen.getByRole("combobox", { name: "otel internal spans" }));
expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual(["include", "exclude"]);
await user.click(screen.getByRole("option", { name: "exclude" }));
expect(screen.queryByPlaceholderText("os.environ/OTEL_SPAN_SCOPE")).not.toBeInTheDocument();
await user.click(screen.getByRole("combobox", { name: "otel span scope" }));
expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual([
"full",
"no_internal",
"llm_only",
]);
await user.click(screen.getByRole("option", { name: "no_internal" }));
expect(mockOnChange).toHaveBeenCalledWith([
expect.objectContaining({ callback_vars: expect.objectContaining({ otel_internal_spans: "exclude" }) }),
expect.objectContaining({ callback_vars: expect.objectContaining({ otel_span_scope: "no_internal" }) }),
]);
});

View file

@ -16363,8 +16363,8 @@ export interface paths {
* - langfuse_secret: The secret for the Langfuse callback
* - 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, "llm_only" sends only the model-call spans
* - otel_internal_spans: For langfuse_otel, arize, weave_otel and newrelic, "include" sends the proxy's own SERVICE and DB_CALL spans (auth, Redis, Postgres) with the trace, "exclude" holds them back; defaults to litellm_settings.otel_tenant_internal_spans
* - 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
* - 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