diff --git a/litellm/_service_logger.py b/litellm/_service_logger.py index 1a5f46e9261..89c177b91a5 100644 --- a/litellm/_service_logger.py +++ b/litellm/_service_logger.py @@ -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( diff --git a/tests/unit/test_service_logger.py b/tests/unit/test_service_logger.py index de46403b64d..a0a175f9f05 100644 --- a/tests/unit/test_service_logger.py +++ b/tests/unit/test_service_logger.py @@ -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()]