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.
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/CHANGELOG.md +15 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/PKG-INFO +2 -2
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/pyproject.toml +1 -1
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/src/haystack_integrations/tracing/langfuse/tracer.py +44 -28
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/tests/test_tracer.py +140 -76
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/tests/test_tracing.py +12 -13
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/.gitignore +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/LICENSE.txt +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/README.md +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/example/basic_rag.py +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/example/chat.py +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/example/requirements.txt +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/pydoc/config.yml +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/src/haystack_integrations/components/connectors/__init__.py +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
- {langfuse_haystack-2.3.0 → langfuse_haystack-3.0.0}/tests/__init__.py +0 -0
- {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:
|
|
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<
|
|
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>=
|
|
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
|
|
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
|
|
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,
|
|
75
|
+
def __init__(self, context_manager: AbstractContextManager) -> None:
|
|
79
76
|
"""
|
|
80
77
|
Initialize a LangfuseSpan instance.
|
|
81
78
|
|
|
82
|
-
:param
|
|
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 =
|
|
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) ->
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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(
|
|
301
|
+
return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name, as_type="generation"))
|
|
289
302
|
else:
|
|
290
|
-
return LangfuseSpan(
|
|
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().
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
74
|
-
span = LangfuseSpan(
|
|
75
|
-
assert span.raw_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
|
-
|
|
80
|
-
span = LangfuseSpan(
|
|
129
|
+
mock_context_manager = MockContextManager()
|
|
130
|
+
span = LangfuseSpan(mock_context_manager)
|
|
81
131
|
|
|
82
132
|
span.set_tag("key", "value")
|
|
83
|
-
|
|
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
|
-
|
|
138
|
+
mock_context_manager = MockContextManager()
|
|
89
139
|
|
|
90
|
-
span = LangfuseSpan(
|
|
91
|
-
span.set_content_tag("
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
span.set_content_tag("
|
|
96
|
-
|
|
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
|
-
|
|
152
|
+
mock_context_manager = MockContextManager()
|
|
101
153
|
|
|
102
154
|
# test message input
|
|
103
|
-
span = LangfuseSpan(
|
|
155
|
+
span = LangfuseSpan(mock_context_manager)
|
|
104
156
|
span.set_content_tag("key.input", {"messages": [ChatMessage.from_user("message")]})
|
|
105
|
-
assert
|
|
157
|
+
assert mock_context_manager._span.update.call_count == 1
|
|
106
158
|
# check we converted ChatMessage to OpenAI format
|
|
107
|
-
assert
|
|
108
|
-
|
|
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
|
-
|
|
163
|
+
mock_context_manager._span.update.reset_mock()
|
|
112
164
|
span.set_content_tag("key.output", {"replies": [ChatMessage.from_system("reply")]})
|
|
113
|
-
assert
|
|
165
|
+
assert mock_context_manager._span.update.call_count == 1
|
|
114
166
|
# check we converted ChatMessage to OpenAI format
|
|
115
|
-
assert
|
|
116
|
-
|
|
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
|
-
|
|
172
|
+
mock_context_manager._span.update.reset_mock()
|
|
120
173
|
span.set_content_tag("key.output", {"replies": ["reply1", "reply2"]})
|
|
121
|
-
assert
|
|
174
|
+
assert mock_context_manager._span.update.call_count == 1
|
|
122
175
|
# check we handle properly string list replies
|
|
123
|
-
assert
|
|
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
|
-
|
|
258
|
-
mock_context_manager
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
"
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
"
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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=
|
|
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=
|
|
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"
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|