langfuse-haystack 4.1.0__tar.gz → 5.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/CHANGELOG.md +32 -0
  2. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/PKG-INFO +2 -2
  3. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/pyproject.toml +6 -2
  4. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/tracing/langfuse/tracer.py +93 -90
  5. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/tests/test_tracer.py +128 -75
  6. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/.gitignore +0 -0
  7. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/LICENSE.txt +0 -0
  8. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/README.md +0 -0
  9. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/example/basic_rag.py +0 -0
  10. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/example/chat.py +0 -0
  11. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/example/requirements.txt +0 -0
  12. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/pydoc/config_docusaurus.yml +0 -0
  13. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/components/connectors/__init__.py +0 -0
  14. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
  15. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +0 -0
  16. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/components/connectors/py.typed +0 -0
  17. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
  18. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/tracing/py.typed +0 -0
  19. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/tests/__init__.py +0 -0
  20. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/tests/test_langfuse_connector.py +0 -0
  21. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/tests/test_tracing.py +0 -0
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/langfuse-v5.0.0] - 2026-04-17
4
+
5
+ ### 🚀 Features
6
+
7
+ - [**breaking**] Migrate to Langfuse Python SDK v4 (#3126)
8
+
9
+ ### 🧪 Testing
10
+
11
+ - Track test coverage for all integrations (#3065)
12
+
13
+
14
+ ## [integrations/langfuse-v4.1.0] - 2026-03-26
15
+
16
+ ### 🚀 Features
17
+
18
+ - Log generation_kwargs in Langfuse spans (#3017)
19
+
20
+ ### 🐛 Bug Fixes
21
+
22
+ - *(langfuse)* Replace MagicMock with Mock to prevent RAM exhaustion (#2543) (#2798)
23
+
24
+ ### 📚 Documentation
25
+
26
+ - Simplify pydoc configs (#2855)
27
+
28
+ ### 🧹 Chores
29
+
30
+ - Remove unused allow-direct-references (#2866)
31
+ - Add ANN type annotations to google_genai, hanlp, jina, langfuse, lara (#2990)
32
+ - Enforce ruff docstring rules (D102/D103/D205/D209/D213/D417/D419) in integrations 21-30 (#3010)
33
+
34
+
3
35
  ## [integrations/langfuse-v4.0.1] - 2026-01-27
4
36
 
5
37
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langfuse-haystack
3
- Version: 4.1.0
3
+ Version: 5.0.1
4
4
  Summary: Langfuse integration for Haystack
5
5
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse#readme
6
6
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: Implementation :: CPython
18
18
  Classifier: Programming Language :: Python :: Implementation :: PyPy
19
19
  Requires-Python: >=3.10
20
20
  Requires-Dist: haystack-ai>=2.22.0
21
- Requires-Dist: langfuse<4.0.0,>=3.3.1
21
+ Requires-Dist: langfuse>=4.0.0
22
22
  Description-Content-Type: text/markdown
23
23
 
24
24
  # langfuse-haystack
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: Implementation :: CPython",
22
22
  "Programming Language :: Python :: Implementation :: PyPy",
23
23
  ]
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"]
25
25
 
26
26
  [project.urls]
27
27
  Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse#readme"
@@ -66,7 +66,8 @@ dependencies = [
66
66
  unit = 'pytest -m "not integration" {args:tests}'
67
67
  integration = 'pytest -m "integration" {args:tests}'
68
68
  all = 'pytest {args:tests}'
69
- cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x {args:tests}'
69
+ unit-cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x -m "not integration" {args:tests}'
70
+ integration-cov-append-retry = 'pytest --cov=haystack_integrations --cov-append --reruns 3 --reruns-delay 30 -x -m "integration" {args:tests}'
70
71
 
71
72
  types = "mypy -p haystack_integrations.components.connectors.langfuse -p haystack_integrations.tracing.langfuse {args}"
72
73
 
@@ -152,6 +153,7 @@ ban-relative-imports = "parents"
152
153
  [tool.coverage.run]
153
154
  source = ["haystack_integrations"]
154
155
  branch = true
156
+ relative_files = true
155
157
  parallel = false
156
158
 
157
159
 
@@ -159,6 +161,8 @@ parallel = false
159
161
  omit = ["*/tests/*", "*/__init__.py"]
160
162
  show_missing = true
161
163
  exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
164
+ # coverage automatically detects some of the test dependencies but cannot find the source, so we ignore the errors
165
+ ignore_errors = true
162
166
 
163
167
  [tool.pytest.ini_options]
164
168
  addopts = "--strict-markers"
@@ -22,7 +22,8 @@ from haystack.tracing import utils as tracing_utils
22
22
 
23
23
  import langfuse
24
24
  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
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
@@ -61,7 +62,6 @@ class LangfuseSpan(Span):
61
62
  Initialize a LangfuseSpan instance.
62
63
 
63
64
  :param context_manager: The context manager from Langfuse created with
64
- `langfuse.get_client().start_as_current_span` or
65
65
  `langfuse.get_client().start_as_current_observation`.
66
66
  """
67
67
  self._span = context_manager.__enter__()
@@ -90,7 +90,7 @@ class LangfuseSpan(Span):
90
90
  return
91
91
  if key.endswith(".input"):
92
92
  if "messages" in value:
93
- messages = [m.to_openai_dict_format(require_tool_call_ids=False) for m in value["messages"]]
93
+ messages = [m.to_openai_dict_format(require_tool_call_ids=False) for m in (value.get("messages") or [])]
94
94
  if isinstance(gen_kwargs := value.get("generation_kwargs"), dict):
95
95
  self._span.update(input={"messages": messages, "generation_kwargs": gen_kwargs})
96
96
  else:
@@ -100,10 +100,11 @@ class LangfuseSpan(Span):
100
100
  self._span.update(input=coerced_value)
101
101
  elif key.endswith(".output"):
102
102
  if "replies" in value:
103
- if all(isinstance(r, ChatMessage) for r in value["replies"]):
104
- replies = [m.to_openai_dict_format(require_tool_call_ids=False) for m in value["replies"]]
103
+ replies_list = value.get("replies") or []
104
+ if all(isinstance(r, ChatMessage) for r in replies_list):
105
+ replies = [m.to_openai_dict_format(require_tool_call_ids=False) for m in replies_list]
105
106
  else:
106
- replies = value["replies"]
107
+ replies = replies_list
107
108
  self._span.update(output=replies)
108
109
  else:
109
110
  coerced_value = tracing_utils.coerce_tag_value(value)
@@ -297,33 +298,17 @@ class DefaultSpanHandler(SpanHandler):
297
298
  trace_context: TraceContext | None = cast(
298
299
  TraceContext, {"trace_id": tracing_ctx.get("trace_id")} if tracing_ctx.get("trace_id") else None
299
300
  )
300
- # Create a new trace when there's no parent span
301
+
301
302
  span_context_manager = self.tracer.start_as_current_observation(
302
303
  trace_context=trace_context,
303
304
  name=context.trace_name,
304
305
  version=tracing_ctx.get("version"),
305
306
  as_type=root_span_type,
306
307
  )
307
-
308
- # Create LangfuseSpan which will handle entering the context manager
309
308
  span = LangfuseSpan(span_context_manager)
310
309
 
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)
310
+ if context.public:
311
+ span.raw_span().set_trace_as_public()
327
312
 
328
313
  return span
329
314
 
@@ -347,7 +332,7 @@ class DefaultSpanHandler(SpanHandler):
347
332
  )
348
333
  )
349
334
  else:
350
- return LangfuseSpan(self.tracer.start_as_current_span(name=context.name))
335
+ return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name))
351
336
 
352
337
  def handle(self, span: LangfuseSpan, component_type: str | None) -> None:
353
338
  """Process and enrich a span after component execution."""
@@ -356,7 +341,7 @@ class DefaultSpanHandler(SpanHandler):
356
341
  if at_pipeline_level:
357
342
  coerced_input = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_INPUT_KEY))
358
343
  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)
344
+ span.raw_span().update(input=coerced_input, output=coerced_output)
360
345
  # special case for ToolInvoker (to update the span name to be: `original_component_name - [tool_names]`)
361
346
  if component_type == "ToolInvoker":
362
347
  tool_names: list[str] = []
@@ -468,6 +453,7 @@ class LangfuseTracer(Tracer):
468
453
  tags = tags or {}
469
454
  span_name = tags.get(_COMPONENT_NAME_KEY, operation_name)
470
455
  component_type = tags.get(_COMPONENT_TYPE_KEY)
456
+ is_root_span = not (parent_span or self.current_span())
471
457
 
472
458
  # Create a new span context
473
459
  span_context = SpanContext(
@@ -482,74 +468,91 @@ class LangfuseTracer(Tracer):
482
468
  public=self._public,
483
469
  )
484
470
 
485
- # Create span using the handler
486
- span = self._span_handler.create_span(span_context)
471
+ tracing_ctx = tracing_context_var.get({})
487
472
 
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)
473
+ # we need to properly exit context managers
474
+ with contextlib.ExitStack() as stack:
475
+ if is_root_span:
476
+ # propagate_attributes sets trace_name and other trace-level metadata on the root
477
+ # span and ALL child spans created within this context (full trace lifetime)
478
+ stack.enter_context(
479
+ propagate_attributes(
480
+ trace_name=self._name,
481
+ user_id=tracing_ctx.get("user_id") if tracing_ctx.get("user_id") is not None else None,
482
+ session_id=tracing_ctx.get("session_id") if tracing_ctx.get("session_id") is not None else None,
483
+ version=tracing_ctx.get("version") if tracing_ctx.get("version") is not None else None,
484
+ tags=tracing_ctx.get("tags") if tracing_ctx.get("tags") is not None else None,
485
+ )
486
+ )
493
487
 
494
- span.set_tags(tags)
488
+ # Create span using the handler
489
+ span = self._span_handler.create_span(span_context)
495
490
 
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
- )
491
+ # Build new span hierarchy: copy existing stack, add new span, save for restoration
492
+ prev_stack = span_stack_var.get()
493
+ new_stack = (prev_stack or []).copy()
494
+ new_stack.append(span)
495
+ token = span_stack_var.set(new_stack)
521
496
 
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)
497
+ span.set_tags(tags)
550
498
 
551
- if self.enforce_flush:
552
- self.flush()
499
+ try:
500
+ yield span
501
+ except Exception:
502
+ # Exception occurred - capture exception info and pass to __exit__
503
+ # This allows Langfuse/OpenTelemetry to properly mark the span with ERROR level
504
+ exc_info = sys.exc_info()
505
+ try:
506
+ # Process span data (may fail with nested pipeline exceptions)
507
+ self._span_handler.handle(span, component_type)
508
+
509
+ # End span with exception info (may fail if span data is corrupted)
510
+ raw_span = span.raw_span()
511
+ if span._context_manager is not None:
512
+ # Pass actual exception info to mark span as failed with ERROR level
513
+ span._context_manager.__exit__(*exc_info)
514
+ elif hasattr(raw_span, "end"):
515
+ # Only call end() if it's not a context manager
516
+ raw_span.end()
517
+ except Exception as cleanup_error:
518
+ # Log cleanup errors but don't let them corrupt context
519
+ logger.warning(
520
+ "Error during span cleanup for {operation_name}: {cleanup_error}",
521
+ operation_name=operation_name,
522
+ cleanup_error=cleanup_error,
523
+ )
524
+
525
+ # Re-raise the original exception
526
+ raise
527
+ else:
528
+ # No exception - clean exit with success status
529
+ # This preserves any manually-set log levels (WARNING, DEBUG)
530
+ try:
531
+ # Process span data
532
+ self._span_handler.handle(span, component_type)
533
+
534
+ # End span successfully
535
+ raw_span = span.raw_span()
536
+ # In v3, we need to properly exit context managers
537
+ if span._context_manager is not None:
538
+ # No exception - pass None to indicate success
539
+ span._context_manager.__exit__(None, None, None)
540
+ elif hasattr(raw_span, "end"):
541
+ # Only call end() if it's not a context manager
542
+ raw_span.end()
543
+ except Exception as cleanup_error:
544
+ # Log cleanup errors but don't let them corrupt context
545
+ logger.warning(
546
+ "Error during span cleanup for {operation_name}: {cleanup_error}",
547
+ operation_name=operation_name,
548
+ cleanup_error=cleanup_error,
549
+ )
550
+ finally:
551
+ # Restore previous span stack using saved token
552
+ span_stack_var.reset(token)
553
+
554
+ if self.enforce_flush:
555
+ self.flush()
553
556
 
554
557
  def flush(self) -> None:
555
558
  """Flush all pending spans to Langfuse."""
@@ -22,17 +22,15 @@ from haystack_integrations.tracing.langfuse.tracer import (
22
22
  )
23
23
 
24
24
 
25
- # Mock functions for Langfuse v3 API
26
25
  def mock_get_client():
27
26
  mock_client = Mock()
28
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
29
27
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
30
28
  mock_client.get_current_trace_id = Mock(return_value="mock_trace_id_123")
31
29
  return mock_client
32
30
 
33
31
 
34
32
  class MockContextManager:
35
- """Mock context manager that simulates Langfuse v3 context managers"""
33
+ """Mock context manager that simulates Langfuse v4 context managers"""
36
34
 
37
35
  def __init__(self, name="mock_span"):
38
36
  self._span = MockSpan(name)
@@ -63,9 +61,8 @@ class MockSpan:
63
61
  # Return a new mock span for child spans
64
62
  return MockSpan(name=name or "child_span")
65
63
 
66
- def update_trace(self, **kwargs):
67
- # v3 API method for updating trace-level data
68
- self._data.update(kwargs)
64
+ def set_trace_as_public(self):
65
+ self._data["public"] = True
69
66
 
70
67
  def generation(self, name=None):
71
68
  # Return a new mock span for generation spans
@@ -90,9 +87,6 @@ class MockLangfuseClient:
90
87
  def __init__(self):
91
88
  self._mock_context_manager = MockContextManager()
92
89
 
93
- def start_as_current_span(self, _name=None, **_kwargs):
94
- return self._mock_context_manager
95
-
96
90
  def start_as_current_observation(self, _name=None, _as_type=None, **_kwargs):
97
91
  return self._mock_context_manager
98
92
 
@@ -134,57 +128,75 @@ class TestLangfuseSpan:
134
128
  # set_content_tag method can update input and output of the span object
135
129
  def test_set_content_tag_updates_input_and_output(self):
136
130
  mock_context_manager = MockContextManager()
137
-
138
131
  span = LangfuseSpan(mock_context_manager)
139
- span.set_content_tag("test.input", "input_value")
140
- # Check that the span.update method was called with input parameter
141
- mock_context_manager._span.update.assert_called_with(input="input_value")
142
132
 
143
- mock_context_manager._span.update.reset_mock()
144
- span.set_content_tag("test.output", "output_value")
145
- # Check that the span.update method was called with output parameter
146
- mock_context_manager._span.update.assert_called_with(output="output_value")
133
+ with patch("haystack_integrations.tracing.langfuse.tracer.proxy_tracer.is_content_tracing_enabled", True):
134
+ span.set_content_tag("test.input", "input_value")
135
+ mock_context_manager._span.update.assert_called_with(input="input_value")
136
+
137
+ mock_context_manager._span.update.reset_mock()
138
+ span.set_content_tag("test.output", "output_value")
139
+ mock_context_manager._span.update.assert_called_with(output="output_value")
147
140
 
148
141
  # set_content_tag method can update input and output of the span object with messages/replies
149
142
  def test_set_content_tag_updates_input_and_output_with_messages(self):
150
143
  mock_context_manager = MockContextManager()
151
-
152
144
  span = LangfuseSpan(mock_context_manager)
153
- span.set_content_tag("key.input", {"messages": [ChatMessage.from_user("message")]})
154
- assert mock_context_manager._span.update.call_count == 1
155
- # check we converted ChatMessage to OpenAI format
156
- assert mock_context_manager._span.update.call_args_list[0][1] == {
157
- "input": [{"role": "user", "content": "message"}]
158
- }
159
145
 
160
- # test generation_kwargs are captured
161
- mock_context_manager._span.update.reset_mock()
162
- span.set_content_tag(
163
- "key.input",
164
- {"messages": [ChatMessage.from_user("message")], "generation_kwargs": {"n": 2, "temperature": 0.7}},
165
- )
166
- assert mock_context_manager._span.update.call_count == 1
167
- assert mock_context_manager._span.update.call_args_list[0][1] == {
168
- "input": {
169
- "messages": [{"role": "user", "content": "message"}],
170
- "generation_kwargs": {"n": 2, "temperature": 0.7},
146
+ with patch("haystack_integrations.tracing.langfuse.tracer.proxy_tracer.is_content_tracing_enabled", True):
147
+ span.set_content_tag("key.input", {"messages": [ChatMessage.from_user("message")]})
148
+ assert mock_context_manager._span.update.call_count == 1
149
+ # check we converted ChatMessage to OpenAI format
150
+ assert mock_context_manager._span.update.call_args_list[0][1] == {
151
+ "input": [{"role": "user", "content": "message"}]
171
152
  }
172
- }
173
- # test replies ChatMessage list
174
- mock_context_manager._span.update.reset_mock()
175
- span.set_content_tag("key.output", {"replies": [ChatMessage.from_system("reply")]})
176
- assert mock_context_manager._span.update.call_count == 1
177
- # check we converted ChatMessage to OpenAI format
178
- assert mock_context_manager._span.update.call_args_list[0][1] == {
179
- "output": [{"role": "system", "content": "reply"}]
180
- }
181
153
 
182
- # test replies string list
183
- mock_context_manager._span.update.reset_mock()
184
- span.set_content_tag("key.output", {"replies": ["reply1", "reply2"]})
185
- assert mock_context_manager._span.update.call_count == 1
186
- # check we handle properly string list replies
187
- assert mock_context_manager._span.update.call_args_list[0][1] == {"output": ["reply1", "reply2"]}
154
+ # test generation_kwargs are captured
155
+ mock_context_manager._span.update.reset_mock()
156
+ span.set_content_tag(
157
+ "key.input",
158
+ {"messages": [ChatMessage.from_user("message")], "generation_kwargs": {"n": 2, "temperature": 0.7}},
159
+ )
160
+ assert mock_context_manager._span.update.call_count == 1
161
+ assert mock_context_manager._span.update.call_args_list[0][1] == {
162
+ "input": {
163
+ "messages": [{"role": "user", "content": "message"}],
164
+ "generation_kwargs": {"n": 2, "temperature": 0.7},
165
+ }
166
+ }
167
+ # test replies ChatMessage list
168
+ mock_context_manager._span.update.reset_mock()
169
+ span.set_content_tag("key.output", {"replies": [ChatMessage.from_system("reply")]})
170
+ assert mock_context_manager._span.update.call_count == 1
171
+ # check we converted ChatMessage to OpenAI format
172
+ assert mock_context_manager._span.update.call_args_list[0][1] == {
173
+ "output": [{"role": "system", "content": "reply"}]
174
+ }
175
+
176
+ # test replies string list
177
+ mock_context_manager._span.update.reset_mock()
178
+ span.set_content_tag("key.output", {"replies": ["reply1", "reply2"]})
179
+ assert mock_context_manager._span.update.call_count == 1
180
+ # check we handle properly string list replies
181
+ assert mock_context_manager._span.update.call_args_list[0][1] == {"output": ["reply1", "reply2"]}
182
+
183
+ def test_set_content_tag_messages_none_does_not_raise(self):
184
+ mock_context_manager = MockContextManager()
185
+ span = LangfuseSpan(mock_context_manager)
186
+
187
+ with patch("haystack_integrations.tracing.langfuse.tracer.proxy_tracer.is_content_tracing_enabled", True):
188
+ span.set_content_tag("key.input", {"messages": None})
189
+ assert mock_context_manager._span.update.call_count == 1
190
+ assert mock_context_manager._span.update.call_args_list[0][1] == {"input": []}
191
+
192
+ def test_set_content_tag_replies_none_does_not_raise(self):
193
+ mock_context_manager = MockContextManager()
194
+ span = LangfuseSpan(mock_context_manager)
195
+
196
+ with patch("haystack_integrations.tracing.langfuse.tracer.proxy_tracer.is_content_tracing_enabled", True):
197
+ span.set_content_tag("key.output", {"replies": None})
198
+ assert mock_context_manager._span.update.call_count == 1
199
+ assert mock_context_manager._span.update.call_args_list[0][1] == {"output": []}
188
200
 
189
201
 
190
202
  class TestSpanContext:
@@ -239,7 +251,6 @@ class TestDefaultSpanHandler:
239
251
  def test_create_span_root_trace(self):
240
252
  """Test creating a root span (no parent)."""
241
253
  mock_client = Mock()
242
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
243
254
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
244
255
 
245
256
  handler = DefaultSpanHandler()
@@ -274,6 +285,24 @@ class TestDefaultSpanHandler:
274
285
  finally:
275
286
  tracing_context_var.reset(token)
276
287
 
288
+ def test_create_span_root_trace_public(self):
289
+ """Test that public=True calls set_trace_as_public on the span."""
290
+ mock_client = Mock()
291
+ mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
292
+
293
+ handler = DefaultSpanHandler()
294
+ handler.init_tracer(mock_client)
295
+
296
+ context = MagicMock(spec=SpanContext)
297
+ context.parent_span = None
298
+ context.trace_name = "public_trace"
299
+ context.operation_name = "haystack.pipeline.run"
300
+ context.public = True
301
+
302
+ result = handler.create_span(context)
303
+
304
+ assert result.raw_span()._data.get("public") is True
305
+
277
306
  def test_handle_generator(self):
278
307
  mock_span = Mock()
279
308
  mock_span.raw_span.return_value = mock_span
@@ -345,7 +374,6 @@ class TestDefaultSpanHandler:
345
374
  def test_create_span_custom_chat_generator(self):
346
375
  """Test that custom chat generators create 'generation' span type."""
347
376
  mock_client = Mock()
348
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
349
377
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
350
378
 
351
379
  handler = DefaultSpanHandler()
@@ -356,8 +384,9 @@ class TestDefaultSpanHandler:
356
384
  operation_name="haystack.component.run",
357
385
  component_type="MistralChatGenerator",
358
386
  tags={},
359
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
387
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
360
388
  )
389
+ mock_client.start_as_current_observation.reset_mock()
361
390
 
362
391
  span = handler.create_span(context)
363
392
  assert isinstance(span, LangfuseSpan)
@@ -368,7 +397,6 @@ class TestDefaultSpanHandler:
368
397
  def test_create_span_custom_generator(self):
369
398
  """Test that custom generators create 'generation' span type."""
370
399
  mock_client = Mock()
371
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
372
400
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
373
401
 
374
402
  handler = DefaultSpanHandler()
@@ -379,8 +407,9 @@ class TestDefaultSpanHandler:
379
407
  operation_name="haystack.component.run",
380
408
  component_type="CustomAPIGenerator",
381
409
  tags={},
382
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
410
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
383
411
  )
412
+ mock_client.start_as_current_observation.reset_mock()
384
413
 
385
414
  span = handler.create_span(context)
386
415
  assert isinstance(span, LangfuseSpan)
@@ -391,7 +420,6 @@ class TestDefaultSpanHandler:
391
420
  def test_create_span_retriever(self):
392
421
  """Test that retrievers create 'retriever' span type."""
393
422
  mock_client = Mock()
394
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
395
423
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
396
424
 
397
425
  handler = DefaultSpanHandler()
@@ -402,8 +430,9 @@ class TestDefaultSpanHandler:
402
430
  operation_name="haystack.component.run",
403
431
  component_type="InMemoryBM25Retriever",
404
432
  tags={},
405
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
433
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
406
434
  )
435
+ mock_client.start_as_current_observation.reset_mock()
407
436
 
408
437
  span = handler.create_span(context)
409
438
  assert isinstance(span, LangfuseSpan)
@@ -414,7 +443,6 @@ class TestDefaultSpanHandler:
414
443
  def test_create_span_embedder(self):
415
444
  """Test that embedders create 'embedding' span type."""
416
445
  mock_client = Mock()
417
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
418
446
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
419
447
 
420
448
  handler = DefaultSpanHandler()
@@ -425,8 +453,9 @@ class TestDefaultSpanHandler:
425
453
  operation_name="haystack.component.run",
426
454
  component_type="SentenceTransformersDocumentEmbedder",
427
455
  tags={},
428
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
456
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
429
457
  )
458
+ mock_client.start_as_current_observation.reset_mock()
430
459
 
431
460
  span = handler.create_span(context)
432
461
  assert isinstance(span, LangfuseSpan)
@@ -435,9 +464,8 @@ class TestDefaultSpanHandler:
435
464
  )
436
465
 
437
466
  def test_create_span_non_component(self):
438
- """Test that non-matching components create regular spans."""
467
+ """Test that non-matching components create default span type."""
439
468
  mock_client = Mock()
440
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
441
469
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
442
470
 
443
471
  handler = DefaultSpanHandler()
@@ -448,15 +476,14 @@ class TestDefaultSpanHandler:
448
476
  operation_name="haystack.component.run",
449
477
  component_type="DocumentJoiner",
450
478
  tags={},
451
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
479
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
452
480
  )
481
+ mock_client.start_as_current_observation.reset_mock()
453
482
 
454
483
  span = handler.create_span(context)
455
484
  assert isinstance(span, LangfuseSpan)
456
- # Non-matching components should use start_as_current_span, not start_as_current_observation
457
- mock_client.start_as_current_observation.assert_not_called()
458
- # Verify start_as_current_span was called for the actual span creation (not just parent)
459
- assert mock_client.start_as_current_span.call_count == 2 # Once for parent, once for the span
485
+ # Non-matching components use start_as_current_observation without explicit as_type
486
+ mock_client.start_as_current_observation.assert_called_once_with(name="DocumentJoiner")
460
487
 
461
488
  def test_handle_embedder_with_openai_format(self):
462
489
  """Test that embedder usage is extracted in OpenAI format."""
@@ -600,7 +627,6 @@ class TestLangfuseTracer:
600
627
  mock_span_instance._context_manager = mock_context_manager
601
628
 
602
629
  mock_tracer = Mock()
603
- mock_tracer.start_as_current_span.return_value = mock_context_manager
604
630
  mock_tracer.start_as_current_observation.return_value = mock_context_manager
605
631
 
606
632
  tracer = LangfuseTracer(tracer=mock_tracer, name="Haystack", public=False)
@@ -616,15 +642,42 @@ class TestLangfuseTracer:
616
642
  # Check that the span is cleaned up after tracing
617
643
  assert tracer.current_span() is None, "There should be no active span after tracing completes"
618
644
 
619
- # check that update method is called on the span instance with the provided key value pairs
620
- def test_update_span_with_pipeline_input_output_data(self):
621
- with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client"):
622
- tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
623
- with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
624
- assert span.raw_span()._data["metadata"] == {"haystack.pipeline.input_data": "hello"}
645
+ def test_pipeline_input_output_written_to_root_observation(self):
646
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
647
+ with tracer.trace(
648
+ operation_name="operation_name",
649
+ tags={
650
+ "haystack.pipeline.input_data": "hello",
651
+ "haystack.pipeline.output_data": "bye",
652
+ },
653
+ ) as span:
654
+ pass
655
+
656
+ # MockSpan._update_data mirrors kwargs into _data, so after the context exits
657
+ # we can verify update(input=..., output=...) was actually called.
658
+ assert span.raw_span()._data["input"] == "hello"
659
+ assert span.raw_span()._data["output"] == "bye"
625
660
 
626
- with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.output_data": "bye"}) as span:
627
- assert span.raw_span()._data["metadata"] == {"haystack.pipeline.output_data": "bye"}
661
+ def test_propagate_attributes_called_with_tracing_ctx_vars(self):
662
+ token = tracing_context_var.set(
663
+ {"user_id": "user123", "session_id": "sess456", "version": "v1", "tags": ["tag1"]}
664
+ )
665
+ try:
666
+ with patch("haystack_integrations.tracing.langfuse.tracer.propagate_attributes") as mock_propagate:
667
+ mock_propagate.return_value.__enter__ = Mock(return_value=None)
668
+ mock_propagate.return_value.__exit__ = Mock(return_value=False)
669
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="MyPipeline", public=False)
670
+ with tracer.trace(operation_name="haystack.pipeline.run"):
671
+ pass
672
+ mock_propagate.assert_called_once_with(
673
+ trace_name="MyPipeline",
674
+ user_id="user123",
675
+ session_id="sess456",
676
+ version="v1",
677
+ tags=["tag1"],
678
+ )
679
+ finally:
680
+ tracing_context_var.reset(token)
628
681
 
629
682
  def test_trace_generation(self):
630
683
  with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client"):