Skip to content

Commit b1e6ca6

Browse files
committed
fix: expose stable Langfuse trace IO
1 parent a93c64a commit b1e6ca6

2 files changed

Lines changed: 273 additions & 13 deletions

File tree

src/backend/base/langflow/services/tracing/langfuse.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,59 @@
2828
LANGFUSE_FEEDBACK_SCORE_NAME = "user-feedback"
2929

3030

31+
def _normalize_boundary_messages(value: Any) -> Any:
32+
"""Replace Langflow messages with their text while preserving container shape."""
33+
from lfx.schema.message import Message
34+
35+
if isinstance(value, Message):
36+
return value.get_text()
37+
if isinstance(value, dict):
38+
return {key: _normalize_boundary_messages(item) for key, item in value.items()}
39+
if isinstance(value, list | tuple):
40+
return [_normalize_boundary_messages(item) for item in value]
41+
return value
42+
43+
44+
def _serialize_component_boundary(component_output: Any) -> Any:
45+
"""Collapse a sole component output and normalize Langflow messages to text."""
46+
value = (
47+
next(iter(component_output.values()))
48+
if isinstance(component_output, dict) and len(component_output) == 1
49+
else component_output
50+
)
51+
return serialize(_normalize_boundary_messages(value))
52+
53+
54+
def _trace_boundary_value(
55+
component_values: dict[str, Any],
56+
boundary_traces: dict[str, str],
57+
*,
58+
fallback_component_values: dict[str, Any] | None = None,
59+
prefer_fallback_trace_ids: set[str] | None = None,
60+
) -> tuple[bool, Any]:
61+
"""Return marked graph-boundary outputs in deterministic component-id order."""
62+
values = []
63+
prefer_fallback_trace_ids = prefer_fallback_trace_ids or set()
64+
for trace_id, trace_name in sorted(boundary_traces.items()):
65+
sources = (
66+
(fallback_component_values, component_values)
67+
if trace_id in prefer_fallback_trace_ids
68+
else (component_values, fallback_component_values)
69+
)
70+
for source in sources:
71+
if source is None or trace_name not in source:
72+
continue
73+
component_value = source[trace_name]
74+
if isinstance(component_value, dict) and not component_value:
75+
continue
76+
values.append(_serialize_component_boundary(component_value))
77+
break
78+
79+
if not values:
80+
return False, None
81+
return True, values[0] if len(boundary_traces) == 1 else values
82+
83+
3184
class _SharedClient:
3285
"""Process-wide cached Langfuse client.
3386
@@ -299,6 +352,8 @@ def __init__(
299352
self.session_id = session_id
300353
self.flow_id = trace_name.split(" - ")[-1]
301354
self.spans: dict[str, LangfuseSpan] = OrderedDict()
355+
self._input_trace_names: dict[str, str] = {}
356+
self._output_trace_names: dict[str, str] = {}
302357
self.langfuse_trace_id = None
303358

304359
config = self._get_config()
@@ -397,6 +452,12 @@ def add_trace(
397452

398453
name = trace_name.removesuffix(f" ({trace_id})")
399454

455+
if vertex is not None:
456+
if vertex.is_input:
457+
self._input_trace_names[trace_id] = trace_name
458+
if vertex.is_output:
459+
self._output_trace_names[trace_id] = trace_name
460+
400461
# Create child span under the root span
401462
span = self._root_span.start_span(
402463
name=name,
@@ -440,11 +501,29 @@ def end(
440501
if not self._ready:
441502
return
442503

443-
# Serialize once and reuse to avoid duplicate work
504+
# Keep the complete component aggregates on the root observation.
444505
inputs_ser = serialize(inputs)
445506
outputs_ser = serialize(outputs)
446507
metadata_ser = serialize(metadata) if metadata else None
447508

509+
# Input components emit the normalized external request as their output;
510+
# output components emit the final graph result. If a custom graph has no
511+
# boundary marker, retain the full aggregate rather than guessing from
512+
# concurrent component completion order.
513+
dual_role_trace_ids = self._input_trace_names.keys() & self._output_trace_names.keys()
514+
input_found, trace_input = _trace_boundary_value(
515+
outputs,
516+
self._input_trace_names,
517+
fallback_component_values=inputs,
518+
prefer_fallback_trace_ids=dual_role_trace_ids,
519+
)
520+
if not input_found:
521+
trace_input = inputs_ser
522+
523+
output_found, trace_output = _trace_boundary_value(outputs, self._output_trace_names)
524+
if not output_found:
525+
trace_output = outputs_ser
526+
448527
# Update the root span with final input/output
449528
self._root_span.update(
450529
input=inputs_ser,
@@ -454,8 +533,8 @@ def end(
454533

455534
# Update trace-level data
456535
self._root_span.update_trace(
457-
input=inputs_ser,
458-
output=outputs_ser,
536+
input={"input": trace_input},
537+
output={"output": trace_output},
459538
metadata=metadata_ser,
460539
)
461540

src/backend/tests/unit/services/tracing/test_langfuse_v3_compatibility.py

Lines changed: 191 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -317,9 +317,11 @@ def test_end_trace_updates_and_ends_span(self, mock_langfuse):
317317
mock_langfuse["child_span"].update.assert_called()
318318
mock_langfuse["child_span"].end.assert_called()
319319

320-
def test_end_updates_root_span_and_trace(self, mock_langfuse):
321-
"""Test that end() updates both root span and trace, then ends."""
320+
def test_end_exposes_stable_evaluator_trace_input_and_output(self, mock_langfuse):
321+
"""Trace input/output should expose stable JSONPath keys with the flow boundary messages."""
322+
from langflow.serialization.serialization import serialize
322323
from langflow.services.tracing.langfuse import LangFuseTracer
324+
from lfx.schema.message import Message
323325

324326
tracer = LangFuseTracer(
325327
trace_name="test-flow - flow-123",
@@ -328,18 +330,197 @@ def test_end_updates_root_span_and_trace(self, mock_langfuse):
328330
trace_id=uuid.uuid4(),
329331
)
330332

333+
component_inputs = {
334+
"Config (config-id)": {"model": "test-model"},
335+
"Chat Input (chat-input-id)": {"input_value": "What is Langflow?", "sender": "User"},
336+
"Prompt (prompt-id)": {"template": "Answer the user"},
337+
}
338+
component_outputs = {
339+
"Chat Input (chat-input-id)": {"message": Message(text="What is Langflow?")},
340+
"Agent (agent-id)": {"response": Message(text="An intermediate response")},
341+
"Chat Output (chat-output-id)": {"message": Message(text="Langflow is a visual workflow builder.")},
342+
"Audit Sink (audit-id)": {"record": {"status": "stored"}},
343+
}
344+
tracer.add_trace(
345+
trace_id="chat-input-id",
346+
trace_name="Chat Input (chat-input-id)",
347+
trace_type="chain",
348+
inputs=component_inputs["Chat Input (chat-input-id)"],
349+
vertex=MagicMock(is_input=True, is_output=False),
350+
)
351+
tracer.add_trace(
352+
trace_id="chat-output-id",
353+
trace_name="Chat Output (chat-output-id)",
354+
trace_type="chain",
355+
inputs={"input_value": "Langflow is a visual workflow builder."},
356+
vertex=MagicMock(is_input=False, is_output=True),
357+
)
331358
tracer.end(
332-
inputs={"flow_input": "test"},
333-
outputs={"flow_output": "result"},
359+
inputs=component_inputs,
360+
outputs=component_outputs,
334361
metadata={"final": True},
335362
)
336363

337-
# Should update root span
338-
mock_langfuse["root_span"].update.assert_called()
339-
# Should update trace metadata
340-
assert mock_langfuse["root_span"].update_trace.call_count >= 2 # init + end
341-
# Should end root span
342-
mock_langfuse["root_span"].end.assert_called()
364+
root_update = mock_langfuse["root_span"].update.call_args.kwargs
365+
assert root_update["input"] == serialize(component_inputs)
366+
assert root_update["output"] == serialize(component_outputs)
367+
368+
trace_update = mock_langfuse["root_span"].update_trace.call_args.kwargs
369+
assert trace_update["input"] == {"input": "What is Langflow?"}
370+
assert trace_update["output"] == {"output": "Langflow is a visual workflow builder."}
371+
assert trace_update["metadata"] == {"final": True}
372+
mock_langfuse["root_span"].end.assert_called_once()
373+
374+
def test_end_exposes_stable_trace_io_for_marked_arbitrary_graphs(self, mock_langfuse):
375+
"""Marked non-chat boundaries should expose their structured input and output values."""
376+
from langflow.services.tracing.langfuse import LangFuseTracer
377+
378+
tracer = LangFuseTracer(
379+
trace_name="test-flow - flow-123",
380+
trace_type="chain",
381+
project_name="test-project",
382+
trace_id=uuid.uuid4(),
383+
)
384+
385+
component_inputs = {
386+
"Webhook (webhook-id)": {"payload": {"order_id": 42}, "method": "POST"},
387+
"Transform (transform-id)": {"mapping": "order"},
388+
}
389+
component_outputs = {
390+
"Webhook (webhook-id)": {},
391+
"Transform (transform-id)": {"record": {"order_id": 42, "status": "ready"}},
392+
"Data Output (data-output-id)": {"data": [{"order_id": 42, "status": "ready"}]},
393+
}
394+
tracer.add_trace(
395+
trace_id="webhook-id",
396+
trace_name="Webhook (webhook-id)",
397+
trace_type="chain",
398+
inputs=component_inputs["Webhook (webhook-id)"],
399+
vertex=MagicMock(is_input=True, is_output=False),
400+
)
401+
tracer.add_trace(
402+
trace_id="data-output-id",
403+
trace_name="Data Output (data-output-id)",
404+
trace_type="chain",
405+
inputs={},
406+
vertex=MagicMock(is_input=False, is_output=True),
407+
)
408+
tracer.end(inputs=component_inputs, outputs=component_outputs)
409+
410+
trace_update = mock_langfuse["root_span"].update_trace.call_args.kwargs
411+
assert trace_update["input"] == {"input": component_inputs["Webhook (webhook-id)"]}
412+
assert trace_update["output"] == {"output": [{"order_id": 42, "status": "ready"}]}
413+
414+
def test_end_falls_back_to_stable_aggregate_when_boundaries_are_unmarked(self, mock_langfuse):
415+
"""Custom graphs without boundary markers should retain their complete structured aggregates."""
416+
from langflow.services.tracing.langfuse import LangFuseTracer
417+
418+
tracer = LangFuseTracer(
419+
trace_name="test-flow - flow-123",
420+
trace_type="chain",
421+
project_name="test-project",
422+
trace_id=uuid.uuid4(),
423+
)
424+
component_inputs = {"Custom Source (source-id)": {"payload": {"order_id": 42}}}
425+
component_outputs = {"Custom Sink (sink-id)": {"record": {"order_id": 42, "status": "ready"}}}
426+
427+
tracer.end(inputs=component_inputs, outputs=component_outputs)
428+
429+
trace_update = mock_langfuse["root_span"].update_trace.call_args.kwargs
430+
assert trace_update["input"] == {"input": component_inputs}
431+
assert trace_update["output"] == {"output": component_outputs}
432+
433+
def test_end_orders_multiple_boundary_outputs_by_component_id(self, mock_langfuse):
434+
"""Multiple graph outputs should be deterministic even when their completion order is not."""
435+
from langflow.services.tracing.langfuse import LangFuseTracer
436+
from lfx.schema.message import Message
437+
438+
tracer = LangFuseTracer(
439+
trace_name="test-flow - flow-123",
440+
trace_type="chain",
441+
project_name="test-project",
442+
trace_id=uuid.uuid4(),
443+
)
444+
component_outputs = {
445+
"Data Output (z-output-id)": {"data": {"order_id": 42}},
446+
"Text Output (a-output-id)": {"text": Message(text="ready")},
447+
}
448+
tracer.add_trace(
449+
trace_id="z-output-id",
450+
trace_name="Data Output (z-output-id)",
451+
trace_type="chain",
452+
inputs={},
453+
vertex=MagicMock(is_input=False, is_output=True),
454+
)
455+
tracer.add_trace(
456+
trace_id="a-output-id",
457+
trace_name="Text Output (a-output-id)",
458+
trace_type="chain",
459+
inputs={},
460+
vertex=MagicMock(is_input=False, is_output=True),
461+
)
462+
463+
tracer.end(inputs={}, outputs=component_outputs)
464+
465+
trace_update = mock_langfuse["root_span"].update_trace.call_args.kwargs
466+
assert trace_update["output"] == {"output": ["ready", {"order_id": 42}]}
467+
468+
def test_end_uses_component_input_for_dual_role_boundary(self, mock_langfuse):
469+
"""A component marked as both input and output should preserve the original request."""
470+
from langflow.services.tracing.langfuse import LangFuseTracer
471+
from lfx.schema.message import Message
472+
473+
tracer = LangFuseTracer(
474+
trace_name="test-flow - flow-123",
475+
trace_type="chain",
476+
project_name="test-project",
477+
trace_id=uuid.uuid4(),
478+
)
479+
trace_name = "Bidirectional (component-id)"
480+
tracer.add_trace(
481+
trace_id="component-id",
482+
trace_name=trace_name,
483+
trace_type="chain",
484+
inputs={"request": "ping"},
485+
vertex=MagicMock(is_input=True, is_output=True),
486+
)
487+
488+
tracer.end(
489+
inputs={trace_name: {"request": "ping"}},
490+
outputs={trace_name: {"response": Message(text="pong")}},
491+
)
492+
493+
trace_update = mock_langfuse["root_span"].update_trace.call_args.kwargs
494+
assert trace_update["input"] == {"input": "ping"}
495+
assert trace_update["output"] == {"output": "pong"}
496+
497+
def test_end_normalizes_messages_nested_in_multi_output_boundary(self, mock_langfuse):
498+
"""Message values should become evaluator-ready text without dropping sibling outputs."""
499+
from langflow.services.tracing.langfuse import LangFuseTracer
500+
from lfx.schema.message import Message
501+
502+
tracer = LangFuseTracer(
503+
trace_name="test-flow - flow-123",
504+
trace_type="chain",
505+
project_name="test-project",
506+
trace_id=uuid.uuid4(),
507+
)
508+
trace_name = "Composite Output (output-id)"
509+
tracer.add_trace(
510+
trace_id="output-id",
511+
trace_name=trace_name,
512+
trace_type="chain",
513+
inputs={},
514+
vertex=MagicMock(is_input=False, is_output=True),
515+
)
516+
517+
tracer.end(
518+
inputs={},
519+
outputs={trace_name: {"message": Message(text="ready"), "usage": {"tokens": 3}}},
520+
)
521+
522+
trace_update = mock_langfuse["root_span"].update_trace.call_args.kwargs
523+
assert trace_update["output"] == {"output": {"message": "ready", "usage": {"tokens": 3}}}
343524

344525
def test_get_langchain_callback_uses_trace_context(self, mock_langfuse):
345526
"""Test that get_langchain_callback creates handler with trace context."""

0 commit comments

Comments
 (0)