langfuse-haystack 2.3.0__tar.gz → 3.1.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 (22) hide show
  1. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/CHANGELOG.md +21 -0
  2. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/PKG-INFO +3 -3
  3. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/example/basic_rag.py +2 -2
  4. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/example/chat.py +2 -3
  5. langfuse_haystack-3.1.0/pydoc/config_docusaurus.yml +29 -0
  6. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/pyproject.toml +7 -28
  7. langfuse_haystack-3.1.0/src/haystack_integrations/components/connectors/py.typed +0 -0
  8. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/src/haystack_integrations/tracing/langfuse/tracer.py +46 -29
  9. langfuse_haystack-3.1.0/src/haystack_integrations/tracing/py.typed +0 -0
  10. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/tests/test_tracer.py +143 -88
  11. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/tests/test_tracing.py +27 -19
  12. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/.gitignore +0 -0
  13. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/LICENSE.txt +0 -0
  14. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/README.md +0 -0
  15. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/example/requirements.txt +0 -0
  16. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/pydoc/config.yml +0 -0
  17. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/src/haystack_integrations/components/connectors/__init__.py +0 -0
  18. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
  19. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +0 -0
  20. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
  21. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/tests/__init__.py +0 -0
  22. {langfuse_haystack-2.3.0 → langfuse_haystack-3.1.0}/tests/test_langfuse_connector.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/langfuse-v3.0.0] - 2025-09-19
