langfuse-haystack 0.5.0__tar.gz → 0.6.1__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.5.0 → langfuse_haystack-0.6.1}/CHANGELOG.md +20 -0
  2. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/PKG-INFO +2 -3
  3. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/pyproject.toml +3 -1
  4. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/src/haystack_integrations/tracing/langfuse/tracer.py +85 -46
  5. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/tests/test_tracer.py +86 -33
  6. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/tests/test_tracing.py +18 -15
  7. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/.gitignore +0 -0
  8. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/LICENSE.txt +0 -0
  9. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/README.md +0 -0
  10. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/example/basic_rag.py +0 -0
  11. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/example/chat.py +0 -0
  12. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/example/requirements.txt +0 -0
  13. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/pydoc/config.yml +0 -0
  14. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
  15. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +0 -0
  16. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
  17. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/tests/__init__.py +0 -0
  18. {langfuse_haystack-0.5.0 → langfuse_haystack-0.6.1}/tests/test_langfuse_span.py +0 -0
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/langfuse-v0.6.0] - 2024-11-18
4
+
5
+ ### 🚀 Features
6
+
7
+ - Add support for ttft (#1161)
8
+
9
+ ### ⚙️ Miscellaneous Tasks
10
+
11
+ - Adopt uv as installer (#1142)
12
+
13
+ ## [integrations/langfuse-v0.5.0] - 2024-10-01
14
+
15
+ ### ⚙️ Miscellaneous Tasks
16
+
17
+ - Update ruff linting scripts and settings (#1105)
18
+
19
+ ### Langfuse
20
+
21
+ - Add invocation_context to identify traces (#1089)
22
+
3
23
  ## [integrations/langfuse-v0.4.0] - 2024-09-17
4
24
 
5
25
  ### 🚀 Features
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: langfuse-haystack
3
- Version: 0.5.0
3
+ Version: 0.6.1
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
7
7
  Author-email: deepset GmbH <info@deepset.ai>
8
- License-Expression: Apache-2.0
9
- License-File: LICENSE.txt
8
+ License: Apache-2.0
10
9
  Classifier: Development Status :: 4 - Beta
11
10
  Classifier: Programming Language :: Python
12
11
  Classifier: Programming Language :: Python :: 3.8
@@ -42,6 +42,7 @@ root = "../.."
42
42
  git_describe_command = 'git describe --tags --match="integrations/langfuse-v[0-9]*"'
43
43
 
44
44
  [tool.hatch.envs.default]
45
+ installer = "uv"
45
46
  dependencies = [
46
47
  "coverage[toml]>=6.5",
47
48
  "pytest",
@@ -64,8 +65,9 @@ docs = ["pydoc-markdown pydoc/config.yml"]
64
65
  python = ["3.8", "3.9", "3.10", "3.11", "3.12"]
65
66
 
66
67
  [tool.hatch.envs.lint]
68
+ installer = "uv"
67
69
  detached = true
68
- dependencies = ["black>=23.1.0", "mypy>=1.0.0", "ruff>=0.0.243"]
70
+ dependencies = ["pip", "black>=23.1.0", "mypy>=1.0.0", "ruff>=0.0.243"]
69
71
 
70
72
  [tool.hatch.envs.lint.scripts]
71
73
  typing = "mypy --install-types --non-interactive --explicit-package-bases {args:src/ tests}"
@@ -1,14 +1,20 @@
1
1
  import contextlib
2
2
  import os
3
- from typing import Any, Dict, Iterator, Optional, Union
3
+ from contextvars import ContextVar
4
+ from datetime import datetime
5
+ from typing import Any, Dict, Iterator, List, Optional, Union
4
6
 
7
+ from haystack import logging
5
8
  from haystack.components.generators.openai_utils import _convert_message_to_openai_format
6
9
  from haystack.dataclasses import ChatMessage
7
- from haystack.tracing import Span, Tracer, tracer
10
+ from haystack.tracing import Span, Tracer
11
+ from haystack.tracing import tracer as proxy_tracer
8
12
  from haystack.tracing import utils as tracing_utils
9
13
 
10
14
  import langfuse
11
15
 
16
+ logger = logging.getLogger(__name__)
17
+
12
18
  HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR = "HAYSTACK_LANGFUSE_ENFORCE_FLUSH"
13
19
  _SUPPORTED_GENERATORS = [
14
20
  "AzureOpenAIGenerator",
@@ -28,6 +34,17 @@ _SUPPORTED_CHAT_GENERATORS = [
28
34
  ]
29
35
  _ALL_SUPPORTED_GENERATORS = _SUPPORTED_GENERATORS + _SUPPORTED_CHAT_GENERATORS
30
36
 
37
+ # These are the keys used by Haystack for traces and span.
38
+ # We keep them here to avoid making typos when using them.
39
+ _PIPELINE_RUN_KEY = "haystack.pipeline.run"
40
+ _COMPONENT_NAME_KEY = "haystack.component.name"
41
+ _COMPONENT_TYPE_KEY = "haystack.component.type"
42
+ _COMPONENT_OUTPUT_KEY = "haystack.component.output"
43
+
44
+ # Context var used to keep track of tracing related info.
45
+ # This mainly useful for parents spans.
46
+ tracing_context_var: ContextVar[Dict[Any, Any]] = ContextVar("tracing_context")
47
+
31
48
 
32
49
  class LangfuseSpan(Span):
33
50
  """
@@ -62,7 +79,7 @@ class LangfuseSpan(Span):
62
79
  :param key: The content tag key.
63
80
  :param value: The content tag value.
64
81
  """
65
- if not tracer.is_content_tracing_enabled:
82
+ if not proxy_tracer.is_content_tracing_enabled:
66
83
  return
67
84
  if key.endswith(".input"):
68
85
  if "messages" in value:
@@ -82,7 +99,7 @@ class LangfuseSpan(Span):
82
99
 
83
100
  self._data[key] = value
84
101
 
85
- def raw_span(self) -> Any:
102
+ def raw_span(self) -> "Union[langfuse.client.StatefulSpanClient, langfuse.client.StatefulTraceClient]":
86
103
  """
87
104
  Return the underlying span instance.
88
105
 
@@ -110,76 +127,98 @@ class LangfuseTracer(Tracer):
110
127
  be publicly accessible to anyone with the tracing URL. If set to `False`, the tracing data will be private
111
128
  and only accessible to the Langfuse account owner.
112
129
  """
130
+ if not proxy_tracer.is_content_tracing_enabled:
131
+ logger.warning(
132
+ "Traces will not be logged to Langfuse because Haystack tracing is disabled. "
133
+ "To enable, set the HAYSTACK_CONTENT_TRACING_ENABLED environment variable to true "
134
+ "before importing Haystack."
135
+ )
113
136
  self._tracer = tracer
114
- self._context: list[LangfuseSpan] = []
137
+ self._context: List[LangfuseSpan] = []
115
138
  self._name = name
116
139
  self._public = public
117
140
  self.enforce_flush = os.getenv(HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR, "true").lower() == "true"
118
141
 
119
142
  @contextlib.contextmanager
120
- def trace(self, operation_name: str, tags: Optional[Dict[str, Any]] = None) -> Iterator[Span]:
121
- """
122
- Start and manage a new trace span.
123
- :param operation_name: The name of the operation.
124
- :param tags: A dictionary of tags to attach to the span.
125
- :return: A context manager yielding the span.
126
- """
143
+ def trace(
144
+ self, operation_name: str, tags: Optional[Dict[str, Any]] = None, parent_span: Optional[Span] = None
145
+ ) -> Iterator[Span]:
127
146
  tags = tags or {}
128
- span_name = tags.get("haystack.component.name", operation_name)
129
-
130
- if tags.get("haystack.component.type") in _ALL_SUPPORTED_GENERATORS:
131
- span = LangfuseSpan(self.current_span().raw_span().generation(name=span_name))
147
+ span_name = tags.get(_COMPONENT_NAME_KEY, operation_name)
148
+
149
+ # Create new span depending whether there's a parent span or not
150
+ if not parent_span:
151
+ if operation_name != _PIPELINE_RUN_KEY:
152
+ logger.warning(
153
+ "Creating a new trace without a parent span is not recommended for operation '{operation_name}'.",
154
+ operation_name=operation_name,
155
+ )
156
+ # Create a new trace if no parent span is provided
157
+ context = tracing_context_var.get({})
158
+ span = LangfuseSpan(
159
+ self._tracer.trace(
160
+ name=self._name,
161
+ public=self._public,
162
+ id=context.get("trace_id"),
163
+ user_id=context.get("user_id"),
164
+ session_id=context.get("session_id"),
165
+ tags=context.get("tags"),
166
+ version=context.get("version"),
167
+ )
168
+ )
169
+ elif tags.get(_COMPONENT_TYPE_KEY) in _ALL_SUPPORTED_GENERATORS:
170
+ span = LangfuseSpan(parent_span.raw_span().generation(name=span_name))
132
171
  else:
133
- span = LangfuseSpan(self.current_span().raw_span().span(name=span_name))
172
+ span = LangfuseSpan(parent_span.raw_span().span(name=span_name))
134
173
 
135
174
  self._context.append(span)
136
175
  span.set_tags(tags)
137
176
 
138
177
  yield span
139
178
 
140
- if tags.get("haystack.component.type") in _SUPPORTED_GENERATORS:
141
- meta = span._data.get("haystack.component.output", {}).get("meta")
179
+ # Update span metadata based on component type
180
+ if tags.get(_COMPONENT_TYPE_KEY) in _SUPPORTED_GENERATORS:
181
+ # Haystack returns one meta dict for each message, but the 'usage' value
182
+ # is always the same, let's just pick the first item
183
+ meta = span._data.get(_COMPONENT_OUTPUT_KEY, {}).get("meta")
142
184
  if meta:
143
- # Haystack returns one meta dict for each message, but the 'usage' value
144
- # is always the same, let's just pick the first item
145
185
  m = meta[0]
146
186
  span._span.update(usage=m.get("usage") or None, model=m.get("model"))
147
- elif tags.get("haystack.component.type") in _SUPPORTED_CHAT_GENERATORS:
148
- replies = span._data.get("haystack.component.output", {}).get("replies")
187
+ elif tags.get(_COMPONENT_TYPE_KEY) in _SUPPORTED_CHAT_GENERATORS:
188
+ replies = span._data.get(_COMPONENT_OUTPUT_KEY, {}).get("replies")
149
189
  if replies:
150
190
  meta = replies[0].meta
151
- span._span.update(usage=meta.get("usage") or None, model=meta.get("model"))
152
-
153
- pipeline_input = tags.get("haystack.pipeline.input_data", None)
154
- if pipeline_input:
155
- span._span.update(input=tags["haystack.pipeline.input_data"])
156
- pipeline_output = tags.get("haystack.pipeline.output_data", None)
157
- if pipeline_output:
158
- span._span.update(output=tags["haystack.pipeline.output_data"])
159
-
160
- span.raw_span().end()
191
+ completion_start_time = meta.get("completion_start_time")
192
+ if completion_start_time:
193
+ try:
194
+ completion_start_time = datetime.fromisoformat(completion_start_time)
195
+ except ValueError:
196
+ logger.error(f"Failed to parse completion_start_time: {completion_start_time}")
197
+ completion_start_time = None
198
+ span._span.update(
199
+ usage=meta.get("usage") or None,
200
+ model=meta.get("model"),
201
+ completion_start_time=completion_start_time,
202
+ )
203
+
204
+ raw_span = span.raw_span()
205
+ if isinstance(raw_span, langfuse.client.StatefulSpanClient):
206
+ raw_span.end()
161
207
  self._context.pop()
162
208
 
163
- if len(self._context) == 1:
164
- # The root span has to be a trace, which need to be removed from the context after the pipeline run
165
- self._context.pop()
166
-
167
- if self.enforce_flush:
168
- self.flush()
209
+ if self.enforce_flush:
210
+ self.flush()
169
211
 
170
212
  def flush(self):
171
213
  self._tracer.flush()
172
214
 
173
- def current_span(self) -> Span:
215
+ def current_span(self) -> Optional[Span]:
174
216
  """
175
- Return the currently active span.
217
+ Return the current active span.
176
218
 
177
- :return: The currently active span.
219
+ :return: The current span if available, else None.
178
220
  """
179
- if not self._context:
180
- # The root span has to be a trace
181
- self._context.append(LangfuseSpan(self._tracer.trace(name=self._name, public=self._public)))
182
- return self._context[-1]
221
+ return self._context[-1] if self._context else None
183
222
 
184
223
  def get_trace_url(self) -> str:
185
224
  """
@@ -1,9 +1,45 @@
1
- import os
1
+ import datetime
2
+ import logging
3
+ import sys
2
4
  from unittest.mock import MagicMock, Mock, patch
3
5
 
6
+ from haystack.dataclasses import ChatMessage
4
7
  from haystack_integrations.tracing.langfuse.tracer import LangfuseTracer
5
8
 
6
9
 
10
+ class MockSpan:
11
+ def __init__(self):
12
+ self._data = {}
13
+ self._span = self
14
+ self.operation_name = "operation_name"
15
+
16
+ def raw_span(self):
17
+ return self
18
+
19
+ def span(self, name=None):
20
+ # assert correct operation name passed to the span
21
+ assert name == "operation_name"
22
+ return self
23
+
24
+ def update(self, **kwargs):
25
+ self._data.update(kwargs)
26
+
27
+ def generation(self, name=None):
28
+ return self
29
+
30
+ def end(self):
31
+ pass
32
+
33
+
34
+ class MockTracer:
35
+
36
+ def trace(self, name, **kwargs):
37
+ return MockSpan()
38
+
39
+ def flush(self):
40
+ pass
41
+
42
+
7
43
  class TestLangfuseTracer:
8
44
 
9
45
  # LangfuseTracer can be initialized with a Langfuse instance, a name and a boolean value for public.
@@ -35,7 +71,7 @@ class TestLangfuseTracer:
35
71
  tracer = LangfuseTracer(tracer=mock_tracer, name="Haystack", public=False)
36
72
 
37
73
  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"
74
+ assert len(tracer._context) == 1, "The trace span should have been added to the the root context span"
39
75
  assert span.raw_span().operation_name == "operation_name"
40
76
  assert span.raw_span().metadata == {"tag1": "value1", "tag2": "value2"}
41
77
 
@@ -45,37 +81,6 @@ class TestLangfuseTracer:
45
81
 
46
82
  # check that update method is called on the span instance with the provided key value pairs
47
83
  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
84
  tracer = LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
80
85
  with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
81
86
  assert span.raw_span()._data["metadata"] == {"haystack.pipeline.input_data": "hello"}
@@ -83,6 +88,40 @@ class TestLangfuseTracer:
83
88
  with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.output_data": "bye"}) as span:
84
89
  assert span.raw_span()._data["metadata"] == {"haystack.pipeline.output_data": "bye"}
85
90
 
91
+ def test_trace_generation(self):
92
+ tracer = LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
93
+ tags = {
94
+ "haystack.component.type": "OpenAIChatGenerator",
95
+ "haystack.component.output": {
96
+ "replies": [
97
+ ChatMessage.from_assistant(
98
+ "", meta={"completion_start_time": "2021-07-27T16:02:08.012345", "model": "test_model"}
99
+ )
100
+ ]
101
+ },
102
+ }
103
+ with tracer.trace(operation_name="operation_name", tags=tags) as span:
104
+ ...
105
+ assert span.raw_span()._data["usage"] is None
106
+ assert span.raw_span()._data["model"] == "test_model"
107
+ assert span.raw_span()._data["completion_start_time"] == datetime.datetime(2021, 7, 27, 16, 2, 8, 12345)
108
+
109
+ def test_trace_generation_invalid_start_time(self):
110
+ tracer = LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
111
+ tags = {
112
+ "haystack.component.type": "OpenAIChatGenerator",
113
+ "haystack.component.output": {
114
+ "replies": [
115
+ ChatMessage.from_assistant("", meta={"completion_start_time": "foobar", "model": "test_model"}),
116
+ ]
117
+ },
118
+ }
119
+ with tracer.trace(operation_name="operation_name", tags=tags) as span:
120
+ ...
121
+ assert span.raw_span()._data["usage"] is None
122
+ assert span.raw_span()._data["model"] == "test_model"
123
+ assert span.raw_span()._data["completion_start_time"] is None
124
+
86
125
  def test_update_span_gets_flushed_by_default(self):
87
126
  tracer_mock = Mock()
88
127
 
@@ -112,3 +151,17 @@ class TestLangfuseTracer:
112
151
  pass
113
152
 
114
153
  assert tracer._context == []
154
+
155
+ def test_init_with_tracing_disabled(self, monkeypatch, caplog):
156
+ # Clear haystack modules because ProxyTracer is initialized whenever haystack is imported
157
+ modules_to_clear = [name for name in sys.modules if name.startswith('haystack')]
158
+ for name in modules_to_clear:
159
+ sys.modules.pop(name, None)
160
+
161
+ # Re-import LangfuseTracer and instantiate it with tracing disabled
162
+ with caplog.at_level(logging.WARNING):
163
+ monkeypatch.setenv("HAYSTACK_CONTENT_TRACING_ENABLED", "false")
164
+ from haystack_integrations.tracing.langfuse import LangfuseTracer
165
+
166
+ LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
167
+ assert "tracing is disabled" in caplog.text
@@ -49,28 +49,31 @@ def test_tracing_integration(llm_class, env_var, expected_trace):
49
49
  "tracer": {"invocation_context": {"user_id": "user_42"}},
50
50
  }
51
51
  )
52
- assert "Berlin" in response["llm"]["replies"][0].content
52
+ assert "Berlin" in response["llm"]["replies"][0].text
53
53
  assert response["tracer"]["trace_url"]
54
54
 
55
- # add a random delay between 1 and 3 seconds to make sure the trace is flushed
56
- # and that the trace is available in Langfuse when we fetch it below
57
- time.sleep(random.uniform(1, 3))
58
-
59
- url = "https://cloud.langfuse.com/api/public/traces/"
60
55
  trace_url = response["tracer"]["trace_url"]
61
56
  uuid = os.path.basename(urlparse(trace_url).path)
57
+ url = f"https://cloud.langfuse.com/api/public/traces/{uuid}"
62
58
 
63
- try:
64
- response = requests.get(
65
- url + uuid, auth=HTTPBasicAuth(os.environ["LANGFUSE_PUBLIC_KEY"], os.environ["LANGFUSE_SECRET_KEY"])
59
+ # Poll the Langfuse API a bit as the trace might not be ready right away
60
+ attempts = 5
61
+ delay = 1
62
+ while attempts >= 0:
63
+ res = requests.get(
64
+ url, auth=HTTPBasicAuth(os.environ["LANGFUSE_PUBLIC_KEY"], os.environ["LANGFUSE_SECRET_KEY"])
66
65
  )
67
- assert response.status_code == 200, f"Failed to retrieve data from Langfuse API: {response.status_code}"
66
+ if attempts > 0 and res.status_code != 200:
67
+ attempts -= 1
68
+ time.sleep(delay)
69
+ delay *= 2
70
+ continue
71
+ assert res.status_code == 200, f"Failed to retrieve data from Langfuse API: {res.status_code}"
68
72
 
69
73
  # check if the trace contains the expected LLM name
70
- assert expected_trace in str(response.content)
74
+ assert expected_trace in str(res.content)
71
75
  # check if the trace contains the expected generation span
72
- assert "GENERATION" in str(response.content)
76
+ assert "GENERATION" in str(res.content)
73
77
  # check if the trace contains the expected user_id
74
- assert "user_42" in str(response.content)
75
- except requests.exceptions.RequestException as e:
76
- pytest.fail(f"Failed to retrieve data from Langfuse API: {e}")
78
+ assert "user_42" in str(res.content)
79
+ break