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>
This commit is contained in:
yucheng 2026-09-15 08:54:57 +00:00
parent 4cfed77d9a
commit 35e5a08f57
3 changed files with 30 additions and 1 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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()