4
+
5
+ ### 🌀 Miscellaneous
6
+
7
+ - Migrate langfuse to v3 (#2247)
8
+
9
+ ## [integrations/langfuse-v2.3.0] - 2025-08-25
10
+
11
+ ### 🐛 Bug Fixes
12
+
13
+ - Avoid mixed Langfuse traces in async envs (#2207)
14
+
15
+ ### 🧪 Testing
16
+
17
+ - Make tests successfully run from forks (#2203)
18
+
19
+ ### 🧹 Chores
20
+
21
+ - Standardize readmes - part 2 (#2205)
22
+
23
+
3
24
  ## [integrations/langfuse-v2.2.1] - 2025-08-07
4
25
 
5
26
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langfuse-haystack
3
- Version: 2.3.0
3
+ Version: 3.1.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,8 +18,8 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: Implementation :: CPython
19
19
  Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Requires-Python: >=3.9
21
- Requires-Dist: haystack-ai>=2.15.1
22
- Requires-Dist: langfuse<3.0.0,>=2.9.0
21
+ Requires-Dist: haystack-ai>=2.17.1
22
+ Requires-Dist: langfuse<4.0.0,>=3.3.0
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  # langfuse-haystack
@@ -62,5 +62,5 @@ if __name__ == "__main__":
62
62
  question = "What does Rhodes Statue look like?"
63
63
  response = pipeline.run({"text_embedder": {"text": question}, "prompt_builder": {"question": question}})
64
64
 
65
- print(response["llm"]["replies"][0])
66
- print(response["tracer"]["trace_url"])
65
+ print(response["llm"]["replies"][0]) # noqa: T201
66
+ print(response["tracer"]["trace_url"]) # noqa: T201
@@ -36,7 +36,6 @@ generators = {
36
36
  selected_chat_generator = generators[selected_chat_generator]()
37
37
 
38
38
  if __name__ == "__main__":
39
-
40
39
  pipe = Pipeline()
41
40
  pipe.add_component("tracer", LangfuseConnector("Chat example"))
42
41
  pipe.add_component("prompt_builder", ChatPromptBuilder())
@@ -60,5 +59,5 @@ if __name__ == "__main__":
60
59
  },
61
60
  }
62
61
  )
63
- print(response["llm"]["replies"][0])
64
- print(response["tracer"]["trace_url"])
62
+ print(response["llm"]["replies"][0]) # noqa: T201
63
+ print(response["tracer"]["trace_url"]) # noqa: T201
@@ -0,0 +1,29 @@
1
+ loaders:
2
+ - ignore_when_discovered:
3
+ - __init__
4
+ modules:
5
+ - haystack_integrations.components.connectors.langfuse.langfuse_connector
6
+ - haystack_integrations.tracing.langfuse.tracer
7
+ search_path:
8
+ - ../src
9
+ type: haystack_pydoc_tools.loaders.CustomPythonLoader
10
+ processors:
11
+ - do_not_filter_modules: false
12
+ documented_only: true
13
+ expression: null
14
+ skip_empty_modules: true
15
+ type: filter
16
+ - type: smart
17
+ - type: crossref
18
+ renderer:
19
+ description: Langfuse integration for Haystack
20
+ id: integrations-langfuse
21
+ markdown:
22
+ add_member_class_prefix: false
23
+ add_method_class_prefix: true
24
+ classdef_code_block: false
25
+ descriptive_class_title: false
26
+ descriptive_module_title: true
27
+ filename: langfuse.md
28
+ title: langfuse
29
+ type: haystack_pydoc_tools.renderers.DocusaurusRenderer
@@ -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.17.1", "langfuse>=3.3.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"
@@ -69,22 +69,13 @@ integration = 'pytest -m "integration" {args:tests}'
69
69
  all = 'pytest {args:tests}'
70
70
  cov-retry = 'all --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x'
71
71
 
72
- types = "mypy --install-types --non-interactive --explicit-package-bases {args:src/ tests}"
72
+ types = "mypy -p haystack_integrations.components.connectors.langfuse -p haystack_integrations.tracing.langfuse {args}"
73
73
 
74
- # TODO: remove lint environment once this integration is properly typed
75
- # test environment should be used instead
76
- # https://github.com/deepset-ai/haystack-core-integrations/issues/1771
77
- [tool.hatch.envs.lint]
78
- installer = "uv"
79
- detached = true
80
- dependencies = [
81
- "pip",
82
- "mypy>=1.0.0",
83
- "ruff>=0.0.243",
84
- ]
85
-
86
- [tool.hatch.envs.lint.scripts]
87
- typing = "mypy --install-types --non-interactive --explicit-package-bases {args:src/ tests}"
74
+ [tool.mypy]
75
+ install_types = true
76
+ non_interactive = true
77
+ check_untyped_defs = true
78
+ disallow_incomplete_defs = true
88
79
 
89
80
  [tool.hatch.metadata]
90
81
  allow-direct-references = true
@@ -93,7 +84,6 @@ allow-direct-references = true
93
84
  [tool.ruff]
94
85
  target-version = "py38"
95
86
  line-length = 120
96
- exclude = ["example", "tests"]
97
87
 
98
88
  [tool.ruff.lint]
99
89
  select = [
@@ -167,17 +157,6 @@ omit = ["*/tests/*", "*/__init__.py"]
167
157
  show_missing = true
168
158
  exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
169
159
 
170
- [[tool.mypy.overrides]]
171
- module = [
172
- "langfuse.*",
173
- "haystack.*",
174
- "haystack_integrations.*",
175
- "pytest.*",
176
- "numpy.*",
177
- "httpx.*",
178
- ]
179
- ignore_missing_imports = true
180
-
181
160
  [tool.pytest.ini_options]
182
161
  addopts = "--strict-markers"
183
162
  markers = ["integration: integration tests"]
@@ -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,36 @@ 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
+ "release": None,
293
+ }
294
+
295
+ # Filter out None values and apply trace attributes
296
+ trace_attrs = {k: v for k, v in trace_metadata.items() if v is not None}
297
+ if trace_attrs:
298
+ span._span.update_trace(**trace_attrs)
299
+
300
+ return span
287
301
  elif context.component_type in _ALL_SUPPORTED_GENERATORS:
288
- return LangfuseSpan(context.parent_span.raw_span().generation(name=context.name))
302
+ return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name, as_type="generation"))
289
303
  else:
290
- return LangfuseSpan(context.parent_span.raw_span().span(name=context.name))
304
+ return LangfuseSpan(self.tracer.start_as_current_span(name=context.name))
291
305
 
292
306
  def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
293
307
  # If the span is at the pipeline level, we add input and output keys to the span
