"""Typed configuration for the OpenTelemetry instrumentation.""" from enum import Enum from typing import Any, List from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from typing_extensions import Annotated from litellm.integrations.otel.model.baggage import ( BAGGAGE_PROMOTED_KEYS, DEFAULT_BAGGAGE_METADATA_KEYS, DEFAULT_BAGGAGE_TEAM_METADATA_KEYS, ) #: Master feature-flag env var. The logger is inert until this is truthy. OTEL_V2_ENV = "LITELLM_OTEL_V2" class CaptureMessageContent(str): NO_CONTENT = "no_content" SPAN_ONLY = "span_only" EVENT_ONLY = "event_only" SPAN_AND_EVENT = "span_and_event" class ExporterOwner(str, Enum): """The preset that contributed an exporter. Values match the callback names in ``presets.PRESET_BY_CALLBACK`` so per-request dynamic-credential routing can match an exporter's owner against the credential source's callback name. A ``str`` enum so the value compares equal to the bare callback-name string.""" # Arize AX (the hosted platform) and Arize Phoenix (the open-source / Phoenix # Cloud tracer) are distinct backends with separate config and auth, so they # are separate owners. The member value stays the public callback name. ARIZE_AX = "arize" ARIZE_PHOENIX = "arize_phoenix" LANGFUSE_OTEL = "langfuse_otel" WEAVE_OTEL = "weave_otel" LEVO = "levo" AGENTOPS = "agentops" class _OTelV2Flag(BaseSettings): model_config = SettingsConfigDict(extra="ignore") enabled: bool = Field(default=False, validation_alias=AliasChoices(OTEL_V2_ENV)) def is_otel_v2_enabled() -> bool: return _OTelV2Flag().enabled class ExporterSpec(BaseModel): """One span-export destination. The shared ``TracerProvider`` attaches one ``SpanProcessor`` per spec, so listing several specs sends every span to all of them at once (e.g. Arize + Phoenix + your own Honeycomb). """ model_config = {"extra": "forbid"} kind: str = Field( default="console", description="console | in_memory | otlp_http | otlp_grpc | ", ) endpoint: str | None = None headers: str | None = None owner: ExporterOwner | None = Field( default=None, description=( "The preset that contributed this exporter. Per-request dynamic OTLP " "credentials are applied only to the exporter whose owner matches the " "credential source, so one tenant's vendor key never lands on a " "different backend's exporter." ), ) options: dict[str, str] | None = Field( default=None, description=( "Factory-specific configuration for a custom exporter ``kind`` " "registered via ``providers.register_exporter_factory`` (e.g. an " "API key a lazy-auth exporter fetches a token with). Ignored by the " "built-in console/in_memory/otlp exporters." ), ) use_simple_processor: bool | None = Field( default=None, description=( "Force SimpleSpanProcessor regardless of exporter kind. Default: " "auto (Simple for console/in_memory, Batch otherwise)." ), ) class OpenTelemetryV2Config(BaseSettings): model_config = SettingsConfigDict(populate_by_name=True, extra="ignore") # ----- single-destination shorthand, read from standard OTEL_* envs ----- # exporter: str = Field( default="console", validation_alias=AliasChoices("OTEL_EXPORTER", "OTEL_EXPORTER_OTLP_PROTOCOL"), description=( "Exporter kind for the single-destination shorthand. The model " "validator folds this (with ``endpoint`` / ``headers``) into a " "one-entry ``exporters`` list when ``exporters`` is empty; set " "``exporters`` directly for multiple destinations." ), ) endpoint: str | None = Field( default=None, validation_alias=AliasChoices("OTEL_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT"), ) headers: str | None = Field( default=None, validation_alias=AliasChoices("OTEL_HEADERS", "OTEL_EXPORTER_OTLP_HEADERS"), ) service_name: str = Field( default="litellm", validation_alias=AliasChoices("OTEL_SERVICE_NAME") ) deployment_environment: str | None = Field( default=None, validation_alias=AliasChoices("OTEL_ENVIRONMENT_NAME") ) enable_metrics: bool = Field( default=False, validation_alias=AliasChoices("LITELLM_OTEL_INTEGRATION_ENABLE_METRICS"), ) enable_events: bool = Field( default=False, validation_alias=AliasChoices("LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS"), ) capture_message_content: str = Field( default=CaptureMessageContent.NO_CONTENT, validation_alias=AliasChoices( "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" ), ) legacy_compat: bool = Field( default=True, validation_alias=AliasChoices("LITELLM_OTEL_LEGACY_COMPAT") ) # ----- explicit multi-destination / vocabulary configuration ------------ # exporters: list[ExporterSpec] = Field( default_factory=list, description=( "One destination per spec. The shared TracerProvider attaches a " "SpanProcessor per entry. When empty, the model validator folds " "the ``exporter`` / ``endpoint`` / ``headers`` shorthand into a " "single spec so there is always at least one destination." ), ) mapper_names: Annotated[List[str], NoDecode] = Field( default_factory=lambda: ["genai"], description=( "Ordered attribute vocabularies to emit. ``genai`` is the " "canonical OTel GenAI vocabulary and is always placed first. " "Vendor names: ``openinference`` (Arize + Phoenix), ``langfuse``, " "``weave``, ``langtrace``." ), ) resource_attributes: dict[str, str] = Field( default_factory=dict, description=( "Extra Resource attributes beyond ``service.name`` and " "``deployment.environment`` (e.g. integration-specific markers)." ), ) baggage_promoted_keys: Annotated[List[str], NoDecode] = Field( default_factory=lambda: list(BAGGAGE_PROMOTED_KEYS), validation_alias=AliasChoices( "baggage_promoted_keys", "LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS" ), description=( "Identity attribute keys written into Baggage and stamped on every " "child span (e.g. ``litellm.team.id``). Configure via the " "``LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS`` env var (comma-separated) or " "``callback_settings.otel.baggage_promoted_keys`` in config.yaml (a " "YAML list)." ), ) baggage_metadata_keys: Annotated[List[str], NoDecode] = Field( default_factory=lambda: list(DEFAULT_BAGGAGE_METADATA_KEYS), validation_alias=AliasChoices( "baggage_metadata_keys", "LITELLM_OTEL_BAGGAGE_METADATA_KEYS" ), description=( "Metadata sub-keys promoted under the ``litellm.metadata.*`` " "namespace. Configure via the ``LITELLM_OTEL_BAGGAGE_METADATA_KEYS`` " "env var (comma-separated) or " "``callback_settings.otel.baggage_metadata_keys`` in config.yaml." ), ) baggage_team_metadata_keys: Annotated[List[str], NoDecode] = Field( default_factory=lambda: list(DEFAULT_BAGGAGE_TEAM_METADATA_KEYS), validation_alias=AliasChoices( "baggage_team_metadata_keys", "LITELLM_OTEL_BAGGAGE_TEAM_METADATA_KEYS" ), description=( "Sub-keys of the team's free-form metadata promoted under " "``litellm.team.metadata``. Empty by default so none of a team's " "metadata leaves the process until explicitly allowlisted. Configure " "via the ``LITELLM_OTEL_BAGGAGE_TEAM_METADATA_KEYS`` env var " "(comma-separated) or " "``callback_settings.otel.baggage_team_metadata_keys`` in config.yaml." ), ) @field_validator("capture_message_content", mode="before") @classmethod def _normalize_capture_message_content(cls, value: object) -> object: """Fold the capture mode to its canonical lower_snake_case form. V1 read this env var case-insensitively, so operators set the UPPER_SNAKE_CASE form (e.g. ``SPAN_AND_EVENT``). The canonical values here are lower_snake_case; normalizing at the boundary keeps both spellings working and lets every downstream comparison stay exact. """ if isinstance(value, str): return value.lower() return value @field_validator( "baggage_promoted_keys", "baggage_metadata_keys", "baggage_team_metadata_keys", "mapper_names", mode="before", ) @classmethod def _split_csv(cls, value: Any) -> Any: """Accept a comma-separated string for list fields. Env vars are strings, but these fields are lists. Pydantic-settings would otherwise require JSON for a list env var; splitting on commas here lets an operator write ``LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS=litellm.team.id,litellm.api_key.hash``. YAML lists (from ``callback_settings.otel.*``) and real lists pass through unchanged. """ if isinstance(value, str): return [item.strip() for item in value.split(",") if item.strip()] return value @model_validator(mode="after") def _normalize(self) -> "OpenTelemetryV2Config": # An endpoint with the default exporter kind implies OTLP/HTTP. if self.endpoint and self.exporter == "console": self.exporter = "otlp_http" # When no explicit destinations are given, fold the single-destination # shorthand into one spec so the provider always has a destination. if not self.exporters: self.exporters = [ ExporterSpec( kind=self.exporter, endpoint=self.endpoint, headers=self.headers, ) ] # Ensure ``genai`` is always present and first. names = list(self.mapper_names) if "genai" in names: names = ["genai"] + [n for n in names if n != "genai"] else: names = ["genai"] + names # When enabled, also emit attribute keys under their semconv-ai / # Traceloop names via the ``legacy`` mapper. Append it at the tail so # the canonical ``genai`` keys win on any conflict. if self.legacy_compat and "legacy" not in names: names.append("legacy") self.mapper_names = names return self @property def capture_span_content(self) -> bool: """Whether prompt/response content may be stamped as span attributes. Defaults off (``no_content``): an operator must opt in before message bodies leave the process, so a user request can never force its prompt or completion into the configured backend while capture is disabled. """ return self.capture_message_content in ( CaptureMessageContent.SPAN_ONLY, CaptureMessageContent.SPAN_AND_EVENT, ) @classmethod def from_env(cls) -> "OpenTelemetryV2Config": return cls()