litellm/litellm/integrations/opentelemetry.py
yucheng-berri 55ec491d03
fix(otel): bound and shut down credential-scoped tracer providers (#36591)
* fix(otel): bound and shut down credential-scoped tracer providers

Each credential-scoped TracerProvider owns a BatchSpanProcessor worker thread that
only stops on shutdown, and the v1 cache holding them was an unbounded, unsynchronized
dict that never shut anything down. Every distinct team/key credential set therefore
added a thread for the life of the process, and concurrent first-requests for the same
credential set orphaned duplicate providers outright.

Make the cache a lock-guarded bounded LRU that shuts down whatever it drops, matching
the v2 TenantTracerCache. Providers wrapping a caller-supplied SpanExporter instance
share that exporter with the logger's own provider, so they are dropped without
shutdown; those use SimpleSpanProcessor and own no thread.

* fix(otel): reclaim dropped providers on a dedicated executor

Sustained credential churn queues one blocking shutdown per eviction, so using the
shared logging executor let an unreachable tenant endpoint stall unrelated logging
work behind the OTLP retry budget. Give provider shutdown its own bounded pool; its
threads spawn lazily, so a proxy that never evicts still pays nothing.

* fix(otel): decide provider shutdown from the victim, not the evicting request

Both dynamic entry points share one provider cache, so it can hold providers of
mixed exporter ownership. Reading the ownership flag from the evicting request
therefore stopped a shared caller-supplied exporter in one direction, silencing
telemetry process-wide, and leaked a BatchSpanProcessor thread in the other.

Cache ownership alongside the provider so the drop decision reads the victim's
own flag.

* fix(otel): honor the widened header mapping type instead of dict only

Widening the header parameter to Mapping left the isinstance check on dict, so a
non-dict Mapping silently returned no headers at all, which for the OTLP path means
an unauthenticated exporter and no traces with nothing raised. The dict branch also
returned the caller's own object, and dropping the defensive copy at the call site
let that alias reach a long-lived exporter. Match on Mapping and copy.

* fix(otel): do not give a provider we may never stop an interpreter-exit hook

Every TracerProvider registers an atexit hook by default, and that hook holds a strong
reference. Providers wrapping a caller-supplied exporter are dropped without shutdown,
so they stayed pinned for the life of the process and then stopped the shared exporter
at exit. Tie shutdown_on_exit to ownership: those providers use SimpleSpanProcessor and
buffer nothing, so they lose no flush, while providers that own their exporter keep the
hook and their exit flush.

Also stop the victim the eviction test leaves behind, and trim the added comments.
2026-08-18 16:21:58 -07:00

3504 lines
150 KiB
Python

import os
import threading
from collections import OrderedDict
from collections.abc import Callable, Mapping
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, Final, TypedDict, cast
import litellm
from litellm._logging import verbose_logger
from litellm.integrations._types.open_inference import (
OpenInferenceSpanKindValues,
SpanAttributes,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.opentelemetry_utils.gen_ai_semconv import (
OTEL_SEMCONV_STABILITY_OPT_IN_ENV,
OTELGenAISemconvMixin,
OTELSemconvCategory,
parse_semconv_opt_in,
)
from litellm.integrations.otel.model.semconv import Metric
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.secret_redaction import redact_string
from litellm.litellm_core_utils.service_tier_utils import (
get_requested_service_tier,
get_served_service_tier,
)
from litellm.secret_managers.main import get_secret_bool, str_to_bool
from litellm.types.services import ServiceLoggerPayload
from litellm.types.utils import (
ChatCompletionMessageToolCall,
CostBreakdown,
Function,
LLMResponseTypes,
StandardCallbackDynamicParams,
StandardLoggingPayload,
)
# OpenTelemetry imports moved to individual functions to avoid import errors when not installed
if TYPE_CHECKING:
from opentelemetry.sdk.trace import TracerProvider as _SDKTracerProvider
from opentelemetry.sdk.trace.export import SpanExporter as _SpanExporter
from opentelemetry.trace import Context as _Context
from opentelemetry.trace import Span as _Span
from opentelemetry.trace import SpanKind as _SpanKind
from opentelemetry.trace import Tracer as _Tracer
from litellm.proxy._types import (
ManagementEndpointLoggingPayload as _ManagementEndpointLoggingPayload,
)
from litellm.proxy.proxy_server import UserAPIKeyAuth as _UserAPIKeyAuth
Span = _Span | Any
Tracer = _Tracer | Any
Context = _Context | Any
SpanExporter = _SpanExporter | Any
UserAPIKeyAuth = _UserAPIKeyAuth | Any
ManagementEndpointLoggingPayload = _ManagementEndpointLoggingPayload | Any
else:
Span = Any
Tracer = Any
SpanExporter = Any
UserAPIKeyAuth = Any
ManagementEndpointLoggingPayload = Any
Context = Any
class _StartSpanRequiredKwargs(TypedDict):
name: str
start_time: int
context: "Context | None"
class _StartSpanKwargs(_StartSpanRequiredKwargs, total=False):
kind: "_SpanKind"
class _UsageCompletionTokensView(TypedDict, total=False):
completion_tokens: int
class _ResponseWithUsageView(TypedDict, total=False):
usage: "_UsageCompletionTokensView | None"
# Cap on credential-scoped providers held at once; each one owns an exporter thread.
_MAX_DYNAMIC_TRACER_PROVIDERS: Final = 256
# Dedicated so a slow exporter shutdown cannot starve the shared logging executor.
_PROVIDER_SHUTDOWN_EXECUTOR: Final = ThreadPoolExecutor(max_workers=4, thread_name_prefix="OtelProviderShutdown")
LITELLM_TRACER_NAME: Final = os.getenv("OTEL_TRACER_NAME", "litellm")
LITELLM_METER_NAME: Final = os.getenv("LITELLM_METER_NAME", "litellm")
LITELLM_LOGGER_NAME: Final = os.getenv("LITELLM_LOGGER_NAME", "litellm")
LITELLM_PROXY_REQUEST_SPAN_NAME: Final = "Received Proxy Server Request"
# OTel-standard names. status is also kept under error.code for back compat.
HTTP_RESPONSE_STATUS_CODE_ATTRIBUTE: Final = "http.response.status_code"
HTTP_ROUTE_ATTRIBUTE: Final = "http.route"
URL_PATH_ATTRIBUTE: Final = "url.path"
PREPROCESSING_DURATION_MS_ATTRIBUTE: Final = "litellm.preprocessing.duration_ms"
TEAM_METADATA_ATTRIBUTE: Final = "litellm.team.metadata"
MODEL_GROUP_ATTRIBUTE: Final = "litellm.model_group"
PROVIDER_MODEL_ATTRIBUTE: Final = "litellm.provider.model"
# semconv names the service tier attributes under the openai namespace, but every
# provider that reports a tier (OpenAI, Anthropic, Bedrock, Groq, Vertex) uses the
# same request param and response field, so both keys carry all of them.
REQUEST_SERVICE_TIER_ATTRIBUTE: Final = "gen_ai.openai.request.service_tier"
RESPONSE_SERVICE_TIER_ATTRIBUTE: Final = "gen_ai.openai.response.service_tier"
# Remove the hardcoded LITELLM_RESOURCE dictionary - we'll create it properly later
RAW_REQUEST_SPAN_NAME: Final = "raw_gen_ai_request"
LITELLM_REQUEST_SPAN_NAME: Final = "litellm_request"
CAPTURE_MODE_NO_CONTENT: Final = "NO_CONTENT"
CAPTURE_MODE_SPAN_ONLY: Final = "SPAN_ONLY"
CAPTURE_MODE_EVENT_ONLY: Final = "EVENT_ONLY"
CAPTURE_MODE_SPAN_AND_EVENT: Final = "SPAN_AND_EVENT"
_VALID_CAPTURE_MODES: Final = {
CAPTURE_MODE_NO_CONTENT,
CAPTURE_MODE_SPAN_ONLY,
CAPTURE_MODE_EVENT_ONLY,
CAPTURE_MODE_SPAN_AND_EVENT,
}
METRIC_METADATA_KEYS: Final[tuple[str, ...]] = (
"user_api_key_hash",
"user_api_key_alias",
"user_api_key_team_id",
"user_api_key_org_id",
"user_api_key_user_id",
"user_api_key_team_alias",
"user_api_key_user_email",
"spend_logs_metadata",
"requester_ip_address",
"requester_metadata",
"user_api_key_end_user_id",
"prompt_management_metadata",
"applied_guardrails",
"mcp_tool_call_metadata",
"vector_store_request_metadata",
)
TOKEN_TYPE_ATTRIBUTE: Final[str] = "gen_ai.token.type"
VALID_METRIC_ATTRIBUTE_NAMES: Final[frozenset[str]] = frozenset(
(
"gen_ai.operation.name",
"gen_ai.provider.name",
"gen_ai.system",
"gen_ai.request.model",
"gen_ai.framework",
"hidden_params",
)
+ tuple(f"metadata.{key}" for key in METRIC_METADATA_KEYS)
)
@dataclass(frozen=True)
class OTELMetricAttributeFilter:
include_list: list[str] | None = None
exclude_list: list[str] | None = None
def _build_metric_attribute_filter(value: Any) -> OTELMetricAttributeFilter:
if isinstance(value, OTELMetricAttributeFilter):
return value
if not isinstance(value, dict):
raise ValueError(
"otel.attributes must be a mapping with optional 'include_list' / "
f"'exclude_list', got {type(value).__name__}"
)
return OTELMetricAttributeFilter(
include_list=value.get("include_list"),
exclude_list=value.get("exclude_list"),
)
def _resolve_metric_attribute_filter(
attributes: OTELMetricAttributeFilter | None,
) -> tuple[frozenset[str] | None, frozenset[str] | None]:
if attributes is None:
return None, None
include: Final = attributes.include_list or None
exclude: Final = attributes.exclude_list or None
if include and exclude:
raise ValueError("otel.attributes: include_list and exclude_list are mutually exclusive")
requested: Final = include or exclude or []
if TOKEN_TYPE_ATTRIBUTE in requested:
raise ValueError(
f"otel.attributes: {TOKEN_TYPE_ATTRIBUTE} is a structural token-usage discriminator and cannot be filtered"
)
unknown: Final = sorted(name for name in requested if name not in VALID_METRIC_ATTRIBUTE_NAMES)
if unknown:
raise ValueError(
f"otel.attributes: unknown attribute name(s) {unknown}. Valid names: {sorted(VALID_METRIC_ATTRIBUTE_NAMES)}"
)
return (
frozenset(include) if include else None,
frozenset(exclude) if exclude else None,
)
def _normalize_team_metadata_keys(value: Any) -> list[str]:
"""Coerce a team-metadata allowlist from a list or comma-separated string.
config.yaml passes a YAML list; an env var passes a comma-separated string.
Both collapse to a list of stripped, non-empty keys.
"""
if value is None:
return []
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
return [str(item).strip() for item in value if str(item).strip()]
_FREEZE_MAX_DEPTH: Final = 16
HashableScope = str | int | float | bool | bytes | None | tuple["HashableScope", ...] | frozenset["HashableScope"]
def _freeze_for_dedupe(value: object, _depth: int = 0) -> HashableScope:
if _depth >= _FREEZE_MAX_DEPTH:
return repr(value)
if isinstance(value, (list, tuple)):
return tuple(_freeze_for_dedupe(item, _depth + 1) for item in value)
if isinstance(value, set):
return frozenset(_freeze_for_dedupe(item, _depth + 1) for item in value)
if isinstance(value, dict):
return frozenset(
(_freeze_for_dedupe(key, _depth + 1), _freeze_for_dedupe(item, _depth + 1)) for key, item in value.items()
)
if isinstance(value, (str, int, float, bytes)) or value is None:
return value
return repr(value)
def _shutdown_tracer_provider(provider: "_SDKTracerProvider") -> None:
"""Flush and stop a dropped provider so its exporter thread is reclaimed."""
try:
provider.shutdown()
except Exception as e: # noqa: BLE001 # exporter shutdown must not fail the request that dropped it
verbose_logger.debug("OpenTelemetry: error shutting down dropped tracer provider: %s", e)
@dataclass(frozen=True, slots=True)
class _CachedTracerProvider:
"""A cached credential-scoped provider plus whether it may be shut down when dropped."""
provider: "_SDKTracerProvider"
owns_exporter: bool
def _provider_owns_exporter(exporter: "str | _SpanExporter") -> bool:
"""Whether a provider built for ``exporter`` may be shut down when it is dropped.
``_get_span_processor`` builds a fresh exporter for a named kind, but wraps a
caller-supplied ``SpanExporter`` instance as-is, and that instance is shared with the
logger's own provider. Shutting a dropped provider down would then stop exporting for
the whole process. The shared case also uses ``SimpleSpanProcessor``, so it owns no
thread and there is nothing to reclaim.
"""
return not hasattr(exporter, "export")
@dataclass
class OpenTelemetryConfig:
exporter: str | SpanExporter = "console"
endpoint: str | None = None
headers: str | None = None
enable_metrics: bool = False
enable_events: bool = False
service_name: str | None = None
deployment_environment: str | None = None
model_id: str | None = None
ignore_context_propagation: bool | None = None
# When True, create a private TracerProvider instead of reusing or setting the global one.
skip_set_global: bool = False
# Programmatic override for OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT.
# One of NO_CONTENT, SPAN_ONLY, EVENT_ONLY, SPAN_AND_EVENT (or "true" as legacy alias).
capture_message_content: str | None = None
semconv_stability_opt_in: set[OTELSemconvCategory] = field(default_factory=set)
# Sub-keys of the team's free-form metadata stamped onto the inference span
# under ``litellm.team.metadata``. Empty by default so none of a team's
# metadata leaves the process until explicitly allowlisted.
baggage_team_metadata_keys: list[str] = field(default_factory=list)
# Prometheus-style include/exclude control over which attributes are stamped
# on emitted metrics, to cap metric cardinality.
attributes: OTELMetricAttributeFilter | None = None
def __post_init__(self) -> None:
# If endpoint is specified but exporter is still the default "console",
# automatically infer "otlp_http" to send traces to the endpoint.
# This fixes an issue where UI-configured OTEL settings would default
# to console output instead of sending traces to the configured endpoint.
if self.endpoint and isinstance(self.exporter, str) and self.exporter == "console":
self.exporter = "otlp_http"
if not self.service_name:
self.service_name = os.getenv("OTEL_SERVICE_NAME", "litellm")
if not self.deployment_environment:
self.deployment_environment = os.getenv("OTEL_ENVIRONMENT_NAME", "production")
if not self.model_id:
self.model_id = os.getenv("OTEL_MODEL_ID", self.service_name)
if self.ignore_context_propagation is None:
self.ignore_context_propagation = str_to_bool(os.getenv("OTEL_IGNORE_CONTEXT_PROPAGATION"))
# Resolve the env opt-in once here so self.semconv_stability_opt_in is the
# single source of truth: the union of programmatic and env categories.
self.semconv_stability_opt_in |= parse_semconv_opt_in(os.getenv(OTEL_SEMCONV_STABILITY_OPT_IN_ENV))
self.baggage_team_metadata_keys = _normalize_team_metadata_keys(
self.baggage_team_metadata_keys
) or _normalize_team_metadata_keys(os.getenv("LITELLM_OTEL_BAGGAGE_TEAM_METADATA_KEYS"))
@classmethod
def from_env(cls):
"""
OTEL_HEADERS=x-honeycomb-team=B85YgLm9****
OTEL_EXPORTER="otlp_http"
OTEL_ENDPOINT="https://api.honeycomb.io/v1/traces"
OTEL_HEADERS gets sent as headers = {"x-honeycomb-team": "B85YgLm96******"}
"""
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
exporter: Final = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", os.getenv("OTEL_EXPORTER", "console"))
endpoint: Final = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", os.getenv("OTEL_ENDPOINT"))
headers: Final = os.getenv(
"OTEL_EXPORTER_OTLP_HEADERS", os.getenv("OTEL_HEADERS")
) # example: OTEL_HEADERS=x-honeycomb-team=B85YgLm96***"
enable_metrics: Final[bool] = os.getenv("LITELLM_OTEL_INTEGRATION_ENABLE_METRICS", "false").lower() == "true"
enable_events: Final[bool] = os.getenv("LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS", "false").lower() == "true"
service_name: Final = os.getenv("OTEL_SERVICE_NAME", "litellm")
deployment_environment: Final = os.getenv("OTEL_ENVIRONMENT_NAME", "production")
model_id: Final = os.getenv("OTEL_MODEL_ID", service_name)
if exporter == "in_memory":
return cls(exporter=InMemorySpanExporter())
return cls(
exporter=exporter,
endpoint=endpoint,
headers=headers, # example: OTEL_HEADERS=x-honeycomb-team=B85YgLm96***"
enable_metrics=enable_metrics,
enable_events=enable_events,
service_name=service_name,
deployment_environment=deployment_environment,
model_id=model_id,
)
class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
def __init__(
self,
config: OpenTelemetryConfig | None = None,
callback_name: str | None = None,
# injection points for testing
tracer_provider: object | None = None,
logger_provider: object | None = None,
meter_provider: object | None = None,
max_dynamic_tracer_providers: int = _MAX_DYNAMIC_TRACER_PROVIDERS,
**kwargs,
):
team_metadata_keys_override: Final = kwargs.pop("baggage_team_metadata_keys", None)
metric_attributes_override: Final = kwargs.pop("attributes", None)
if config is None:
config = OpenTelemetryConfig.from_env()
if team_metadata_keys_override is not None:
config.baggage_team_metadata_keys = _normalize_team_metadata_keys(team_metadata_keys_override)
if metric_attributes_override is not None:
config.attributes = _build_metric_attribute_filter(metric_attributes_override)
self.config = config
self.callback_name = callback_name
# Resolved on first metric record, not here: the proxy populates
# callback_settings.otel.attributes after this logger is constructed, so
# reading it now would miss it. An explicit config is validated eagerly so
# a bad config still fails at startup.
self._metric_attr_include: frozenset[str] | None = None
self._metric_attr_exclude: frozenset[str] | None = None
self._metric_attr_filter_resolved = False
if config.attributes is not None:
self._ensure_metric_attribute_filter()
self.OTEL_EXPORTER = self.config.exporter
self.OTEL_ENDPOINT = self.config.endpoint
self.OTEL_HEADERS = self.config.headers
self._tracer_provider_cache: OrderedDict[str, _CachedTracerProvider] = OrderedDict()
self._tracer_provider_cache_lock: Final = threading.Lock()
self._max_dynamic_tracer_providers: Final = max(1, max_dynamic_tracer_providers)
self._init_tracing(tracer_provider)
_debug_otel: Final = str(os.getenv("DEBUG_OTEL", "False")).lower()
if _debug_otel == "true":
# Set up logging
import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger(__name__)
# Enable OpenTelemetry logging
otel_exporter_logger: Final = logging.getLogger("opentelemetry.sdk.trace.export")
otel_exporter_logger.setLevel(logging.DEBUG)
# init CustomLogger params
super().__init__(**kwargs)
self._init_metrics(meter_provider)
self._init_logs(logger_provider)
# Sample env-var / config / message_logging at init so subsequent
# _capture_in_span / _capture_in_event calls are deterministic.
self._capture_mode_cached = self._compute_capture_mode_from_init_state()
self._init_otel_logger_on_litellm_proxy()
@staticmethod
def _get_litellm_resource(config: OpenTelemetryConfig):
"""Create an OpenTelemetry Resource using config-driven defaults."""
from opentelemetry.sdk.resources import OTELResourceDetector, Resource
base_attributes: Final[dict[str, str | None]] = {
"service.name": config.service_name,
"deployment.environment": config.deployment_environment,
"model_id": config.model_id or config.service_name,
}
base_resource: Final = Resource.create(base_attributes)
otel_resource_detector: Final = OTELResourceDetector()
env_resource: Final = otel_resource_detector.detect()
return base_resource.merge(env_resource)
def _init_otel_logger_on_litellm_proxy(self):
"""
Initializes OpenTelemetry for litellm proxy server
- Adds Otel as a service callback
- Sets `proxy_server.open_telemetry_logger` to self
"""
try:
from litellm.proxy import proxy_server
except ImportError:
verbose_logger.warning("Proxy Server is not installed. Skipping OpenTelemetry initialization.")
return
# Add self as a service callback
if "otel" not in litellm.service_callback and all(
not isinstance(cb, OpenTelemetry) for cb in litellm.service_callback
):
litellm.service_callback.append(self)
# avoid proxy logger ownership being overwritten by later
# handlers. Multiple integrations (default OTEL, Langfuse OTEL,
# Arize OTEL, etc.) may initialize in sequence; without this guard,
# the last one silently replaces the first and breaks expected
# routing for proxy_server.open_telemetry_logger consumers.
# Behavior: first-registered wins.
if getattr(proxy_server, "open_telemetry_logger", None) is None:
setattr(proxy_server, "open_telemetry_logger", self)
def _get_or_create_provider(
self,
provider,
provider_name: str,
get_existing_provider_fn,
sdk_provider_class,
create_new_provider_fn,
set_provider_fn,
skip_set_global: bool = False,
):
"""
Generic helper to get or create an OpenTelemetry provider (Tracer, Meter, or Logger).
Args:
provider: The provider instance passed to the init function (can be None)
provider_name: Name for logging (e.g., "TracerProvider")
get_existing_provider_fn: Function to get the existing global provider
sdk_provider_class: The SDK provider class to check for (e.g., TracerProvider from SDK)
create_new_provider_fn: Function to create a new provider instance
set_provider_fn: Function to set the provider globally
skip_set_global: If True, don't set the provider globally (for dynamic-only providers)
Returns:
The provider to use (either existing, new, or explicitly provided)
"""
if provider is not None:
# Provider explicitly provided (e.g., for testing)
# Do NOT call set_provider_fn - the caller is responsible for managing global state
# If they want it to be global, they've already set it before passing it to us
verbose_logger.debug(
"OpenTelemetry: Using provided TracerProvider: %s",
type(provider).__name__,
)
return provider
# Check if a provider is already set globally
try:
existing_provider: Final = get_existing_provider_fn()
if isinstance(existing_provider, sdk_provider_class):
if skip_set_global:
verbose_logger.debug(
"OpenTelemetry: existing %s found but skip_set_global=True; creating private %s for isolation",
provider_name,
provider_name,
)
provider = create_new_provider_fn()
else:
verbose_logger.debug(
"OpenTelemetry: Using existing %s: %s",
provider_name,
type(existing_provider).__name__,
)
provider = existing_provider
else:
# Default proxy provider or unknown type, create our own
verbose_logger.debug("OpenTelemetry: Creating new %s", provider_name)
provider = create_new_provider_fn()
if not skip_set_global:
set_provider_fn(provider)
else:
verbose_logger.info(
"OpenTelemetry: Created %s but NOT setting it globally (will use dynamic providers per-request)",
provider_name,
)
except Exception as e:
# Fallback: create a new provider if something goes wrong
verbose_logger.debug(
"OpenTelemetry: Exception checking existing %s, creating new one: %s",
provider_name,
str(e),
)
provider = create_new_provider_fn()
if not skip_set_global:
set_provider_fn(provider)
return provider
def _skip_set_global(self) -> bool:
# langfuse_otel relies on the Langfuse SDK's providers; don't overwrite them.
return self.config.skip_set_global or (hasattr(self, "callback_name") and self.callback_name == "langfuse_otel")
def _compute_capture_mode_from_init_state(self) -> str | None:
"""Sample explicit settings at init. Returns the resolved mode or
None if nothing explicit is set (in which case the legacy
``self.message_logging`` flag is consulted dynamically per request).
``"true"``/``"1"`` map to ``EVENT_ONLY`` per the contrib convention.
``"false"``/``"0"`` map to ``NO_CONTENT``.
Unknown values are ignored.
"""
explicit: Final = self.config.capture_message_content or os.getenv(
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
)
if not explicit:
return None
normalized: Final = explicit.upper()
if normalized in ("TRUE", "1"):
return CAPTURE_MODE_EVENT_ONLY
if normalized in ("FALSE", "0"):
return CAPTURE_MODE_NO_CONTENT
if normalized in _VALID_CAPTURE_MODES:
return normalized
return None
def _resolve_capture_mode(self) -> str:
"""Return the active capture mode for this request.
Precedence:
1. ``litellm.turn_off_message_logging=True`` forces ``NO_CONTENT``
(kill-switch checked dynamically).
2. Explicit setting sampled at init from
``OpenTelemetryConfig.capture_message_content`` or
``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT``.
3. Legacy ``self.message_logging`` (checked dynamically).
"""
if litellm.turn_off_message_logging:
return CAPTURE_MODE_NO_CONTENT
if self._capture_mode_cached is not None:
return self._capture_mode_cached
return CAPTURE_MODE_SPAN_AND_EVENT if self.message_logging else CAPTURE_MODE_NO_CONTENT
def _capture_in_span(self) -> bool:
return self._resolve_capture_mode() in (
CAPTURE_MODE_SPAN_ONLY,
CAPTURE_MODE_SPAN_AND_EVENT,
)
def _capture_in_event(self) -> bool:
return self._resolve_capture_mode() in (
CAPTURE_MODE_EVENT_ONLY,
CAPTURE_MODE_SPAN_AND_EVENT,
)
def _init_tracing(self, tracer_provider):
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.trace import SpanKind
def create_tracer_provider():
provider: Final = TracerProvider(resource=self._get_litellm_resource(self.config))
provider.add_span_processor(self._get_span_processor())
return provider
tracer_provider = self._get_or_create_provider(
provider=tracer_provider,
provider_name="TracerProvider",
get_existing_provider_fn=trace.get_tracer_provider,
sdk_provider_class=TracerProvider,
create_new_provider_fn=create_tracer_provider,
set_provider_fn=trace.set_tracer_provider,
skip_set_global=self._skip_set_global(),
)
# Grab our tracer from the TracerProvider (not from global context)
# This ensures we use the provided TracerProvider (e.g., for testing)
self.tracer = tracer_provider.get_tracer(LITELLM_TRACER_NAME)
self._tracer_provider = tracer_provider
self.span_kind = SpanKind
def _init_metrics(self, meter_provider):
if not self.config.enable_metrics:
self._meter_provider = None
self._operation_duration_histogram = None
self._token_usage_histogram = None
self._cost_histogram = None
self._time_to_first_token_histogram = None
self._time_per_output_token_histogram = None
self._response_duration_histogram = None
return
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
def create_meter_provider():
metric_reader: Final = self._get_metric_reader()
return MeterProvider(
metric_readers=[metric_reader],
resource=self._get_litellm_resource(self.config),
)
meter_provider = self._get_or_create_provider(
provider=meter_provider,
provider_name="MeterProvider",
get_existing_provider_fn=metrics.get_meter_provider,
sdk_provider_class=MeterProvider,
create_new_provider_fn=create_meter_provider,
set_provider_fn=metrics.set_meter_provider,
skip_set_global=self._skip_set_global(),
)
self._meter_provider = meter_provider
meter: Final = meter_provider.get_meter(__name__)
self._operation_duration_histogram = meter.create_histogram(
name=Metric.OPERATION_DURATION,
description="GenAI operation duration",
unit="s",
)
self._token_usage_histogram = meter.create_histogram(
name=Metric.TOKEN_USAGE,
description="GenAI token usage",
unit="{token}",
)
self._cost_histogram = meter.create_histogram(
name=Metric.TOKEN_COST,
description="GenAI request cost",
unit="USD",
)
self._time_to_first_token_histogram = meter.create_histogram(
name=Metric.TIME_TO_FIRST_TOKEN,
description="Time to first token for streaming requests",
unit="s",
)
self._time_per_output_token_histogram = meter.create_histogram(
name=Metric.TIME_PER_OUTPUT_TOKEN,
description="Average time per output token (generation time / completion tokens)",
unit="s",
)
self._response_duration_histogram = meter.create_histogram(
name=Metric.RESPONSE_DURATION,
description="Total LLM API generation time (excludes LiteLLM overhead)",
unit="s",
)
def _init_logs(self, logger_provider):
# nothing to do if events disabled
if not self.config.enable_events:
self._logger_provider = None
return
from opentelemetry._logs import get_logger_provider, set_logger_provider
from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
def create_logger_provider():
provider: Final = OTLoggerProvider(resource=self._get_litellm_resource(self.config))
log_exporter: Final = self._get_log_exporter()
provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter))
return provider
self._logger_provider = self._get_or_create_provider(
provider=logger_provider,
provider_name="LoggerProvider",
get_existing_provider_fn=get_logger_provider,
sdk_provider_class=OTLoggerProvider,
create_new_provider_fn=create_logger_provider,
set_provider_fn=set_logger_provider,
skip_set_global=self._skip_set_global(),
)
def log_success_event(self, kwargs, response_obj, start_time, end_time):
self._handle_success(kwargs, response_obj, start_time, end_time)
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
self._handle_failure(kwargs, response_obj, start_time, end_time)
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
self._handle_success(kwargs, response_obj, start_time, end_time)
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
self._handle_failure(kwargs, response_obj, start_time, end_time)
async def async_service_success_hook(
self,
payload: ServiceLoggerPayload,
parent_otel_span: Span | None = None,
start_time: datetime | float | None = None,
end_time: datetime | float | None = None,
event_metadata: dict | None = None,
):
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
_start_time_ns = 0
_end_time_ns = 0
if isinstance(start_time, float):
_start_time_ns = int(start_time * 1e9)
else:
_start_time_ns = self._to_ns(start_time)
if isinstance(end_time, float):
_end_time_ns = int(end_time * 1e9)
else:
_end_time_ns = self._to_ns(end_time)
if parent_otel_span is not None:
_span_name: Final = payload.service
service_logging_span: Final = self.tracer.start_span(
name=_span_name,
context=trace.set_span_in_context(parent_otel_span),
start_time=_start_time_ns,
)
self.safe_set_attribute(
span=service_logging_span,
key="call_type",
value=payload.call_type,
)
self.safe_set_attribute(
span=service_logging_span,
key="service",
value=payload.service.value,
)
if event_metadata:
for key, value in event_metadata.items():
if value is None:
value = "None"
if isinstance(value, dict):
try:
value = str(value)
except Exception:
value = "litellm logging error - could_not_json_serialize"
self.safe_set_attribute(
span=service_logging_span,
key=key,
value=value,
)
service_logging_span.set_status(Status(StatusCode.OK))
service_logging_span.end(end_time=_end_time_ns)
async def async_service_failure_hook(
self,
payload: ServiceLoggerPayload,
error: str | None = "",
parent_otel_span: Span | None = None,
start_time: datetime | float | None = None,
end_time: float | datetime | None = None,
event_metadata: dict | None = None,
):
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
_start_time_ns = 0
_end_time_ns = 0
if isinstance(start_time, float):
_start_time_ns = int(int(start_time) * 1e9)
else:
_start_time_ns = self._to_ns(start_time)
if isinstance(end_time, float):
_end_time_ns = int(int(end_time) * 1e9)
else:
_end_time_ns = self._to_ns(end_time)
if parent_otel_span is not None:
_span_name: Final = payload.service
service_logging_span: Final = self.tracer.start_span(
name=_span_name,
context=trace.set_span_in_context(parent_otel_span),
start_time=_start_time_ns,
)
self.safe_set_attribute(
span=service_logging_span,
key="call_type",
value=payload.call_type,
)
self.safe_set_attribute(
span=service_logging_span,
key="service",
value=payload.service.value,
)
if error:
self.safe_set_attribute(
span=service_logging_span,
key="error",
value=error,
)
if event_metadata:
for key, value in event_metadata.items():
if isinstance(value, dict):
try:
value = str(value)
except Exception:
value = "litllm logging error - could_not_json_serialize"
self.safe_set_attribute(
span=service_logging_span,
key=key,
value=value,
)
service_logging_span.set_status(Status(StatusCode.ERROR))
service_logging_span.end(end_time=_end_time_ns)
async def async_post_call_failure_hook(
self,
request_data: dict,
original_exception: Exception,
user_api_key_dict: UserAPIKeyAuth,
traceback_str: str | None = None,
):
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
parent_otel_span: Final = user_api_key_dict.parent_otel_span
if parent_otel_span is not None:
parent_otel_span.set_status(Status(StatusCode.ERROR))
# Stamp team attributes onto the SERVER (root) span too, so the
# trace root is team-filterable on the failure path like the
# child exception span below.
self._set_team_attributes_on_span(
span=parent_otel_span,
team_id=user_api_key_dict.team_id,
team_alias=user_api_key_dict.team_alias,
)
# Stamp structured error attrs on the SERVER span itself; the
# failure path otherwise only sets its status (_handle_failure
# records on the litellm_request child span). Inline import:
# litellm_logging <-> integrations is circular.
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
error_information: Final = StandardLoggingPayloadSetup.get_error_information(
original_exception=original_exception,
traceback_str=traceback_str,
)
self._record_exception_on_span(
span=parent_otel_span,
kwargs={
"exception": original_exception,
"standard_logging_object": {"error_information": error_information},
},
)
# _record_exception_on_span only stamps when error_code is set;
# bare TypeError etc. has none, and the span is about to be ended.
error_code: Final = error_information.get("error_code") if error_information else None
if not error_code:
self.set_response_status_code_attribute(parent_otel_span, 500)
# Pre-request latency (request_data carries the propagated
# metadata on the failure path; omitted if it failed before handoff).
self.set_preprocessing_duration_attribute(parent_otel_span, request_data)
_span_name: Final = "Failed Proxy Server Request"
# Exception Logging Child Span
exception_logging_span: Final = self.tracer.start_span(
name=_span_name,
context=trace.set_span_in_context(parent_otel_span),
)
self.safe_set_attribute(
span=exception_logging_span,
key="exception",
value=str(original_exception),
)
self._set_team_attributes_on_span(
span=exception_logging_span,
team_id=user_api_key_dict.team_id,
team_alias=user_api_key_dict.team_alias,
)
exception_logging_span.set_status(Status(StatusCode.ERROR))
exception_logging_span.end(end_time=self._to_ns(datetime.now()))
# Emit guardrail spans for any guardrail invocations that
# ran during this request. _handle_failure typically does this,
# but for pre-call guardrail blocks the standard_logging_object
# may not carry guardrail_information by the time _handle_failure
# fires (the data lives only in request_data["metadata"]). Pull
# directly from request_data so the span is recorded either way;
# _emit_once dedupes if _handle_failure already emitted it.
self._emit_guardrail_spans_from_request_data(
request_data=request_data,
parent_span=parent_otel_span,
)
# End Parent OTEL Sspan
parent_otel_span.end(end_time=self._to_ns(datetime.now()))
def _emit_guardrail_spans_from_request_data(
self,
request_data: dict,
parent_span: "Span | None",
) -> None:
"""Emit ``guardrail`` spans from the request's proxy-internal metadata bucket
(``standard_logging_guardrail_information``).
Routed through ``_create_guardrail_span`` so the dedupe state in
``_otel_internal`` is honoured — if ``_handle_failure`` already
emitted these spans for the same kwargs, this is a no-op.
"""
from opentelemetry import trace as _trace
from litellm.litellm_core_utils.core_helpers import (
get_metadata_variable_name_from_kwargs,
)
request_data = request_data or {}
metadata: Final = request_data.get(get_metadata_variable_name_from_kwargs(request_data)) or {}
guardrail_information: Final = metadata.get("standard_logging_guardrail_information")
if not guardrail_information:
return
# _create_guardrail_span reads guardrail_information from
# kwargs["standard_logging_object"] and shares its dedupe state via
# kwargs["litellm_params"]["metadata"]["_otel_internal"]. Pass the
# SAME metadata dict the proxy populated so _handle_failure and
# this hook see the same dedupe markers.
kwargs: Final[dict[str, object]] = {
"litellm_params": {"metadata": metadata},
"standard_logging_object": {
"guardrail_information": guardrail_information,
"metadata": metadata,
},
}
context: Final = _trace.set_span_in_context(parent_span) if parent_span is not None else None
self._create_guardrail_span(kwargs=kwargs, context=context)
async def async_post_call_success_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: LLMResponseTypes,
):
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
litellm_logging_obj: Final = data.get("litellm_logging_obj")
if litellm_logging_obj is not None and isinstance(litellm_logging_obj, LiteLLMLogging):
kwargs: Final = litellm_logging_obj.model_call_details
parent_span: Final = user_api_key_dict.parent_otel_span
ctx, _ = self._get_span_context(kwargs, default_span=parent_span)
# Pre-request latency on the SERVER span (success path).
self.set_preprocessing_duration_attribute(parent_span, kwargs)
# 3. Guardrail span
self._create_guardrail_span(kwargs=kwargs, context=ctx)
return response
#########################################################
# Team/Key Based Logging Control Flow
#########################################################
def get_tracer_to_use_for_request(self, kwargs: dict) -> Tracer:
"""
Get the tracer to use for this request
If dynamic headers are present, a temporary tracer is created with the dynamic headers.
Otherwise, the default tracer is used.
Returns:
Tracer: The tracer to use for this request
"""
dynamic_config: Final = self._get_dynamic_otel_config_from_kwargs(kwargs)
if dynamic_config is not None:
verbose_logger.debug(
"[OTEL DEBUG] Using DYNAMIC config tracer with endpoint: %s",
dynamic_config.endpoint,
)
return self._get_tracer_with_dynamic_config(dynamic_config)
dynamic_headers: Final = self._get_dynamic_otel_headers_from_kwargs(kwargs)
if dynamic_headers is not None:
# Create spans using a temporary tracer with dynamic headers
tracer_to_use = self._get_tracer_with_dynamic_headers(dynamic_headers)
verbose_logger.debug(
"[OTEL DEBUG] Using DYNAMIC tracer with headers: %s", redact_string(str(dynamic_headers))
)
else:
# For langfuse_otel without dynamic headers, create a provider with env var credentials
if hasattr(self, "callback_name") and self.callback_name == "langfuse_otel":
# Use the headers from config (which were set from env vars during init)
env_var_headers: Final = self._get_headers_dictionary(self.OTEL_HEADERS) if self.OTEL_HEADERS else {}
if env_var_headers:
tracer_to_use = self._get_tracer_with_dynamic_headers(env_var_headers)
verbose_logger.debug(
"[OTEL DEBUG] Using env var credentials for langfuse_otel (master key request)"
)
else:
# No env vars set, use global tracer (will be NoOp)
tracer_to_use = self.tracer
verbose_logger.debug("[OTEL DEBUG] No credentials available for langfuse_otel")
else:
tracer_to_use = self.tracer
verbose_logger.debug("[OTEL DEBUG] Using GLOBAL tracer (no dynamic headers)")
return tracer_to_use
def _get_dynamic_otel_headers_from_kwargs(self, kwargs) -> dict | None:
"""Extract dynamic headers from kwargs if available."""
standard_callback_dynamic_params: Final[StandardCallbackDynamicParams | None] = kwargs.get(
"standard_callback_dynamic_params"
)
if not standard_callback_dynamic_params:
return None
dynamic_headers: Final = self.construct_dynamic_otel_headers(
standard_callback_dynamic_params=standard_callback_dynamic_params
)
return dynamic_headers if dynamic_headers else None
def _get_dynamic_otel_config_from_kwargs(self, kwargs: dict) -> OpenTelemetryConfig | None:
"""Extract a full dynamic exporter config from kwargs if available."""
standard_callback_dynamic_params: Final[StandardCallbackDynamicParams | None] = kwargs.get(
"standard_callback_dynamic_params"
)
if not standard_callback_dynamic_params:
return None
return self.construct_dynamic_otel_config(standard_callback_dynamic_params=standard_callback_dynamic_params)
def _insert_or_drop(
self, cache_key: str, built: "_CachedTracerProvider"
) -> "tuple[_CachedTracerProvider, _CachedTracerProvider | None]":
"""Cache ``built`` under ``cache_key``, returning the entry to use and what to drop.
Caller holds ``_tracer_provider_cache_lock``. The drop is either the loser of a
concurrent build for this key or the LRU victim its insertion pushed out.
"""
raced: Final = self._tracer_provider_cache.get(cache_key)
if raced is not None:
self._tracer_provider_cache.move_to_end(cache_key)
return raced, built
self._tracer_provider_cache[cache_key] = built
if len(self._tracer_provider_cache) > self._max_dynamic_tracer_providers:
return built, self._tracer_provider_cache.popitem(last=False)[1]
return built, None
def _cached_dynamic_tracer(
self,
cache_key: str,
build: Callable[[], "_SDKTracerProvider"],
owns_exporter: bool,
) -> "_Tracer":
"""Return the tracer for ``cache_key``, building and caching a provider on miss.
A provider that owns its exporter also owns a ``BatchSpanProcessor`` worker thread
that only stops on ``shutdown()``, so the cache is a bounded LRU and whatever it
drops is shut down. Without both, a proxy serving key-scoped credentials accumulates
one live thread per credential set for the life of the process.
``owns_exporter`` also decides ``shutdown_on_exit`` at build time: a provider we may
never shut down must not hold an interpreter-exit hook, which would both pin it in
memory for the life of the process and stop the shared exporter at exit. Those
providers use ``SimpleSpanProcessor``, which buffers nothing, so the hook costs them
no flush.
``owns_exporter`` describes the provider being built, and is cached with it, because
the two dynamic entry points share this cache and can disagree: whether the LRU
victim may be shut down is a property of the victim, never of the request that
happened to evict it.
"""
with self._tracer_provider_cache_lock:
cached: Final = self._tracer_provider_cache.get(cache_key)
if cached is not None:
self._tracer_provider_cache.move_to_end(cache_key)
return cached.provider.get_tracer(LITELLM_TRACER_NAME)
# Built outside the lock: exporter construction can block on DNS/TLS.
built: Final = _CachedTracerProvider(provider=build(), owns_exporter=owns_exporter)
with self._tracer_provider_cache_lock:
winner, dropped = self._insert_or_drop(cache_key, built)
if dropped is not None and dropped.owns_exporter:
# Off the caller's thread: shutdown joins the exporter worker.
_PROVIDER_SHUTDOWN_EXECUTOR.submit(_shutdown_tracer_provider, dropped.provider)
return winner.provider.get_tracer(LITELLM_TRACER_NAME)
def _get_tracer_with_dynamic_config(self, dynamic_config: OpenTelemetryConfig) -> "_Tracer":
"""Create (or reuse) a tracer whose exporter target comes from a per-request config."""
from opentelemetry.sdk.trace import TracerProvider
owns_exporter: Final = _provider_owns_exporter(dynamic_config.exporter)
def _build() -> "_SDKTracerProvider":
provider: Final = TracerProvider(
resource=self._get_litellm_resource(self.config), shutdown_on_exit=owns_exporter
)
provider.add_span_processor(self._get_span_processor(config_override=dynamic_config))
return provider
cache_key: Final = (
f"dynamic_config:{dynamic_config.exporter}:{dynamic_config.endpoint}:{dynamic_config.headers}"
)
return self._cached_dynamic_tracer(cache_key, _build, owns_exporter)
def _get_tracer_with_dynamic_headers(self, dynamic_headers: Mapping[str, str]) -> "_Tracer":
"""Create (or reuse) a tracer whose OTLP headers come from a per-request credential set."""
from opentelemetry.sdk.trace import TracerProvider
owns_exporter: Final = _provider_owns_exporter(self.OTEL_EXPORTER)
def _build() -> "_SDKTracerProvider":
provider: Final = TracerProvider(
resource=self._get_litellm_resource(self.config), shutdown_on_exit=owns_exporter
)
provider.add_span_processor(self._get_span_processor(dynamic_headers=dynamic_headers))
return provider
cache_key: Final = str(sorted(dynamic_headers.items()))
return self._cached_dynamic_tracer(cache_key, _build, owns_exporter)
def construct_dynamic_otel_headers(
self, standard_callback_dynamic_params: StandardCallbackDynamicParams
) -> dict | None:
"""
Construct dynamic headers from standard callback dynamic params
Note: You just need to override this method in Arize, Langfuse Otel if you want to allow team/key based logging.
Returns:
dict: A dictionary of dynamic headers
"""
return None
def construct_dynamic_otel_config(
self, standard_callback_dynamic_params: StandardCallbackDynamicParams
) -> OpenTelemetryConfig | None:
"""
Construct a full exporter config from standard callback dynamic params.
Override this when team/key dynamic params must control the export
target (exporter kind + endpoint), not just the request headers. When
this returns a config, it takes precedence over
construct_dynamic_otel_headers for the request.
"""
return None
#########################################################
# End of Team/Key Based Logging Control Flow
#########################################################
def _emit_once(self, kwargs: dict, *scope: object) -> bool:
"""Return True the first time this handler is asked to emit a span
for the given (handler, scope) on this kwargs; False on repeats.
Used to suppress duplicate span emission for two distinct patterns:
1. **Handler-level dual-fire**: streaming code paths trigger both
the sync and async callback for one request, so ``_handle_success``
/ ``_handle_failure`` would otherwise produce two
``litellm_request`` spans. Scope: ``("success",)`` / ``("failure",)``.
2. **Payload-driven multi-entrypoint emission**: a span loop that
reads entries from ``standard_logging_payload`` (currently only
guardrails) is invoked from multiple lifecycle points
(post-call hooks, success callback, failure callback). The list
can be re-read with mutated entries between calls, so dedupe
must be at entry granularity. Scope: the entry's stable identity.
``scope`` parts may include unhashable containers (list, dict, set);
they are normalized into a hashable shape via ``_freeze_for_dedupe``
before keying the marker dict. The marker is stored in
``kwargs["litellm_params"]["metadata"]["_otel_internal"]`` so it is
request-local (kwargs is shared across the sync/async callbacks and
lifecycle hooks for one request).
"""
litellm_params = kwargs.get("litellm_params")
if not isinstance(litellm_params, dict):
litellm_params = {}
kwargs["litellm_params"] = litellm_params
_metadata = litellm_params.get("metadata")
if not isinstance(_metadata, dict):
_metadata = {}
litellm_params["metadata"] = _metadata
_otel_internal = _metadata.get("_otel_internal")
if not isinstance(_otel_internal, dict):
_otel_internal = {}
_metadata["_otel_internal"] = _otel_internal
spans_logged = _otel_internal.get("spans_logged")
if not isinstance(spans_logged, dict):
spans_logged = {}
_otel_internal["spans_logged"] = spans_logged
dedupe_key: Final = (
self.__class__.__name__,
id(self),
*(_freeze_for_dedupe(part) for part in scope),
)
if spans_logged.get(dedupe_key) is True:
return False
spans_logged[dedupe_key] = True
return True
def _end_proxy_span_from_kwargs(self, kwargs: dict, end_time) -> None:
"""Close the proxy-level parent span if it is still recording.
This helper retrieves the proxy span directly from kwargs metadata
and closes it after all child spans have been recorded.
Only called from the success path. The failure path deliberately
leaves the proxy span open so ``async_post_call_failure_hook`` can
append the ``"Failed Proxy Server Request"`` child span before
closing it.
Only spans named ``LITELLM_PROXY_REQUEST_SPAN_NAME`` are closed —
externally provided spans must not be closed by LiteLLM.
"""
litellm_params: Final = kwargs.get("litellm_params", {}) or {}
_metadata: Final = litellm_params.get("metadata", {}) or {}
proxy_span = _metadata.get("litellm_parent_otel_span", None)
# Fallback: check litellm_metadata (used by /v1/messages and other
# LITELLM_METADATA_ROUTES).
if proxy_span is None:
_litellm_metadata: Final = litellm_params.get("litellm_metadata", {}) or {}
proxy_span = _litellm_metadata.get("litellm_parent_otel_span", None)
if (
proxy_span is not None
and getattr(proxy_span, "name", None) == LITELLM_PROXY_REQUEST_SPAN_NAME
and hasattr(proxy_span, "is_recording")
and proxy_span.is_recording()
):
self._close_proxy_span_ok(proxy_span, end_time)
def _close_proxy_span_ok(self, span: Span, end_time) -> None:
"""Stamp http.response.status_code=200 + status=OK, then end the span."""
from opentelemetry.trace import Status, StatusCode
self.set_response_status_code_attribute(span, 200)
span.set_status(Status(StatusCode.OK))
span.end(end_time=self._to_ns(end_time))
def _handle_success(self, kwargs, response_obj, start_time, end_time):
"""Create the litellm_request span then close the proxy span."""
verbose_logger.debug(
"OpenTelemetry Logger: Logging kwargs: %s, OTEL config settings=%s",
kwargs,
self.config,
)
# sync + async success handlers can both fire for one
# request (notably in streaming code paths). Guard against duplicate
# span writes — but still close the proxy span on the skip path so
# the trace doesn't leak an open root span.
if not self._emit_once(kwargs, "success"):
verbose_logger.debug(
"OpenTelemetry: skipping duplicate success span for handler=%s",
self.__class__.__name__,
)
self._end_proxy_span_from_kwargs(kwargs, end_time)
return
ctx, parent_span = self._get_span_context(kwargs)
if self.config.ignore_context_propagation:
parent_span = None # Ignore parent spans from other providers
ctx = None
# Decide whether to create a primary span
# Always create if no parent span exists (backward compatibility)
# OR if USE_OTEL_LITELLM_REQUEST_SPAN is explicitly enabled
should_create_primary_span: Final = parent_span is None or get_secret_bool("USE_OTEL_LITELLM_REQUEST_SPAN")
if should_create_primary_span:
# Create a new litellm_request span
span = self._start_primary_span(kwargs, response_obj, start_time, end_time, ctx)
# Raw-request sub-span (if enabled) - child of litellm_request span
self._maybe_log_raw_request(kwargs, response_obj, start_time, end_time, span)
# Do NOT duplicate attributes onto the parent proxy-request span.
# The child litellm_request span already carries all attributes;
# copying them to the parent doubles storage and complicates
# search (Issue #4).
else:
# Do not create primary span (keep hierarchy shallow when parent exists)
from opentelemetry.trace import Status, StatusCode
span = None
# Only set attributes if the span is still recording (not closed)
# Note: parent_span is guaranteed to be not None here
if hasattr(parent_span, "set_status"):
parent_span.set_status(Status(StatusCode.OK))
self.set_attributes(parent_span, kwargs, response_obj)
# Raw-request as direct child of parent_span
self._maybe_log_raw_request(kwargs, response_obj, start_time, end_time, parent_span)
# 3. Guardrail span — ensure guardrails are always parented to an
# existing span so they never become orphaned root spans (Issue #5).
guardrail_ctx: Final = self._resolve_guardrail_context(span=span, parent_span=parent_span, fallback_ctx=ctx)
self._create_guardrail_span(kwargs=kwargs, context=guardrail_ctx)
# 4. Metrics & cost recording
self._record_metrics(kwargs, response_obj, start_time, end_time)
# 5. Semantic logs.
if self.config.enable_events:
log_span: Final = span if span is not None else parent_span
if log_span is not None:
self._emit_semantic_logs(kwargs, response_obj, log_span)
# 6. Do NOT end parent span - it should be managed by its creator
# External spans (from Langfuse, user code, HTTP headers, global context) must not be closed by LiteLLM
# However, proxy-created spans should be closed here.
if (
parent_span is not None
and hasattr(parent_span, "name")
and parent_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME
and hasattr(parent_span, "is_recording")
and parent_span.is_recording()
):
self._close_proxy_span_ok(parent_span, end_time)
# Stamp team attributes onto the SERVER (root) span before it is
# closed, so the trace root carries them like every child span.
self._set_team_attributes_on_proxy_span_from_kwargs(kwargs)
# close the proxy span explicitly from kwargs metadata
# after all child spans (litellm_request, guardrail, raw_request)
# have been fully recorded and exported.
self._end_proxy_span_from_kwargs(kwargs, end_time)
def _start_primary_span(
self,
kwargs,
response_obj,
start_time,
end_time,
context: "Context | None",
):
from opentelemetry.trace import Status, StatusCode
otel_tracer: Final[Tracer] = self.get_tracer_to_use_for_request(kwargs)
span_kwargs: Final[_StartSpanKwargs] = {
"name": self._get_span_name(kwargs),
"start_time": self._to_ns(start_time),
"context": context,
}
if self._gen_ai_semconv_latest_experimental:
span_kwargs["kind"] = self.span_kind.CLIENT
span: Final = otel_tracer.start_span(**span_kwargs)
span.set_status(Status(StatusCode.OK))
self.set_attributes(span, kwargs, response_obj)
span.end(end_time=self._to_ns(end_time))
return span
def _maybe_log_raw_request(self, kwargs, response_obj, start_time, end_time, parent_span):
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
# raw_gen_ai_request is non-standard in semconv mode.
if self._gen_ai_semconv_latest_experimental:
return
if not self._capture_in_span():
return
litellm_params: Final = kwargs.get("litellm_params", {})
metadata: Final = litellm_params.get("metadata") or {}
generation_name: Final = metadata.get("generation_name")
raw_span_name: Final = generation_name if generation_name else RAW_REQUEST_SPAN_NAME
otel_tracer: Final[Tracer] = self.get_tracer_to_use_for_request(kwargs)
raw_span: Final = otel_tracer.start_span(
name=raw_span_name,
start_time=self._to_ns(start_time),
context=trace.set_span_in_context(parent_span),
)
raw_span.set_status(Status(StatusCode.OK))
self.set_raw_request_attributes(raw_span, kwargs, response_obj)
self._set_team_attributes_from_kwargs(raw_span, kwargs)
raw_span.end(end_time=self._to_ns(end_time))
def _set_team_attributes_on_span(
self,
span: Span,
team_id: str | None,
team_alias: str | None,
) -> None:
"""Stamp team_id / team_alias onto a span so every child span of a
litellm_request trace carries them, not just the root span.
Empty strings are treated as absent: a request made with the master
key or a team-less virtual key carries ``user_api_key_team_id=""``
in ``standard_logging_object.metadata``; propagating that to every
span only adds noise that makes traces look mis-instrumented.
"""
if team_id:
self.safe_set_attribute(
span=span,
key="metadata.user_api_key_team_id",
value=team_id,
)
if team_alias:
self.safe_set_attribute(
span=span,
key="metadata.user_api_key_team_alias",
value=team_alias,
)
def _set_team_attributes_from_kwargs(self, span: Span, kwargs: dict) -> None:
"""Pull team_id / team_alias from the standard logging metadata in kwargs and stamp them onto span."""
std_log: Final = kwargs.get("standard_logging_object")
md: dict = {}
if isinstance(std_log, dict):
md = std_log.get("metadata") or {}
elif std_log is not None:
md = getattr(std_log, "metadata", None) or {}
self._set_team_attributes_on_span(
span=span,
team_id=md.get("user_api_key_team_id"),
team_alias=md.get("user_api_key_team_alias"),
)
def _set_team_attributes_on_proxy_span_from_kwargs(self, kwargs: dict) -> None:
"""Stamp team attributes onto the proxy SERVER (root) span so the
trace root is filterable by team, not just its children. The root
span is created in auth before the team is resolved and is
otherwise only closed (never re-attributed) on the success path.
Guarded to the LiteLLM-created proxy span (by name + recording) so
externally provided parent spans are never mutated.
"""
litellm_params: Final = kwargs.get("litellm_params") or {}
metadata: Final = litellm_params.get("metadata") or {}
proxy_span: Final = metadata.get("litellm_parent_otel_span")
if (
proxy_span is not None
and getattr(proxy_span, "name", None) == LITELLM_PROXY_REQUEST_SPAN_NAME
and hasattr(proxy_span, "is_recording")
and proxy_span.is_recording()
):
self._set_team_attributes_from_kwargs(proxy_span, kwargs)
def _set_inference_identity_attributes(
self,
span: Span,
standard_logging_payload: StandardLoggingPayload,
litellm_params: dict,
) -> None:
"""Stamp request-identity attributes onto an inference span so every
LLM-call span is filterable by the route it came in on, the team's
metadata, and both the user-facing (model_group alias) and the
dispatched (provider) model names. Empty/absent values are skipped.
"""
metadata: Final = standard_logging_payload.get("metadata") or {}
http_route: Final = metadata.get("user_api_key_request_route")
if http_route:
self.safe_set_attribute(span=span, key=HTTP_ROUTE_ATTRIBUTE, value=http_route)
# ``user_api_key_team_metadata`` is dropped from the standard logging
# payload metadata, so read it from the raw request metadata in kwargs.
# ``metadata`` and ``litellm_metadata`` are alternate names for the same
# full metadata dict (the name varies by endpoint), so first-truthy wins.
raw_metadata: Final = litellm_params.get("metadata") or litellm_params.get("litellm_metadata") or {}
team_metadata: Final = self._team_metadata_json(
raw_metadata.get("user_api_key_team_metadata"),
self.config.baggage_team_metadata_keys,
)
if team_metadata:
self.safe_set_attribute(span=span, key=TEAM_METADATA_ATTRIBUTE, value=team_metadata)
model_group: Final = standard_logging_payload.get("model_group")
if model_group:
self.safe_set_attribute(span=span, key=MODEL_GROUP_ATTRIBUTE, value=model_group)
hidden_params: Final = standard_logging_payload.get("hidden_params") or {}
provider_model: Final = hidden_params.get("litellm_model_name") or standard_logging_payload.get("model")
if provider_model:
self.safe_set_attribute(span=span, key=PROVIDER_MODEL_ATTRIBUTE, value=provider_model)
def _set_service_tier_attributes(
self,
span: Span,
standard_logging_payload: StandardLoggingPayload,
) -> None:
"""Stamp the tier the caller asked for and the tier the provider reports it
served, so tier usage is segmentable in traces. Both are optional: a caller
may not name a tier, and streaming responses carry no served tier.
"""
requested_tier: Final = get_requested_service_tier(standard_logging_payload)
if requested_tier is not None:
self.safe_set_attribute(span=span, key=REQUEST_SERVICE_TIER_ATTRIBUTE, value=requested_tier)
served_tier: Final = get_served_service_tier(standard_logging_payload)
if served_tier is not None:
self.safe_set_attribute(span=span, key=RESPONSE_SERVICE_TIER_ATTRIBUTE, value=served_tier)
@staticmethod
def _team_metadata_json(value: Any, allowed_keys: list[str]) -> str | None:
"""JSON-serialize only the allowlisted sub-keys of a team's metadata.
Returns ``None`` when nothing is allowlisted or no allowlisted key is
present, so the empty case is dropped rather than stamping a useless
``"{}"`` (and so a team's metadata never leaves the process until an
operator opts each sub-key in via ``baggage_team_metadata_keys``).
"""
if not isinstance(value, dict) or not value or not allowed_keys:
return None
filtered: Final = {key: value[key] for key in allowed_keys if key in value}
if not filtered:
return None
return safe_dumps(filtered)
def _ensure_metric_attribute_filter(self) -> None:
"""Resolve the include/exclude filter once, falling back to the proxy's
callback_settings.otel.attributes when no explicit config was passed."""
if self._metric_attr_filter_resolved:
return
attributes = self.config.attributes
if attributes is None and self.callback_name in (None, "otel"):
otel_settings: Final = (litellm.callback_settings or {}).get("otel") or {}
raw: Final = otel_settings.get("attributes") if isinstance(otel_settings, dict) else None
if raw is not None:
attributes = _build_metric_attribute_filter(raw)
(
self._metric_attr_include,
self._metric_attr_exclude,
) = _resolve_metric_attribute_filter(attributes)
self._metric_attr_filter_resolved = True
def _filter_metric_attributes(self, attrs: dict[str, str]) -> dict[str, str]:
if not self._metric_attr_filter_resolved:
self._ensure_metric_attribute_filter()
if self._metric_attr_include is not None:
return {k: v for k, v in attrs.items() if k in self._metric_attr_include}
if self._metric_attr_exclude is not None:
return {k: v for k, v in attrs.items() if k not in self._metric_attr_exclude}
return attrs
def _record_metrics(self, kwargs, response_obj, start_time, end_time):
duration_s: Final = (end_time - start_time).total_seconds()
params: Final = kwargs.get("litellm_params") or {}
provider: Final = params.get("custom_llm_provider", "Unknown")
common_attrs = {
"gen_ai.operation.name": (
self._gen_ai_operation_name(kwargs) if self._gen_ai_semconv_latest_experimental else "chat"
),
"gen_ai.system": provider,
"gen_ai.request.model": kwargs.get("model"),
"gen_ai.framework": "litellm",
}
std_log: Final = kwargs.get("standard_logging_object")
md: Final = getattr(std_log, "metadata", None) or (std_log or {}).get("metadata", {})
for key in METRIC_METADATA_KEYS:
value = md.get(key)
if value is None:
continue
if isinstance(value, (dict, list)):
common_attrs[f"metadata.{key}"] = safe_dumps(value)
else:
common_attrs[f"metadata.{key}"] = str(value)
# get hidden params
hidden_params: Final = getattr(std_log, "hidden_params", None) or (std_log or {}).get("hidden_params", {})
if hidden_params:
common_attrs["hidden_params"] = safe_dumps(hidden_params)
common_attrs = self._filter_metric_attributes(common_attrs)
if self._operation_duration_histogram:
self._operation_duration_histogram.record(duration_s, attributes=common_attrs)
if response_obj and (usage := response_obj.get("usage")) and self._token_usage_histogram:
in_attrs: Final = {**common_attrs, TOKEN_TYPE_ATTRIBUTE: "input"}
out_attrs: Final = {**common_attrs, TOKEN_TYPE_ATTRIBUTE: "output"}
self._token_usage_histogram.record(usage.get("prompt_tokens", 0), attributes=in_attrs)
self._token_usage_histogram.record(usage.get("completion_tokens", 0), attributes=out_attrs)
cost: Final = kwargs.get("response_cost")
if self._cost_histogram and cost:
self._cost_histogram.record(cost, attributes=common_attrs)
# Record latency metrics (TTFT, TPOT, and Total Generation Time)
self._record_time_to_first_token_metric(kwargs, common_attrs)
self._record_time_per_output_token_metric(kwargs, response_obj, end_time, duration_s, common_attrs)
self._record_response_duration_metric(kwargs, end_time, common_attrs)
@staticmethod
def _to_timestamp(
val: datetime | float | str | None,
) -> float | None:
"""Convert datetime/float/string to timestamp."""
if val is None:
return None
if isinstance(val, datetime):
return val.timestamp()
if isinstance(val, (int, float)):
return float(val)
# isinstance(val, str) - parse datetime string (with or without microseconds)
try:
return datetime.strptime(val, "%Y-%m-%d %H:%M:%S.%f").timestamp()
except ValueError:
try:
return datetime.strptime(val, "%Y-%m-%d %H:%M:%S").timestamp()
except ValueError:
return None
def _record_time_to_first_token_metric(self, kwargs: dict, common_attrs: dict):
"""Record Time to First Token (TTFT) metric for streaming requests."""
optional_params: Final = kwargs.get("optional_params", {})
is_streaming: Final = optional_params.get("stream", False)
if not (self._time_to_first_token_histogram and is_streaming):
return
# Use api_call_start_time for precision (matches Prometheus implementation)
# This excludes LiteLLM overhead and measures pure LLM API latency
api_call_start_time: Final = kwargs.get("api_call_start_time", None)
completion_start_time: Final = kwargs.get("completion_start_time", None)
if api_call_start_time is not None and completion_start_time is not None:
# Convert to timestamps if needed (handles datetime, float, and string)
api_call_start_ts: Final = self._to_timestamp(api_call_start_time)
completion_start_ts: Final = self._to_timestamp(completion_start_time)
if api_call_start_ts is None or completion_start_ts is None:
return # Skip recording if conversion failed
time_to_first_token_seconds: Final = completion_start_ts - api_call_start_ts
self._time_to_first_token_histogram.record(time_to_first_token_seconds, attributes=common_attrs)
def _record_time_per_output_token_metric(
self,
kwargs: dict,
response_obj: "_ResponseWithUsageView | None",
end_time: datetime,
duration_s: float,
common_attrs: dict,
):
"""Record Time Per Output Token (TPOT) metric.
Calculated as: generation_time / completion_tokens
- For streaming: uses end_time - completion_start_time (time to generate all tokens after first)
- For non-streaming: uses end_time - api_call_start_time (total generation time)
"""
if not self._time_per_output_token_histogram:
return
# Get completion tokens from response_obj
completion_tokens = None
if response_obj and (usage := response_obj.get("usage")):
completion_tokens = usage.get("completion_tokens")
if completion_tokens is None or completion_tokens <= 0:
return
# Calculate generation time
completion_start_time: Final = kwargs.get("completion_start_time", None)
api_call_start_time: Final = kwargs.get("api_call_start_time", None)
# Convert end_time to timestamp (handles datetime, float, and string)
end_time_ts: Final = self._to_timestamp(end_time)
if end_time_ts is None:
# Fallback to duration_s if conversion failed
generation_time_seconds = duration_s
if generation_time_seconds > 0:
time_per_output_token_seconds = generation_time_seconds / completion_tokens
self._time_per_output_token_histogram.record(time_per_output_token_seconds, attributes=common_attrs)
return
if completion_start_time is not None:
# Streaming: use completion_start_time (when first token arrived)
# This measures time to generate all tokens after the first one
completion_start_ts: Final = self._to_timestamp(completion_start_time)
if completion_start_ts is None:
# Fallback to duration_s if conversion failed
generation_time_seconds = duration_s
else:
generation_time_seconds = end_time_ts - completion_start_ts
elif api_call_start_time is not None:
# Non-streaming: use api_call_start_time (total generation time)
api_call_start_ts: Final = self._to_timestamp(api_call_start_time)
if api_call_start_ts is None:
# Fallback to duration_s if conversion failed
generation_time_seconds = duration_s
else:
generation_time_seconds = end_time_ts - api_call_start_ts
else:
# Fallback: use duration_s (already calculated as (end_time - start_time).total_seconds())
generation_time_seconds = duration_s
if generation_time_seconds > 0:
time_per_output_token_seconds = generation_time_seconds / completion_tokens
self._time_per_output_token_histogram.record(time_per_output_token_seconds, attributes=common_attrs)
def _record_response_duration_metric(
self,
kwargs: dict,
end_time: datetime | float,
common_attrs: dict,
):
"""Record Total Generation Time (response duration) metric.
Measures pure LLM API generation time: end_time - api_call_start_time
This excludes LiteLLM overhead and measures only the LLM provider's response time.
Works for both streaming and non-streaming requests.
Mirrors Prometheus's litellm_llm_api_latency_metric.
Uses kwargs.get("end_time") with fallback to parameter for consistency with Prometheus.
"""
if not self._response_duration_histogram:
return
api_call_start_time: Final = kwargs.get("api_call_start_time", None)
if api_call_start_time is None:
return
# Use end_time from kwargs if available (matches Prometheus), otherwise use parameter
# For streaming: end_time is when the stream completes (final chunk received)
# For non-streaming: end_time is when the response is received
_end_time = kwargs.get("end_time") or end_time
if _end_time is None:
_end_time = datetime.now()
# Convert to timestamps if needed (handles datetime, float, and string)
api_call_start_ts: Final = self._to_timestamp(api_call_start_time)
end_time_ts: Final = self._to_timestamp(_end_time)
if api_call_start_ts is None or end_time_ts is None:
return # Skip recording if conversion failed
response_duration_seconds: Final = end_time_ts - api_call_start_ts
if response_duration_seconds > 0:
self._response_duration_histogram.record(response_duration_seconds, attributes=common_attrs)
@staticmethod
def _otel_log_types():
"""Resolve ``(LogRecord, SeverityNumber)`` across OTEL SDK versions.
``LogRecord`` moved out of ``opentelemetry.sdk._logs`` in OTEL >= 1.39.0
(open-telemetry/opentelemetry-python#4676). Imports stay function-local
because the SDK is an optional dependency.
"""
from opentelemetry._logs import SeverityNumber
try:
from opentelemetry.sdk._logs import LogRecord # OTEL < 1.39.0
except ImportError:
from opentelemetry.sdk._logs._internal import ( # OTEL >= 1.39.0
LogRecord,
)
return LogRecord, SeverityNumber
def _emit_semantic_logs(self, kwargs, response_obj, span: Span):
if not self.config.enable_events:
return
# NOTE: Semantic logs (gen_ai.content.prompt/completion events) have compatibility issues
# with OTEL SDK >= 1.39.0 due to breaking changes in PR #4676:
# - LogRecord moved from opentelemetry.sdk._logs to opentelemetry.sdk._logs._internal
# - LogRecord constructor no longer accepts 'resource' parameter (now inherited from LoggerProvider)
# - LogData class was removed entirely
# These logs work correctly in OTEL SDK < 1.39.0 but may fail in >= 1.39.0.
# See: https://github.com/open-telemetry/opentelemetry-python/pull/4676
# TODO: Refactor to use the proper OTEL Logs API instead of directly creating SDK LogRecords
SdkLogRecord, SeverityNumber = self._otel_log_types()
# Resolve through the handler's own LoggerProvider (which may be a
# private one when skip_set_global=True) rather than the module-level
# get_logger() which always goes through the global provider.
otel_logger: Final = self._logger_provider.get_logger(LITELLM_LOGGER_NAME)
parent_ctx: Final = span.get_span_context()
provider: Final = (kwargs.get("litellm_params") or {}).get("custom_llm_provider", "Unknown")
if self._gen_ai_semconv_latest_experimental:
self._emit_inference_details_event(
kwargs=kwargs,
response_obj=response_obj,
provider=provider,
otel_logger=otel_logger,
parent_ctx=parent_ctx,
)
return
# per-message events
for msg in kwargs.get("messages", []):
role = msg.get("role", "user")
attrs = {
"event_name": "gen_ai.content.prompt",
"gen_ai.system": provider,
}
if role == "tool" and msg.get("id"):
attrs["id"] = msg["id"]
capture_event_content = self._capture_in_event()
if capture_event_content and msg.get("content"):
attrs["gen_ai.prompt"] = msg["content"]
body = msg.copy()
if not capture_event_content:
body.pop("content", None)
log_record = SdkLogRecord(
timestamp=self._to_ns(datetime.now()),
trace_id=parent_ctx.trace_id,
span_id=parent_ctx.span_id,
trace_flags=parent_ctx.trace_flags,
severity_number=SeverityNumber.INFO,
severity_text="INFO",
body=body,
attributes=attrs,
)
otel_logger.emit(log_record)
# per-choice events
for idx, choice in enumerate(response_obj.get("choices", [])):
attrs = {
"event_name": "gen_ai.content.completion",
"gen_ai.system": provider,
"index": idx,
"finish_reason": choice.get("finish_reason"),
}
body_msg = choice.get("message", {})
capture_event_content = self._capture_in_event()
if capture_event_content and body_msg.get("content"):
attrs["message.content"] = body_msg["content"]
body = {
"index": idx,
"finish_reason": choice.get("finish_reason"),
"message": {"role": body_msg.get("role", "assistant")},
}
if capture_event_content and body_msg.get("content"):
body["message"]["content"] = body_msg["content"]
log_record = SdkLogRecord(
timestamp=self._to_ns(datetime.now()),
trace_id=parent_ctx.trace_id,
span_id=parent_ctx.span_id,
trace_flags=parent_ctx.trace_flags,
severity_number=SeverityNumber.INFO,
severity_text="INFO",
body=body,
attributes=attrs,
)
otel_logger.emit(log_record)
@staticmethod
def _resolve_guardrail_context(
span: "Span | None",
parent_span: "Span | None",
fallback_ctx: "Context | None",
) -> "Context | None":
"""
Return a valid OTEL context for guardrail child spans so they are
never orphaned (Issue #5). Priority:
1. The litellm_request span that was just created
2. The parent proxy-request span
3. The original fallback context (may be None — last resort)
"""
from opentelemetry import trace as _trace
if span is not None:
return _trace.set_span_in_context(span)
if parent_span is not None:
return _trace.set_span_in_context(parent_span)
return fallback_ctx
def _create_guardrail_span(self, kwargs: dict | None, context: Context | None):
"""
Creates a span for Guardrail, if any guardrail information is present in standard_logging_object
"""
# Create span for guardrail information
kwargs = kwargs or {}
standard_logging_payload: Final[StandardLoggingPayload | None] = kwargs.get("standard_logging_object")
if standard_logging_payload is None:
return
guardrail_information_data: Final = standard_logging_payload.get("guardrail_information")
if not guardrail_information_data:
return
guardrail_information_list: Final = [
information for information in guardrail_information_data if isinstance(information, dict)
]
if not guardrail_information_list:
return
otel_tracer: Final[Tracer] = self.get_tracer_to_use_for_request(kwargs)
for guardrail_information in guardrail_information_list:
start_time_float = guardrail_information.get("start_time")
end_time_float = guardrail_information.get("end_time")
# ``_create_guardrail_span`` is called from three lifecycle
# points (``async_post_call_success_hook``, ``_handle_success``,
# ``_handle_failure``) and re-reads the (mutating) entry list
# each time. Dedupe at entry granularity so a single real
# guardrail invocation produces exactly one span per handler.
if not self._emit_once(
kwargs,
"guardrail",
guardrail_information.get("guardrail_name"),
start_time_float,
guardrail_information.get("guardrail_mode"),
):
continue
start_time_datetime = datetime.now()
if start_time_float is not None:
start_time_datetime = datetime.fromtimestamp(start_time_float)
end_time_datetime = datetime.now()
if end_time_float is not None:
end_time_datetime = datetime.fromtimestamp(end_time_float)
guardrail_span = otel_tracer.start_span(
name="guardrail",
start_time=self._to_ns(start_time_datetime),
context=context,
)
self.safe_set_attribute(
span=guardrail_span,
key=SpanAttributes.OPENINFERENCE_SPAN_KIND,
value=OpenInferenceSpanKindValues.GUARDRAIL.value,
)
self.safe_set_attribute(
span=guardrail_span,
key="guardrail_name",
value=guardrail_information.get("guardrail_name"),
)
self.safe_set_attribute(
span=guardrail_span,
key="guardrail_mode",
value=guardrail_information.get("guardrail_mode"),
)
masked_entity_count = guardrail_information.get("masked_entity_count")
if masked_entity_count is not None:
guardrail_span.set_attribute("masked_entity_count", safe_dumps(masked_entity_count))
guardrail_response = guardrail_information.get("guardrail_response")
if guardrail_response is not None:
guardrail_span.set_attribute("guardrail_response", safe_dumps(guardrail_response))
# Surface guardrail_status (success / guardrail_intervened /
# guardrail_failed_to_respond / not_run) as a top-level span
# attribute so trace backends can filter on it without parsing
# guardrail_response.
self.safe_set_attribute(
span=guardrail_span,
key="guardrail_status",
value=guardrail_information.get("guardrail_status"),
)
# Provider's raw top-level action (e.g. Bedrock's
# ``GUARDRAIL_INTERVENED`` / ``NONE``). Populated by the provider
# hook onto StandardLoggingGuardrailInformation so this integration
# stays provider-agnostic — we only read a normalised string.
guardrail_action = guardrail_information.get("guardrail_action")
if guardrail_action:
guardrail_span.set_attribute("guardrail_action", guardrail_action)
# The provider hook (e.g. Bedrock) extracts violation_categories
# from the raw response BEFORE redaction and stamps them onto
# StandardLoggingGuardrailInformation. Surfacing them here as a
# queryable attribute lets dashboards group by violation category
# without parsing the redacted guardrail_response blob.
violation_categories = guardrail_information.get("violation_categories")
if violation_categories:
# OTel sequence attributes must be homogeneous primitives;
# serialise to JSON once so set_attribute never coerces.
guardrail_span.set_attribute("guardrail_violation_categories", safe_dumps(violation_categories))
self._set_team_attributes_from_kwargs(guardrail_span, kwargs)
guardrail_span.end(end_time=self._to_ns(end_time_datetime))
def _handle_failure(self, kwargs, response_obj, start_time, end_time):
from opentelemetry.trace import Status, StatusCode
verbose_logger.debug(
"OpenTelemetry Logger: Failure HandlerLogging kwargs: %s, OTEL config settings=%s",
kwargs,
self.config,
)
# sync + async failure handlers can both fire for one
# request (notably in streaming code paths), producing two
# semantically identical ERROR spans. Unlike the success path, the
# proxy span is intentionally left open here so that
# ``async_post_call_failure_hook`` can append the
# "Failed Proxy Server Request" child span before closing it —
# there is no proxy-span side-effect to preserve on the skip path.
if not self._emit_once(kwargs, "failure"):
verbose_logger.debug(
"OpenTelemetry: skipping duplicate failure span for handler=%s",
self.__class__.__name__,
)
return
_parent_context, parent_otel_span = self._get_span_context(kwargs)
if self.config.ignore_context_propagation:
parent_otel_span = None # Ignore parent spans from other providers
_parent_context = None
# Decide whether to create a primary span
# Always create if no parent span exists (backward compatibility)
# OR if USE_OTEL_LITELLM_REQUEST_SPAN is explicitly enabled
should_create_primary_span: Final = parent_otel_span is None or get_secret_bool("USE_OTEL_LITELLM_REQUEST_SPAN")
span = None
if should_create_primary_span:
# Span 1: Request sent to litellm SDK
otel_tracer: Final[Tracer] = self.get_tracer_to_use_for_request(kwargs)
span_kwargs: Final[_StartSpanKwargs] = {
"name": self._get_span_name(kwargs),
"start_time": self._to_ns(start_time),
"context": _parent_context,
}
if self._gen_ai_semconv_latest_experimental:
span_kwargs["kind"] = self.span_kind.CLIENT
span = otel_tracer.start_span(**span_kwargs)
span.set_status(Status(StatusCode.ERROR))
self.set_attributes(span, kwargs, response_obj)
# Record exception information using OTEL standard method
self._record_exception_on_span(span=span, kwargs=kwargs)
span.end(end_time=self._to_ns(end_time))
else:
# When parent span exists and USE_OTEL_LITELLM_REQUEST_SPAN=false,
# record error on parent span (keeps hierarchy shallow)
# Only set attributes if the span is still recording (not closed)
# Note: parent_otel_span is guaranteed to be not None here
if parent_otel_span.is_recording():
parent_otel_span.set_status(Status(StatusCode.ERROR))
self.set_attributes(parent_otel_span, kwargs, response_obj)
self._record_exception_on_span(span=parent_otel_span, kwargs=kwargs)
# Create span for guardrail information — ensure proper parenting (Issue #5)
guardrail_ctx: Final = self._resolve_guardrail_context(
span=span, parent_span=parent_otel_span, fallback_ctx=_parent_context
)
self._create_guardrail_span(kwargs=kwargs, context=guardrail_ctx)
# Do NOT end parent span - it should be managed by its creator
# External spans (from Langfuse, user code, HTTP headers, global context) must not be closed by LiteLLM
# However, proxy-created spans should be closed here
if (
parent_otel_span is not None
and hasattr(parent_otel_span, "name")
and parent_otel_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME
):
parent_otel_span.end(end_time=self._to_ns(end_time))
def _record_exception_on_span(self, span: Span, kwargs: dict):
"""
Record exception information on the span using OTEL standard methods.
This extracts error information from StandardLoggingPayload and:
1. Uses span.record_exception() for the actual exception object (OTEL standard)
2. Sets structured error attributes from StandardLoggingPayloadErrorInformation
"""
try:
from litellm.integrations._types.open_inference import (
ErrorAttributes,
)
# Get the exception object if available
exception: Final = kwargs.get("exception")
# Record the exception using OTEL's standard method
if exception is not None:
span.record_exception(exception)
# Get StandardLoggingPayload for structured error information
standard_logging_payload: Final[StandardLoggingPayload | None] = kwargs.get("standard_logging_object")
if standard_logging_payload is None:
return
# Extract error_information from StandardLoggingPayload
error_information: Final = standard_logging_payload.get("error_information")
if error_information is None:
# Fallback to error_str if error_information is not available
error_str: Final = standard_logging_payload.get("error_str")
if error_str:
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_MESSAGE,
value=error_str,
)
return
# Set structured error attributes from StandardLoggingPayloadErrorInformation
if error_information.get("error_code"):
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_CODE,
value=error_information["error_code"],
)
# Also expose under the OTel-standard name as an int
# (error_code is a str, may be non-numeric).
_error_code_val: Final = error_information["error_code"]
if _error_code_val is not None:
try:
self.safe_set_attribute(
span=span,
key=HTTP_RESPONSE_STATUS_CODE_ATTRIBUTE,
value=int(_error_code_val),
)
except (ValueError, TypeError):
pass
if error_information.get("error_class"):
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_TYPE,
value=error_information["error_class"],
)
if error_information.get("error_message"):
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_MESSAGE,
value=error_information["error_message"],
)
if error_information.get("llm_provider"):
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_LLM_PROVIDER,
value=error_information["llm_provider"],
)
if error_information.get("traceback"):
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_STACK_TRACE,
value=error_information["traceback"],
)
except Exception as e:
verbose_logger.exception("OpenTelemetry: Error recording exception on span: %s", str(e))
def set_tools_attributes(self, span: Span, tools):
import json
from litellm.proxy._types import SpanAttributes
if not tools:
return
try:
for i, tool in enumerate(tools):
function = tool.get("function")
if not function:
continue
prefix = f"{SpanAttributes.LLM_REQUEST_FUNCTIONS.value}.{i}"
self.safe_set_attribute(
span=span,
key=f"{prefix}.name",
value=function.get("name"),
)
self.safe_set_attribute(
span=span,
key=f"{prefix}.description",
value=function.get("description"),
)
self.safe_set_attribute(
span=span,
key=f"{prefix}.parameters",
value=json.dumps(function.get("parameters")),
)
except Exception as e:
verbose_logger.error("OpenTelemetry: Error setting tools attributes: %s", str(e))
def cast_as_primitive_value_type(self, value) -> str | bool | int | float:
"""
Casts the value to a primitive OTEL type if it is not already a primitive type.
OTEL supports - str, bool, int, float
If it's not a primitive type, then it's converted to a string
"""
if value is None:
return ""
if isinstance(value, (str, bool, int, float)):
return value
try:
return str(value)
except Exception:
return ""
@staticmethod
def _tool_calls_kv_pair(
tool_calls: list[ChatCompletionMessageToolCall],
) -> dict[str, object]:
from litellm.proxy._types import SpanAttributes
kv_pairs: Final[dict[str, object]] = {}
for idx, tool_call in enumerate(tool_calls):
_function = tool_call.get("function")
if not _function:
continue
keys = Function.__annotations__.keys()
for key in keys:
_value = _function.get(key)
if _value:
kv_pairs[f"{SpanAttributes.LLM_COMPLETIONS.value}.{idx}.function_call.{key}"] = _value
return kv_pairs
def set_attributes(self, span: Span, kwargs, response_obj: Any | None):
try:
if self.callback_name == "langtrace":
from litellm.integrations.langtrace import LangtraceAttributes
LangtraceAttributes().set_langtrace_attributes(span, kwargs, response_obj)
return
elif self.callback_name == "langfuse_otel":
from litellm.integrations.langfuse.langfuse_otel import (
LangfuseOtelLogger,
)
LangfuseOtelLogger.set_langfuse_otel_attributes(span, kwargs, response_obj)
return
elif self.callback_name == "weave_otel":
from litellm.integrations.weave.weave_otel import (
set_weave_otel_attributes,
)
set_weave_otel_attributes(span, kwargs, response_obj)
return
from litellm.proxy._types import SpanAttributes
optional_params: Final = kwargs.get("optional_params", {})
litellm_params: Final = kwargs.get("litellm_params", {}) or {}
standard_logging_payload: Final[StandardLoggingPayload | None] = kwargs.get("standard_logging_object")
if standard_logging_payload is None:
raise ValueError("standard_logging_object not found in kwargs")
# https://github.com/open-telemetry/semantic-conventions/blob/main/model/registry/gen-ai.yaml
# Following Conventions here: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/llm-spans.md
#############################################
############ LLM CALL METADATA ##############
#############################################
metadata: Final = standard_logging_payload["metadata"]
for key, value in metadata.items():
self.safe_set_attribute(span=span, key=f"metadata.{key}", value=value)
# get hidden params
hidden_params: Final = getattr(standard_logging_payload, "hidden_params", None) or (
standard_logging_payload or {}
).get("hidden_params", {})
if hidden_params:
self.safe_set_attribute(
span=span,
key="hidden_params",
value=safe_dumps(hidden_params),
)
self._set_inference_identity_attributes(
span=span,
standard_logging_payload=standard_logging_payload,
litellm_params=litellm_params,
)
# Cost breakdown tracking
cost_breakdown: Final[CostBreakdown | None] = standard_logging_payload.get("cost_breakdown")
if cost_breakdown:
for key, value in cost_breakdown.items():
if value is not None:
self.safe_set_attribute(
span=span,
key=f"gen_ai.cost.{key}",
value=value,
)
#############################################
########## LLM Request Attributes ###########
#############################################
# The name of the LLM a request is being made to
if kwargs.get("model"):
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_REQUEST_MODEL.value,
value=kwargs.get("model"),
)
# The LLM request type
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_REQUEST_TYPE.value,
value=standard_logging_payload["call_type"],
)
# The Generative AI Provider: Azure, OpenAI, etc.
provider_name: Final = litellm_params.get("custom_llm_provider", "Unknown")
# Latest-experimental semconv replaced gen_ai.system with
# gen_ai.provider.name; emit only the conformant key in that mode.
if self._gen_ai_semconv_latest_experimental:
self.safe_set_attribute(
span=span,
key="gen_ai.provider.name",
value=provider_name,
)
else:
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_SYSTEM.value,
value=provider_name,
)
# The maximum number of tokens the LLM generates for a request.
if optional_params.get("max_tokens"):
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_REQUEST_MAX_TOKENS.value,
value=optional_params.get("max_tokens"),
)
# The temperature setting for the LLM request.
if optional_params.get("temperature"):
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_REQUEST_TEMPERATURE.value,
value=optional_params.get("temperature"),
)
# The top_p sampling setting for the LLM request.
if optional_params.get("top_p"):
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_REQUEST_TOP_P.value,
value=optional_params.get("top_p"),
)
if self._gen_ai_semconv_latest_experimental:
# Semconv emits gen_ai.request.stream (only when streaming) via
# _set_semconv_request_attributes; skip the legacy llm.is_streaming.
self._set_semconv_request_attributes(span, optional_params)
self._set_semconv_cache_token_attributes(span, standard_logging_payload)
else:
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_IS_STREAMING.value,
value=str(optional_params.get("stream", False)),
)
if optional_params.get("user"):
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_USER.value,
value=optional_params.get("user"),
)
# The unique identifier for the LLM call.
# Completions have a provider response ID (e.g. "chatcmpl-xxx"),
# but Embeddings and Image-gen responses do not. Fall back to
# the litellm call ID so every call type can be correlated
# across LiteLLM UI, Phoenix traces, and provider logs (Issue #8).
response_id = (response_obj.get("id") if response_obj else None) or standard_logging_payload.get("id")
if response_id:
self.safe_set_attribute(
span=span,
key="gen_ai.response.id",
value=response_id,
)
litellm_call_id: Final = standard_logging_payload.get("litellm_call_id")
if litellm_call_id:
self.safe_set_attribute(
span=span,
key="litellm.call_id",
value=litellm_call_id,
)
# The model used to generate the response.
if response_obj and response_obj.get("model"):
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_RESPONSE_MODEL.value,
value=response_obj.get("model"),
)
self._set_service_tier_attributes(span=span, standard_logging_payload=standard_logging_payload)
usage: Final = response_obj and response_obj.get("usage")
if usage:
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS.value,
value=usage.get("total_tokens"),
)
# The number of tokens used in the LLM response (completion).
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_USAGE_OUTPUT_TOKENS.value,
value=usage.get("completion_tokens"),
)
# The number of tokens used in the LLM prompt.
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_USAGE_INPUT_TOKENS.value,
value=usage.get("prompt_tokens"),
)
########################################################################
########## LLM Request Medssages / tools / content Attributes ###########
#########################################################################
if not self._capture_in_span():
return
if optional_params.get("tools"):
tools: Final = optional_params["tools"]
self.set_tools_attributes(span, tools)
if kwargs.get("messages"):
transformed_messages = self._transform_messages_to_otel_semantic_conventions(kwargs.get("messages"))
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_INPUT_MESSAGES.value,
value=safe_dumps(transformed_messages),
)
# Coalesce the different kwarg names that carry the system
# prompt depending on the call path:
# - "system_instructions" — Vertex AI Gemini chat-completion
# - "instructions" — OpenAI Responses API
# - "system" — Anthropic Messages API
# Use `is not None` rather than truthiness to avoid falsy
# values (e.g. []) falling through to the wrong kwarg.
system_instructions: Final = (
kwargs.get("system_instructions")
if kwargs.get("system_instructions") is not None
else (kwargs.get("instructions") if kwargs.get("instructions") is not None else kwargs.get("system"))
)
if system_instructions:
if isinstance(system_instructions, str):
# Plain text system prompt — no transformation needed
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
value=system_instructions,
)
else:
transformed_system_instructions: Final = self._transform_messages_to_otel_semantic_conventions(
system_instructions
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
value=safe_dumps(transformed_system_instructions),
)
if self._gen_ai_semconv_latest_experimental:
operation_name = self._gen_ai_operation_name(kwargs)
else:
operation_name = (
"chat"
if standard_logging_payload.get("call_type") == "completion"
else standard_logging_payload.get("call_type") or "chat"
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_OPERATION_NAME.value,
value=operation_name,
)
if standard_logging_payload.get("request_id"):
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_REQUEST_ID.value,
value=standard_logging_payload.get("request_id"),
)
#############################################
########## LLM Response Attributes ##########
#############################################
if response_obj is not None:
if response_obj.get("choices"):
transformed_choices: Final = self._transform_choices_to_otel_semantic_conventions(
response_obj.get("choices")
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value,
value=safe_dumps(transformed_choices),
)
finish_reasons: Final = []
for idx, choice in enumerate(response_obj.get("choices")):
if choice.get("finish_reason"):
finish_reasons.append(choice.get("finish_reason"))
if finish_reasons:
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value,
value=safe_dumps(finish_reasons),
)
for idx, choice in enumerate(response_obj.get("choices")):
if choice.get("finish_reason"):
message = choice.get("message")
tool_calls = message.get("tool_calls")
if tool_calls:
kv_pairs = OpenTelemetry._tool_calls_kv_pair(tool_calls)
for key, value in kv_pairs.items():
self.safe_set_attribute(
span=span,
key=key,
value=value,
)
elif response_obj.get("output"):
# Responses API: ResponsesAPIResponse has an "output"
# list instead of "choices". Each item with
# type="message" contains a "content" list of
# OutputText objects (type="output_text").
output_items: Final = response_obj.get("output")
output_messages: Final = self._transform_responses_api_output_to_otel(output_items)
if output_messages:
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value,
value=safe_dumps(output_messages),
)
# Emit per-tool-call span attributes (parity with
# the choices branch that calls _tool_calls_kv_pair).
# Convert Responses API function_call items to the
# ChatCompletionMessageToolCall format expected by
# _tool_calls_kv_pair.
tool_calls = []
for out_item in output_items:
item_d = self._to_dict(out_item)
if item_d and item_d.get("type") == "function_call":
tool_calls.append(
{
"function": {
"name": item_d.get("name", ""),
"arguments": item_d.get("arguments", ""),
}
}
)
if tool_calls:
kv_pairs = OpenTelemetry._tool_calls_kv_pair(tool_calls)
for key, value in kv_pairs.items():
self.safe_set_attribute(
span=span,
key=key,
value=value,
)
# Extract finish reason from ResponsesAPIResponse.status
status: Final = response_obj.get("status")
if status:
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value,
value=safe_dumps([status]),
)
except Exception as e:
self.handle_callback_failure(callback_name=self.callback_name or "opentelemetry")
verbose_logger.exception("OpenTelemetry logging error in set_attributes %s", str(e))
def _cast_as_primitive_value_type(self, value) -> str | bool | int | float:
"""
Casts the value to a primitive OTEL type if it is not already a primitive type.
OTEL supports - str, bool, int, float
If it's not a primitive type, then it's converted to a string
"""
if value is None:
return ""
if isinstance(value, (str, bool, int, float)):
return value
try:
return str(value)
except Exception:
return ""
def safe_set_attribute(self, span: Span, key: str, value: Any):
"""
Safely sets an attribute on the span, ensuring the value is a primitive type.
"""
primitive_value: Final = self._cast_as_primitive_value_type(value)
span.set_attribute(key, primitive_value)
def _transform_messages_to_otel_semantic_conventions(self, messages: list[dict] | str) -> list[dict]:
"""
Transforms LiteLLM/OpenAI style messages into OTEL GenAI 1.38 compliant format.
OTEL expects a 'parts' array instead of a single 'content' string.
"""
if isinstance(messages, str):
# Handle system_instructions passed as a string
return [
{
"role": "system",
"parts": [{"type": "text", "content": messages}],
}
]
transformed: Final = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
parts = []
if isinstance(content, str):
parts.append({"type": "text", "content": content})
elif isinstance(content, list):
# Handle multi-modal content if necessary
for part in content:
if isinstance(part, dict):
parts.append(part)
else:
parts.append({"type": "text", "content": str(part)})
transformed_msg = {"role": role, "parts": parts}
if "id" in msg:
transformed_msg["id"] = msg["id"]
if "tool_calls" in msg:
transformed_msg["tool_calls"] = msg["tool_calls"]
if "tool_call_id" in msg:
transformed_msg["tool_call_id"] = msg["tool_call_id"]
transformed.append(transformed_msg)
return transformed
def _transform_choices_to_otel_semantic_conventions(self, choices: list[dict]) -> list[dict]:
"""
Transforms choices into OTEL GenAI 1.38 compliant format for output.messages.
"""
transformed: Final = []
for choice in choices:
message = choice.get("message") or {}
finish_reason = choice.get("finish_reason")
transformed_msg = self._transform_messages_to_otel_semantic_conventions([message])[0]
if finish_reason:
transformed_msg["finish_reason"] = finish_reason
transformed.append(transformed_msg)
return transformed
@staticmethod
def _to_dict(obj) -> dict | None:
"""Normalize an object to a plain dict.
Handles three forms that appear in practice:
1. Plain ``dict`` — returned as-is.
2. LiteLLM's ``BaseLiteLLMOpenAIResponseObject`` — exposes a
``.get()`` method that delegates to ``__dict__``.
3. Raw Pydantic v2 models from the ``openai`` SDK (e.g.
``ResponseOutputMessage``, ``ResponseOutputText``) — these do
**not** have ``.get()`` but do have ``.model_dump()``.
Returns ``None`` for anything else so callers can skip it.
"""
if isinstance(obj, dict):
return obj
if hasattr(obj, "get"):
# BaseLiteLLMOpenAIResponseObject duck-type
return obj
if hasattr(obj, "model_dump"):
# Raw Pydantic v2 model (e.g. openai SDK types)
return obj.model_dump()
return None
def _transform_responses_api_output_to_otel(self, output: list) -> list[dict]:
"""
Transform Responses API output items into OTEL GenAI 1.38 format.
The Responses API returns output as a list of items, each with a
``type`` field. Message items (``type="message"``) contain a
``content`` list of ``OutputText`` objects with ``type="output_text"``
and ``text`` fields.
Items may be plain dicts, LiteLLM wrapper objects (with ``.get()``),
or raw Pydantic v2 models from the ``openai`` SDK (with
``.model_dump()``). We normalize each item to a dict via
``_to_dict`` before processing.
This method converts them to the same ``{"role": ..., "parts": [...]}``
format used by ``_transform_choices_to_otel_semantic_conventions``.
"""
transformed: Final = []
for raw_item in output:
item = self._to_dict(raw_item)
if item is None:
continue
if item.get("type") == "message":
role = item.get("role", "assistant")
parts = []
for raw_content in item.get("content", []):
content = self._to_dict(raw_content)
if content is None:
continue
if content.get("type") == "output_text":
text = content.get("text", "")
if text:
parts.append({"type": "text", "content": text})
if parts:
transformed.append({"role": role, "parts": parts})
elif item.get("type") == "function_call":
# Surface tool calls from Responses API output
part: dict = {
"type": "tool_call",
"name": item.get("name", ""),
"arguments": item.get("arguments", ""),
}
if item.get("call_id"):
part["id"] = item["call_id"]
transformed.append({"role": "assistant", "parts": [part]})
return transformed
def set_raw_request_attributes(self, span: Span, kwargs, response_obj):
try:
# Only set provider-specific raw payload attributes on this span.
# The parent litellm_request span already carries the standard
# gen_ai.* / metadata.* attributes — duplicating them here doubles
# storage and adds noise (Issue #3).
litellm_params: Final = kwargs.get("litellm_params", {}) or {}
custom_llm_provider: Final = litellm_params.get("custom_llm_provider", "Unknown")
_raw_response = kwargs.get("original_response")
_additional_args: Final = kwargs.get("additional_args", {}) or {}
complete_input_dict: Final = _additional_args.get("complete_input_dict")
#############################################
########## LLM Request Attributes ###########
#############################################
# OTEL Attributes for the RAW Request to https://docs.anthropic.com/en/api/messages
if complete_input_dict and isinstance(complete_input_dict, dict):
for param, val in complete_input_dict.items():
self.safe_set_attribute(
span=span,
key=f"llm.{custom_llm_provider}.{param}",
value=val,
)
#############################################
########## LLM Response Attributes ##########
#############################################
if _raw_response and isinstance(_raw_response, str):
# cast sr -> dict
import json
try:
_parsed: Final[Mapping[str, object]] = json.loads(_raw_response)
for param, val in _parsed.items():
self.safe_set_attribute(
span=span,
key=f"llm.{custom_llm_provider}.{param}",
value=val,
)
except json.JSONDecodeError:
verbose_logger.debug(
"litellm.integrations.opentelemetry.py::set_raw_request_attributes() - raw_response not json string - %s",
_raw_response,
)
self.safe_set_attribute(
span=span,
key=f"llm.{custom_llm_provider}.stringified_raw_response",
value=_raw_response,
)
except Exception as e:
verbose_logger.exception(
"OpenTelemetry logging error in set_raw_request_attributes %s",
str(e),
)
def _to_ns(self, dt):
if dt is None:
return int(datetime.now().timestamp() * 1e9)
if isinstance(dt, (int, float)):
return int(dt * 1e9)
return int(dt.timestamp() * 1e9)
def _get_span_name(self, kwargs) -> str:
litellm_params: Final = kwargs.get("litellm_params", {})
metadata: Final = litellm_params.get("metadata") or {}
generation_name: Final = metadata.get("generation_name")
if generation_name:
return generation_name
if self._gen_ai_semconv_latest_experimental:
model: Final = kwargs.get("model") or "unknown"
return f"{self._gen_ai_operation_name(kwargs)} {model}"
return LITELLM_REQUEST_SPAN_NAME
def get_traceparent_from_header(self, headers):
if headers is None:
return None
_traceparent: Final = headers.get("traceparent", None)
if _traceparent is None:
return None
from opentelemetry.trace.propagation.tracecontext import (
TraceContextTextMapPropagator,
)
propagator: Final = TraceContextTextMapPropagator()
carrier: Final = {"traceparent": _traceparent}
_parent_context: Final = propagator.extract(carrier=carrier)
return _parent_context
def _get_span_context(self, kwargs, default_span: Span | None = None):
from opentelemetry import context, trace
from opentelemetry.trace.propagation.tracecontext import (
TraceContextTextMapPropagator,
)
litellm_params: Final = kwargs.get("litellm_params", {}) or {}
proxy_server_request: Final = litellm_params.get("proxy_server_request", {}) or {}
headers: Final = proxy_server_request.get("headers", {}) or {}
traceparent: Final = headers.get("traceparent", None)
_metadata: Final = litellm_params.get("metadata", {}) or {}
parent_otel_span = _metadata.get("litellm_parent_otel_span", None)
# Fallback: check litellm_metadata (used by /v1/messages and other
# LITELLM_METADATA_ROUTES that store proxy-internal metadata
# separately from the provider's native "metadata" field).
if parent_otel_span is None:
_litellm_metadata: Final = litellm_params.get("litellm_metadata", {}) or {}
parent_otel_span = _litellm_metadata.get("litellm_parent_otel_span", None)
# Priority 1: Explicit parent span from metadata
if parent_otel_span is not None:
verbose_logger.debug("OpenTelemetry: Using explicit parent span from metadata")
return trace.set_span_in_context(parent_otel_span), None
# Priority 2: HTTP traceparent header
if traceparent is not None:
verbose_logger.debug("OpenTelemetry: Using traceparent header for context propagation")
carrier: Final = {"traceparent": traceparent}
return (
TraceContextTextMapPropagator().extract(carrier=carrier),
None,
)
# Priority 3: Active span from global context (auto-detection)
try:
current_span: Final = trace.get_current_span()
if current_span is not None:
span_context: Final = current_span.get_span_context()
if span_context.is_valid:
verbose_logger.debug(
"OpenTelemetry: Using active span from global context: %s (trace_id=%s, span_id=%s, is_recording=%s)",
current_span,
format(span_context.trace_id, "032x"),
format(span_context.span_id, "016x"),
current_span.is_recording(),
)
return context.get_current(), current_span
except Exception as e:
verbose_logger.debug("OpenTelemetry: Error getting current span: %s", str(e))
# Priority 4: No parent context
verbose_logger.debug("OpenTelemetry: No parent context found, creating root span")
return None, None
def _get_span_processor(
self,
dynamic_headers: Mapping[str, str] | None = None,
config_override: OpenTelemetryConfig | None = None,
):
from opentelemetry.sdk.trace.export import (
BatchSpanProcessor,
ConsoleSpanExporter,
SimpleSpanProcessor,
SpanExporter,
)
otel_exporter: Final = config_override.exporter if config_override else self.OTEL_EXPORTER
otel_endpoint: Final = config_override.endpoint if config_override else self.OTEL_ENDPOINT
otel_headers: Final = config_override.headers if config_override else self.OTEL_HEADERS
verbose_logger.debug(
"OpenTelemetry Logger, initializing span processor \nexporter: %s\nendpoint: %s\nheaders: %s",
otel_exporter,
otel_endpoint,
redact_string(str(otel_headers)),
)
_split_otel_headers: Final = OpenTelemetry._get_headers_dictionary(headers=dynamic_headers or otel_headers)
if dynamic_headers:
verbose_logger.debug(
"[OTEL DEBUG] Creating span processor with DYNAMIC headers: %s",
redact_string(str(_split_otel_headers)),
)
elif config_override:
verbose_logger.debug(
"[OTEL DEBUG] Creating span processor with DYNAMIC config, endpoint: %s",
otel_endpoint,
)
else:
verbose_logger.debug("[OTEL DEBUG] Creating span processor with GLOBAL headers")
if hasattr(otel_exporter, "export"): # Check if it has the export method that SpanExporter requires
verbose_logger.debug(
"OpenTelemetry: intiializing SpanExporter. Value of OTEL_EXPORTER: %s",
otel_exporter,
)
return SimpleSpanProcessor(cast(SpanExporter, otel_exporter))
if otel_exporter == "console":
verbose_logger.debug(
"OpenTelemetry: intiializing console exporter. Value of OTEL_EXPORTER: %s",
otel_exporter,
)
return BatchSpanProcessor(ConsoleSpanExporter())
elif otel_exporter == "otlp_http" or otel_exporter == "http/protobuf" or otel_exporter == "http/json":
try:
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter as OTLPSpanExporterHTTP,
)
except ImportError as exc:
raise ImportError(
"OpenTelemetry OTLP HTTP exporter is not available. Install "
"`opentelemetry-exporter-otlp` to enable OTLP HTTP."
) from exc
verbose_logger.debug(
"OpenTelemetry: intiializing http exporter. Value of OTEL_EXPORTER: %s",
otel_exporter,
)
normalized_endpoint = self._normalize_otel_endpoint(otel_endpoint, "traces")
return BatchSpanProcessor(
OTLPSpanExporterHTTP(endpoint=normalized_endpoint, headers=_split_otel_headers),
)
elif otel_exporter == "otlp_grpc" or otel_exporter == "grpc":
try:
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter as OTLPSpanExporterGRPC,
)
except ImportError as exc:
raise ImportError(
"OpenTelemetry OTLP gRPC exporter is not available. Install "
"`opentelemetry-exporter-otlp` and `grpcio` (or `litellm[grpc]`)."
) from exc
verbose_logger.debug(
"OpenTelemetry: intiializing grpc exporter. Value of OTEL_EXPORTER: %s",
otel_exporter,
)
normalized_endpoint = self._normalize_otel_endpoint(otel_endpoint, "traces")
return BatchSpanProcessor(
OTLPSpanExporterGRPC(endpoint=normalized_endpoint, headers=_split_otel_headers),
)
else:
verbose_logger.debug(
"OpenTelemetry: intiializing console exporter. Value of OTEL_EXPORTER: %s",
otel_exporter,
)
return BatchSpanProcessor(ConsoleSpanExporter())
def _get_log_exporter(self):
"""
Get the appropriate log exporter based on the configuration.
"""
verbose_logger.debug(
"OpenTelemetry Logger, initializing log exporter \nself.OTEL_EXPORTER: %s\nself.OTEL_ENDPOINT: %s\nself.OTEL_HEADERS: %s",
self.OTEL_EXPORTER,
self.OTEL_ENDPOINT,
redact_string(str(self.OTEL_HEADERS)),
)
_split_otel_headers: Final = OpenTelemetry._get_headers_dictionary(self.OTEL_HEADERS)
# Normalize endpoint for logs - ensure it points to /v1/logs instead of /v1/traces
normalized_endpoint: Final = self._normalize_otel_endpoint(self.OTEL_ENDPOINT, "logs")
verbose_logger.debug(
"OpenTelemetry: Log endpoint normalized from %s to %s",
self.OTEL_ENDPOINT,
normalized_endpoint,
)
if hasattr(self.OTEL_EXPORTER, "export"):
# Custom exporter provided
verbose_logger.debug(
"OpenTelemetry: Using custom log exporter. Value of OTEL_EXPORTER: %s",
self.OTEL_EXPORTER,
)
return self.OTEL_EXPORTER
otel_logs_exporter: Final = os.getenv("OTEL_LOGS_EXPORTER")
if self.OTEL_EXPORTER == "console" or otel_logs_exporter == "console":
from opentelemetry.sdk._logs.export import ConsoleLogExporter
verbose_logger.debug(
"OpenTelemetry: Using console log exporter. Value of OTEL_EXPORTER: %s",
self.OTEL_EXPORTER,
)
return ConsoleLogExporter()
elif (
self.OTEL_EXPORTER == "otlp_http"
or self.OTEL_EXPORTER == "http/protobuf"
or self.OTEL_EXPORTER == "http/json"
):
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
OTLPLogExporter,
)
verbose_logger.debug(
"OpenTelemetry: Using HTTP log exporter. Value of OTEL_EXPORTER: %s, endpoint: %s",
self.OTEL_EXPORTER,
normalized_endpoint,
)
return OTLPLogExporter(endpoint=normalized_endpoint, headers=_split_otel_headers)
elif self.OTEL_EXPORTER == "otlp_grpc" or self.OTEL_EXPORTER == "grpc":
try:
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
OTLPLogExporter,
)
except ImportError as exc:
raise ImportError(
"OpenTelemetry OTLP gRPC log exporter is not available. Install "
"`opentelemetry-exporter-otlp` and `grpcio` (or `litellm[grpc]`)."
) from exc
verbose_logger.debug(
"OpenTelemetry: Using gRPC log exporter. Value of OTEL_EXPORTER: %s, endpoint: %s",
self.OTEL_EXPORTER,
normalized_endpoint,
)
return OTLPLogExporter(endpoint=normalized_endpoint, headers=_split_otel_headers)
else:
verbose_logger.warning(
"OpenTelemetry: Unknown log exporter '%s', defaulting to console. Supported: console, otlp_http, otlp_grpc",
self.OTEL_EXPORTER,
)
from opentelemetry.sdk._logs.export import ConsoleLogExporter
return ConsoleLogExporter()
def _get_metric_reader(self):
"""
Get the appropriate metric reader based on the configuration.
Histograms keep the SDK's default cumulative temporality: Prometheus-backed
OTLP receivers reject delta histograms and drop the whole batch, while
backends that prefer delta still accept cumulative.
"""
from opentelemetry.sdk.metrics.export import (
ConsoleMetricExporter,
PeriodicExportingMetricReader,
)
verbose_logger.debug(
"OpenTelemetry Logger, initializing metric reader\nself.OTEL_EXPORTER: %s\nself.OTEL_ENDPOINT: %s\nself.OTEL_HEADERS: %s",
self.OTEL_EXPORTER,
self.OTEL_ENDPOINT,
redact_string(str(self.OTEL_HEADERS)),
)
_split_otel_headers: Final = OpenTelemetry._get_headers_dictionary(self.OTEL_HEADERS)
normalized_endpoint: Final = self._normalize_otel_endpoint(self.OTEL_ENDPOINT, "metrics")
if self.OTEL_EXPORTER == "console":
exporter = ConsoleMetricExporter()
return PeriodicExportingMetricReader(exporter, export_interval_millis=5000)
elif (
self.OTEL_EXPORTER == "otlp_http"
or self.OTEL_EXPORTER == "http/protobuf"
or self.OTEL_EXPORTER == "http/json"
):
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
OTLPMetricExporter,
)
exporter = OTLPMetricExporter(
endpoint=normalized_endpoint,
headers=_split_otel_headers,
)
return PeriodicExportingMetricReader(exporter, export_interval_millis=5000)
elif self.OTEL_EXPORTER == "otlp_grpc" or self.OTEL_EXPORTER == "grpc":
try:
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter,
)
except ImportError as exc:
raise ImportError(
"OpenTelemetry OTLP gRPC metric exporter is not available. Install "
"`opentelemetry-exporter-otlp` and `grpcio` (or `litellm[grpc]`)."
) from exc
exporter = OTLPMetricExporter(
endpoint=normalized_endpoint,
headers=_split_otel_headers,
)
return PeriodicExportingMetricReader(exporter, export_interval_millis=5000)
else:
verbose_logger.warning(
"OpenTelemetry: Unknown metric exporter '%s', defaulting to console. Supported: console, otlp_http, otlp_grpc",
self.OTEL_EXPORTER,
)
exporter = ConsoleMetricExporter()
return PeriodicExportingMetricReader(exporter, export_interval_millis=5000)
def _normalize_otel_endpoint(self, endpoint: str | None, signal_type: str) -> str | None:
"""
Normalize the endpoint URL for a specific OpenTelemetry signal type.
The OTLP exporters expect endpoints to use signal-specific paths:
- traces: /v1/traces
- metrics: /v1/metrics
- logs: /v1/logs
This method ensures the endpoint has the correct path for the given signal type.
Args:
endpoint: The endpoint URL to normalize
signal_type: The telemetry signal type ('traces', 'metrics', or 'logs')
Returns:
Normalized endpoint URL with the correct signal path
Examples:
_normalize_otel_endpoint("http://collector:4318/v1/traces", "logs")
-> "http://collector:4318/v1/logs"
_normalize_otel_endpoint("http://collector:4318", "traces")
-> "http://collector:4318/v1/traces"
_normalize_otel_endpoint("http://collector:4318/v1/logs", "metrics")
-> "http://collector:4318/v1/metrics"
"""
if not endpoint:
return endpoint
# Validate signal_type
valid_signals: Final = {"traces", "metrics", "logs"}
if signal_type not in valid_signals:
verbose_logger.warning(
"Invalid signal_type '%s' provided to _normalize_otel_endpoint. "
"Valid values: %s. Returning endpoint unchanged.",
signal_type,
valid_signals,
)
return endpoint
# Remove trailing slash
endpoint = endpoint.rstrip("/")
# Splunk Observability Cloud OTLP/HTTP uses /v2/trace/otlp (not /v1/traces). Do not rewrite.
if signal_type == "traces" and "/v2/trace/otlp" in endpoint:
return endpoint
# Check if endpoint already ends with the correct signal path
target_path: Final = f"/v1/{signal_type}"
if endpoint.endswith(target_path):
return endpoint
# Replace existing signal path with the target signal path
other_signals: Final = valid_signals - {signal_type}
for other_signal in other_signals:
other_path = f"/v1/{other_signal}"
if endpoint.endswith(other_path):
endpoint = endpoint.rsplit("/", 1)[0] + f"/{signal_type}"
return endpoint
# No existing signal path found, append the target path
if not endpoint.endswith("/v1"):
endpoint = endpoint + target_path
else:
endpoint = endpoint + f"/{signal_type}"
return endpoint
@staticmethod
def _get_headers_dictionary(
headers: "str | Mapping[str, str] | None",
) -> dict[str, str]:
"""
Convert a string or dictionary of headers into a dictionary of headers.
"""
_split_otel_headers: dict[str, str] = {}
if headers:
if isinstance(headers, str):
# when passed HEADERS="x-honeycomb-team=B85YgLm96******"
# Split only on first '=' occurrence
parts: Final = headers.split(",")
for part in parts:
key, value = part.split("=", 1)
_split_otel_headers[key] = value
elif isinstance(headers, Mapping):
_split_otel_headers.update(headers)
return _split_otel_headers
async def async_management_endpoint_success_hook(
self,
logging_payload: ManagementEndpointLoggingPayload,
parent_otel_span: Span | None = None,
):
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
_start_time_ns = 0
_end_time_ns = 0
start_time: Final = logging_payload.start_time
end_time: Final = logging_payload.end_time
if isinstance(start_time, float):
_start_time_ns = int(start_time * 1e9)
else:
_start_time_ns = self._to_ns(start_time)
if isinstance(end_time, float):
_end_time_ns = int(end_time * 1e9)
else:
_end_time_ns = self._to_ns(end_time)
if parent_otel_span is not None:
_span_name: Final = logging_payload.route
management_endpoint_span: Final = self.tracer.start_span(
name=_span_name,
context=trace.set_span_in_context(parent_otel_span),
start_time=_start_time_ns,
)
_request_data: Final = logging_payload.request_data
if _request_data is not None:
for key, value in _request_data.items():
self.safe_set_attribute(
span=management_endpoint_span,
key=f"request.{key}",
value=value,
)
_response: Final = logging_payload.response
if _response is not None:
for key, value in _response.items():
self.safe_set_attribute(
span=management_endpoint_span,
key=f"response.{key}",
value=value,
)
management_endpoint_span.set_status(Status(StatusCode.OK))
management_endpoint_span.end(end_time=_end_time_ns)
# The management wrapper has no other hook that closes the SERVER span.
self.set_response_status_code_attribute(parent_otel_span, 200)
parent_otel_span.set_status(Status(StatusCode.OK))
parent_otel_span.end(end_time=_end_time_ns)
async def async_management_endpoint_failure_hook(
self,
logging_payload: ManagementEndpointLoggingPayload,
parent_otel_span: Span | None = None,
):
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
_start_time_ns = 0
_end_time_ns = 0
start_time: Final = logging_payload.start_time
end_time: Final = logging_payload.end_time
if isinstance(start_time, float):
_start_time_ns = int(int(start_time) * 1e9)
else:
_start_time_ns = self._to_ns(start_time)
if isinstance(end_time, float):
_end_time_ns = int(int(end_time) * 1e9)
else:
_end_time_ns = self._to_ns(end_time)
if parent_otel_span is not None:
_span_name: Final = logging_payload.route
management_endpoint_span: Final = self.tracer.start_span(
name=_span_name,
context=trace.set_span_in_context(parent_otel_span),
start_time=_start_time_ns,
)
_request_data: Final = logging_payload.request_data
if _request_data is not None:
for key, value in _request_data.items():
self.safe_set_attribute(
span=management_endpoint_span,
key=f"request.{key}",
value=value,
)
_exception: Final = logging_payload.exception
self.safe_set_attribute(
span=management_endpoint_span,
key="exception",
value=str(_exception),
)
management_endpoint_span.set_status(Status(StatusCode.ERROR))
management_endpoint_span.end(end_time=_end_time_ns)
# The management wrapper has no other hook that closes the SERVER span.
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
error_information: Final = StandardLoggingPayloadSetup.get_error_information(
original_exception=_exception,
)
parent_otel_span.set_status(Status(StatusCode.ERROR))
self._record_exception_on_span(
span=parent_otel_span,
kwargs={
"exception": _exception,
"standard_logging_object": {"error_information": error_information},
},
)
parent_otel_span.end(end_time=_end_time_ns)
def create_litellm_proxy_request_started_span(
self,
start_time: datetime,
headers: dict,
) -> Span | None:
"""
Create a span for the received proxy server request.
"""
return self.tracer.start_span(
name=LITELLM_PROXY_REQUEST_SPAN_NAME,
start_time=self._to_ns(start_time),
context=self.get_traceparent_from_header(headers=headers),
kind=self.span_kind.SERVER,
)
def set_proxy_request_route_attributes(
self,
span: Span | None,
*,
url_path: str | None = None,
http_route: str | None = None,
) -> None:
"""
Set OTel-standard ``http.route`` / ``url.path`` on the proxy SERVER
span. Called from the auth path, the only point where both the
SERVER span and the request are in hand. No-op if span/value missing.
"""
if span is None:
return
if url_path:
self.safe_set_attribute(span=span, key=URL_PATH_ATTRIBUTE, value=url_path)
if http_route:
self.safe_set_attribute(span=span, key=HTTP_ROUTE_ATTRIBUTE, value=http_route)
def set_response_status_code_attribute(self, span: Span | None, status_code: int | None) -> None:
"""
Set OTel-standard ``http.response.status_code`` (int) on the proxy
SERVER span. The failure path sets this from the error code in
``_record_exception_on_span``; this is the success-path counterpart
so the attribute is present on every SERVER span regardless of
outcome (required by the HTTP semconv, and needed for error-ratio /
status-breakdown dashboards). No-op if span/value missing.
"""
if span is None or status_code is None:
return
self.safe_set_attribute(
span=span,
key=HTTP_RESPONSE_STATUS_CODE_ATTRIBUTE,
value=int(status_code),
)
def record_error_attributes_on_span(
self,
span: Span | None,
exception: Exception | None,
status_code: int,
) -> None:
"""Stamp structured ``error.*`` attributes on the SERVER span from the
exception returned to the client, with ``error.code`` pinned to the real
response status. Idempotent (overwrites); emits no exception event."""
if span is None or exception is None:
return
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
error_information: Final = StandardLoggingPayloadSetup.get_error_information(original_exception=exception)
error_information["error_code"] = str(status_code)
self._record_exception_on_span(
span=span,
kwargs={"standard_logging_object": {"error_information": error_information}},
)
def set_preprocessing_duration_attribute(self, span: Span | None, container: Any) -> None:
"""
Set ``litellm.preprocessing.duration_ms`` (proxy-receive -> first
provider handoff) on the proxy SERVER span. ``litellm_received_at``
rides request metadata; ``first_api_call_start_time`` is the
set-once first-handoff instant (retries/backoff excluded). Works
uniformly for the success (model_call_details) and failure
(request_data) containers. No-op if span/either anchor is missing.
"""
if span is None or not isinstance(container, dict):
return
received_at = None
# first_api_call_start_time is top-level (never in user metadata).
first_handoff: Final = container.get("first_api_call_start_time")
_lp: Final = container.get("litellm_params")
for _md in (
(_lp or {}).get("metadata") if isinstance(_lp, dict) else None,
container.get("metadata"),
container.get("litellm_metadata"),
):
if isinstance(_md, dict):
received_at = received_at or _md.get("litellm_received_at")
if received_at is None or first_handoff is None:
return
try:
start_ts: Final = self._to_timestamp(received_at)
end_ts: Final = self._to_timestamp(first_handoff)
except Exception:
return
if start_ts is None or end_ts is None:
return
duration_ms: Final = (end_ts - start_ts) * 1000.0
# Clock skew → omit rather than emit a negative latency.
if duration_ms < 0:
return
self.safe_set_attribute(
span=span,
key=PREPROCESSING_DURATION_MS_ATTRIBUTE,
value=duration_ms,
)