@@ -295,8 +309,7 @@ class DefaultSpanHandler(SpanHandler):
295
309
  if at_pipeline_level:
296
310
  coerced_input = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_INPUT_KEY))
297
311
  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
-
312
+ span.raw_span().update_trace(input=coerced_input, output=coerced_output)
300
313
  # special case for ToolInvoker (to update the span name to be: `original_component_name - [tool_names]`)
301
314
  if component_type == "ToolInvoker":
302
315
  tool_names: List[str] = []
@@ -415,7 +428,11 @@ class LangfuseTracer(Tracer):
415
428
 
416
429
  # End span (may fail if span data is corrupted)
417
430
  raw_span = span.raw_span()
418
- if isinstance(raw_span, (StatefulSpanClient, StatefulGenerationClient)):
431
+ # In v3, we need to properly exit context managers
432
+ if span._context_manager is not None:
433
+ span._context_manager.__exit__(None, None, None)
434
+ elif hasattr(raw_span, "end"):
435
+ # Only call end() if it's not a context manager
419
436
  raw_span.end()
420
437
  except Exception as cleanup_error:
421
438
  # Log cleanup errors but don't let them corrupt context
@@ -449,11 +466,11 @@ class LangfuseTracer(Tracer):
449
466
  Return the URL to the tracing data.
450
467
  :return: The URL to the tracing data.
451
468
  """
452
- return self._tracer.get_trace_url()
469
+ return self._tracer.get_trace_url() or ""
453
470
 
454
471
  def get_trace_id(self) -> str:
455
472
  """
456
473
  Return the trace ID.
457
474
  :return: The trace ID.
