langfuse-haystack 4.1.0__tar.gz → 5.0.0__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.0}/CHANGELOG.md +21 -0
  2. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/PKG-INFO +2 -2
  3. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/pyproject.toml +6 -2
  4. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/src/haystack_integrations/tracing/langfuse/tracer.py +88 -86
  5. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/tests/test_tracer.py +110 -75
  6. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/.gitignore +0 -0
  7. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/LICENSE.txt +0 -0
  8. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/README.md +0 -0
  9. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/example/basic_rag.py +0 -0
  10. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/example/chat.py +0 -0
  11. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/example/requirements.txt +0 -0
  12. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/pydoc/config_docusaurus.yml +0 -0
  13. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/src/haystack_integrations/components/connectors/__init__.py +0 -0
  14. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
  15. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +0 -0
  16. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/src/haystack_integrations/components/connectors/py.typed +0 -0
  17. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
  18. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/src/haystack_integrations/tracing/py.typed +0 -0
  19. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/tests/__init__.py +0 -0
  20. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/tests/test_langfuse_connector.py +0 -0
  21. {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.0}/tests/test_tracing.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/langfuse-v4.1.0] - 2026-03-26
4
+
5
+ ### 🚀 Features
6
+
7
+ - Log generation_kwargs in Langfuse spans (#3017)
8
+
9
+ ### 🐛 Bug Fixes
10
+
11
+ - *(langfuse)* Replace MagicMock with Mock to prevent RAM exhaustion (#2543) (#2798)
12
+
13
+ ### 📚 Documentation
14
+
15
+ - Simplify pydoc configs (#2855)
16
+
17
+ ### 🧹 Chores
18
+
19
+ - Remove unused allow-direct-references (#2866)
20
+ - Add ANN type annotations to google_genai, hanlp, jina, langfuse, lara (#2990)
21
+ - Enforce ruff docstring rules (D102/D103/D205/D209/D213/D417/D419) in integrations 21-30 (#3010)
22
+
23
+
3
24
  ## [integrations/langfuse-v4.0.1] - 2026-01-27
4
25
 
5
26
  ### 🐛 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.0
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__()
@@ -297,33 +297,17 @@ class DefaultSpanHandler(SpanHandler):
297
297
  trace_context: TraceContext | None = cast(
298
298
  TraceContext, {"trace_id": tracing_ctx.get("trace_id")} if tracing_ctx.get("trace_id") else None
299
299
  )
300
- # Create a new trace when there's no parent span
300
+
301
301
  span_context_manager = self.tracer.start_as_current_observation(
302
302
  trace_context=trace_context,
303
303
  name=context.trace_name,
304
304
  version=tracing_ctx.get("version"),
305
305
  as_type=root_span_type,
306
306
  )
307
-
308
- # Create LangfuseSpan which will handle entering the context manager
309
307
  span = LangfuseSpan(span_context_manager)
310
308
 
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()
327
311
 
328
312
  return span
329
313
 
@@ -347,7 +331,7 @@ class DefaultSpanHandler(SpanHandler):
347
331
  )
348
332
  )
349
333
  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))
351
335
 
352
336
  def handle(self, span: LangfuseSpan, component_type: str | None) -> None:
353
337
  """Process and enrich a span after component execution."""
@@ -356,7 +340,7 @@ class DefaultSpanHandler(SpanHandler):
356
340
  if at_pipeline_level:
357
341
  coerced_input = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_INPUT_KEY))
358
342
  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)
360
344
  # special case for ToolInvoker (to update the span name to be: `original_component_name - [tool_names]`)
361
345
  if component_type == "ToolInvoker":
362
346
  tool_names: list[str] = []
@@ -468,6 +452,7 @@ class LangfuseTracer(Tracer):
468
452
  tags = tags or {}
469
453
  span_name = tags.get(_COMPONENT_NAME_KEY, operation_name)
470
454
  component_type = tags.get(_COMPONENT_TYPE_KEY)
455
+ is_root_span = not (parent_span or self.current_span())
471
456
 
472
457
  # Create a new span context
473
458
  span_context = SpanContext(
@@ -482,74 +467,91 @@ class LangfuseTracer(Tracer):
482
467
  public=self._public,
483
468
  )
