This commit is contained in:
Yashvardhan Didwania 2026-10-01 05:16:36 +05:30 • committed by GitHub
commit 2fc3b841ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 71 additions and 0 deletions

View file

@ -133,6 +133,16 @@ class ServiceLogging(CustomLogger):
except Exception as e:
verbose_logger.exception("Error emitting service event - %s", e)
def _has_service_event_observers(self) -> bool:
"""Whether anything would receive a service event sent from a blocking caller.
Sending one runs an event loop on the calling thread. Under gevent, every
greenlet on that thread then sees a running loop, and Django rejects their
database queries with ``SynchronousOnlyOperation``. So skip it when there is
no service callback (or test counter) to receive the event.
"""
return bool(litellm.service_callback) or self.mock_testing
@staticmethod
def _dispatch_from_sync(hook: Callable[[], Coroutine[object, object, None]]) -> None:
"""Run an async service hook from a blocking caller, whatever event loop it holds.
@ -167,6 +177,8 @@ class ServiceLogging(CustomLogger):
if self.mock_testing:
self.mock_testing_sync_success_hook += 1
if not self._has_service_event_observers():
return
self._dispatch_from_sync(
lambda: self.async_service_success_hook(
@ -196,6 +208,8 @@ class ServiceLogging(CustomLogger):
"""
if self.mock_testing:
self.mock_testing_sync_failure_hook += 1
if not self._has_service_event_observers():
return
self._dispatch_from_sync(
lambda: self.async_service_failure_hook(

View file

@ -5,6 +5,9 @@ Regression test for KeyError: 'call_type' when async_log_success_event
is called without call_type in kwargs (e.g. from batch polling callbacks).
"""
import asyncio
import asyncio.events
import pytest
from datetime import datetime
from unittest.mock import AsyncMock, patch
@ -277,3 +280,57 @@ async def test_service_failure_span_not_duplicated_for_string_and_instance(
s for s in exporter.get_finished_spans() if s.name == "postgres get_user_object"
]
assert len(db_spans) == 1
_SYNC_HOOK_CALLS = [
pytest.param("service_success_hook", {}, id="success"),
pytest.param("service_failure_hook", {"error": RuntimeError("boom")}, id="failure"),
]
@pytest.fixture
def started_loops(monkeypatch) -> list[asyncio.AbstractEventLoop]:
"""Event loops started during the test."""
started: list[asyncio.AbstractEventLoop] = []
original = asyncio.events._set_running_loop
def recording_set_running_loop(loop):
if loop is not None:
started.append(loop)
original(loop)
monkeypatch.setattr(asyncio.events, "_set_running_loop", recording_set_running_loop)
return started
@pytest.mark.parametrize("hook_name, extra_kwargs", _SYNC_HOOK_CALLS)
def test_sync_service_hook_starts_no_event_loop_without_callbacks(monkeypatch, started_loops, hook_name, extra_kwargs):
"""With no service callback, nothing receives the event, so no loop should start."""
monkeypatch.setattr(litellm, "service_callback", [])
getattr(ServiceLogging(), hook_name)(service=ServiceTypes.REDIS, duration=0.1, call_type="test", **extra_kwargs)
assert started_loops == []
@pytest.mark.parametrize("hook_name, extra_kwargs", _SYNC_HOOK_CALLS)
def test_sync_service_hook_still_delivers_to_configured_callbacks(
monkeypatch, hook_name, extra_kwargs
):
"""With a service callback, the event still reaches it: a real OTel logger exports the span."""
from litellm.integrations.otel.model.spans import SpanRole
v2_logger, exporter = _make_otel_v2_logger()
parent = v2_logger._emitter.start_span(SpanRole.PROXY_REQUEST, "POST /chat/completions")
monkeypatch.setattr(litellm, "service_callback", [v2_logger])
getattr(ServiceLogging(), hook_name)(
service=ServiceTypes.REDIS,
duration=0.1,
call_type="test",
parent_otel_span=parent,
**extra_kwargs,
)
parent.end()
assert "redis test" in [span.name for span in exporter.get_finished_spans()]