langfuse-haystack 0.0.4__tar.gz → 0.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: langfuse-haystack
3
- Version: 0.0.4
3
+ Version: 0.1.0
4
4
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse#readme
5
5
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
6
6
  Project-URL: Source, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: Implementation :: CPython
18
18
  Classifier: Programming Language :: Python :: Implementation :: PyPy
19
19
  Requires-Python: >=3.8
20
- Requires-Dist: haystack-ai
20
+ Requires-Dist: haystack-ai>=2.1.0
21
21
  Requires-Dist: langfuse
22
22
  Description-Content-Type: text/markdown
23
23
 
@@ -15,12 +15,12 @@ processors:
15
15
  - type: smart
16
16
  - type: crossref
17
17
  renderer:
18
- type: haystack_pydoc_tools.renderers.ReadmePreviewRenderer
18
+ type: haystack_pydoc_tools.renderers.ReadmeIntegrationRenderer
19
19
  excerpt: Langfuse integration for Haystack
20
20
  category_slug: integrations-api
21
21
  title: langfuse
22
22
  slug: integrations-langfuse
23
- order: 135
23
+ order: 136
24
24
  markdown:
25
25
  descriptive_class_title: false
26
26
  classdef_code_block: false
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Programming Language :: Python :: Implementation :: PyPy",
26
26
  ]
27
27
  dependencies = [
28
- "haystack-ai",
28
+ "haystack-ai>=2.1.0",
29
29
  "langfuse"
30
30
  ]
31
31
 
@@ -20,6 +20,31 @@ class LangfuseConnector:
20
20
  In addition, you need to set the `HAYSTACK_CONTENT_TRACING_ENABLED` environment variable to `true` in order to
21
21
  enable Haystack tracing in your pipeline.
22
22
 
23
+ Lastly, you may disable flushing the data after each component by setting the `HAYSTACK_LANGFUSE_ENFORCE_FLUSH`
24
+ environent variable to `false`. By default, the data is flushed after each component and blocks the thread until
25
+ the data is sent to Langfuse. **Caution**: Disabling this feature may result in data loss if the program crashes
26
+ before the data is sent to Langfuse. Make sure you will call langfuse.flush() explicitly before the program exits.
27
+ E.g. by using tracer.actual_tracer.flush():
28
+
29
+ ```python
30
+ from haystack.tracing import tracer
31
+
32
+ try:
33
+ # your code here
34
+ finally:
35
+ tracer.actual_tracer.flush()
36
+ ```
37
+ or in FastAPI by defining a shutdown event handler:
38
+ ```python
39
+ from haystack.tracing import tracer
40
+
41
+ # ...
42
+
43
+ @app.on_event("shutdown")
44
+ async def shutdown_event():
45
+ tracer.actual_tracer.flush()
46
+ ```
47
+
23
48
  Here is an example of how to use it:
24
49
 
