diff --git a/litellm/__init__.py b/litellm/__init__.py index a83b6161119..2a4d6af9292 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -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 diff --git a/litellm/constants.py b/litellm/constants.py index 613df5ebf8f..ab744b72cde 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -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") diff --git a/litellm/integrations/callback_configs.json b/litellm/integrations/callback_configs.json index b227f27b47a..cec32049251 100644 --- a/litellm/integrations/callback_configs.json +++ b/litellm/integrations/callback_configs.json @@ -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 } }, diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py index 5447a8ee80a..c3a603facdb 100644 --- a/litellm/integrations/otel/model/config.py +++ b/litellm/integrations/otel/model/config.py @@ -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." diff --git a/litellm/integrations/otel/model/destination.py b/litellm/integrations/otel/model/destination.py index 096b49159dc..687fa88a5da 100644 --- a/litellm/integrations/otel/model/destination.py +++ b/litellm/integrations/otel/model/destination.py @@ -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, diff --git a/litellm/integrations/otel/plumbing/context.py b/litellm/integrations/otel/plumbing/context.py index dd3d60305bf..50de22a5bc2 100644 --- a/litellm/integrations/otel/plumbing/context.py +++ b/litellm/integrations/otel/plumbing/context.py @@ -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]: diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index e0669224053..0419f0a80bf 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -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: diff --git a/litellm/integrations/otel/presets/destinations.py b/litellm/integrations/otel/presets/destinations.py index bde1ecacc5e..8e0c44d85bf 100644 --- a/litellm/integrations/otel/presets/destinations.py +++ b/litellm/integrations/otel/presets/destinations.py @@ -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), ) diff --git a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py index c2cd813e021..0817976f816 100644 --- a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py +++ b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py @@ -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 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index f2af5825497..1ca7c8467d4 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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 diff --git a/litellm/proxy/common_utils/callback_config_validation.py b/litellm/proxy/common_utils/callback_config_validation.py index 8f8e189d03b..53e417d3086 100644 --- a/litellm/proxy/common_utils/callback_config_validation.py +++ b/litellm/proxy/common_utils/callback_config_validation.py @@ -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]), ) diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index c678c51083d..1165c26b897 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -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 diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 0f004d24c3c..1a4d6d61857 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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 diff --git a/tests/e2e/ui/tests/integrationCritical/expected.json b/tests/e2e/ui/tests/integrationCritical/expected.json index aa2e17fa24f..e9ce929f7e4 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/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", diff --git a/tests/e2e/ui/tests/integrationCritical/otelInternalSpans.spec.ts b/tests/e2e/ui/tests/integrationCritical/otelInternalSpans.spec.ts deleted file mode 100644 index 58ba64415a1..00000000000 --- a/tests/e2e/ui/tests/integrationCritical/otelInternalSpans.spec.ts +++ /dev/null @@ -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] }); - } -}); 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 f94ee8ce3cf..77dfb884539 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py @@ -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. diff --git a/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py b/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py index f76152861d1..7bedd3de97b 100644 --- a/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py +++ b/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py index 414c7d50480..7080406c871 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py @@ -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: diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py index 6c0695c3bb0..e8389611de4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 28df88db03e..2a120a29215 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -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() diff --git a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx index 582ddf0987c..d8f41e76861 100644 --- a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx @@ -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", }, diff --git a/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx b/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx index 4943687452e..929806f78c8 100644 --- a/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx @@ -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(); - 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" }) }), ]); }); diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e84b2801145..9374f4ec250 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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