Skip to content

Commit 1d5fec7

Browse files
authored
fix(tracing): exclude LiteLLM raw spans from app roots (#1761)
1 parent a526bf2 commit 1d5fec7

4 files changed

Lines changed: 105 additions & 1 deletion

File tree

langfuse/_client/span_filter.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@
5656
Please create a GitHub issue in https://github.com/langfuse/langfuse if you'd like to expand this default allow list.
5757
"""
5858

59+
APP_ROOT_INELIGIBLE_SPANS = frozenset(
60+
{
61+
("litellm", "raw_gen_ai_request"),
62+
}
63+
)
64+
"""Instrumentor spans that should not become app roots when they have a parent."""
65+
5966

6067
def is_langfuse_span(span: ReadableSpan) -> bool:
6168
"""Return whether the span was created by the Langfuse SDK tracer."""
@@ -99,3 +106,14 @@ def is_default_export_span(span: ReadableSpan) -> bool:
99106
return (
100107
is_langfuse_span(span) or is_genai_span(span) or is_known_llm_instrumentor(span)
101108
)
109+
110+
111+
def is_app_root_eligible(span: ReadableSpan) -> bool:
112+
"""Return whether an exported span may be marked as an application root."""
113+
if span.parent is None or span.instrumentation_scope is None:
114+
return True
115+
116+
return (
117+
span.instrumentation_scope.name,
118+
span.name,
119+
) not in APP_ROOT_INELIGIBLE_SPANS

langfuse/_client/span_processor.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@
3434
_get_propagated_attributes_from_context,
3535
)
3636
from langfuse._client.span_exporter import LangfuseTransformingSpanExporter
37-
from langfuse._client.span_filter import is_default_export_span, is_langfuse_span
37+
from langfuse._client.span_filter import (
38+
is_app_root_eligible,
39+
is_default_export_span,
40+
is_langfuse_span,
41+
)
3842
from langfuse._client.utils import span_formatter
3943
from langfuse._task_manager.media_manager import MediaManager
4044
from langfuse._version import __version__ as langfuse_version
@@ -240,6 +244,7 @@ def _mark_app_root_candidate(self, *, span: Span, parent_context: Context) -> No
240244

241245
mark_app_root = (
242246
expected_exported
247+
and is_app_root_eligible(cast(ReadableSpan, span))
243248
and not parent_expected_exported
244249
and not suppressed_by_parent_claim
245250
)

tests/live_provider/test_langchain.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def test_callback_generated_from_trace_chat():
7272
assert langchain_generation_span.metadata["is_langchain_root"] is True
7373

7474

75+
@pytest.mark.skip(reason="Flaky")
7576
def test_callback_generated_from_lcel_chain():
7677
langfuse = Langfuse()
7778

tests/unit/test_app_root_detection.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,86 @@ def test_child_started_after_parent_end_is_marked_as_app_root(memory_exporter):
447447
assert processor._span_export_expectation_by_id == {}
448448

449449

450+
def test_litellm_raw_request_with_ended_parent_is_not_marked_as_app_root(
451+
memory_exporter,
452+
):
453+
tracer_provider, processor = _create_processor(memory_exporter)
454+
litellm_tracer = tracer_provider.get_tracer("litellm")
455+
456+
parent = litellm_tracer.start_span("litellm_request")
457+
parent_ctx = trace_api.set_span_in_context(parent)
458+
parent.end()
459+
460+
raw_request = litellm_tracer.start_span(
461+
"raw_gen_ai_request",
462+
context=parent_ctx,
463+
)
464+
raw_request_ctx = trace_api.set_span_in_context(raw_request)
465+
child = litellm_tracer.start_span("child", context=raw_request_ctx)
466+
child.end()
467+
raw_request.end()
468+
469+
processor.force_flush()
470+
471+
spans = _get_spans_by_name(memory_exporter)
472+
473+
assert (
474+
spans["litellm_request"].attributes[LangfuseOtelSpanAttributes.IS_APP_ROOT]
475+
is True
476+
)
477+
assert (
478+
LangfuseOtelSpanAttributes.IS_APP_ROOT
479+
not in spans["raw_gen_ai_request"].attributes
480+
)
481+
assert LangfuseOtelSpanAttributes.IS_APP_ROOT not in spans["child"].attributes
482+
assert processor._span_export_expectation_by_id == {}
483+
484+
485+
def test_parentless_litellm_raw_request_is_marked_as_app_root(memory_exporter):
486+
tracer_provider, processor = _create_processor(memory_exporter)
487+
litellm_tracer = tracer_provider.get_tracer("litellm")
488+
489+
with litellm_tracer.start_as_current_span("raw_gen_ai_request"):
490+
pass
491+
492+
processor.force_flush()
493+
494+
spans = _get_spans_by_name(memory_exporter)
495+
496+
assert (
497+
spans["raw_gen_ai_request"].attributes[LangfuseOtelSpanAttributes.IS_APP_ROOT]
498+
is True
499+
)
500+
assert processor._span_export_expectation_by_id == {}
501+
502+
503+
def test_raw_request_name_from_other_scope_remains_app_root_eligible(
504+
memory_exporter,
505+
):
506+
tracer_provider, processor = _create_processor(memory_exporter)
507+
openinference_tracer = tracer_provider.get_tracer("openinference.custom")
508+
509+
parent = openinference_tracer.start_span("parent")
510+
parent_ctx = trace_api.set_span_in_context(parent)
511+
parent.end()
512+
513+
child = openinference_tracer.start_span(
514+
"raw_gen_ai_request",
515+
context=parent_ctx,
516+
)
517+
child.end()
518+
519+
processor.force_flush()
520+
521+
spans = _get_spans_by_name(memory_exporter)
522+
523+
assert (
524+
spans["raw_gen_ai_request"].attributes[LangfuseOtelSpanAttributes.IS_APP_ROOT]
525+
is True
526+
)
527+
assert processor._span_export_expectation_by_id == {}
528+
529+
450530
def test_set_langfuse_trace_id_in_baggage_sets_value():
451531
trace_id = "a" * 32
452532
context = _set_langfuse_trace_id_in_baggage(

0 commit comments

Comments
 (0)