458
475
  """
459
- return self._tracer.get_trace_id()
476
+ return self._tracer.get_current_trace_id() or ""
@@ -6,12 +6,10 @@ import asyncio
6
6
  import datetime
7
7
  import logging
8
8
  import sys
9
- import json
10
9
  from typing import Optional
11
10
  from unittest.mock import MagicMock, Mock, patch
12
11
 
13
12
  import pytest
14
- from haystack import Pipeline, component
15
13
  from haystack.dataclasses import ChatMessage, ToolCall
16
14
 
17
15
  from haystack_integrations.tracing.langfuse.tracer import (
@@ -21,15 +19,41 @@ from haystack_integrations.tracing.langfuse.tracer import (
21
19
  LangfuseTracer,
22
20
  SpanContext,
23
21
  )
24
- from haystack_integrations.components.connectors.langfuse import LangfuseConnector
22
+
23
+
24
+ # Mock functions for Langfuse v3 API
25
+ def mock_get_client():
26
+ mock_client = Mock()
27
+ mock_client.start_as_current_span = Mock(return_value=MockContextManager())
28
+ mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
29
+ mock_client.get_current_trace_id = Mock(return_value="mock_trace_id_123")
30
+ return mock_client
31
+
32
+
33
+ class MockContextManager:
34
+ """Mock context manager that simulates Langfuse v3 context managers"""
35
+
36
+ def __init__(self, name="mock_span"):
37
+ self._span = MockSpan(name)
38
+
39
+ def __enter__(self):
40
+ return self._span
41
+
42
+ def __exit__(self, exc_type, exc_val, exc_tb):
43
+ pass
25
44
 
26
45
 
27
46
  class MockSpan:
28
47
  def __init__(self, name="mock_span"):
29
48
  self._data = {}
30
- self._span = self
31
49
  self.operation_name = name
32
50
  self._name = name
51
+ # Make update a Mock so we can assert on it, but also make it actually work
52
+ self.update = Mock(side_effect=self._update_data)
53
+
54
+ def _update_data(self, **kwargs):
55
+ """Helper method to actually update _data when update is called"""
56
+ self._data.update(kwargs)
33
57
 
34
58
  def raw_span(self):
35
59
  return self
@@ -38,7 +62,8 @@ class MockSpan:
38
62
  # Return a new mock span for child spans
39
63
  return MockSpan(name=name or "child_span")
40
64
 
41
- def update(self, **kwargs):
65
+ def update_trace(self, **kwargs):
66
+ # v3 API method for updating trace-level data
42
67
  self._data.update(kwargs)
43
68
 
44
69
  def generation(self, name=None):
@@ -50,7 +75,7 @@ class MockSpan:
50
75
 
51
76
 
52
77
  class MockTracer:
53
- def trace(self, name, **kwargs):
78
+ def trace(self, name, **kwargs): # noqa: ARG002
54
79
  # Return a unique mock span for each trace call
55
80
  return MockSpan(name=name)
56
81
 
@@ -58,6 +83,28 @@ class MockTracer:
58
83
  pass
59
84
 
60
85
 
86
+ class MockLangfuseClient:
87
+ """Mock Langfuse client that has all the required methods"""
88
+
89
+ def __init__(self):
90
+ self._mock_context_manager = MockContextManager()
91
+
92
+ def start_as_current_span(self, _name=None, **_kwargs):
93
+ return self._mock_context_manager
94
+
95
+ def start_as_current_observation(self, _name=None, _as_type=None, **_kwargs):
96
+ return self._mock_context_manager
97
+
98
+ def get_current_trace_id(self):
99
+ return "mock_trace_id_123"
100
+
101
+ def get_current_observation_id(self):
102
+ return "mock_observation_id_123"
103
+
104
+ def flush(self):
105
+ pass
106
+
107
+
61
108
  class CustomSpanHandler(DefaultSpanHandler):
62
109
  def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
63
110
  if component_type == "OpenAIChatGenerator":
@@ -70,58 +117,60 @@ class CustomSpanHandler(DefaultSpanHandler):
70
117
  class TestLangfuseSpan:
71
118
  # LangfuseSpan can be initialized with a span object
72
119
  def test_initialized_with_span_object(self):
73
- mock_span = Mock()
74
- span = LangfuseSpan(mock_span)
75
- assert span.raw_span() == mock_span
120
+ mock_context_manager = MockContextManager()
121
+ span = LangfuseSpan(mock_context_manager)
122
+ assert span.raw_span() == mock_context_manager._span
76
123
 
77
124
  # set_tag method can update metadata of the span object
78
125
  def test_set_tag_updates_metadata(self):
79
- mock_span = Mock()
80
- span = LangfuseSpan(mock_span)
126
+ mock_context_manager = MockContextManager()
127
+ span = LangfuseSpan(mock_context_manager)
81
128
 
82
129
  span.set_tag("key", "value")
83
- mock_span.update.assert_called_once_with(metadata={"key": "value"})
130
+ mock_context_manager._span.update.assert_called_once_with(metadata={"key": "value"})
84
131
  assert span._data["key"] == "value"
85
132
 
86
133
  # set_content_tag method can update input and output of the span object
87
134
  def test_set_content_tag_updates_input_and_output(self):
88
- mock_span = Mock()
135
+ mock_context_manager = MockContextManager()
89
136
 
90
- span = LangfuseSpan(mock_span)
91
- span.set_content_tag("input_key", "input_value")
92
- assert span._data["input_key"] == "input_value"
137
+ span = LangfuseSpan(mock_context_manager)
138
+ span.set_content_tag("test.input", "input_value")
139
+ # Check that the span.update method was called with input parameter
140
+ mock_context_manager._span.update.assert_called_with(input="input_value")
93
141
 
94
- mock_span.reset_mock()
95
- span.set_content_tag("output_key", "output_value")
96
- assert span._data["output_key"] == "output_value"
142
+ mock_context_manager._span.update.reset_mock()
143
+ span.set_content_tag("test.output", "output_value")
144
+ # Check that the span.update method was called with output parameter
145
+ mock_context_manager._span.update.assert_called_with(output="output_value")
97
146
 
98
147
  # set_content_tag method can update input and output of the span object with messages/replies
99
148
  def test_set_content_tag_updates_input_and_output_with_messages(self):
100
- mock_span = Mock()
149
+ mock_context_manager = MockContextManager()
101
150
 
102
151
  # test message input
103
- span = LangfuseSpan(mock_span)
152
+ span = LangfuseSpan(mock_context_manager)
104
153
  span.set_content_tag("key.input", {"messages": [ChatMessage.from_user("message")]})
105
- assert mock_span.update.call_count == 1
154
+ assert mock_context_manager._span.update.call_count == 1
106
155
  # 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
-
156
+ assert mock_context_manager._span.update.call_args_list[0][1] == {
157
+ "input": [{"role": "user", "content": "message"}]
158
+ }
110
159
  # test replies ChatMessage list
111
- mock_span.reset_mock()
160
+ mock_context_manager._span.update.reset_mock()
112
161
  span.set_content_tag("key.output", {"replies": [ChatMessage.from_system("reply")]})
113
- assert mock_span.update.call_count == 1
162
+ assert mock_context_manager._span.update.call_count == 1
114
163
  # 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")]}
164
+ assert mock_context_manager._span.update.call_args_list[0][1] == {
165
+ "output": [{"role": "system", "content": "reply"}]
166
+ }
117
167
 
118
168
  # test replies string list
119
- mock_span.reset_mock()
169
+ mock_context_manager._span.update.reset_mock()
120
170
  span.set_content_tag("key.output", {"replies": ["reply1", "reply2"]})
121
- assert mock_span.update.call_count == 1
171
+ assert mock_context_manager._span.update.call_count == 1
122
172
  # 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"]}
173
+ assert mock_context_manager._span.update.call_args_list[0][1] == {"output": ["reply1", "reply2"]}
125
174
 
126
175
 
127
176
  class TestSpanContext:
@@ -178,7 +227,9 @@ class TestDefaultSpanHandler:
178
227
  assert mock_span.update.call_args_list[0][1] == {
179
228
  "usage": None,
180
229
  "model": "test_model",
181
- "completion_start_time": datetime.datetime(2021, 7, 27, 16, 2, 8, 12345),
230
+ "completion_start_time": datetime.datetime( # noqa: DTZ001
231
+ 2021, 7, 27, 16, 2, 8, 12345
232
+ ),
182
233
  }
183
234
 
184
235
  def test_handle_bad_completion_start_time(self, caplog):
@@ -250,15 +301,14 @@ class TestLangfuseTracer:
250
301
  mock_raw_span.operation_name = "operation_name"
251
302
  mock_raw_span.metadata = {"tag1": "value1", "tag2": "value2"}
252
303
 
253
- with patch("haystack_integrations.tracing.langfuse.tracer.LangfuseSpan") as MockLangfuseSpan:
254
- mock_span_instance = MockLangfuseSpan.return_value
304
+ with patch("haystack_integrations.tracing.langfuse.tracer.LangfuseSpan") as mock_langfuse_span:
305
+ mock_span_instance = mock_langfuse_span.return_value
255
306
  mock_span_instance.raw_span.return_value = mock_raw_span
256
307
 
257
- mock_context_manager = MagicMock()
258
- mock_context_manager.__enter__.return_value = mock_span_instance
259
-
308
+ mock_context_manager = MockContextManager()
309
+ mock_context_manager._span = mock_raw_span
260
310
  mock_tracer = MagicMock()
261
- mock_tracer.trace.return_value = mock_context_manager
311
+ mock_tracer.start_as_current_span.return_value = mock_context_manager
262
312
 
263
313
  tracer = LangfuseTracer(tracer=mock_tracer, name="Haystack", public=False)
264
314
 
@@ -275,30 +325,32 @@ class TestLangfuseTracer:
275
325
 
276
326
  # check that update method is called on the span instance with the provided key value pairs
277
327
  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"}
328
+ with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client"):
329
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
330
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
331
+ assert span.raw_span()._data["metadata"] == {"haystack.pipeline.input_data": "hello"}
281
332
 
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"}
333
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.output_data": "bye"}) as span:
334
+ assert span.raw_span()._data["metadata"] == {"haystack.pipeline.output_data": "bye"}
284
335
 
285
336
  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)
337
+ with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client"):
338
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
339
+ tags = {
340
+ "haystack.component.type": "OpenAIChatGenerator",
341
+ "haystack.component.output": {
342
+ "replies": [
343
+ ChatMessage.from_assistant(
344
+ "", meta={"completion_start_time": "2021-07-27T16:02:08.012345", "model": "test_model"}
345
+ )
346
+ ]
347
+ },
348
+ }
349
+ with tracer.trace(operation_name="operation_name", tags=tags) as span:
350
+ ...
351
+ assert span.raw_span()._data["usage"] is None
352
+ assert span.raw_span()._data["model"] == "test_model"
353
+ assert span.raw_span()._data["completion_start_time"] == datetime.datetime(2021, 7, 27, 16, 2, 8, 12345) # noqa: DTZ001
302
354
 
303
355
  def test_handle_tool_invoker(self):
304
356
  """
