Skip to content

Commit 6475242

Browse files
feat!: migrate to Langfuse Python SDK v4 (#3126)
* feat(langfuse): migrate to Langfuse Python SDK v4 - Update dependency from langfuse>=3.3.1,<4.0.0 to langfuse>=4.0.0 - Replace TraceMetadata import (removed in v4) with propagate_attributes - Use propagate_attributes() context manager for user_id, session_id, version, tags on root traces - Replace span.update_trace() (removed in v4) with span.set_trace_io() for pipeline-level input/output - Replace start_as_current_span() (removed in v4) with start_as_current_observation() for generic spans - Handle public traces via span.set_trace_as_public() (v4 API) - Update tests to reflect v4 API: remove start_as_current_span mocks, add set_trace_io/set_trace_as_public, fix content tracing patch * fix(langfuse): replace deprecated set_trace_io with update() for pipeline I/O * fix(langfuse): fix trace name bug and address Copilot review comments - Move propagate_attributes to LangfuseTracer.trace() via ExitStack so trace_name and other attributes cover the full span lifetime, not just root observation creation (fixes trace name showing as 'tracer') - Add trace_name=self._name to propagate_attributes call - Use span.raw_span().set_trace_as_public() instead of span._span (private) - Add set_trace_io to MockSpan; add unit test for public=True trace path * fix: add noqa A002 to suppress builtin shadowing lint warning in MockSpan.set_trace_io * fix(langfuse): address review comments on SDK v4 migration - Fix falsy value handling in propagate_attributes: use explicit `is not None` check instead of `or None` to preserve values like empty strings and zeros - Add comment explaining ExitStack is needed to properly exit context managers - Add test verifying propagate_attributes is called with tracing_ctx vars - Replace test_update_span_with_pipeline_input_output_data with test_pipeline_input_output_written_to_root_observation that checks input/output are written via update() on the root observation - Remove unused set_trace_io method from MockSpan
1 parent 1642f63 commit 6475242

3 files changed

Lines changed: 199 additions & 162 deletions

File tree

integrations/langfuse/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ classifiers = [
2121
"Programming Language :: Python :: Implementation :: CPython",
2222
"Programming Language :: Python :: Implementation :: PyPy",
2323
]
24-
dependencies = ["haystack-ai>=2.22.0", "langfuse>=3.3.1, <4.0.0"]
24+
dependencies = ["haystack-ai>=2.22.0", "langfuse>=4.0.0"]
2525

2626
[project.urls]
2727
Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse#readme"

integrations/langfuse/src/haystack_integrations/tracing/langfuse/tracer.py

Lines changed: 88 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222

2323
import langfuse
2424
from langfuse import LangfuseSpan as LangfuseClientSpan
25-
from langfuse.types import TraceContext, TraceMetadata
25+
from langfuse import propagate_attributes
26+
from langfuse.types import TraceContext
2627

2728
logger = logging.getLogger(__name__)
2829

@@ -61,7 +62,6 @@ def __init__(self, context_manager: AbstractContextManager) -> None:
6162
Initialize a LangfuseSpan instance.
6263
6364
:param context_manager: The context manager from Langfuse created with
64-
`langfuse.get_client().start_as_current_span` or
6565
`langfuse.get_client().start_as_current_observation`.
6666
"""
6767
self._span = context_manager.__enter__()
@@ -297,33 +297,17 @@ def create_span(self, context: SpanContext) -> LangfuseSpan:
297297
trace_context: TraceContext | None = cast(
298298
TraceContext, {"trace_id": tracing_ctx.get("trace_id")} if tracing_ctx.get("trace_id") else None
299299
)
300-
# Create a new trace when there's no parent span
300+
301301
span_context_manager = self.tracer.start_as_current_observation(
302302
trace_context=trace_context,
303303
name=context.trace_name,
304304
version=tracing_ctx.get("version"),
305305
as_type=root_span_type,
306306
)
307-
308-
# Create LangfuseSpan which will handle entering the context manager
309307
span = LangfuseSpan(span_context_manager)
310308

311-
# Build trace metadata from context
312-
trace_metadata: TraceMetadata = {
313-
"name": context.trace_name,
314-
"user_id": tracing_ctx.get("user_id"),
315-
"session_id": tracing_ctx.get("session_id"),
316-
"version": tracing_ctx.get("version"),
317-
"metadata": None,
318-
"tags": tracing_ctx.get("tags"),
319-
"public": context.public,
320-
"release": None,
321-
}
322-
323-
# Filter out None values and apply trace attributes
324-
trace_attrs = {k: v for k, v in trace_metadata.items() if v is not None}
325-
if trace_attrs:
326-
span._span.update_trace(**trace_attrs)
309+
if context.public:
310+
span.raw_span().set_trace_as_public()
327311

328312
return span
329313

@@ -347,7 +331,7 @@ def create_span(self, context: SpanContext) -> LangfuseSpan:
347331
)
348332
)
349333
else:
350-
return LangfuseSpan(self.tracer.start_as_current_span(name=context.name))
334+
return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name))
351335

352336
def handle(self, span: LangfuseSpan, component_type: str | None) -> None:
353337
"""Process and enrich a span after component execution."""
@@ -356,7 +340,7 @@ def handle(self, span: LangfuseSpan, component_type: str | None) -> None:
356340
if at_pipeline_level:
357341
coerced_input = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_INPUT_KEY))
358342
coerced_output = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_OUTPUT_KEY))
359-
span.raw_span().update_trace(input=coerced_input, output=coerced_output)
343+
span.raw_span().update(input=coerced_input, output=coerced_output)
360344
# special case for ToolInvoker (to update the span name to be: `original_component_name - [tool_names]`)
361345
if component_type == "ToolInvoker":
362346
tool_names: list[str] = []
@@ -468,6 +452,7 @@ def trace(
468452
tags = tags or {}
469453
span_name = tags.get(_COMPONENT_NAME_KEY, operation_name)
470454
component_type = tags.get(_COMPONENT_TYPE_KEY)
455+
is_root_span = not (parent_span or self.current_span())
471456

472457
# Create a new span context
473458
span_context = SpanContext(
@@ -482,74 +467,91 @@ def trace(
482467
public=self._public,
483468
)
484469

485-
# Create span using the handler
486-
span = self._span_handler.create_span(span_context)
470+
tracing_ctx = tracing_context_var.get({})
487471

488-
# Build new span hierarchy: copy existing stack, add new span, save for restoration
489-
prev_stack = span_stack_var.get()
490-
new_stack = (prev_stack or []).copy()
491-
new_stack.append(span)
492-
token = span_stack_var.set(new_stack)
472+
# we need to properly exit context managers
473+
with contextlib.ExitStack() as stack:
474+
if is_root_span:
475+
# propagate_attributes sets trace_name and other trace-level metadata on the root
476+
# span and ALL child spans created within this context (full trace lifetime)
477+
stack.enter_context(
478+
propagate_attributes(
479+
trace_name=self._name,
480+
user_id=tracing_ctx.get("user_id") if tracing_ctx.get("user_id") is not None else None,
481+
session_id=tracing_ctx.get("session_id") if tracing_ctx.get("session_id") is not None else None,
482+
version=tracing_ctx.get("version") if tracing_ctx.get("version") is not None else None,
483+
tags=tracing_ctx.get("tags") if tracing_ctx.get("tags") is not None else None,
484+
)
485+
)
493486

494-
span.set_tags(tags)
487+
# Create span using the handler
488+
span = self._span_handler.create_span(span_context)
495489

496-
try:
497-
yield span
498-
except Exception:
499-
# Exception occurred - capture exception info and pass to __exit__
500-
# This allows Langfuse/OpenTelemetry to properly mark the span with ERROR level
501-
exc_info = sys.exc_info()
502-
try:
503-
# Process span data (may fail with nested pipeline exceptions)
504-
self._span_handler.handle(span, component_type)
505-
506-
# End span with exception info (may fail if span data is corrupted)
507-
raw_span = span.raw_span()
508-
if span._context_manager is not None:
509-
# Pass actual exception info to mark span as failed with ERROR level
510-
span._context_manager.__exit__(*exc_info)
511-
elif hasattr(raw_span, "end"):
512-
# Only call end() if it's not a context manager
513-
raw_span.end()
514-
except Exception as cleanup_error:
515-
# Log cleanup errors but don't let them corrupt context
516-
logger.warning(
517-
"Error during span cleanup for {operation_name}: {cleanup_error}",
518-
operation_name=operation_name,
519-
cleanup_error=cleanup_error,
520-
)
490+
# Build new span hierarchy: copy existing stack, add new span, save for restoration
491+
prev_stack = span_stack_var.get()
492+
new_stack = (prev_stack or []).copy()
493+
new_stack.append(span)
494+
token = span_stack_var.set(new_stack)
521495

522-
# Re-raise the original exception
523-
raise
524-
else:
525-
# No exception - clean exit with success status
526-
# This preserves any manually-set log levels (WARNING, DEBUG)
527-
try:
528-
# Process span data
529-
self._span_handler.handle(span, component_type)
530-
531-
# End span successfully
532-
raw_span = span.raw_span()
533-
# In v3, we need to properly exit context managers
534-
if span._context_manager is not None:
535-
# No exception - pass None to indicate success
536-
span._context_manager.__exit__(None, None, None)
537-
elif hasattr(raw_span, "end"):
538-
# Only call end() if it's not a context manager
539-
raw_span.end()
540-
except Exception as cleanup_error:
541-
# Log cleanup errors but don't let them corrupt context
542-
logger.warning(
543-
"Error during span cleanup for {operation_name}: {cleanup_error}",
544-
operation_name=operation_name,
545-
cleanup_error=cleanup_error,
546-
)
547-
finally:
548-
# Restore previous span stack using saved token
549-
span_stack_var.reset(token)
496+
span.set_tags(tags)
550497

551-
if self.enforce_flush:
552-
self.flush()
498+
try:
499+
yield span
500+
except Exception:
501+
# Exception occurred - capture exception info and pass to __exit__
502+
# This allows Langfuse/OpenTelemetry to properly mark the span with ERROR level
503+
exc_info = sys.exc_info()
504+
try:
505+
# Process span data (may fail with nested pipeline exceptions)
506+
self._span_handler.handle(span, component_type)
507+
508+
# End span with exception info (may fail if span data is corrupted)
509+
raw_span = span.raw_span()
510+
if span._context_manager is not None:
511+
# Pass actual exception info to mark span as failed with ERROR level
512+
span._context_manager.__exit__(*exc_info)
513+
elif hasattr(raw_span, "end"):
514+
# Only call end() if it's not a context manager
515+
raw_span.end()
516+
except Exception as cleanup_error:
517+
# Log cleanup errors but don't let them corrupt context
518+
logger.warning(
519+
"Error during span cleanup for {operation_name}: {cleanup_error}",
520+
operation_name=operation_name,
521+
cleanup_error=cleanup_error,
522+
)
523+
524+
# Re-raise the original exception
525+
raise
526+
else:
527+
# No exception - clean exit with success status
528+
# This preserves any manually-set log levels (WARNING, DEBUG)
529+
try:
530+
# Process span data
531+
self._span_handler.handle(span, component_type)
532+
533+
# End span successfully
534+
raw_span = span.raw_span()
535+
# In v3, we need to properly exit context managers
536+
if span._context_manager is not None:
537+
# No exception - pass None to indicate success
538+
span._context_manager.__exit__(None, None, None)
539+
elif hasattr(raw_span, "end"):
540+
# Only call end() if it's not a context manager
541+
raw_span.end()
542+
except Exception as cleanup_error:
543+
# Log cleanup errors but don't let them corrupt context
544+
logger.warning(
545+
"Error during span cleanup for {operation_name}: {cleanup_error}",
546+
operation_name=operation_name,
547+
cleanup_error=cleanup_error,
548+
)
549+
finally:
550+
# Restore previous span stack using saved token
551+
span_stack_var.reset(token)
552+
553+
if self.enforce_flush:
554+
self.flush()
553555

554556
def flush(self) -> None:
555557
"""Flush all pending spans to Langfuse."""

0 commit comments

Comments
 (0)