25
50
  ```python
@@ -1,4 +1,5 @@
1
1
  import contextlib
2
+ import os
2
3
  from typing import Any, Dict, Iterator, Optional, Union
3
4
 
4
5
  from haystack.dataclasses import ChatMessage
@@ -7,31 +8,54 @@ from haystack.tracing import utils as tracing_utils
7
8
 
8
9
  import langfuse
9
10
 
11
+ HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR = "HAYSTACK_LANGFUSE_ENFORCE_FLUSH"
12
+
10
13
 
11
14
  class LangfuseSpan(Span):
15
+ """
16
+ Internal class representing a bridge between the Haystack span tracing API and Langfuse.
17
+ """
18
+
12
19
  def __init__(self, span: "Union[langfuse.client.StatefulSpanClient, langfuse.client.StatefulTraceClient]") -> None:
20
+ """
21
+ Initialize a LangfuseSpan instance.
22
+
23
+ :param span: The span instance managed by Langfuse.
24
+ """
13
25
  self._span = span
14
26
  # locally cache tags
15
27
  self._data: Dict[str, Any] = {}
16
28
 
17
29
  def set_tag(self, key: str, value: Any) -> None:
30
+ """
31
+ Set a generic tag for this span.
32
+
33
+ :param key: The tag key.
34
+ :param value: The tag value.
35
+ """
18
36
  coerced_value = tracing_utils.coerce_tag_value(value)
19
37
  self._span.update(metadata={key: coerced_value})
20
38
  self._data[key] = value
21
39
 
22
40
  def set_content_tag(self, key: str, value: Any) -> None:
41
+ """
42
+ Set a content-specific tag for this span.
43
+
44
+ :param key: The content tag key.
45
+ :param value: The content tag value.
46
+ """
23
47
  if not tracer.is_content_tracing_enabled:
24
48
  return
25
49
  if key.endswith(".input"):
26
50
  if "messages" in value:
27
- messages = [self.to_openai_format(m) for m in value["messages"]]
51
+ messages = [m.to_openai_format() for m in value["messages"]]
28
52
  self._span.update(input=messages)
29
53
  else:
30
54
  self._span.update(input=value)
31
55
  elif key.endswith(".output"):
32
56
  if "replies" in value:
33
57
  if all(isinstance(r, ChatMessage) for r in value["replies"]):
34
- replies = [self.to_openai_format(m) for m in value["replies"]]
58
+ replies = [m.to_openai_format() for m in value["replies"]]
35
59
  else:
36
60
  replies = value["replies"]
37
61
  self._span.update(output=replies)
@@ -41,31 +65,47 @@ class LangfuseSpan(Span):
41
65
  self._data[key] = value
42
66
 
43
67
  def raw_span(self) -> Any:
68
+ """
69
+ Return the underlying span instance.
70
+
71
+ :return: The Langfuse span instance.
72
+ """
44
73
  return self._span
45
74
 
46
75
  def get_correlation_data_for_logs(self) -> Dict[str, Any]:
47
76
  return {}
48
77
 
49
- def to_openai_format(self, m: ChatMessage) -> Dict[str, Any]:
50
- """
51
- Remove after haystack 2.0.1 has been released and use the `to_openai_format` method from the ChatMessage class
52
- """
53
- msg = {"role": m.role.value, "content": m.content}
54
- if m.name:
55
- msg["name"] = m.name
56
-
57
- return msg
58
-
59
78
 
60
79
  class LangfuseTracer(Tracer):
80
+ """
81
+ Internal class representing a bridge between the Haystack tracer and Langfuse.
82
+ """
83
+
61
84
  def __init__(self, tracer: "langfuse.Langfuse", name: str = "Haystack", public: bool = False) -> None:
85
+ """
86
+ Initialize a LangfuseTracer instance.
87
+
88
+ :param tracer: The Langfuse tracer instance.
89
+ :param name: The name of the pipeline or component. This name will be used to identify the tracing run on the
90
+ Langfuse dashboard.
91
+ :param public: Whether the tracing data should be public or private. If set to `True`, the tracing data will
92
+ be publicly accessible to anyone with the tracing URL. If set to `False`, the tracing data will be private
93
+ and only accessible to the Langfuse account owner.
94
+ """
62
95
  self._tracer = tracer
63
96
  self._context: list[LangfuseSpan] = []
64
97
  self._name = name
65
98
  self._public = public
99
+ self.enforce_flush = os.getenv(HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR, "true").lower() == "true"
66
100
 
67
101
  @contextlib.contextmanager
68
102
  def trace(self, operation_name: str, tags: Optional[Dict[str, Any]] = None) -> Iterator[Span]:
103
+ """
104
+ Start and manage a new trace span.
105
+ :param operation_name: The name of the operation.
106
+ :param tags: A dictionary of tags to attach to the span.
107
+ :return: A context manager yielding the span.
108
+ """
69
109
  tags = tags or {}
70
110
  span_name = tags.get("haystack.component.name", operation_name)
71
111
 
@@ -85,12 +125,12 @@ class LangfuseTracer(Tracer):
85
125
  # Haystack returns one meta dict for each message, but the 'usage' value
86
126
  # is always the same, let's just pick the first item
87
127
  m = meta[0]
88
- span._span.update(usage=m.get("usage"), model=m.get("model"))
128
+ span._span.update(usage=m.get("usage") or None, model=m.get("model"))
89
129
  elif tags.get("haystack.component.type") == "OpenAIChatGenerator":
90
130
  replies = span._data.get("haystack.component.output", {}).get("replies")
91
131
  if replies:
92
132
  meta = replies[0].meta
93
- span._span.update(usage=meta.get("usage"), model=meta.get("model"))
133
+ span._span.update(usage=meta.get("usage") or None, model=meta.get("model"))
94
134
 
95
135
  pipeline_input = tags.get("haystack.pipeline.input_data", None)
96
136
  if pipeline_input:
@@ -101,13 +141,31 @@ class LangfuseTracer(Tracer):
101
141
 
102
142
  span.raw_span().end()
103
143
  self._context.pop()
144
+
145
+ if len(self._context) == 1:
146
+ # The root span has to be a trace, which need to be removed from the context after the pipeline run
147
+ self._context.pop()
148
+
149
+ if self.enforce_flush:
150
+ self.flush()
151
+
152
+ def flush(self):
104
153
  self._tracer.flush()
105
154
 
106
155
  def current_span(self) -> Span:
156
+ """
157
+ Return the currently active span.
158
+
159
+ :return: The currently active span.
160
+ """
107
161
  if not self._context:
108
162
  # The root span has to be a trace
109
163
  self._context.append(LangfuseSpan(self._tracer.trace(name=self._name, public=self._public)))
110
164
  return self._context[-1]
111
165
 
112
166
  def get_trace_url(self) -> str:
167
+ """
168
+ Return the URL to the tracing data.
169
+ :return: The URL to the tracing data.
170
+ """
113
171
  return self._tracer.get_trace_url()
@@ -0,0 +1,65 @@
1
+ import os
2
+
3
+ os.environ["HAYSTACK_CONTENT_TRACING_ENABLED"] = "true"
4
+
5
+ from unittest.mock import Mock
6
+ from haystack.dataclasses import ChatMessage
7
+ from haystack_integrations.tracing.langfuse.tracer import LangfuseSpan
8
+
9
+
10
+ class TestLangfuseSpan:
11
+
12
+ # LangfuseSpan can be initialized with a span object
13
+ def test_initialized_with_span_object(self):
14
+ mock_span = Mock()
15
+ span = LangfuseSpan(mock_span)
16
+ assert span.raw_span() == mock_span
17
+
18
+ # set_tag method can update metadata of the span object
19
+ def test_set_tag_updates_metadata(self):
20
+ mock_span = Mock()
21
+ span = LangfuseSpan(mock_span)
22
+
23
+ span.set_tag("key", "value")
24
+ mock_span.update.assert_called_once_with(metadata={"key": "value"})
25
+ assert span._data["key"] == "value"
26
+
27
+ # set_content_tag method can update input and output of the span object
28
+ def test_set_content_tag_updates_input_and_output(self):
29
+ mock_span = Mock()
30
+
31
+ span = LangfuseSpan(mock_span)
32
+ span.set_content_tag("input_key", "input_value")
33
+ assert span._data["input_key"] == "input_value"
34
+
35
+ mock_span.reset_mock()
36
+ span.set_content_tag("output_key", "output_value")
37
+ assert span._data["output_key"] == "output_value"
38
+
39
+ # set_content_tag method can update input and output of the span object with messages/replies
40
+ def test_set_content_tag_updates_input_and_output_with_messages(self):
41
+ mock_span = Mock()
42
+
43
+ # test message input
44
+ span = LangfuseSpan(mock_span)
45
+ span.set_content_tag("key.input", {"messages": [ChatMessage.from_user("message")]})
46
+ assert mock_span.update.call_count == 1
47
+ # check we converted ChatMessage to OpenAI format
48
+ assert mock_span.update.call_args_list[0][1] == {"input": [{"role": "user", "content": "message"}]}
49
+ assert span._data["key.input"] == {"messages": [ChatMessage.from_user("message")]}
50
+
51
+ # test replies ChatMessage list
52
+ mock_span.reset_mock()
53
+ span.set_content_tag("key.output", {"replies": [ChatMessage.from_system("reply")]})
54
+ assert mock_span.update.call_count == 1
55
+ # check we converted ChatMessage to OpenAI format
56
+ assert mock_span.update.call_args_list[0][1] == {"output": [{"role": "system", "content": "reply"}]}
57
+ assert span._data["key.output"] == {"replies": [ChatMessage.from_system("reply")]}
58
+
59
+ # test replies string list
60
+ mock_span.reset_mock()
61
+ span.set_content_tag("key.output", {"replies": ["reply1", "reply2"]})
62
+ assert mock_span.update.call_count == 1
63
+ # check we handle properly string list replies
64
+ assert mock_span.update.call_args_list[0][1] == {"output": ["reply1", "reply2"]}
65
+ assert span._data["key.output"] == {"replies": ["reply1", "reply2"]}
@@ -0,0 +1,114 @@
1
+ import os
2
+ from unittest.mock import Mock, MagicMock, patch
3
+
4
+ from haystack_integrations.tracing.langfuse.tracer import LangfuseTracer
5
+
6
+
7
+ class TestLangfuseTracer:
8
+
9
+ # LangfuseTracer can be initialized with a Langfuse instance, a name and a boolean value for public.
10
+ def test_initialization(self):
11
+ langfuse_instance = Mock()
12
+ tracer = LangfuseTracer(tracer=langfuse_instance, name="Haystack", public=True)
13
+ assert tracer._tracer == langfuse_instance
14
+ assert tracer._context == []
15
+ assert tracer._name == "Haystack"
16
+ assert tracer._public
17
+
18
+ # check that the trace method is called on the tracer instance with the provided operation name and tags
19
+ # check that the span is added to the context and removed after the context manager exits
20
+ def test_create_new_span(self):
21
+ mock_raw_span = MagicMock()
22
+ mock_raw_span.operation_name = "operation_name"
23
+ mock_raw_span.metadata = {"tag1": "value1", "tag2": "value2"}
24
+
25
+ with patch("haystack_integrations.tracing.langfuse.tracer.LangfuseSpan") as MockLangfuseSpan:
26
+ mock_span_instance = MockLangfuseSpan.return_value
27
+ mock_span_instance.raw_span.return_value = mock_raw_span
28
+
29
+ mock_context_manager = MagicMock()
30
+ mock_context_manager.__enter__.return_value = mock_span_instance
31
+
32
+ mock_tracer = MagicMock()
33
+ mock_tracer.trace.return_value = mock_context_manager
34
+
35
+ tracer = LangfuseTracer(tracer=mock_tracer, name="Haystack", public=False)
36
+
37
+ with tracer.trace("operation_name", tags={"tag1": "value1", "tag2": "value2"}) as span:
38
+ assert len(tracer._context) == 2, "The trace span should have been added to the the root context span"
39
+ assert span.raw_span().operation_name == "operation_name"
40
+ assert span.raw_span().metadata == {"tag1": "value1", "tag2": "value2"}
41
+
42
+ assert (
43
+ len(tracer._context) == 0
44
+ ), "The trace span should have been popped, and the root span is closed as well"
45
+
46
+ # check that update method is called on the span instance with the provided key value pairs
47
+ def test_update_span_with_pipeline_input_output_data(self):
48
+ class MockTracer:
49
+
50
+ def trace(self, name, **kwargs):
51
+ return MockSpan()
52
+
53
+ def flush(self):
54
+ pass
55
+
56
+ class MockSpan:
57
+ def __init__(self):
58
+ self._data = {}
59
+ self._span = self
60
+ self.operation_name = "operation_name"
61
+
62
+ def raw_span(self):
63
+ return self
64
+
65
+ def span(self, name=None):
66
+ # assert correct operation name passed to the span
67
+ assert name == "operation_name"
68
+ return self
69
+
70
+ def update(self, **kwargs):
71
+ self._data.update(kwargs)
72
+
73
+ def generation(self, name=None):
74
+ return self
75
+
76
+ def end(self):
77
+ pass
78
+
79
+ tracer = LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
80
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
81
+ assert span.raw_span()._data["metadata"] == {"haystack.pipeline.input_data": "hello"}
82
+
83
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.output_data": "bye"}) as span:
84
+ assert span.raw_span()._data["metadata"] == {"haystack.pipeline.output_data": "bye"}
85
+
86
+ def test_update_span_gets_flushed_by_default(self):
87
+ tracer_mock = Mock()
88
+
89
+ tracer = LangfuseTracer(tracer=tracer_mock, name="Haystack", public=False)
90
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
91
+ pass
92
+
93
+ tracer_mock.flush.assert_called_once()
94
+
95
+ def test_update_span_flush_disable(self, monkeypatch):
96
+ monkeypatch.setenv("HAYSTACK_LANGFUSE_ENFORCE_FLUSH", "false")
97
+ tracer_mock = Mock()
98
+
99
+ from haystack_integrations.tracing.langfuse.tracer import LangfuseTracer
100
+
101
+ tracer = LangfuseTracer(tracer=tracer_mock, name="Haystack", public=False)
102
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
103
+ pass
104
+
105
+ tracer_mock.flush.assert_not_called()
106
+
107
+ def test_context_is_empty_after_tracing(self):
108
+ tracer_mock = Mock()
109
+
110
+ tracer = LangfuseTracer(tracer=tracer_mock, name="Haystack", public=False)
111
+ with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
112
+ pass
113
+
114
+ assert tracer._context == []