From 2dbbea5b6b6cdc23421fbc3224112eea22734e8f Mon Sep 17 00:00:00 2001 From: yucheng Date: Thu, 17 Sep 2026 05:22:03 +0000 Subject: [PATCH] fix(langfuse): keep trace_public true when a guardrail span is exported Langfuse folds langfuse.trace.public across every observation in the trace and reads a missing attribute as false, so a guardrail child span without the flag turned a trace_public: true request private on Langfuse Cloud. Child spans now repeat the generation's value Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/langfuse/langfuse_sdk.py | 13 +++++++++- .../langfuse/test_langfuse_sdk.py | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/langfuse/langfuse_sdk.py b/litellm/integrations/langfuse/langfuse_sdk.py index bf8d1d8e5fd..4576282f63a 100644 --- a/litellm/integrations/langfuse/langfuse_sdk.py +++ b/litellm/integrations/langfuse/langfuse_sdk.py @@ -178,12 +178,23 @@ def start_child_span( The server derives the trace's name and I/O from every observation marked root, last start time wins, so only the generation may claim root. Nesting the rest under it keeps a post-call guardrail from rewriting the trace. + + The trace's ``public`` flag is folded the same way, with a missing attribute + read as ``False``, so the child repeats the generation's value. """ + parent_span: Final = parent._otel_span # pyright: ignore[reportPrivateUsage] # the wrapper exposes no public span handle otel_span: Final = client._otel_tracer.start_span( # pyright: ignore[reportPrivateUsage] # only route to a historical start time name=name, - context=otel_trace.set_span_in_context(parent._otel_span), # pyright: ignore[reportPrivateUsage] # the wrapper exposes no public span handle + context=otel_trace.set_span_in_context(parent_span), start_time=to_unix_nanos(start_time), ) + public: Final = ( + parent_span.attributes.get(PUBLIC_ATTRIBUTE) + if isinstance(parent_span, ReadableSpan) and parent_span.attributes is not None + else None + ) + if public is not None: + otel_span.set_attribute(PUBLIC_ATTRIBUTE, public) return LangfuseSpan(otel_span=otel_span, langfuse_client=client, **attributes) # pyright: ignore[reportArgumentType] # kwargs-ok: callback-built params, v2 accepted the same shapes diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py index 8bc8ed75b10..d5e173160d2 100644 --- a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py @@ -227,6 +227,30 @@ def test_trace_public_flag_is_absent_when_not_requested(client): assert PUBLIC_ATTRIBUTE not in _only_span(exporter, "gen").attributes +@pytest.mark.parametrize("public", [True, False, None], ids=["public", "private", "unset"]) +def test_child_span_repeats_the_generation_public_flag(client, public): + """The server folds ``public`` across observations and reads a missing value as False. + + A guardrail span without the flag turned a ``trace_public: true`` request private on Langfuse Cloud. + """ + lf, exporter = client + context, claim_root = open_trace_context(client=lf, trace_id="c" * 32, parent_observation_id=None) + generation = start_generation( + client=lf, + context=context, + name="gen", + start_time=CALL_START, + claim_trace_root=claim_root, + public=public, + attributes={}, + ) + start_child_span(client=lf, parent=generation, name="guardrail", start_time=CALL_END, attributes={}).end() + generation.end(end_time=to_unix_nanos(CALL_END)) + lf.flush() + + assert _only_span(exporter, "guardrail").attributes.get(PUBLIC_ATTRIBUTE) is public + + def test_request_release_beats_the_client_wide_release(monkeypatch): """A client configured with its own release must not overwrite trace_release.""" monkeypatch.setenv("LANGFUSE_RELEASE", "client-wide-release")