langfuse-haystack 0.0.4__tar.gz → 0.2.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 (18) hide show
  1. langfuse_haystack-0.2.0/CHANGELOG.md +17 -0
  2. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/PKG-INFO +2 -2
  3. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/pydoc/config.yml +2 -2
  4. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/pyproject.toml +1 -1
  5. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +25 -0
  6. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/src/haystack_integrations/tracing/langfuse/tracer.py +78 -17
  7. langfuse_haystack-0.2.0/tests/test_langfuse_span.py +65 -0
  8. langfuse_haystack-0.2.0/tests/test_tracer.py +114 -0
  9. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/.gitignore +0 -0
  10. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/LICENSE.txt +0 -0
  11. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/README.md +0 -0
  12. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/example/basic_rag.py +0 -0
  13. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/example/chat.py +0 -0
  14. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/example/requirements.txt +0 -0
  15. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
  16. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
  17. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/tests/__init__.py +0 -0
  18. {langfuse_haystack-0.0.4 → langfuse_haystack-0.2.0}/tests/test_tracing.py +0 -0
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## [integrations/langfuse-v0.1.0] - 2024-06-13
4
+
5
+ ### 🚀 Features
6
+
7
+ - Langfuse integration (#686)
8
+
9
+ ### 🐛 Bug Fixes
10
+
11
+ - Performance optimizations and value error when streaming in langfuse (#798)
12
+
13
+ ### ⚙️ Miscellaneous Tasks
14
+
15
+ - Use ChatMessage to_openai_format, update unit tests, pydocs (#725)
16
+
17
+ <!-- generated by git-cliff -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: langfuse-haystack
3
- Version: 0.0.4
3
+ Version: 0.2.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,57 @@ 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
+ _SUPPORTED_GENERATORS = ["AzureOpenAIGenerator", "OpenAIGenerator"]
13
+ _SUPPORTED_CHAT_GENERATORS = ["AzureOpenAIChatGenerator", "OpenAIChatGenerator"]
14
+ _ALL_SUPPORTED_GENERATORS = _SUPPORTED_GENERATORS + _SUPPORTED_CHAT_GENERATORS
15
+
10
16
 
11
17
  class LangfuseSpan(Span):
18
+ """
19
+ Internal class representing a bridge between the Haystack span tracing API and Langfuse.
20
+ """
21
+
12
22
  def __init__(self, span: "Union[langfuse.client.StatefulSpanClient, langfuse.client.StatefulTraceClient]") -> None:
23
+ """
24
+ Initialize a LangfuseSpan instance.
25
+
26
+ :param span: The span instance managed by Langfuse.
27
+ """
13
28
  self._span = span
14
29
  # locally cache tags
15
30
  self._data: Dict[str, Any] = {}
16
31
 
17
32
  def set_tag(self, key: str, value: Any) -> None:
33
+ """
34
+ Set a generic tag for this span.
35
+
36
+ :param key: The tag key.
37
+ :param value: The tag value.
38
+ """
18
39
  coerced_value = tracing_utils.coerce_tag_value(value)
19
40
  self._span.update(metadata={key: coerced_value})
20
41
  self._data[key] = value
21
42
 
22
43
  def set_content_tag(self, key: str, value: Any) -> None:
44
+ """
45
+ Set a content-specific tag for this span.
46
+
47
+ :param key: The content tag key.
48
+ :param value: The content tag value.
49
+ """
23
50
  if not tracer.is_content_tracing_enabled:
24
51
  return
25
52
  if key.endswith(".input"):
26
53
  if "messages" in value:
27
- messages = [self.to_openai_format(m) for m in value["messages"]]
54
+ messages = [m.to_openai_format() for m in value["messages"]]
28
55
  self._span.update(input=messages)
29
56
  else:
30
57
  self._span.update(input=value)
31
58
  elif key.endswith(".output"):
32
59
  if "replies" in value:
33
60
  if all(isinstance(r, ChatMessage) for r in value["replies"]):
34
- replies = [self.to_openai_format(m) for m in value["replies"]]
61
+ replies = [m.to_openai_format() for m in value["replies"]]
35
62
  else:
36
63
  replies = value["replies"]
37
64
  self._span.update(output=replies)
@@ -41,35 +68,51 @@ class LangfuseSpan(Span):
41
68
  self._data[key] = value
42
69
 
43
70
  def raw_span(self) -> Any:
71
+ """
72
+ Return the underlying span instance.
73
+
74
+ :return: The Langfuse span instance.
75
+ """
44
76
  return self._span
45
77
 
46
78
  def get_correlation_data_for_logs(self) -> Dict[str, Any]:
47
79
  return {}
48
80
 
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
81
 
60
82
  class LangfuseTracer(Tracer):
83
+ """
84
+ Internal class representing a bridge between the Haystack tracer and Langfuse.
85
+ """
86
+
61
87
  def __init__(self, tracer: "langfuse.Langfuse", name: str = "Haystack", public: bool = False) -> None:
88
+ """
89
+ Initialize a LangfuseTracer instance.
90
+
91
+ :param tracer: The Langfuse tracer instance.
92
+ :param name: The name of the pipeline or component. This name will be used to identify the tracing run on the
93
+ Langfuse dashboard.
94
+ :param public: Whether the tracing data should be public or private. If set to `True`, the tracing data will
95
+ be publicly accessible to anyone with the tracing URL. If set to `False`, the tracing data will be private
96
+ and only accessible to the Langfuse account owner.
97
+ """
62
98
  self._tracer = tracer
63
99
  self._context: list[LangfuseSpan] = []
64
100
  self._name = name
65
101
  self._public = public
102
+ self.enforce_flush = os.getenv(HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR, "true").lower() == "true"
66
103
 
67
104
  @contextlib.contextmanager
68
105
  def trace(self, operation_name: str, tags: Optional[Dict[str, Any]] = None) -> Iterator[Span]:
106
+ """
107
+ Start and manage a new trace span.
108
+ :param operation_name: The name of the operation.
109
+ :param tags: A dictionary of tags to attach to the span.
110
+ :return: A context manager yielding the span.
111
+ """
69
112
  tags = tags or {}
70
113
  span_name = tags.get("haystack.component.name", operation_name)
71
114
 
72
- if tags.get("haystack.component.type") in ["OpenAIGenerator", "OpenAIChatGenerator"]:
115
+ if tags.get("haystack.component.type") in _ALL_SUPPORTED_GENERATORS:
73
116
  span = LangfuseSpan(self.current_span().raw_span().generation(name=span_name))
74
117
  else:
75
118
  span = LangfuseSpan(self.current_span().raw_span().span(name=span_name))
@@ -79,18 +122,18 @@ class LangfuseTracer(Tracer):
79
122
 
80
123
  yield span
81
124
 
82
- if tags.get("haystack.component.type") == "OpenAIGenerator":
125
+ if tags.get("haystack.component.type") in _SUPPORTED_GENERATORS:
83
126
  meta = span._data.get("haystack.component.output", {}).get("meta")
84
127
  if meta:
85
128
  # Haystack returns one meta dict for each message, but the 'usage' value
86
129
  # is always the same, let's just pick the first item
87
130
  m = meta[0]
88
- span._span.update(usage=m.get("usage"), model=m.get("model"))
89
- elif tags.get("haystack.component.type") == "OpenAIChatGenerator":
131
+ span._span.update(usage=m.get("usage") or None, model=m.get("model"))
132
+ elif tags.get("haystack.component.type") in _SUPPORTED_CHAT_GENERATORS:
90
133
  replies = span._data.get("haystack.component.output", {}).get("replies")
91
134
  if replies:
92
135
  meta = replies[0].meta
93
- span._span.update(usage=meta.get("usage"), model=meta.get("model"))
136
+ span._span.update(usage=meta.get("usage") or None, model=meta.get("model"))
94
137
 
95
138
  pipeline_input = tags.get("haystack.pipeline.input_data", None)
96
139
  if pipeline_input:
@@ -101,13 +144,31 @@ class LangfuseTracer(Tracer):
101
144
 
102
145
  span.raw_span().end()
103
146
  self._context.pop()
147
+
148
+ if len(self._context) == 1:
149
+ # The root span has to be a trace, which need to be removed from the context after the pipeline run
150
+ self._context.pop()
151
+
152
+ if self.enforce_flush:
153
+ self.flush()
154
+
155
+ def flush(self):
104
156
  self._tracer.flush()
105
157
 
106
158
  def current_span(self) -> Span:
159
+ """
160
+ Return the currently active span.
161
+
162
+ :return: The currently active span.
163
+ """
107
164
  if not self._context:
108
165
  # The root span has to be a trace
109
166
  self._context.append(LangfuseSpan(self._tracer.trace(name=self._name, public=self._public)))
110
167
  return self._context[-1]
111
168
 
112
169
  def get_trace_url(self) -> str:
170
+ """
171
+ Return the URL to the tracing data.
172
+ :return: The URL to the tracing data.
173
+ """
113
174
  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 == []