Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions litellm/integrations/otel/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,23 @@
"""

from contextlib import contextmanager
from typing import Any, Iterator
from functools import cache
from typing import Any, Callable, Iterator, Optional


@cache
def _otel_runtime() -> "Optional[tuple[Callable[[str], Any], Callable[..., None]]]":
"""Resolve the SDK-backed hooks once and cache the outcome, absence included.

CPython never caches a failed import, so without this memoization every call
site re-attempts the import on each request; when the OTel SDK is not installed
that re-scans ``sys.path`` and contends on the import lock on the hot path.
"""
try:
from litellm.integrations.otel import logger
except Exception:
return None
return (logger.phase_span, logger.seed_request_identity)


@contextmanager
Expand All @@ -18,21 +34,17 @@ def phase_span(name: str) -> "Iterator[Any]":
Yields ``None`` (a plain no-op) when the OTel SDK is unavailable or V2 is not
the active logger.
"""
try:
from litellm.integrations.otel.logger import phase_span as _phase_span
except Exception:
runtime = _otel_runtime()
if runtime is None:
yield None
return
with _phase_span(name) as span:
with runtime[0](name) as span:
yield span


def seed_request_identity(user_api_key_dict: Any, model: Any = None) -> None:
"""Seed request-identity Baggage at the auth boundary (no-op without V2)."""
try:
from litellm.integrations.otel.logger import (
seed_request_identity as _seed_request_identity,
)
except Exception:
runtime = _otel_runtime()
if runtime is None:
return
_seed_request_identity(user_api_key_dict, model=model)
runtime[1](user_api_key_dict, model=model)
64 changes: 64 additions & 0 deletions tests/test_litellm/integrations/otel/test_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Regression tests for the SDK-free OTel runtime shim.

The proxy auth hot path calls ``phase_span`` and ``seed_request_identity`` on
every request. These wrappers resolve the SDK-backed implementations with a
lazy import. CPython never caches a failed import, so before memoization an
absent OTel SDK made every request re-scan ``sys.path`` and contend on the
import lock. These tests pin the import to a single resolution.
"""

import builtins

import litellm.integrations.otel.runtime as runtime


def test_logger_not_reimported_after_first_resolution(monkeypatch):
runtime._otel_runtime.cache_clear()

counts = {"n": 0}
real_import = builtins.__import__

def counting_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == "litellm.integrations.otel" and fromlist and "logger" in fromlist:
counts["n"] += 1
return real_import(name, globals, locals, fromlist, level)

monkeypatch.setattr(builtins, "__import__", counting_import)

with runtime.phase_span("auth /v1/chat/completions"):
pass
after_first = counts["n"]

for _ in range(49):
with runtime.phase_span("auth /v1/chat/completions"):
pass

assert counts["n"] == after_first, (
f"otel.logger re-imported {counts['n'] - after_first} times after the first "
"resolution; it must be memoized so it does not re-scan sys.path per request"
)

runtime._otel_runtime.cache_clear()


def test_resolution_is_memoized():
runtime._otel_runtime.cache_clear()

for _ in range(25):
with runtime.phase_span("p"):
pass

info = runtime._otel_runtime.cache_info()
assert info.misses == 1
assert info.hits >= 24

runtime._otel_runtime.cache_clear()


def test_wrappers_no_op_when_runtime_absent(monkeypatch):
monkeypatch.setattr(runtime, "_otel_runtime", lambda: None)

with runtime.phase_span("auth") as span:
assert span is None

assert runtime.seed_request_identity({"token": "sk-x"}, model="gpt-4o") is None
Loading