@@ -342,7 +394,7 @@ class TestLangfuseTracer:
342
394
  updated_name = name_update_call[1]["name"]
343
395
 
344
396
  # verify the format of the updated span name to be: `original_component_name - [list_of_tool_names]`
345
- assert updated_name != "tool_invoker", f"Expected 'tool_invoker` to be upddated with tool names"
397
+ assert updated_name != "tool_invoker", "Expected 'tool_invoker` to be upddated with tool names"
346
398
  assert " - " in updated_name, f"Expected ' - ' in {updated_name}"
347
399
  assert "[" in updated_name, f"Expected '[' in {updated_name}"
348
400
  assert "]" in updated_name, f"Expected ']' in {updated_name}"
@@ -351,47 +403,51 @@ class TestLangfuseTracer:
351
403
  assert "weather_tool" in updated_name, f"Expected 'weather_tool' in {updated_name}"
352
404
 
353
405
  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
406
+ with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client"):
407
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
408
+ tags = {
409
+ "haystack.component.type": "OpenAIChatGenerator",
410
+ "haystack.component.output": {
411
+ "replies": [
412
+ ChatMessage.from_assistant("", meta={"completion_start_time": "foobar", "model": "test_model"}),
413
+ ]
414
+ },
415
+ }
416
+ with tracer.trace(operation_name="operation_name", tags=tags) as span:
417
+ ...
418
+ assert span.raw_span()._data["usage"] is None
419
+ assert span.raw_span()._data["model"] == "test_model"
420
+ assert span.raw_span()._data["completion_start_time"] is None
368
421
 
