langfuse-haystack 2.3.0__tar.gz → 3.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 (19) hide show
  1. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/CHANGELOG.md +15 -0
  2. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/PKG-INFO +2 -2
  3. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/pyproject.toml +1 -1
  4. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/src/haystack_integrations/tracing/langfuse/tracer.py +44 -28
  5. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/tests/test_tracer.py +140 -76
  6. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/tests/test_tracing.py +12 -13
  7. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/.gitignore +0 -0
  8. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/LICENSE.txt +0 -0
  9. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/README.md +0 -0
  10. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/example/basic_rag.py +0 -0
  11. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/example/chat.py +0 -0
  12. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/example/requirements.txt +0 -0
  13. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/pydoc/config.yml +0 -0
  14. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/src/haystack_integrations/components/connectors/__init__.py +0 -0
  15. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
  16. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +0 -0
  17. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
  18. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/tests/__init__.py +0 -0
  19. {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/tests/test_langfuse_connector.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/langfuse-v2.3.0] - 2025-08-25
4
+
5
+ ### 🐛 Bug Fixes
6
+
7
+ - Avoid mixed Langfuse traces in async envs (#2207)
8
+
9
+ ### 🧪 Testing
10
+
11
+ - Make tests successfully run from forks (#2203)
12
+
13
+ ### 🧹 Chores
14
+
15
+ - Standardize readmes - part 2 (#2205)
16
+
17
+
3
18
  ## [integrations/langfuse-v2.2.1] - 2025-08-07
4
19
 
5
20
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langfuse-haystack
3
- Version: 2.3.0
3
+ Version: 3.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
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: Implementation :: CPython
19
19
  Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Requires-Python: >=3.9
21
21
  Requires-Dist: haystack-ai>=2.15.1
22
- Requires-Dist: langfuse<3.0.0,>=2.9.0
22
+ Requires-Dist: langfuse<4.0.0,>=3.0.0
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  # langfuse-haystack
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Programming Language :: Python :: Implementation :: CPython",
23
23
  "Programming Language :: Python :: Implementation :: PyPy",
24
24
  ]
25
- dependencies = ["haystack-ai>=2.15.1", "langfuse>=2.9.0, <3.0.0"]
25
+ dependencies = ["haystack-ai>=2.15.1", "langfuse>=3.0.0, <4.0.0"]
26
26
 
27
27
  [project.urls]
28
28
  Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse#readme"
@@ -6,24 +6,21 @@ import contextlib
6
6
  import os
7
7
  from abc import ABC, abstractmethod
8
8
  from collections import Counter
9
+ from contextlib import AbstractContextManager
9
10
  from contextvars import ContextVar
10
11
  from dataclasses import dataclass
11
12
  from datetime import datetime
12
- from typing import Any, Dict, Iterator, List, Optional, Union
13
+ from typing import Any, Dict, Iterator, List, Optional
13
14
 
14
15
  from haystack import default_from_dict, default_to_dict, logging
15
16
  from haystack.dataclasses import ChatMessage
16
17
  from haystack.tracing import Span, Tracer
17
18
  from haystack.tracing import tracer as proxy_tracer
18
19
  from haystack.tracing import utils as tracing_utils
19
- from typing_extensions import TypeAlias
20
20
 
21
21
  import langfuse
22
- from langfuse.client import StatefulGenerationClient, StatefulSpanClient, StatefulTraceClient
23
-
24
- # Type alias for Langfuse stateful clients
25
- LangfuseStatefulClient: TypeAlias = Union[StatefulTraceClient, StatefulSpanClient, StatefulGenerationClient]
26
-
22
+ from langfuse import LangfuseSpan as LangfuseClientSpan
23
+ from langfuse.types import TraceMetadata
27
24
 
28
25
  logger = logging.getLogger(__name__)
29
26
 
@@ -75,15 +72,17 @@ class LangfuseSpan(Span):
75
72
  Internal class representing a bridge between the Haystack span tracing API and Langfuse.
76
73
  """
77
74
 
78
- def __init__(self, span: LangfuseStatefulClient) -> None:
75
+ def __init__(self, context_manager: AbstractContextManager) -> None:
79
76
  """
80
77
  Initialize a LangfuseSpan instance.
81
78
 
82
- :param span: The span instance managed by Langfuse.
79
+ :param context_manager: The context manager from Langfuse created with
80
+ `langfuse.get_client().start_as_current_span` or
81
+ `langfuse.get_client().start_as_current_observation`.
83
82
  """
84
- self._span = span
85
- # locally cache tags
83
+ self._span = context_manager.__enter__()
86
84
  self._data: Dict[str, Any] = {}
85
+ self._context_manager = context_manager
87
86
 
88
87
  def set_tag(self, key: str, value: Any) -> None:
89
88
  """
@@ -125,7 +124,7 @@ class LangfuseSpan(Span):
125
124
 
126
125
  self._data[key] = value
127
126
 
128
- def raw_span(self) -> LangfuseStatefulClient:
127
+ def raw_span(self) -> LangfuseClientSpan:
129
128
  """
130
129
  Return the underlying span instance.
131
130
 
@@ -273,21 +272,35 @@ class DefaultSpanHandler(SpanHandler):
273
272
  tracing_ctx = tracing_context_var.get({})
274
273
  if not context.parent_span:
275
274
  # Create a new trace when there's no parent span
276
- return LangfuseSpan(
277
- self.tracer.trace(
278
- name=context.trace_name,
279
- public=context.public,
280
- id=tracing_ctx.get("trace_id"),
281
- user_id=tracing_ctx.get("user_id"),
282
- session_id=tracing_ctx.get("session_id"),
283
- tags=tracing_ctx.get("tags"),
284
- version=tracing_ctx.get("version"),
285
- )
275
+ span_context_manager = self.tracer.start_as_current_span(
276
+ name=context.trace_name,
277
+ version=tracing_ctx.get("version"),
286
278
  )
279
+
280
+ # Create LangfuseSpan which will handle entering the context manager
281
+ span = LangfuseSpan(span_context_manager)
282
+
283
+ # Build trace metadata from context
284
+ trace_metadata: TraceMetadata = {
285
+ "name": context.trace_name,
286
+ "user_id": tracing_ctx.get("user_id"),
287
+ "session_id": tracing_ctx.get("session_id"),
288
+ "version": tracing_ctx.get("version"),
289
+ "metadata": None,
290
+ "tags": tracing_ctx.get("tags"),
291
+ "public": context.public,
292
+ }
293
+
294
+ # Filter out None values and apply trace attributes
295
+ trace_attrs = {k: v for k, v in trace_metadata.items() if v is not None}
296
+ if trace_attrs:
297
+ span._span.update_trace(**trace_attrs)
298
+
299
+ return span
287
300
  elif context.component_type in _ALL_SUPPORTED_GENERATORS:
288
- return LangfuseSpan(context.parent_span.raw_span().generation(name=context.name))
301
+ return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name, as_type="generation"))
289
302
  else:
290
- return LangfuseSpan(context.parent_span.raw_span().span(name=context.name))
303
+ return LangfuseSpan(self.tracer.start_as_current_span(name=context.name))
291
304
 
292
305
  def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
293
306
  # If the span is at the pipeline level, we add input and output keys to the span
@@ -295,8 +308,7 @@ class DefaultSpanHandler(SpanHandler):
295
308
  if at_pipeline_level:
296
309
  coerced_input = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_INPUT_KEY))
297
310
  coerced_output = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_OUTPUT_KEY))
298
- span.raw_span().update(input=coerced_input, output=coerced_output)
299
-
311
+ span.raw_span().update_trace(input=coerced_input, output=coerced_output)
300
312
  # special case for ToolInvoker (to update the span name to be: `original_component_name - [tool_names]`)
301
313
  if component_type == "ToolInvoker":
302
314
  tool_names: List[str] = []
@@ -415,7 +427,11 @@ class LangfuseTracer(Tracer):
415
427
 
416
428
  # End span (may fail if span data is corrupted)
417
429
  raw_span = span.raw_span()
418
- if isinstance(raw_span, (StatefulSpanClient, StatefulGenerationClient)):
430
+ # In v3, we need to properly exit context managers
431
+ if span._context_manager is not None:
432
+ span._context_manager.__exit__(None, None, None)
433
+ elif hasattr(raw_span, "end"):
434
+ # Only call end() if it's not a context manager
419
435
  raw_span.end()
420
436
  except Exception as cleanup_error:
421
437
  # Log cleanup errors but don't let them corrupt context
@@ -456,4 +472,4 @@ class LangfuseTracer(Tracer):
456
472
  Return the trace ID.
457
473
  :return: The trace ID.
458
474
  """
459
- return self._tracer.get_trace_id()
475
+ return self._tracer.get_current_trace_id()
@@ -24,12 +24,39 @@ from haystack_integrations.tracing.langfuse.tracer import (
24
24
  from haystack_integrations.components.connectors.langfuse import LangfuseConnector
25
25
 
26
26
 
27
+ # Mock functions for Langfuse v3 API
28
+ def mock_get_client():
29
+ mock_client = Mock()
30
+ mock_client.start_as_current_span = Mock(return_value=MockContextManager())
31
+ mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
32
+ mock_client.get_current_trace_id = Mock(return_value="mock_trace_id_123")
33
+ return mock_client
34
+
35
+
36
+ class MockContextManager:
37
+ """Mock context manager that simulates Langfuse v3 context managers"""
38
+
39
+ def __init__(self, name="mock_span"):
40
+ self._span = MockSpan(name)
41
+
42
+ def __enter__(self):
43
+ return self._span
44
+
45
+ def __exit__(self, exc_type, exc_val, exc_tb):
46
+ pass
47
+
48
+
27
49
  class MockSpan:
28
50
  def __init__(self, name="mock_span"):
29
51
  self._data = {}
30
- self._span = self
31
52
  self.operation_name = name
32
53
  self._name = name
54
+ # Make update a Mock so we can assert on it, but also make it actually work
55
+ self.update = Mock(side_effect=self._update_data)
56
+
57
+ def _update_data(self, **kwargs):
58
+ """Helper method to actually update _data when update is called"""
59
+ self._data.update(kwargs)
33
60
 
34
61
  def raw_span(self):
35
62
  return self
@@ -38,7 +65,8 @@ class MockSpan:
38
65
  # Return a new mock span for child spans
39
66
  return MockSpan(name=name or "child_span")
40
67
 
41
- def update(self, **kwargs):
68
+ def update_trace(self, **kwargs):
69
+ # v3 API method for updating trace-level data
42
70
  self._data.update(kwargs)
43
71
 
44
72
  def generation(self, name=None):
@@ -58,6 +86,28 @@ class MockTracer:
58
86
  pass
59
87
 
60
88
 
89
+ class MockLangfuseClient:
90
+ """Mock Langfuse client that has all the required methods"""
91
+
92
+ def __init__(self):
93
+ self._mock_context_manager = MockContextManager()
94
+
95
+ def start_as_current_span(self, name=None, **kwargs):
96
+ return self._mock_context_manager
97
+
98
+ def start_as_current_observation(self, name=None, as_type=None, **kwargs):
99
+ return self._mock_context_manager
100
+
101
+ def get_current_trace_id(self):
102
+ return "mock_trace_id_123"
103
+
104
+ def get_current_observation_id(self):
105
+ return "mock_observation_id_123"
106
+
107
+ def flush(self):
108
+ pass
109
+
110
+
61
111
  class CustomSpanHandler(DefaultSpanHandler):
62
112
  def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
63
113
  if component_type == "OpenAIChatGenerator":
@@ -70,58 +120,60 @@ class CustomSpanHandler(DefaultSpanHandler):
70
120
  class TestLangfuseSpan:
71
121
  # LangfuseSpan can be initialized with a span object
72
122
  def test_initialized_with_span_object(self):
73
- mock_span = Mock()
74
- span = LangfuseSpan(mock_span)
75
- assert span.raw_span() == mock_span
123
+ mock_context_manager = MockContextManager()
124
+ span = LangfuseSpan(mock_context_manager)
125
+ assert span.raw_span() == mock_context_manager._span
76
126
 
77
127
  # set_tag method can update metadata of the span object
78
128
  def test_set_tag_updates_metadata(self):
79
- mock_span = Mock()
80
- span = LangfuseSpan(mock_span)
129
+ mock_context_manager = MockContextManager()
130
+ span = LangfuseSpan(mock_context_manager)
81
131
 
82
132
  span.set_tag("key", "value")
83
- mock_span.update.assert_called_once_with(metadata={"key": "value"})
133
+ mock_context_manager._span.update.assert_called_once_with(metadata={"key": "value"})
84
134
  assert span._data["key"] == "value"
85
135
 
86
136
  # set_content_tag method can update input and output of the span object
87
137
  def test_set_content_tag_updates_input_and_output(self):
88
- mock_span = Mock()
138
+ mock_context_manager = MockContextManager()
89
139
 
90
- span = LangfuseSpan(mock_span)
91
- span.set_content_tag("input_key", "input_value")
92
- assert span._data["input_key"] == "input_value"
140
+ span = LangfuseSpan(mock_context_manager)
141
+ span.set_content_tag("test.input", "input_value")
142
+ # Check that the span.update method was called with input parameter
143
+ mock_context_manager._span.update.assert_called_with(input="input_value")
93
144
 
94
- mock_span.reset_mock()
95
- span.set_content_tag("output_key", "output_value")
96
- assert span._data["output_key"] == "output_value"
145
+ mock_context_manager._span.update.reset_mock()
146
+ span.set_content_tag("test.output", "output_value")
147
+ # Check that the span.update method was called with output parameter
148
+ mock_context_manager._span.update.assert_called_with(output="output_value")
97
149
 
98
150
  # set_content_tag method can update input and output of the span object with messages/replies
99
151
  def test_set_content_tag_updates_input_and_output_with_messages(self):
100
- mock_span = Mock()
152
+ mock_context_manager = MockContextManager()
101
153
 
102
154
  # test message input
103
- span = LangfuseSpan(mock_span)
155
+ span = LangfuseSpan(mock_context_manager)
104
156
  span.set_content_tag("key.input", {"messages": [ChatMessage.from_user("message")]})
105
- assert mock_span.update.call_count == 1
157
+ assert mock_context_manager._span.update.call_count == 1
106
158
  # check we converted ChatMessage to OpenAI format
107
- assert mock_span.update.call_args_list[0][1] == {"input": [{"role": "user", "content": "message"}]}
108
- assert span._data["key.input"] == {"messages": [ChatMessage.from_user("message")]}
109
-
159
+ assert mock_context_manager._span.update.call_args_list[0][1] == {
160
+ "input": [{"role": "user", "content": "message"}]
161
+ }
110
162
  # test replies ChatMessage list
111
- mock_span.reset_mock()
163
+ mock_context_manager._span.update.reset_mock()
112
164
  span.set_content_tag("key.output", {"replies": [ChatMessage.from_system("reply")]})
113
- assert mock_span.update.call_count == 1
165
+ assert mock_context_manager._span.update.call_count == 1
114
166
  # check we converted ChatMessage to OpenAI format
115
- assert mock_span.update.call_args_list[0][1] == {"output": [{"role": "system", "content": "reply"}]}
116
- assert span._data["key.output"] == {"replies": [ChatMessage.from_system("reply")]}
167
+ assert mock_context_manager._span.update.call_args_list[0][1] == {
168
+ "output": [{"role": "system", "content": "reply"}]
169
+ }
117
170
 
118
171
  # test replies string list
119
- mock_span.reset_mock()
172
+ mock_context_manager._span.update.reset_mock()
120
173
  span.set_content_tag("key.output", {"replies": ["reply1", "reply2"]})
121
- assert mock_span.update.call_count == 1
174
+ assert mock_context_manager._span.update.call_count == 1
122
175
  # check we handle properly string list replies
123
- assert mock_span.update.call_args_list[0][1] == {"output": ["reply1", "reply2"]}
124
- assert span._data["key.output"] == {"replies": ["reply1", "reply2"]}
176
+ assert mock_context_manager._span.update.call_args_list[0][1] == {"output": ["reply1", "reply2"]}
125
177
 
126
178
 
127
179
  class TestSpanContext:
@@ -250,16 +302,18 @@ class TestLangfuseTracer:
250
302
  mock_raw_span.operation_name = "operation_name"
251
303
  mock_raw_span.metadata = {"tag1": "value1", "tag2": "value2"}
252
304
 
253
- with patch("haystack_integrations.tracing.langfuse.tracer.LangfuseSpan") as MockLangfuseSpan:
305
+ with patch("haystack_integrations.tracing.langfuse.tracer.LangfuseSpan") as MockLangfuseSpan, patch(
306
+ "haystack_integrations.tracing.langfuse.tracer.langfuse.get_client"
307
+ ) as mock_get_client:
254
308
  mock_span_instance = MockLangfuseSpan.return_value
255
309
  mock_span_instance.raw_span.return_value = mock_raw_span
256
310
 
257
- mock_context_manager = MagicMock()
258
- mock_context_manager.__enter__.return_value = mock_span_instance
311
+ mock_client = mock_get_client()
312
+ mock_context_manager = MockContextManager()
313
+ mock_context_manager._span = mock_raw_span
314
+ mock_client.start_as_current_span.return_value = mock_context_manager
259
315
 
260
316
  mock_tracer = MagicMock()
261
- mock_tracer.trace.return_value = mock_context_manager
262
-
263
317
  tracer = LangfuseTracer(tracer=mock_tracer, name="Haystack", public=False)
264
318
 
265
319
  # check that the trace method is called on the tracer instance with the provided operation name and tags
@@ -275,30 +329,36 @@ class TestLangfuseTracer:
275
329
 
276
330
  # check that update method is called on the span instance with the provided key value pairs
277
331
  def test_update_span_with_pipeline_input_output_data(self):
278
- tracer = LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
279
- with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
280
- assert span.raw_span()._data["metadata"] == {"haystack.pipeline.input_data": "hello"}
332
+ with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client") as mock_get_client:
333
+ mock_client = mock_get_client()
334
+
335
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
336
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
337
+ assert span.raw_span()._data["metadata"] == {"haystack.pipeline.input_data": "hello"}
281
338
 
282
- with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.output_data": "bye"}) as span:
283
- assert span.raw_span()._data["metadata"] == {"haystack.pipeline.output_data": "bye"}
339
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.output_data": "bye"}) as span:
340
+ assert span.raw_span()._data["metadata"] == {"haystack.pipeline.output_data": "bye"}
284
341
 
285
342
  def test_trace_generation(self):
286
- tracer = LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
287
- tags = {
288
- "haystack.component.type": "OpenAIChatGenerator",
289
- "haystack.component.output": {
290
- "replies": [
291
- ChatMessage.from_assistant(
292
- "", meta={"completion_start_time": "2021-07-27T16:02:08.012345", "model": "test_model"}
293
- )
294
- ]
295
- },
296
- }
297
- with tracer.trace(operation_name="operation_name", tags=tags) as span:
298
- ...
299
- assert span.raw_span()._data["usage"] is None
300
- assert span.raw_span()._data["model"] == "test_model"
301
- assert span.raw_span()._data["completion_start_time"] == datetime.datetime(2021, 7, 27, 16, 2, 8, 12345)
343
+ with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client") as mock_get_client:
344
+ mock_client = mock_get_client()
345
+
346
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
347
+ tags = {
348
+ "haystack.component.type": "OpenAIChatGenerator",
349
+ "haystack.component.output": {
350
+ "replies": [
351
+ ChatMessage.from_assistant(
352
+ "", meta={"completion_start_time": "2021-07-27T16:02:08.012345", "model": "test_model"}
353
+ )
354
+ ]
355
+ },
356
+ }
357
+ with tracer.trace(operation_name="operation_name", tags=tags) as span:
358
+ ...
359
+ assert span.raw_span()._data["usage"] is None
360
+ assert span.raw_span()._data["model"] == "test_model"
361
+ assert span.raw_span()._data["completion_start_time"] == datetime.datetime(2021, 7, 27, 16, 2, 8, 12345)
302
362
 
303
363
  def test_handle_tool_invoker(self):
304
364
  """
@@ -351,23 +411,27 @@ class TestLangfuseTracer:
351
411
  assert "weather_tool" in updated_name, f"Expected 'weather_tool' in {updated_name}"
352
412
 
353
413
  def test_trace_generation_invalid_start_time(self):
354
- tracer = LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
355
- tags = {
356
- "haystack.component.type": "OpenAIChatGenerator",
357
- "haystack.component.output": {
358
- "replies": [
359
- ChatMessage.from_assistant("", meta={"completion_start_time": "foobar", "model": "test_model"}),
360
- ]
361
- },
362
- }
363
- with tracer.trace(operation_name="operation_name", tags=tags) as span:
364
- ...
365
- assert span.raw_span()._data["usage"] is None
366
- assert span.raw_span()._data["model"] == "test_model"
367
- assert span.raw_span()._data["completion_start_time"] is None
414
+ with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client") as mock_get_client:
415
+ mock_client = mock_get_client()
416
+
417
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
418
+ tags = {
419
+ "haystack.component.type": "OpenAIChatGenerator",
420
+ "haystack.component.output": {
421
+ "replies": [
422
+ ChatMessage.from_assistant("", meta={"completion_start_time": "foobar", "model": "test_model"}),
423
+ ]
424
+ },
425
+ }
426
+ with tracer.trace(operation_name="operation_name", tags=tags) as span:
427
+ ...
428
+ assert span.raw_span()._data["usage"] is None
429
+ assert span.raw_span()._data["model"] == "test_model"
430
+ assert span.raw_span()._data["completion_start_time"] is None
368
431
 
369
432
  def test_update_span_gets_flushed_by_default(self):
370
- tracer_mock = Mock()
433
+ tracer_mock = MockLangfuseClient()
434
+ tracer_mock.flush = Mock() # Make flush a mock for assertions
371
435
 
372
436
  tracer = LangfuseTracer(tracer=tracer_mock, name="Haystack", public=False)
373
437
  with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
@@ -377,7 +441,8 @@ class TestLangfuseTracer:
377
441
 
378
442
  def test_update_span_flush_disable(self, monkeypatch):
379
443
  monkeypatch.setenv("HAYSTACK_LANGFUSE_ENFORCE_FLUSH", "false")
380
- tracer_mock = Mock()
444
+ tracer_mock = MockLangfuseClient()
445
+ tracer_mock.flush = Mock() # Make flush a mock for assertions
381
446
 
382
447
  from haystack_integrations.tracing.langfuse.tracer import LangfuseTracer
383
448
 
@@ -388,7 +453,7 @@ class TestLangfuseTracer:
388
453
  tracer_mock.flush.assert_not_called()
389
454
 
390
455
  def test_context_is_empty_after_tracing(self):
391
- tracer_mock = Mock()
456
+ tracer_mock = MockLangfuseClient()
392
457
 
393
458
  tracer = LangfuseTracer(tracer=tracer_mock, name="Haystack", public=False)
394
459
  with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
@@ -408,9 +473,9 @@ class TestLangfuseTracer:
408
473
  monkeypatch.setenv("HAYSTACK_CONTENT_TRACING_ENABLED", "false")
409
474
  from haystack_integrations.tracing.langfuse import LangfuseTracer
410
475
 
411
- LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
476
+ LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
412
477
  assert "tracing is disabled" in caplog.text
413
-
478
+
414
479
  def test_async_concurrency_span_isolation(self):
415
480
  """
416
481
  Test that concurrent async traces maintain isolated span contexts.
@@ -418,7 +483,7 @@ class TestLangfuseTracer:
418
483
  This test verifies that the context-local span stack prevents cross-request
419
484
  span interleaving in concurrent environments like FastAPI servers.
420
485
  """
421
- tracer = LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
486
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
422
487
 
423
488
  # Track spans from each task for verification
424
489
  task1_spans = []
@@ -482,4 +547,3 @@ class TestLangfuseTracer:
482
547
  assert task2_spans[1][2] == task2_inner # current_span during inner
483
548
  assert task2_spans[2][2] == task2_outer # current_span after inner
484
549
  assert task2_spans[3][2] is None # current_span after outer
485
-
@@ -23,6 +23,8 @@ from haystack_integrations.components.generators.cohere import CohereChatGenerat
23
23
  # don't remove (or move) this env var setting from here, it's needed to turn tracing on
24
24
  os.environ["HAYSTACK_CONTENT_TRACING_ENABLED"] = "true"
25
25
 
26
+ os.environ.setdefault("LANGFUSE_HOST", "https://cloud.langfuse.com")
27
+
26
28
 
27
29
  def poll_langfuse(url: str):
28
30
  """Utility function to poll Langfuse API until the trace is ready"""
@@ -92,7 +94,7 @@ def test_tracing_integration(llm_class, env_var, expected_trace, basic_pipeline)
92
94
 
93
95
  trace_url = response["tracer"]["trace_url"]
94
96
  uuid = os.path.basename(urlparse(trace_url).path)
95
- url = f"https://cloud.langfuse.com/api/public/traces/{uuid}"
97
+ url = f"{os.environ['LANGFUSE_HOST']}/api/public/traces/{uuid}"
96
98
 
97
99
  res = poll_langfuse(url)
98
100
  assert res.status_code == 200, f"Failed to retrieve data from Langfuse API: {res.status_code}"
@@ -104,7 +106,8 @@ def test_tracing_integration(llm_class, env_var, expected_trace, basic_pipeline)
104
106
  assert isinstance(res_json["output"], dict)
105
107
  assert isinstance(res_json["metadata"], dict)
106
108
  assert isinstance(res_json["observations"], list)
107
- assert res_json["observations"][0]["type"] == "GENERATION"
109
+ # at least one observation should be a generation
110
+ assert any(obs["type"] == "GENERATION" for obs in res_json["observations"])
108
111
 
109
112
 
110
113
  @pytest.mark.skipif(
@@ -119,7 +122,6 @@ def test_tracing_integration(llm_class, env_var, expected_trace, basic_pipeline)
119
122
  )
120
123
  @pytest.mark.integration
121
124
  def test_tracing_with_sub_pipelines():
122
-
123
125
  @component
124
126
  class SubGenerator:
125
127
  def __init__(self):
@@ -165,7 +167,7 @@ def test_tracing_with_sub_pipelines():
165
167
 
166
168
  trace_url = response["tracer"]["trace_url"]
167
169
  uuid = os.path.basename(urlparse(trace_url).path)
168
- url = f"https://cloud.langfuse.com/api/public/traces/{uuid}"
170
+ url = f"{os.environ['LANGFUSE_HOST']}/api/public/traces/{uuid}"
169
171
 
170
172
  res = poll_langfuse(url)
171
173
  assert res.status_code == 200, f"Failed to retrieve data from Langfuse API: {res.status_code}"
@@ -173,23 +175,20 @@ def test_tracing_with_sub_pipelines():
173
175
  res_json = res.json()
174
176
  assert res_json["name"] == "Sub-pipeline example"
175
177
  assert isinstance(res_json["input"], dict)
176
- assert "sub_pipeline" in res_json["input"]
177
- assert "messages" in res_json["input"]["sub_pipeline"]
178
- assert res_json["input"]["tracer"]["invocation_context"]["user_id"] == "user_42"
179
178
  assert isinstance(res_json["output"], dict)
180
179
  assert isinstance(res_json["metadata"], dict)
181
180
  assert isinstance(res_json["observations"], list)
182
181
 
183
182
  observations = res_json["observations"]
183
+ assert len(observations) == 8
184
184
 
185
185
  haystack_pipeline_run_observations = [obs for obs in observations if obs["name"] == "haystack.pipeline.run"]
186
186
  # There should be two observations for the haystack.pipeline.run span: one for each sub pipeline
187
187
  # Main pipeline is stored under the name "Sub-pipeline example"
188
188
  assert len(haystack_pipeline_run_observations) == 2
189
- # Apparently the order of haystack_pipeline_run_observations isn't deterministic
190
- component_names = [key for obs in haystack_pipeline_run_observations for key in obs["input"].keys()]
191
- assert "prompt_builder" in component_names
192
- assert "llm" in component_names
189
+ assert "prompt_builder" in str(haystack_pipeline_run_observations[0])
190
+ assert "llm" in str(haystack_pipeline_run_observations[1])
191
+
193
192
 
194
193
  @pytest.mark.skipif(
195
194
  not all(
@@ -251,6 +250,6 @@ def test_context_cleanup_after_nested_failures():
251
250
 
252
251
  # Test 2: Second run should work normally with clean context
253
252
  main_pipeline.run({"nested_component": {"input_data": '{"key": "valid"}'}})
254
-
253
+
255
254
  # Critical assertion: context should be empty after successful operation
256
- assert len(tracer.tracer._context) == 0
255
+ assert len(tracer.tracer._context) == 0