When using langfuse_otel callback, empty traces were being sent to Langfuse for requests that didn't result in actual LLM calls (e.g., auth operations, health checks, failed requests). These traces contained only internal proxy operations (auth, postgres, proxy_pre_call) with no useful LLM data. Root cause: LangfuseOtelLogger extends OpenTelemetry, which sets itself as the proxy's open_telemetry_logger. This caused create_litellm_proxy_request_started_span to be called for every request, creating a parent span that was sent to Langfuse even when no LLM call occurred. Fix: Override create_litellm_proxy_request_started_span in LangfuseOtelLogger to return None, preventing the creation of empty parent spans. This is consistent with the existing overrides for async_service_success_hook and async_service_failure_hook which already prevent service-level logs from being sent to Langfuse. Fixes: Empty traces in Langfuse v3 when using langfuse_otel callback
114 lines
4.2 KiB
Python
114 lines
4.2 KiB
Python
import os
|
|
import sys
|
|
import unittest
|
|
from datetime import datetime
|
|
from unittest.mock import patch, AsyncMock, MagicMock
|
|
|
|
# Add the project root to sys.path
|
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
|
|
|
import litellm
|
|
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
|
|
from litellm.integrations.opentelemetry import OpenTelemetry
|
|
from litellm.types.services import ServiceTypes
|
|
from litellm._service_logger import ServiceLogging
|
|
|
|
|
|
class TestServiceLoggerOTEL(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self):
|
|
# Reset callbacks before each test
|
|
litellm.service_callback = []
|
|
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-lf-123"
|
|
os.environ["LANGFUSE_SECRET_KEY"] = "sk-lf-123"
|
|
|
|
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_tracing")
|
|
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_metrics")
|
|
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_logs")
|
|
async def test_langfuse_otel_ignores_service_logs(
|
|
self, mock_logs, mock_metrics, mock_tracing
|
|
):
|
|
"""
|
|
Test that LangfuseOtelLogger overrides the service logging hooks with 'pass'.
|
|
"""
|
|
logger = LangfuseOtelLogger()
|
|
|
|
# Verify hooks are overriden
|
|
self.assertEqual(
|
|
logger.async_service_success_hook.__qualname__,
|
|
"LangfuseOtelLogger.async_service_success_hook",
|
|
)
|
|
self.assertEqual(
|
|
logger.async_service_failure_hook.__qualname__,
|
|
"LangfuseOtelLogger.async_service_failure_hook",
|
|
)
|
|
|
|
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_tracing")
|
|
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_metrics")
|
|
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_logs")
|
|
async def test_langfuse_otel_does_not_create_proxy_request_span(
|
|
self, mock_logs, mock_metrics, mock_tracing
|
|
):
|
|
"""
|
|
Test that LangfuseOtelLogger returns None for create_litellm_proxy_request_started_span.
|
|
|
|
This prevents empty proxy request spans from being sent to Langfuse when
|
|
requests don't result in actual LLM calls (e.g., auth failures, health checks).
|
|
"""
|
|
logger = LangfuseOtelLogger()
|
|
|
|
# Verify the method is overridden
|
|
self.assertEqual(
|
|
logger.create_litellm_proxy_request_started_span.__qualname__,
|
|
"LangfuseOtelLogger.create_litellm_proxy_request_started_span",
|
|
)
|
|
|
|
# Verify it returns None
|
|
result = logger.create_litellm_proxy_request_started_span(
|
|
start_time=datetime.now(),
|
|
headers={"Authorization": "Bearer test"},
|
|
)
|
|
self.assertIsNone(result)
|
|
|
|
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_tracing")
|
|
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_metrics")
|
|
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_logs")
|
|
async def test_service_logging_shadowing_fix(
|
|
self, mock_logs, mock_metrics, mock_tracing
|
|
):
|
|
"""
|
|
Test the architectural fix: multiple OTEL loggers should receive logs independently.
|
|
"""
|
|
# 1. Initialize two loggers
|
|
langfuse_logger = LangfuseOtelLogger()
|
|
otel_logger = OpenTelemetry()
|
|
|
|
# 2. Setup service_callback list
|
|
litellm.service_callback = [langfuse_logger, otel_logger]
|
|
|
|
service_logging = ServiceLogging()
|
|
|
|
# 3. Mock the base OpenTelemetry hook
|
|
with patch.object(
|
|
OpenTelemetry, "async_service_success_hook", new_callable=AsyncMock
|
|
) as mock_base_hook:
|
|
# Trigger a service event
|
|
await service_logging.async_service_success_hook(
|
|
service=ServiceTypes.DB,
|
|
call_type="success",
|
|
duration=0.1,
|
|
parent_otel_span=MagicMock(),
|
|
start_time=0.0,
|
|
end_time=1.0,
|
|
)
|
|
|
|
# The architectural fix ensures we call each correctly.
|
|
self.assertEqual(
|
|
mock_base_hook.call_count,
|
|
1,
|
|
"Generic OTEL logger should have received the log exactly once.",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|