diff --git a/litellm/_service_logger.py b/litellm/_service_logger.py index 1a3be203fec..5d90055acd7 100644 --- a/litellm/_service_logger.py +++ b/litellm/_service_logger.py @@ -24,6 +24,22 @@ UserAPIKeyAuth = Any +def _get_otel_v2_class() -> Optional[type]: + """Return the ``OpenTelemetryV2`` class, or ``None`` if the OTel SDK is absent. + + Imported lazily: ``litellm.integrations.otel.logger`` imports the OpenTelemetry + SDK at module scope, so importing it eagerly would break installs without the + SDK. The V2 logger only exists when ``LITELLM_OTEL_V2`` is enabled (which + requires the SDK), so a failed import simply means "no V2 logger in play". + """ + try: + from litellm.integrations.otel.logger import OpenTelemetryV2 + + return OpenTelemetryV2 + except Exception: + return None + + class ServiceLogging(CustomLogger): """ Separate class used for monitoring health of litellm-adjacent services (redis/postgres). @@ -38,6 +54,37 @@ def __init__(self, mock_testing: bool = False) -> None: if "prometheus_system" in litellm.service_callback: self.prometheusServicesLogger = PrometheusServicesLogger() + def _resolve_otel_service_logger(self, callback: Any) -> Optional[Any]: + """Resolve the OTel logger (legacy or V2) to emit a service span on. + + Returns the logger instance whose ``async_service_*_hook`` should fire for + this ``callback``, or ``None`` when ``callback`` is not an OTel callback. + + The V2 ``OpenTelemetryV2`` logger is a plain ``CustomLogger`` and is NOT a + subclass of the legacy ``OpenTelemetry``, so the legacy ``isinstance`` + check alone misses it — which is why redis/postgres service spans never + showed up under ``LITELLM_OTEL_V2``. Match both the legacy and V2 types, + whether the callback is the logger instance itself or the ``"otel"`` string + (which routes to the proxy's registered ``open_telemetry_logger``). + """ + otel_v2_cls = _get_otel_v2_class() + + def _is_otel_logger(obj: Any) -> bool: + if isinstance(obj, OpenTelemetry): + return True + return otel_v2_cls is not None and isinstance(obj, otel_v2_cls) + + if _is_otel_logger(callback): + return callback + if callback == "otel": + from litellm.proxy.proxy_server import open_telemetry_logger + + if open_telemetry_logger is not None and _is_otel_logger( + open_telemetry_logger + ): + return open_telemetry_logger + return None + def service_success_hook( self, service: ServiceTypes, @@ -144,18 +191,8 @@ async def async_service_success_hook( end_time=end_time, event_metadata=event_metadata, ) - elif callback == "otel" or isinstance(callback, OpenTelemetry): - _otel_logger_to_use: Optional[OpenTelemetry] = None - if isinstance(callback, OpenTelemetry): - _otel_logger_to_use = callback - else: - from litellm.proxy.proxy_server import open_telemetry_logger - - if open_telemetry_logger is not None and isinstance( - open_telemetry_logger, OpenTelemetry - ): - _otel_logger_to_use = open_telemetry_logger - + else: + _otel_logger_to_use = self._resolve_otel_service_logger(callback) if _otel_logger_to_use is not None and parent_otel_span is not None: await _otel_logger_to_use.async_service_success_hook( payload=payload, @@ -255,17 +292,8 @@ async def async_service_failure_hook( end_time=end_time, event_metadata=event_metadata, ) - elif callback == "otel" or isinstance(callback, OpenTelemetry): - _otel_logger_to_use: Optional[OpenTelemetry] = None - if isinstance(callback, OpenTelemetry): - _otel_logger_to_use = callback - else: - from litellm.proxy.proxy_server import open_telemetry_logger - - if open_telemetry_logger is not None and isinstance( - open_telemetry_logger, OpenTelemetry - ): - _otel_logger_to_use = open_telemetry_logger + else: + _otel_logger_to_use = self._resolve_otel_service_logger(callback) if not isinstance(error, str): error = str(error) diff --git a/litellm/integrations/otel/README.md b/litellm/integrations/otel/README.md index 41c09344b33..47103bfb6c7 100644 --- a/litellm/integrations/otel/README.md +++ b/litellm/integrations/otel/README.md @@ -63,9 +63,19 @@ becomes the global, so server spans export to that backend too. `standard_logging_object` and hands it to the engine, which creates the LLM span as a child of the server span. Emission is **async-only**; the synchronous callback runs in a worker thread without the request context and - is a no-op. + is a no-op. **Pass-through** endpoints dispatch their logging from a detached + `asyncio.create_task` whose copied context may no longer carry the server + span, so the proxy also threads the span explicitly as + `litellm_parent_otel_span`; the adapter falls back to it when the ambient + context has no recordable span, so the pass-through LLM-call span still nests + under the request instead of becoming its own root trace. 5. **Guardrails / services**: the post-call and service hooks emit guardrail and - service spans the same way — typed data → engine → span. + service spans the same way — typed data → engine → span. Service spans + (Redis/Postgres) are dispatched by `litellm/_service_logger.py`, which + recognizes the V2 `OpenTelemetryV2` logger (a plain `CustomLogger`, not a + subclass of the legacy `OpenTelemetry`). Guardrail span data is built from the + typed, provider-agnostic `StandardLoggingGuardrailInformation` — no single + provider's field shape is assumed. 6. **Export**: each span ends and is handed to the provider's span processors, which export to the configured backends (OTLP, console, in-memory, …). @@ -87,7 +97,12 @@ be imported anywhere: - [`config.py`](./config.py) — `OpenTelemetryV2Config`, a pydantic-settings model that reads `OTEL_*` / `LITELLM_OTEL_*` env vars, plus the feature gate. `capture_span_content` gates whether prompt/response bodies may be written as - span attributes; it defaults **off** (`no_content`). + span attributes; it defaults **off** (`no_content`). The Baggage allowlists are + configurable, not hard-coded: set `LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS` / + `LITELLM_OTEL_BAGGAGE_METADATA_KEYS` (comma-separated) as env vars, or + `baggage_promoted_keys` / `baggage_metadata_keys` (YAML lists) under + `callback_settings.otel` in `config.yaml` — the latter reach the config through + the logger's constructor kwargs. ### Engine diff --git a/litellm/integrations/otel/config.py b/litellm/integrations/otel/config.py index e8749e31343..f44cd699292 100644 --- a/litellm/integrations/otel/config.py +++ b/litellm/integrations/otel/config.py @@ -1,7 +1,10 @@ """Typed configuration for the OpenTelemetry instrumentation.""" -from pydantic import AliasChoices, BaseModel, Field, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import Any, List + +from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict +from typing_extensions import Annotated from litellm.integrations.otel.baggage import ( BAGGAGE_PROMOTED_KEYS, @@ -122,7 +125,7 @@ class OpenTelemetryV2Config(BaseSettings): ), ) - mapper_names: list[str] = Field( + mapper_names: Annotated[List[str], NoDecode] = Field( default_factory=lambda: ["genai"], description=( "Ordered attribute vocabularies to emit. ``genai`` is the " @@ -140,12 +143,51 @@ class OpenTelemetryV2Config(BaseSettings): ), ) - baggage_promoted_keys: list[str] = Field( - default_factory=lambda: list(BAGGAGE_PROMOTED_KEYS) + baggage_promoted_keys: Annotated[List[str], NoDecode] = Field( + default_factory=lambda: list(BAGGAGE_PROMOTED_KEYS), + validation_alias=AliasChoices( + "baggage_promoted_keys", "LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS" + ), + description=( + "Identity attribute keys written into Baggage and stamped on every " + "child span (e.g. ``litellm.team.id``). Configure via the " + "``LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS`` env var (comma-separated) or " + "``callback_settings.otel.baggage_promoted_keys`` in config.yaml (a " + "YAML list)." + ), ) - baggage_metadata_keys: list[str] = Field( - default_factory=lambda: list(DEFAULT_BAGGAGE_METADATA_KEYS) + baggage_metadata_keys: Annotated[List[str], NoDecode] = Field( + default_factory=lambda: list(DEFAULT_BAGGAGE_METADATA_KEYS), + validation_alias=AliasChoices( + "baggage_metadata_keys", "LITELLM_OTEL_BAGGAGE_METADATA_KEYS" + ), + description=( + "Metadata sub-keys promoted under the ``litellm.metadata.*`` " + "namespace. Configure via the ``LITELLM_OTEL_BAGGAGE_METADATA_KEYS`` " + "env var (comma-separated) or " + "``callback_settings.otel.baggage_metadata_keys`` in config.yaml." + ), + ) + + @field_validator( + "baggage_promoted_keys", + "baggage_metadata_keys", + "mapper_names", + mode="before", ) + @classmethod + def _split_csv(cls, value: Any) -> Any: + """Accept a comma-separated string for list fields. + + Env vars are strings, but these fields are lists. Pydantic-settings would + otherwise require JSON for a list env var; splitting on commas here lets + an operator write ``LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS=litellm.team.id,litellm.api_key.hash``. + YAML lists (from ``callback_settings.otel.*``) and real lists pass through + unchanged. + """ + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + return value @model_validator(mode="after") def _normalize(self) -> "OpenTelemetryV2Config": diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index c55732e6d28..63c6b84e061 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -17,7 +17,7 @@ """ from datetime import datetime -from typing import Any, Mapping, cast +from typing import TYPE_CHECKING, Any, Mapping, cast from opentelemetry.context import attach, get_current from opentelemetry.sdk.trace import TracerProvider @@ -46,6 +46,9 @@ from litellm.integrations.otel.spans import SpanRole from litellm.integrations.otel.utils import to_ns +if TYPE_CHECKING: + from litellm.types.utils import StandardLoggingGuardrailInformation + LITELLM_TRACER_NAME = "litellm" LITELLM_PROXY_REQUEST_SPAN_NAME = "Received Proxy Server Request" @@ -57,6 +60,30 @@ ) +def _threaded_parent_span(kwargs: Mapping[str, Any]) -> Span | None: + """The proxy SERVER span threaded through request metadata, if any. + + Normally the LLM-call span parents to the ambient OTel context (the active + server span). But pass-through logging runs in a detached + ``asyncio.create_task`` whose copied context may no longer carry that span, + so the proxy also threads it explicitly as ``litellm_parent_otel_span`` (see + ``litellm_pre_call_utils`` for proxy routes and the pass-through endpoint for + catch-all routes). This reads it back so the call span can fall back to it. + """ + litellm_params = kwargs.get("litellm_params") + candidates: list[Any] = [] + if isinstance(litellm_params, Mapping): + candidates.append(litellm_params.get("metadata")) + candidates.append(litellm_params.get("litellm_metadata")) + candidates.append(kwargs.get("metadata")) + for meta in candidates: + if isinstance(meta, Mapping): + span = meta.get("litellm_parent_otel_span") + if span is not None: + return cast("Span", span) + return None + + def _pre_call_guardrail_blocked(payload: Mapping[str, Any]) -> bool: """True when a pre-call guardrail blocked the request (no LLM call happened). @@ -103,7 +130,11 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(**kwargs) - self.config: OpenTelemetryV2Config = config or OpenTelemetryV2Config() + # Build the config from any settings passed through ``kwargs`` so + # ``callback_settings.otel.*`` in config.yaml (e.g. ``baggage_promoted_keys``, + # ``capture_message_content``) configures the logger. ``OpenTelemetryV2Config`` + # ignores extra keys, so unrelated kwargs are dropped harmlessly. + self.config: OpenTelemetryV2Config = config or OpenTelemetryV2Config(**kwargs) self.callback_name = callback_name self._tracer_provider: TracerProvider = ( tracer_provider @@ -187,8 +218,17 @@ def _emit_llm_call( cast("Any", payload), capture_content=self.config.capture_span_content ) # Parent is the ambient context (the instrumentor's server span, - # restored by the logging worker); no span is threaded through metadata. + # restored by the logging worker). When the ambient context has lost the + # server span — e.g. pass-through logging fired from a detached task — + # fall back to the server span the proxy threaded through metadata so the + # call span still nests under the request instead of being dropped. parent_ctx = get_current() + if not is_recordable_span(get_current_span(parent_ctx)): + threaded_parent = _threaded_parent_span(kwargs) + if is_recordable_span(threaded_parent): + parent_ctx = context_from_span( + cast("Span", threaded_parent), context=parent_ctx + ) # Write identity into Baggage so child spans (guardrails, services) # inherit it. bag = promoted_baggage( @@ -361,7 +401,10 @@ def _emit_guardrail_spans(self, request_data: Mapping[str, Any]) -> None: if not isinstance(entry, dict): continue self._emitter.emit( - SpanRole.GUARDRAIL, GuardrailSpanData.from_logging_entry(entry) + SpanRole.GUARDRAIL, + GuardrailSpanData.from_logging_entry( + cast("StandardLoggingGuardrailInformation", entry) + ), ) # ====================================================================== # diff --git a/litellm/integrations/otel/mappers/genai.py b/litellm/integrations/otel/mappers/genai.py index 0ceb2e5126c..839fcf2a1a6 100644 --- a/litellm/integrations/otel/mappers/genai.py +++ b/litellm/integrations/otel/mappers/genai.py @@ -73,6 +73,9 @@ class GenAIMapper: LiteLLM.GUARDRAIL_RISK_SCORE: lambda d: d.risk_score, LiteLLM.GUARDRAIL_MASKED_ENTITY_COUNT: lambda d: d.masked_entity_count, LiteLLM.GUARDRAIL_DURATION: lambda d: d.duration, + LiteLLM.GUARDRAIL_ID: lambda d: d.guardrail_id, + LiteLLM.GUARDRAIL_POLICY_TEMPLATE: lambda d: d.policy_template, + LiteLLM.GUARDRAIL_DETECTION_METHOD: lambda d: d.detection_method, } _SERVICE_ATTRS: dict[str, Callable[[ServiceSpanData], AttrValue | None]] = { diff --git a/litellm/integrations/otel/payloads.py b/litellm/integrations/otel/payloads.py index b91509c19ab..945d4da9ec2 100644 --- a/litellm/integrations/otel/payloads.py +++ b/litellm/integrations/otel/payloads.py @@ -4,6 +4,7 @@ import json from dataclasses import dataclass, field +from enum import Enum from typing import TYPE_CHECKING, ClassVar, Mapping, cast from urllib.parse import urlsplit @@ -22,7 +23,10 @@ if TYPE_CHECKING: from litellm.types.services import ServiceLoggerPayload - from litellm.types.utils import StandardLoggingPayload + from litellm.types.utils import ( + StandardLoggingGuardrailInformation, + StandardLoggingPayload, + ) # --- typed sub-structures ---------------------------------------------------- # @@ -162,6 +166,12 @@ class GuardrailSpanData: confidence_score: float | None = None risk_score: float | None = None duration: float | None = None + # Provider-agnostic configuration/detection metadata (see + # ``StandardLoggingGuardrailInformation``). Present for any guardrail that + # populates them, not just one provider's shape. + guardrail_id: str | None = None + policy_template: str | None = None + detection_method: str | None = None # Set when the guardrail intervened/blocked or failed, so the emitter marks # the span ERROR — a blocking guardrail is an error outcome for that span. error: SpanError | None = None @@ -172,34 +182,39 @@ class GuardrailSpanData: ) @classmethod - def from_logging_entry(cls, entry: Mapping[str, object]) -> "GuardrailSpanData": - """Build from one ``standard_logging_guardrail_information`` entry.""" - name = ( - as_str(entry.get("guardrail_name")) - or as_str(entry.get("name")) - or "guardrail" - ) - status = as_str(entry.get("guardrail_status")) or as_str(entry.get("status")) - response = entry.get("guardrail_response") + def from_logging_entry( + cls, entry: "StandardLoggingGuardrailInformation" + ) -> "GuardrailSpanData": + """Build from one ``standard_logging_guardrail_information`` entry. + + Reads the canonical, provider-agnostic ``StandardLoggingGuardrailInformation`` + keys only — no guessing at a single provider's field names. Values that are + typed as enums or lists (e.g. ``guardrail_mode``) are normalized to a + stable string rather than assumed to already be plain strings. + """ + get = cast(Mapping[str, object], entry).get + status = as_str(get("guardrail_status")) + response = get("guardrail_response") error = ( - SpanError(error_type=status, message=as_str(entry.get("guardrail_action"))) + SpanError(error_type=status, message=as_str(get("guardrail_action"))) if status in cls._ERROR_STATUSES else None ) return cls( - guardrail_name=name, - mode=as_str(entry.get("guardrail_mode")) or as_str(entry.get("mode")), + guardrail_name=as_str(get("guardrail_name")) or "guardrail", + mode=_guardrail_mode_str(get("guardrail_mode")), status=status, - masked_entity_count=_total_masked_entities( - entry.get("masked_entity_count") - ), - provider=as_str(entry.get("guardrail_provider")), - action=as_str(entry.get("guardrail_action")), + masked_entity_count=_total_masked_entities(get("masked_entity_count")), + provider=as_str(get("guardrail_provider")), + action=as_str(get("guardrail_action")), response_json=_json_or_none(response) if response is not None else None, - violation_categories=as_str_tuple(entry.get("violation_categories")) or (), - confidence_score=as_float(entry.get("confidence_score")), - risk_score=as_float(entry.get("risk_score")), - duration=as_float(entry.get("duration")), + violation_categories=as_str_tuple(get("violation_categories")) or (), + confidence_score=as_float(get("confidence_score")), + risk_score=as_float(get("risk_score")), + duration=as_float(get("duration")), + guardrail_id=as_str(get("guardrail_id")), + policy_template=as_str(get("policy_template")), + detection_method=as_str(get("detection_method")), error=error, ) @@ -337,6 +352,25 @@ def _json_or_none(value: object) -> str | None: return None +def _guardrail_mode_str(value: object) -> str | None: + """Normalize ``guardrail_mode`` to a stable string. + + ``guardrail_mode`` is typed as a ``GuardrailEventHooks`` enum, a list of them, + or a ``GuardrailMode`` — not a plain string. Emit the enum *value* (e.g. + ``"pre_call"``) rather than ``str(enum)`` (``"GuardrailEventHooks.pre_call"``), + and join a list of modes so a guardrail that runs at multiple hooks is + represented faithfully. + """ + if value is None: + return None + if isinstance(value, (list, tuple)): + parts = [part for item in value if (part := _guardrail_mode_str(item))] + return ",".join(parts) or None + if isinstance(value, Enum): + return as_str(value.value) + return as_str(value) + + def _total_masked_entities(value: object) -> int | None: """``masked_entity_count`` is a ``{entity_type: count}`` map — sum to a total.""" if isinstance(value, Mapping): diff --git a/litellm/integrations/otel/semconv.py b/litellm/integrations/otel/semconv.py index f6e39ce5a0e..09bb6829f44 100644 --- a/litellm/integrations/otel/semconv.py +++ b/litellm/integrations/otel/semconv.py @@ -115,6 +115,9 @@ class LiteLLM: GUARDRAIL_RISK_SCORE: Final = "litellm.guardrail.risk_score" GUARDRAIL_MASKED_ENTITY_COUNT: Final = "litellm.guardrail.masked_entity_count" GUARDRAIL_DURATION: Final = "litellm.guardrail.duration" + GUARDRAIL_ID: Final = "litellm.guardrail.id" + GUARDRAIL_POLICY_TEMPLATE: Final = "litellm.guardrail.policy_template" + GUARDRAIL_DETECTION_METHOD: Final = "litellm.guardrail.detection_method" SERVICE_NAME: Final = "litellm.service.name" SERVICE_CALL_TYPE: Final = "litellm.service.call_type" PREPROCESSING_MS: Final = "litellm.preprocessing.duration_ms" diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 36a389d233d..0902b977e19 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -551,6 +551,14 @@ def _init_kwargs_for_pass_through_endpoint( metadata=_metadata, ) + # Thread the proxy SERVER span through so observability loggers can parent + # the LLM-call span under it. Pass-through logging runs in a detached + # ``asyncio.create_task`` where the ambient OTel context may no longer + # carry the server span, so the OTel adapter falls back to this explicit + # parent — without it the pass-through LLM-call span is dropped (or + # orphaned into its own trace). + _metadata["litellm_parent_otel_span"] = user_api_key_dict.parent_otel_span + kwargs = { "litellm_params": { **litellm_params_in_body, # type: ignore diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_config_fixes.py b/tests/test_litellm/integrations/otel/test_otel_v2_config_fixes.py new file mode 100644 index 00000000000..32d15204e73 --- /dev/null +++ b/tests/test_litellm/integrations/otel/test_otel_v2_config_fixes.py @@ -0,0 +1,228 @@ +"""Tests for the follow-up fixes to the V2 OTel instrumentation: + +1. Baggage allowlists are configurable via env vars and config.yaml + (``callback_settings.otel.*``), not just hard-coded. +2. Pass-through LLM-call spans nest under the proxy server span via the + explicitly threaded ``litellm_parent_otel_span`` when the ambient context + has lost it. +4. Guardrail span data is built from the typed + ``StandardLoggingGuardrailInformation`` shape (provider-agnostic), not from + one provider's assumed field names. +""" + +import asyncio + +import pytest + +pytest.importorskip("opentelemetry") + +from opentelemetry import trace # noqa: E402 +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402 + InMemorySpanExporter, +) + +from litellm.integrations.otel import LiteLLM, OpenTelemetryV2Config # noqa: E402 +from litellm.integrations.otel import providers # noqa: E402 +from litellm.integrations.otel.baggage import ( # noqa: E402 + BAGGAGE_PROMOTED_KEYS, + DEFAULT_BAGGAGE_METADATA_KEYS, +) +from litellm.integrations.otel.logger import ( # noqa: E402 + LITELLM_PROXY_REQUEST_SPAN_NAME, + OpenTelemetryV2, +) +from litellm.integrations.otel.payloads import GuardrailSpanData # noqa: E402 +from litellm.integrations.otel.spans import SpanRole # noqa: E402 + +# --------------------------------------------------------------------------- # +# Problem 1 — baggage allowlists configurable +# --------------------------------------------------------------------------- # + + +def test_baggage_keys_default_when_unset(): + cfg = OpenTelemetryV2Config() + assert cfg.baggage_promoted_keys == list(BAGGAGE_PROMOTED_KEYS) + assert cfg.baggage_metadata_keys == list(DEFAULT_BAGGAGE_METADATA_KEYS) + + +def test_baggage_promoted_keys_from_env_csv(monkeypatch): + monkeypatch.setenv( + "LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS", + f"{LiteLLM.TEAM_ID}, {LiteLLM.KEY_HASH}", + ) + monkeypatch.setenv( + "LITELLM_OTEL_BAGGAGE_METADATA_KEYS", + "user_api_key_user_id,requester_ip_address", + ) + cfg = OpenTelemetryV2Config() + # Whitespace around comma-separated entries is trimmed. + assert cfg.baggage_promoted_keys == [LiteLLM.TEAM_ID, LiteLLM.KEY_HASH] + assert cfg.baggage_metadata_keys == [ + "user_api_key_user_id", + "requester_ip_address", + ] + + +def test_baggage_keys_from_config_yaml_kwargs(): + """``callback_settings.otel.*`` reaches the config through the logger kwargs.""" + logger = OpenTelemetryV2( + baggage_promoted_keys=[LiteLLM.TEAM_ALIAS], + baggage_metadata_keys=["user_api_key_alias"], + ) + assert logger.config.baggage_promoted_keys == [LiteLLM.TEAM_ALIAS] + assert logger.config.baggage_metadata_keys == ["user_api_key_alias"] + + +def test_baggage_processor_allowlist_uses_config_keys(): + cfg = OpenTelemetryV2Config( + exporter="in_memory", baggage_promoted_keys=[LiteLLM.TEAM_ID] + ) + provider, exporter = providers.in_memory_provider(cfg) + from litellm.integrations.otel import context as ctx_mod + from litellm.integrations.otel.emitter import SpanEmitter + from litellm.integrations.otel.payloads import ServiceSpanData + + engine = SpanEmitter(providers.get_tracer(provider, "t"), cfg) + ctx = ctx_mod.set_request_baggage({LiteLLM.TEAM_ID: "t1", LiteLLM.TEAM_ALIAS: "ta"}) + engine.emit(SpanRole.SERVICE, ServiceSpanData("redis"), ctx) + (span,) = exporter.get_finished_spans() + assert span.attributes.get(LiteLLM.TEAM_ID) == "t1" + assert LiteLLM.TEAM_ALIAS not in span.attributes # not in this allowlist + + +# --------------------------------------------------------------------------- # +# Problem 2 — pass-through LLM span parents to the threaded server span +# --------------------------------------------------------------------------- # + + +def _logger(): + cfg = OpenTelemetryV2Config(exporter="in_memory") + exporter = InMemorySpanExporter() + tracer_provider = providers.build_tracer_provider(cfg, exporter=exporter) + return OpenTelemetryV2(config=cfg, tracer_provider=tracer_provider), exporter + + +def _payload(): + return { + "call_type": "pass_through_endpoint", + "custom_llm_provider": "openai", + "model": "gpt-4o", + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + "status": "success", + "litellm_call_id": "call_pt", + "metadata": {}, + "hidden_params": {}, + } + + +def test_passthrough_llm_span_uses_threaded_parent_without_ambient_context(): + """Pass-through logging runs in a detached task with no ambient server span. + The LLM-call span must still nest under the ``litellm_parent_otel_span`` + threaded through metadata instead of becoming a separate root trace.""" + logger, exporter = _logger() + server = logger._emitter.start_span( + SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME + ) + kwargs = { + "standard_logging_object": _payload(), + # No ambient span active (we do NOT use_span here) — only the explicit + # threaded parent, exactly as pass-through threads it. + "litellm_params": {"metadata": {"litellm_parent_otel_span": server}}, + } + asyncio.run(logger.async_log_success_event(kwargs, None, None, None)) + server.end() + + by_name = {s.name: s for s in exporter.get_finished_spans()} + llm_span = by_name["chat gpt-4o"] + assert llm_span.parent is not None + assert llm_span.parent.span_id == server.get_span_context().span_id + + +def test_ambient_server_span_wins_over_threaded_parent(): + """When the ambient context still carries a server span, it is used as the + parent (threaded fallback only kicks in when ambient is missing).""" + logger, exporter = _logger() + ambient = logger._emitter.start_span( + SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME + ) + other = logger._emitter.start_span(SpanRole.PROXY_REQUEST, "other-span") + kwargs = { + "standard_logging_object": _payload(), + "litellm_params": {"metadata": {"litellm_parent_otel_span": other}}, + } + with trace.use_span(ambient, end_on_exit=False): + asyncio.run(logger.async_log_success_event(kwargs, None, None, None)) + ambient.end() + other.end() + by_name = {s.name: s for s in exporter.get_finished_spans()} + llm_span = by_name["chat gpt-4o"] + assert llm_span.parent.span_id == ambient.get_span_context().span_id + + +# --------------------------------------------------------------------------- # +# Problem 4 — typed, provider-agnostic guardrail span data +# --------------------------------------------------------------------------- # + + +def test_guardrail_mode_enum_normalized_to_value(): + from litellm.types.guardrails import GuardrailEventHooks + + d = GuardrailSpanData.from_logging_entry( + { + "guardrail_name": "bedrock-guardrail", + "guardrail_mode": GuardrailEventHooks.pre_call, + "guardrail_status": "success", + } + ) + # The enum *value* ("pre_call"), not "GuardrailEventHooks.pre_call". + assert d.mode == "pre_call" + + +def test_guardrail_mode_list_of_enums_joined(): + from litellm.types.guardrails import GuardrailEventHooks + + d = GuardrailSpanData.from_logging_entry( + { + "guardrail_name": "g", + "guardrail_mode": [ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.post_call, + ], + "guardrail_status": "success", + } + ) + assert d.mode == "pre_call,post_call" + + +def test_guardrail_typed_metadata_fields_mapped_to_span(): + from litellm.integrations.otel.mappers.genai import GenAIMapper + + d = GuardrailSpanData.from_logging_entry( + { + "guardrail_name": "eu-pii", + "guardrail_status": "success", + "guardrail_id": "gd-eu-pii-001", + "policy_template": "EU AI Act Article 5", + "detection_method": "presidio", + } + ) + assert d.guardrail_id == "gd-eu-pii-001" + assert d.policy_template == "EU AI Act Article 5" + assert d.detection_method == "presidio" + attrs = GenAIMapper().map(d) + assert attrs[LiteLLM.GUARDRAIL_ID] == "gd-eu-pii-001" + assert attrs[LiteLLM.GUARDRAIL_POLICY_TEMPLATE] == "EU AI Act Article 5" + assert attrs[LiteLLM.GUARDRAIL_DETECTION_METHOD] == "presidio" + + +def test_guardrail_ignores_non_canonical_provider_keys(): + """Only canonical ``StandardLoggingGuardrailInformation`` keys are read; a + provider's ad-hoc bare ``name``/``status``/``mode`` keys are not assumed.""" + d = GuardrailSpanData.from_logging_entry( + {"name": "bare", "status": "blocked", "mode": "pre"} # type: ignore[typeddict-unknown-key] + ) + assert d.guardrail_name == "guardrail" # fell back to the default + assert d.status is None + assert d.mode is None diff --git a/tests/test_litellm/test_service_logger.py b/tests/test_litellm/test_service_logger.py index ed44fe9b9f2..7211fc8195f 100644 --- a/tests/test_litellm/test_service_logger.py +++ b/tests/test_litellm/test_service_logger.py @@ -6,10 +6,12 @@ """ import pytest -from datetime import datetime, timedelta +from datetime import datetime from unittest.mock import AsyncMock, patch +import litellm from litellm._service_logger import ServiceLogging +from litellm.types.services import ServiceTypes @pytest.mark.asyncio @@ -95,3 +97,79 @@ async def test_async_log_success_event_should_handle_float_duration(): mock_hook.assert_called_once() call_kwargs = mock_hook.call_args assert call_kwargs.kwargs["duration"] == 1.5 + + +# --------------------------------------------------------------------------- # +# V2 OpenTelemetry service-span dispatch (regression: service spans were always +# dropped because the dispatch only recognized the legacy OpenTelemetry class). +# --------------------------------------------------------------------------- # + + +def _make_otel_v2_logger(): + pytest.importorskip("opentelemetry") + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + from litellm.integrations.otel import OpenTelemetryV2Config, providers + from litellm.integrations.otel.logger import OpenTelemetryV2 + + cfg = OpenTelemetryV2Config(exporter="in_memory") + exporter = InMemorySpanExporter() + tracer_provider = providers.build_tracer_provider(cfg, exporter=exporter) + return OpenTelemetryV2(config=cfg, tracer_provider=tracer_provider), exporter + + +def test_resolve_otel_service_logger_recognizes_v2_instance(): + """The V2 logger is a plain CustomLogger, not a subclass of the legacy + OpenTelemetry. The resolver must still recognize it (else service spans are + silently dropped).""" + service_logger = ServiceLogging() + v2_logger, _ = _make_otel_v2_logger() + assert service_logger._resolve_otel_service_logger(v2_logger) is v2_logger + + +def test_resolve_otel_service_logger_recognizes_otel_string(monkeypatch): + # The "otel" string path resolves through the proxy's registered logger, so + # it needs the proxy server module importable. + try: + import litellm.proxy.proxy_server as proxy_server + except ImportError: + pytest.skip("proxy server dependencies not installed") + service_logger = ServiceLogging() + v2_logger, _ = _make_otel_v2_logger() + + monkeypatch.setattr(proxy_server, "open_telemetry_logger", v2_logger, raising=False) + assert service_logger._resolve_otel_service_logger("otel") is v2_logger + + +def test_resolve_otel_service_logger_ignores_unrelated_callback(): + service_logger = ServiceLogging() + assert service_logger._resolve_otel_service_logger("prometheus_system") is None + assert service_logger._resolve_otel_service_logger(object()) is None + + +@pytest.mark.asyncio +async def test_service_span_emitted_for_v2_logger_in_service_callback(monkeypatch): + """End-to-end: a V2 logger registered in ``litellm.service_callback`` produces + a service span when ``async_service_success_hook`` fires with a parent span.""" + from litellm.integrations.otel.spans import SpanRole + + v2_logger, exporter = _make_otel_v2_logger() + parent = v2_logger._emitter.start_span( + SpanRole.PROXY_REQUEST, "POST /chat/completions" + ) + + monkeypatch.setattr(litellm, "service_callback", [v2_logger]) + service_logger = ServiceLogging() + + await service_logger.async_service_success_hook( + service=ServiceTypes.REDIS, + call_type="async_set_cache", + duration=0.01, + parent_otel_span=parent, + ) + parent.end() + + names = [s.name for s in exporter.get_finished_spans()] + assert "redis" in names