mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Under otel_v2, a client that propagates W3C trace context in params._meta (SEP-414) pulled the tools/call span out of the gateway's trace: resolve_mcp_span_context parented the MCP span to the client's remote context and demoted the gateway's own transport span to a span link. The gateway's tracing backend only ever receives the gateway's half of such a trace, so the span was unreachable from the trace view and the POST transaction showed a dangling link. Invert the anchoring: the MCP tool-call and tools/list spans now always nest under the transport span of the request carrying the message, and the client's propagated context is recorded as the span link instead, so the correlation survives while every trace stays renderable. With no transport at all the span roots its own trace and still carries the link, keeping a single shape for the event. Both returned contexts are built on an explicitly empty base so ambient session state can never leak in, and the span inherits the transport's sampling decision like every other request-level span.
228 lines
10 KiB
Python
228 lines
10 KiB
Python
"""
|
|
This module declares every span the instrumentation can emit and the hierarchy.
|
|
|
|
Span-name patterns live here as typed builder functions.
|
|
|
|
Canonical hierarchy::
|
|
|
|
PROXY_REQUEST (SERVER, root) # owned by the FastAPI instrumentor
|
|
├── SERVICE (INTERNAL) # auth phase span (live; see logger.phase_span)
|
|
│ └── DB_CALL (CLIENT) # its key/user/team lookups nest here
|
|
├── GUARDRAIL (INTERNAL) # request-lifecycle hook, sibling of LLM_CALL
|
|
├── LLM_CALL (CLIENT)
|
|
├── MCP_TOOL_CALL (CLIENT) # nests under the POST carrying the message
|
|
├── MCP_LIST_TOOLS (CLIENT) # (client-propagated context is a span link)
|
|
└── DB_CALL (CLIENT) # e.g. the spend-log write
|
|
|
|
Guardrails parent to PROXY_REQUEST, not LLM_CALL: pre/during/post-call guardrail
|
|
hooks are orchestrated by the request lifecycle (a pre-call guardrail runs
|
|
before the LLM call even starts), so a guardrail is a sibling of the LLM call,
|
|
not a child of it. The emitter parents every span to the ambient OTel context
|
|
(the active server span), which matches this.
|
|
|
|
MCP spans (``MCP_TOOL_CALL``, ``MCP_LIST_TOOLS``) are parented at emit time by
|
|
:func:`resolve_mcp_span_context`: they nest under the ``PROXY_REQUEST`` transport
|
|
span of the request carrying that message, so the tool call stays in one trace.
|
|
Trace context the client propagated in ``params._meta`` (SEP-414) is recorded as
|
|
a span *link*, never the parent — a remote parent would root the span in a trace
|
|
whose root never reaches the gateway's tracing backend. Links always target that
|
|
remote client context, never a registry role, so ``SpanSpec`` declares no link
|
|
field; the concrete transport parent is resolved per message at emit time.
|
|
|
|
Not every service call becomes a span — :func:`span_role_for_service` decides:
|
|
|
|
- ``DB_CALL`` (CLIENT) — outbound datastores (redis, postgres,
|
|
``batch_write_to_db``), carrying ``db.*`` semconv.
|
|
- ``SERVICE`` (INTERNAL) — genuine internal work worth a span (background
|
|
budget/reset jobs, pod-lock manager).
|
|
- ``None`` (metrics-only) — framework instrumentation that duplicates a gen-AI
|
|
span (``self`` = the ``track_llm_api_timing`` wrapper, ``router``,
|
|
``proxy_pre_call``) or ``auth`` (which gets a live phase span instead). These
|
|
still feed Prometheus/Datadog; they just never enter the trace.
|
|
|
|
``DB_CALL`` and ``SERVICE`` are built from the same ``ServiceSpanData``; only the
|
|
role (hence span kind and attribute vocabulary) differs. A service call can fire
|
|
outside any request (a background job), in which case it parents to no server
|
|
span and starts its own root trace rather than being dropped.
|
|
|
|
Management/admin endpoints are ordinary FastAPI routes — their SERVER spans are
|
|
owned by the instrumentor too, so they don't appear as a role here.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
if TYPE_CHECKING:
|
|
from litellm.integrations.otel.model.payloads import (
|
|
GuardrailSpanData,
|
|
LLMCallSpanData,
|
|
MCPListToolsSpanData,
|
|
MCPToolCallSpanData,
|
|
ProxyRequestSpanData,
|
|
ServiceSpanData,
|
|
)
|
|
|
|
|
|
class SpanRole(str, Enum):
|
|
PROXY_REQUEST = "proxy_request"
|
|
LLM_CALL = "llm_call"
|
|
MCP_TOOL_CALL = "mcp_tool_call"
|
|
MCP_LIST_TOOLS = "mcp_list_tools"
|
|
GUARDRAIL = "guardrail"
|
|
DB_CALL = "db_call"
|
|
SERVICE = "service"
|
|
|
|
|
|
class LiteLLMSpanKind(str, Enum):
|
|
SERVER = "server"
|
|
CLIENT = "client"
|
|
INTERNAL = "internal"
|
|
PRODUCER = "producer"
|
|
CONSUMER = "consumer"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SpanSpec:
|
|
role: SpanRole
|
|
kind: LiteLLMSpanKind
|
|
parent: SpanRole | None
|
|
|
|
|
|
SPAN_REGISTRY: Final[dict[SpanRole, SpanSpec]] = {
|
|
SpanRole.PROXY_REQUEST: SpanSpec(SpanRole.PROXY_REQUEST, LiteLLMSpanKind.SERVER, parent=None),
|
|
SpanRole.LLM_CALL: SpanSpec(SpanRole.LLM_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
|
|
# The proxy is an MCP client to the upstream server, so MCP spans are CLIENT
|
|
# spans. ``resolve_mcp_span_context`` nests them under the PROXY_REQUEST
|
|
# transport span of the request carrying that message (resolved per message at
|
|
# emit time), keeping the call in one trace. Trace context the client
|
|
# propagated in ``params._meta`` becomes a span *link* to that remote context,
|
|
# which is not a registry role, so ``SpanSpec`` has no link field.
|
|
SpanRole.MCP_TOOL_CALL: SpanSpec(SpanRole.MCP_TOOL_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
|
|
SpanRole.MCP_LIST_TOOLS: SpanSpec(SpanRole.MCP_LIST_TOOLS, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
|
|
SpanRole.GUARDRAIL: SpanSpec(SpanRole.GUARDRAIL, LiteLLMSpanKind.INTERNAL, parent=SpanRole.PROXY_REQUEST),
|
|
SpanRole.DB_CALL: SpanSpec(SpanRole.DB_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
|
|
SpanRole.SERVICE: SpanSpec(SpanRole.SERVICE, LiteLLMSpanKind.INTERNAL, parent=SpanRole.PROXY_REQUEST),
|
|
}
|
|
|
|
|
|
# ``ServiceTypes`` value -> ``db.system.name``. These are outbound datastore
|
|
# calls and become CLIENT ``DB_CALL`` spans; ``redis_``-prefixed names cover the
|
|
# redis-backed spend queues. Any service not mapped here is litellm-internal work
|
|
# and stays an INTERNAL ``SERVICE`` span. This table is the single source of
|
|
# datastore knowledge — both the role classifier and the mapper read it.
|
|
POSTGRESQL: Final = "postgresql"
|
|
|
|
_DB_SYSTEM_BY_SERVICE: Final[dict[str, str]] = {
|
|
"redis": "redis",
|
|
"postgres": POSTGRESQL,
|
|
"batch_write_to_db": POSTGRESQL,
|
|
}
|
|
|
|
|
|
def db_system(service_name: str) -> str | None:
|
|
"""The ``db.system.name`` for a datastore service, else ``None``.
|
|
|
|
``None`` means the service is not an outbound datastore call. Redis-backed
|
|
spend queues (``redis_*``) map to ``redis``.
|
|
"""
|
|
if service_name in _DB_SYSTEM_BY_SERVICE:
|
|
return _DB_SYSTEM_BY_SERVICE[service_name]
|
|
if service_name.startswith("redis_"):
|
|
return "redis"
|
|
return None
|
|
|
|
|
|
# ``ServiceTypes`` values that are NOT emitted as spans — they are framework
|
|
# instrumentation that either duplicates a gen-AI span or has a better home as a
|
|
# Prometheus/Datadog metric. They still flow to those metric backends via their
|
|
# own hooks; the v2 logger just does not put them in the trace:
|
|
#
|
|
# - ``self`` — ``track_llm_api_timing`` wraps the LLM call; the
|
|
# ``chat {model}`` CLIENT span already represents it.
|
|
# - ``router`` — wraps the whole request; duplicates the server span.
|
|
# - ``proxy_pre_call`` — per-callback pre-call timing; a guardrail's real span
|
|
# is ``execute_guardrail {name}``.
|
|
# - ``auth`` — emitted instead as a live phase span (see
|
|
# ``logger.phase_span``) so its DB lookups nest under it,
|
|
# not as a flat post-hoc service span.
|
|
_METRICS_ONLY_SERVICES: Final[frozenset[str]] = frozenset({"self", "router", "proxy_pre_call", "auth"})
|
|
|
|
|
|
def span_role_for_service(service_name: str) -> SpanRole | None:
|
|
"""The span role for a service call, or ``None`` when it must not be a span.
|
|
|
|
``DB_CALL`` for outbound datastores, ``SERVICE`` for genuine internal work
|
|
worth a span (background jobs), and ``None`` for framework instrumentation
|
|
that duplicates a gen-AI span or belongs in metrics only
|
|
(see ``_METRICS_ONLY_SERVICES``).
|
|
"""
|
|
if service_name in _METRICS_ONLY_SERVICES:
|
|
return None
|
|
return SpanRole.DB_CALL if db_system(service_name) is not None else SpanRole.SERVICE
|
|
|
|
|
|
# --- span name builders (the naming convention, per role) ------------------- #
|
|
|
|
|
|
# The name the FastAPI instrumentor gives the root server span. V2 never creates
|
|
# this span (the instrumentor owns it), but it anchors request-level spans to it
|
|
# and tests assert against it by name, so the literal lives here with the rest of
|
|
# the span vocabulary rather than being duplicated at each call site.
|
|
LITELLM_PROXY_REQUEST_SPAN_NAME: Final = "Received Proxy Server Request"
|
|
|
|
|
|
def llm_call_span_name(data: "LLMCallSpanData") -> str:
|
|
"""``"{operation} {model}"`` e.g. ``"chat gpt-4o"`` (GenAI semconv)."""
|
|
model: Final = data.request_model or ""
|
|
return f"{data.operation.value} {model}".strip()
|
|
|
|
|
|
def mcp_tool_call_span_name(data: "MCPToolCallSpanData") -> str:
|
|
"""``"{mcp.method.name} {tool}"`` e.g. ``"tools/call get-weather"`` (MCP semconv)."""
|
|
return f"{data.method} {data.tool_name}".strip()
|
|
|
|
|
|
def mcp_list_tools_span_name(data: "MCPListToolsSpanData") -> str:
|
|
"""``"{mcp.method.name}"`` i.e. ``"tools/list"`` — no low-cardinality target, so
|
|
the method name alone names the span (MCP semconv)."""
|
|
return data.method
|
|
|
|
|
|
def proxy_request_span_name(data: "ProxyRequestSpanData") -> str:
|
|
"""``"{method} {route}"`` (HTTP semconv)."""
|
|
return f"{data.http_method} {data.route}".strip()
|
|
|
|
|
|
def guardrail_span_name(data: "GuardrailSpanData") -> str:
|
|
return f"execute_guardrail {data.guardrail_name}".strip()
|
|
|
|
|
|
def service_span_name(data: "ServiceSpanData") -> str:
|
|
"""``"{service} {call_type}"`` e.g. ``"redis set"`` — service name alone when
|
|
no call type is known, so identically-named calls stay distinguishable."""
|
|
return f"{data.service_name} {data.call_type or ''}".strip()
|
|
|
|
|
|
def root_roles() -> list[SpanRole]:
|
|
"""Roles with no in-process parent, i.e. they start a new trace (only the
|
|
instrumentor-owned ``PROXY_REQUEST`` server span today)."""
|
|
return [role for role, spec in SPAN_REGISTRY.items() if spec.parent is None]
|
|
|
|
|
|
def child_roles(parent: SpanRole) -> list[SpanRole]:
|
|
return [role for role, spec in SPAN_REGISTRY.items() if spec.parent == parent]
|
|
|
|
|
|
def validate_registry(
|
|
registry: dict[SpanRole, SpanSpec] | None = None,
|
|
) -> None:
|
|
reg: Final = registry if registry is not None else SPAN_REGISTRY
|
|
for role, spec in reg.items():
|
|
if spec.role is not role:
|
|
raise ValueError(f"SPAN_REGISTRY[{role}] has mismatched role {spec.role}")
|
|
if spec.parent is not None and spec.parent not in reg:
|
|
raise ValueError(f"span role {role} declares unknown parent {spec.parent}")
|
|
missing: Final = [role for role in SpanRole if role not in reg]
|
|
if missing:
|
|
raise ValueError(f"SPAN_REGISTRY is missing roles: {missing}")
|