mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
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:
parent
683a049ca8
commit
c6b3fccc6f
23 changed files with 264 additions and 277 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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] });
|
||||
}
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue