From 35e5a08f57fa78d10b30fd4797c9380b55a25efa Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 15 Sep 2026 08:54:57 +0000 Subject: [PATCH] fix(langfuse): do not claim trace root when continuing an existing trace Langfuse derives a trace's name and I/O from any observation flagged langfuse.internal.as_root, so a request carrying existing_trace_id renamed the trace to the generation name and replaced the trace input and output on every continuation. v2 only updated the keys listed in update_trace_keys. Continuations now export as plain children of the remote parent and keep the explicit langfuse.trace.* attributes for the fields they do want changed. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/langfuse/langfuse.py | 1 + litellm/integrations/langfuse/langfuse_sdk.py | 8 ++++++- .../integrations/test_langfuse.py | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index b6884b399de..21cc74a7e93 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -976,6 +976,7 @@ class LangFuseLogger: client=self.Langfuse, trace_id=resolved_trace_id, parent_observation_id=resolve_observation_id(parent_observation_id), # pyright: ignore[reportArgumentType] # metadata value, str or None at runtime + existing_trace=existing_trace_id is not None, ) log_provider_specific_information_as_span( client=self.Langfuse, diff --git a/litellm/integrations/langfuse/langfuse_sdk.py b/litellm/integrations/langfuse/langfuse_sdk.py index fe0f7332503..8c2f1983fd6 100644 --- a/litellm/integrations/langfuse/langfuse_sdk.py +++ b/litellm/integrations/langfuse/langfuse_sdk.py @@ -88,6 +88,7 @@ def open_trace_context( client: Langfuse, trace_id: str, parent_observation_id: str | None, + existing_trace: bool = False, ) -> tuple[Context, bool]: """Build the OTel context that places new observations inside ``trace_id``. @@ -96,11 +97,16 @@ def open_trace_context( observation is a child of something that will never be exported; the public SDK path compensates by marking the span as root and this path must do the same. + + ``existing_trace`` is the v2 ``existing_trace_id`` contract: the trace is + appended to, never rewritten. The server takes a root observation's name and + I/O as the trace's, so a continuation must not claim root; trace fields it + does want changed travel as explicit ``langfuse.trace.*`` attributes. """ remote_parent: Final = client._create_remote_parent_span( # pyright: ignore[reportPrivateUsage] # no public equivalent in v4 trace_id=trace_id, parent_span_id=parent_observation_id ) - return otel_trace.set_span_in_context(remote_parent), parent_observation_id is None + return otel_trace.set_span_in_context(remote_parent), parent_observation_id is None and not existing_trace def start_generation( diff --git a/tests/test_litellm/integrations/test_langfuse.py b/tests/test_litellm/integrations/test_langfuse.py index f228cc23652..65456b92e41 100644 --- a/tests/test_litellm/integrations/test_langfuse.py +++ b/tests/test_litellm/integrations/test_langfuse.py @@ -1923,6 +1923,28 @@ def test_update_trace_keys_input_output_reach_the_trace_even_under_a_parent(): assert "the-output" in str(span.attributes["langfuse.trace.output"]) +def test_existing_trace_id_appends_without_claiming_trace_root(): + """Langfuse copies a root observation's name and I/O onto the trace, so a + continuation that claimed root would rename the trace after every request; + v2 only ever touched the keys in ``update_trace_keys``.""" + rig = _steering_logger() + + _, _, span = _emit(rig, metadata={"existing_trace_id": "trace-1", "trace_name": "second-call"}) + + assert span.parent is not None + assert span.attributes.get("langfuse.internal.as_root") is None + assert "langfuse.trace.name" not in (span.attributes or {}) + + +def test_a_fresh_trace_still_claims_root_so_its_generation_names_it(): + rig = _steering_logger() + + _, _, span = _emit(rig, metadata={"trace_id": "a" * 32, "trace_name": "first-call"}) + + assert span.attributes.get("langfuse.internal.as_root") is True + assert span.attributes["langfuse.trace.name"] == "first-call" + + def test_trace_io_is_not_stamped_when_update_trace_keys_does_not_ask(): rig = _steering_logger()