369
422
  def test_update_span_gets_flushed_by_default(self):
370
- tracer_mock = Mock()
423
+ tracer_mock = MockLangfuseClient()
424
+ tracer_mock.flush = Mock() # Make flush a mock for assertions
371
425
 
372
426
  tracer = LangfuseTracer(tracer=tracer_mock, name="Haystack", public=False)
373
- with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
427
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}):
374
428
  pass
375
429
 
376
430
  tracer_mock.flush.assert_called_once()
377
431
 
378
432
  def test_update_span_flush_disable(self, monkeypatch):
379
433
  monkeypatch.setenv("HAYSTACK_LANGFUSE_ENFORCE_FLUSH", "false")
380
- tracer_mock = Mock()
434
+ tracer_mock = MockLangfuseClient()
435
+ tracer_mock.flush = Mock() # Make flush a mock for assertions
381
436
 
382
- from haystack_integrations.tracing.langfuse.tracer import LangfuseTracer
437
+ # Re-import LangfuseTracer to ensure it picks up the new environment variable
438
+ from haystack_integrations.tracing.langfuse.tracer import LangfuseTracer # noqa: PLC0415
383
439
 
384
440
  tracer = LangfuseTracer(tracer=tracer_mock, name="Haystack", public=False)
385
- with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
441
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}):
386
442
  pass
387
443
 
388
444
  tracer_mock.flush.assert_not_called()
389
445
 
390
446
  def test_context_is_empty_after_tracing(self):
391
- tracer_mock = Mock()
447
+ tracer_mock = MockLangfuseClient()
392
448
 
393
449
  tracer = LangfuseTracer(tracer=tracer_mock, name="Haystack", public=False)
394
- with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
450
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}):
395
451
  pass
396
452
 
397
453
  # Check behavioral state instead of internal _context list
@@ -406,11 +462,11 @@ class TestLangfuseTracer:
406
462
  # Re-import LangfuseTracer and instantiate it with tracing disabled
407
463
  with caplog.at_level(logging.WARNING):
408
464
  monkeypatch.setenv("HAYSTACK_CONTENT_TRACING_ENABLED", "false")
409
- from haystack_integrations.tracing.langfuse import LangfuseTracer
465
+ from haystack_integrations.tracing.langfuse import LangfuseTracer # noqa: PLC0415
410
466
 
411
- LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
467
+ LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
412
468
  assert "tracing is disabled" in caplog.text
413
-
469
+
414
470
  def test_async_concurrency_span_isolation(self):
415
471
  """
416
472
  Test that concurrent async traces maintain isolated span contexts.
@@ -418,7 +474,7 @@ class TestLangfuseTracer:
418
474
  This test verifies that the context-local span stack prevents cross-request
419
475
  span interleaving in concurrent environments like FastAPI servers.
420
476
  """
421
- tracer = LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
477
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
422
478
 
423
479
  # Track spans from each task for verification
424
480
  task1_spans = []
@@ -482,4 +538,3 @@ class TestLangfuseTracer:
482
538
  assert task2_spans[1][2] == task2_inner # current_span during inner
483
539
  assert task2_spans[2][2] == task2_outer # current_span after inner
484
540
  assert task2_spans[3][2] is None # current_span after outer
485
-
@@ -2,11 +2,11 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
+ import json
5
6
  import os
6
7
  import time
7
8
  from typing import Any, Dict, List
8
9
  from urllib.parse import urlparse
9
- import json
10
10
 
11
11
  import pytest
12
12
  import requests
@@ -17,12 +17,23 @@ from haystack.dataclasses import ChatMessage
17
17
  from requests.auth import HTTPBasicAuth
18
18
 
19
19
  from haystack_integrations.components.connectors.langfuse import LangfuseConnector
20
- from haystack_integrations.components.generators.anthropic import AnthropicChatGenerator
21
- from haystack_integrations.components.generators.cohere import CohereChatGenerator
20
+
21
+ # We use the try/except block to prevent import errors when running unit tests with lowest direct dependencies,
22
+ # where the subdependencies of LLM integrations might not be respected.
23
+ try:
24
+ from haystack_integrations.components.generators.anthropic import AnthropicChatGenerator
25
+ except ImportError:
26
+ AnthropicChatGenerator = None
27
+ try:
28
+ from haystack_integrations.components.generators.cohere import CohereChatGenerator
29
+ except ImportError:
30
+ CohereChatGenerator = None
22
31
 
23
32
  # don't remove (or move) this env var setting from here, it's needed to turn tracing on
24
33
  os.environ["HAYSTACK_CONTENT_TRACING_ENABLED"] = "true"
25
34
 
35
+ os.environ.setdefault("LANGFUSE_HOST", "https://cloud.langfuse.com")
36
+
26
37
 
27
38
  def poll_langfuse(url: str):
28
39
  """Utility function to poll Langfuse API until the trace is ready"""
@@ -36,7 +47,7 @@ def poll_langfuse(url: str):
36
47
 
37
48
  res = None
38
49
  while attempts > 0:
39
- res = requests.get(url, auth=auth)
50
+ res = requests.get(url, auth=auth, timeout=60.0)
40
51
  if res.status_code == 200:
41
52
  return res
42
53
 
@@ -71,7 +82,7 @@ def basic_pipeline(llm_class, expected_trace):
71
82
  (CohereChatGenerator, "COHERE_API_KEY", "Cohere"),
72
83
  ],
73
84
  )
74
- def test_tracing_integration(llm_class, env_var, expected_trace, basic_pipeline):
85
+ def test_tracing_integration(env_var, expected_trace, basic_pipeline):
75
86
  if not all([os.environ.get("LANGFUSE_SECRET_KEY"), os.environ.get("LANGFUSE_PUBLIC_KEY"), os.environ.get(env_var)]):
76
87
  pytest.skip(f"Missing required environment variable: {env_var}")
77
88
 