484
469
 
485
- # Create span using the handler
486
- span = self._span_handler.create_span(span_context)
470
+ tracing_ctx = tracing_context_var.get({})
487
471
 
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
+ )
493
486
 
494
- span.set_tags(tags)
487
+ # Create span using the handler
488
+ span = self._span_handler.create_span(span_context)
495
489
 
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)
521
495
 
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)
550
497
 
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()
553
555
 
554
556
  def flush(self) -> None:
555
557
  """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,57 @@ 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"]}
188
182
 
189
183
 
190
184
  class TestSpanContext:
@@ -239,7 +233,6 @@ class TestDefaultSpanHandler:
239
233
  def test_create_span_root_trace(self):
240
234
  """Test creating a root span (no parent)."""
241
235
  mock_client = Mock()
242
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
243
236
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
244
237
 
245
238
  handler = DefaultSpanHandler()
@@ -274,6 +267,24 @@ class TestDefaultSpanHandler:
274
267
  finally:
275
268
  tracing_context_var.reset(token)
276
269
 
270
+ def test_create_span_root_trace_public(self):
271
+ """Test that public=True calls set_trace_as_public on the span."""
272
+ mock_client = Mock()
273
+ mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
274
+
275
+ handler = DefaultSpanHandler()
276
+ handler.init_tracer(mock_client)
277
+
278
+ context = MagicMock(spec=SpanContext)
279
+ context.parent_span = None
280
+ context.trace_name = "public_trace"
281
+ context.operation_name = "haystack.pipeline.run"
282
+ context.public = True
283
+
284
+ result = handler.create_span(context)
285
+
286
+ assert result.raw_span()._data.get("public") is True
287
+
277
288
  def test_handle_generator(self):
278
289
  mock_span = Mock()
279
290
  mock_span.raw_span.return_value = mock_span
@@ -345,7 +356,6 @@ class TestDefaultSpanHandler:
345
356
  def test_create_span_custom_chat_generator(self):
346
357
  """Test that custom chat generators create 'generation' span type."""
347
358
  mock_client = Mock()
348
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
349
359
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
350
360
 
351
361
  handler = DefaultSpanHandler()
@@ -356,8 +366,9 @@ class TestDefaultSpanHandler:
356
366
  operation_name="haystack.component.run",
357
367
  component_type="MistralChatGenerator",
358
368
  tags={},
359
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
369
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
360
370
  )
371
+ mock_client.start_as_current_observation.reset_mock()
361
372
 
362
373
  span = handler.create_span(context)
363
374
  assert isinstance(span, LangfuseSpan)
@@ -368,7 +379,6 @@ class TestDefaultSpanHandler:
368
379
  def test_create_span_custom_generator(self):
369
380
  """Test that custom generators create 'generation' span type."""
370
381
  mock_client = Mock()
371
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
372
382
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
373
383
 
374
384
  handler = DefaultSpanHandler()
@@ -379,8 +389,9 @@ class TestDefaultSpanHandler:
379
389
  operation_name="haystack.component.run",
380
390
  component_type="CustomAPIGenerator",
381
391
  tags={},
382
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
392
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
383
393
  )
394
+ mock_client.start_as_current_observation.reset_mock()
384
395
 
385
396
  span = handler.create_span(context)
386
397
  assert isinstance(span, LangfuseSpan)
@@ -391,7 +402,6 @@ class TestDefaultSpanHandler:
391
402
  def test_create_span_retriever(self):
392
403
  """Test that retrievers create 'retriever' span type."""
393
404
  mock_client = Mock()
394
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
395
405
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
396
406
 
397
407
  handler = DefaultSpanHandler()
@@ -402,8 +412,9 @@ class TestDefaultSpanHandler:
402
412
  operation_name="haystack.component.run",
403
413
  component_type="InMemoryBM25Retriever",
404
414
  tags={},
405
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
415
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
406
416
  )
417
+ mock_client.start_as_current_observation.reset_mock()
407
418
 
408
419
  span = handler.create_span(context)
409
420
  assert isinstance(span, LangfuseSpan)
@@ -414,7 +425,6 @@ class TestDefaultSpanHandler:
414
425
  def test_create_span_embedder(self):
415
426
  """Test that embedders create 'embedding' span type."""
416
427
  mock_client = Mock()
417
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
418
428
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
419
429
 
420
430
  handler = DefaultSpanHandler()
@@ -425,8 +435,9 @@ class TestDefaultSpanHandler:
425
435
  operation_name="haystack.component.run",
426
436
  component_type="SentenceTransformersDocumentEmbedder",
427
437
  tags={},
428
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
438
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
429
439
  )
440
+ mock_client.start_as_current_observation.reset_mock()
430
441
 
431
442
  span = handler.create_span(context)
432
443
  assert isinstance(span, LangfuseSpan)
@@ -435,9 +446,8 @@ class TestDefaultSpanHandler:
435
446
  )
436
447
 
437
448
  def test_create_span_non_component(self):
438
- """Test that non-matching components create regular spans."""
449
+ """Test that non-matching components create default span type."""
439
450
  mock_client = Mock()
440
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
441
451
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
442
452
 
443
453
  handler = DefaultSpanHandler()
@@ -448,15 +458,14 @@ class TestDefaultSpanHandler:
448
458
  operation_name="haystack.component.run",
449
459
  component_type="DocumentJoiner",
450
460
  tags={},
451
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
461
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
452
462
  )
463
+ mock_client.start_as_current_observation.reset_mock()
453
464
 
454
465
  span = handler.create_span(context)
455
466
  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
467
+ # Non-matching components use start_as_current_observation without explicit as_type
468
+ mock_client.start_as_current_observation.assert_called_once_with(name="DocumentJoiner")
460
469
 
461
470
  def test_handle_embedder_with_openai_format(self):
462
471
  """Test that embedder usage is extracted in OpenAI format."""
@@ -600,7 +609,6 @@ class TestLangfuseTracer:
600
609
  mock_span_instance._context_manager = mock_context_manager
601
610
 
602
611
  mock_tracer = Mock()
603
- mock_tracer.start_as_current_span.return_value = mock_context_manager
604
612
  mock_tracer.start_as_current_observation.return_value = mock_context_manager
605
613
 
606
614
  tracer = LangfuseTracer(tracer=mock_tracer, name="Haystack", public=False)
@@ -616,15 +624,42 @@ class TestLangfuseTracer:
616
624
  # Check that the span is cleaned up after tracing
617
625
  assert tracer.current_span() is None, "There should be no active span after tracing completes"
618
626
 
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"}
627
+ def test_pipeline_input_output_written_to_root_observation(self):
628
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
629
+ with tracer.trace(
630
+ operation_name="operation_name",
631
+ tags={
632
+ "haystack.pipeline.input_data": "hello",
633
+ "haystack.pipeline.output_data": "bye",
634
+ },
635
+ ) as span:
636
+ pass
625
637
 
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"}
638
+ # MockSpan._update_data mirrors kwargs into _data, so after the context exits
639
+ # we can verify update(input=..., output=...) was actually called.
640
+ assert span.raw_span()._data["input"] == "hello"
641
+ assert span.raw_span()._data["output"] == "bye"
642
+
643
+ def test_propagate_attributes_called_with_tracing_ctx_vars(self):
644
+ token = tracing_context_var.set(
645
+ {"user_id": "user123", "session_id": "sess456", "version": "v1", "tags": ["tag1"]}
646
+ )
647
+ try:
648
+ with patch("haystack_integrations.tracing.langfuse.tracer.propagate_attributes") as mock_propagate:
649
+ mock_propagate.return_value.__enter__ = Mock(return_value=None)
650
+ mock_propagate.return_value.__exit__ = Mock(return_value=False)
651
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="MyPipeline", public=False)
652
+ with tracer.trace(operation_name="haystack.pipeline.run"):
653
+ pass
654
+ mock_propagate.assert_called_once_with(
655
+ trace_name="MyPipeline",
656
+ user_id="user123",
657
+ session_id="sess456",
658
+ version="v1",
659
+ tags=["tag1"],
660
+ )
661
+ finally:
662
+ tracing_context_var.reset(token)
628
663
 
629
664
  def test_trace_generation(self):
630
665
  with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client"):