fix(langfuse_otel): prevent empty proxy request spans from being sent to Langfuse

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
This commit is contained in:
Luis Gallego Ledesma 2026-01-28 15:35:35 +01:00
parent 169c9dae79
commit 52372dcbe9
2 changed files with 45 additions and 0 deletions

View file

@ -1,6 +1,7 @@
import base64
import json # <--- NEW
import os
from datetime import datetime
from typing import TYPE_CHECKING, Any, Optional, Union
from litellm._logging import verbose_logger
@ -353,6 +354,22 @@ class LangfuseOtelLogger(OpenTelemetry):
return dynamic_headers
def create_litellm_proxy_request_started_span(
self,
start_time: datetime,
headers: dict,
) -> Optional[Span]:
"""
Override to prevent creating empty proxy request spans.
Langfuse should only receive spans for actual LLM calls, not for
internal proxy operations (auth, postgres, proxy_pre_call, etc.).
By returning None, we prevent the parent span from being created,
which in turn prevents empty traces from being sent to Langfuse.
"""
return None
async def async_service_success_hook(self, *args, **kwargs):
"""
Langfuse should not receive service success logs.

View file

@ -1,6 +1,7 @@
import os
import sys
import unittest
from datetime import datetime
from unittest.mock import patch, AsyncMock, MagicMock
# Add the project root to sys.path
@ -41,6 +42,33 @@ class TestServiceLoggerOTEL(unittest.IsolatedAsyncioTestCase):
"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")