@@ -92,7 +103,7 @@ def test_tracing_integration(llm_class, env_var, expected_trace, basic_pipeline)
92
103
 
93
104
  trace_url = response["tracer"]["trace_url"]
94
105
  uuid = os.path.basename(urlparse(trace_url).path)
95
- url = f"https://cloud.langfuse.com/api/public/traces/{uuid}"
106
+ url = f"{os.environ['LANGFUSE_HOST']}/api/public/traces/{uuid}"
96
107
 
97
108
  res = poll_langfuse(url)
98
109
  assert res.status_code == 200, f"Failed to retrieve data from Langfuse API: {res.status_code}"
@@ -104,7 +115,8 @@ def test_tracing_integration(llm_class, env_var, expected_trace, basic_pipeline)
104
115
  assert isinstance(res_json["output"], dict)
105
116
  assert isinstance(res_json["metadata"], dict)
106
117
  assert isinstance(res_json["observations"], list)
107
- assert res_json["observations"][0]["type"] == "GENERATION"
118
+ # at least one observation should be a generation
119
+ assert any(obs["type"] == "GENERATION" for obs in res_json["observations"])
108
120
 
109
121
 
110
122
  @pytest.mark.skipif(
@@ -119,7 +131,6 @@ def test_tracing_integration(llm_class, env_var, expected_trace, basic_pipeline)
119
131
  )
120
132
  @pytest.mark.integration
121
133
  def test_tracing_with_sub_pipelines():
122
-
123
134
  @component
124
135
  class SubGenerator:
125
136
  def __init__(self):
@@ -165,7 +176,7 @@ def test_tracing_with_sub_pipelines():
165
176
 
166
177
  trace_url = response["tracer"]["trace_url"]
167
178
  uuid = os.path.basename(urlparse(trace_url).path)
168
- url = f"https://cloud.langfuse.com/api/public/traces/{uuid}"
179
+ url = f"{os.environ['LANGFUSE_HOST']}/api/public/traces/{uuid}"
169
180
 
170
181
  res = poll_langfuse(url)
171
182
  assert res.status_code == 200, f"Failed to retrieve data from Langfuse API: {res.status_code}"
@@ -173,23 +184,20 @@ def test_tracing_with_sub_pipelines():
173
184
  res_json = res.json()
174
185
  assert res_json["name"] == "Sub-pipeline example"
175
186
  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
187
  assert isinstance(res_json["output"], dict)
180
188
  assert isinstance(res_json["metadata"], dict)
181
189
  assert isinstance(res_json["observations"], list)
182
190
 
183
191
  observations = res_json["observations"]
192
+ assert len(observations) == 8
184
193
 
185
194
  haystack_pipeline_run_observations = [obs for obs in observations if obs["name"] == "haystack.pipeline.run"]
186
195
  # There should be two observations for the haystack.pipeline.run span: one for each sub pipeline
187
196
  # Main pipeline is stored under the name "Sub-pipeline example"
188
197
  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
198
+ assert "prompt_builder" in str(haystack_pipeline_run_observations[0])
199
+ assert "llm" in str(haystack_pipeline_run_observations[1])
200
+
193
201
 
194
202
  @pytest.mark.skipif(
195
203
  not all(
@@ -243,7 +251,7 @@ def test_context_cleanup_after_nested_failures():
243
251
  # Test 1: First run will fail and should clean up context
244
252
  try:
245
253
  main_pipeline.run({"nested_component": {"input_data": "invalid json"}})
246
- except Exception:
254
+ except Exception: # noqa: S110
247
255
  pass # Expected to fail
248
256
 
249
257
  # Critical assertion: context should be empty after failed operation
@@ -251,6 +259,6 @@ def test_context_cleanup_after_nested_failures():
251
259
 
252
260
  # Test 2: Second run should work normally with clean context
253
261
  main_pipeline.run({"nested_component": {"input_data": '{"key": "valid"}'}})
254
-
262
+
255
263
  # Critical assertion: context should be empty after successful operation
256
- assert len(tracer.tracer._context) == 0
264
+ assert len(tracer.tracer._context) == 0