langfuse-haystack 0.7.0__tar.gz → 0.9.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-0.7.0 → langfuse_haystack-0.9.0}/CHANGELOG.md +46 -9
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/PKG-INFO +3 -3
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/pyproject.toml +2 -6
- langfuse_haystack-0.9.0/src/haystack_integrations/components/connectors/__init__.py +3 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +1 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +42 -6
- langfuse_haystack-0.9.0/src/haystack_integrations/tracing/langfuse/__init__.py +7 -0
- langfuse_haystack-0.9.0/src/haystack_integrations/tracing/langfuse/tracer.py +411 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/tests/test_langfuse_span.py +4 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/tests/test_tracer.py +4 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/tests/test_tracing.py +112 -63
- langfuse_haystack-0.7.0/src/haystack_integrations/tracing/langfuse/__init__.py +0 -6
- langfuse_haystack-0.7.0/src/haystack_integrations/tracing/langfuse/tracer.py +0 -231
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/.gitignore +0 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/LICENSE.txt +0 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/README.md +0 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/example/basic_rag.py +0 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/example/chat.py +0 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/example/requirements.txt +0 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/pydoc/config.yml +0 -0
- {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/tests/__init__.py +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [integrations/langfuse-v0.8.0] - 2025-01-28
|
|
4
|
+
|
|
5
|
+
### 🚀 Features
|
|
6
|
+
|
|
7
|
+
- Add custom Langfuse span handling support (#1313)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [integrations/langfuse-v0.7.0] - 2025-01-21
|
|
11
|
+
|
|
12
|
+
### 🚀 Features
|
|
13
|
+
|
|
14
|
+
- LangfuseConnector - add httpx.Client init param (#1308)
|
|
15
|
+
|
|
16
|
+
### 🐛 Bug Fixes
|
|
17
|
+
|
|
18
|
+
- End langfuse generation spans properly (#1301)
|
|
19
|
+
|
|
20
|
+
|
|
3
21
|
## [integrations/langfuse-v0.6.4] - 2025-01-17
|
|
4
22
|
|
|
5
23
|
### 🚀 Features
|
|
@@ -11,7 +29,7 @@
|
|
|
11
29
|
|
|
12
30
|
### 🌀 Miscellaneous
|
|
13
31
|
|
|
14
|
-
-
|
|
32
|
+
- Pin haystack-ai>=2.9.0 and simplify (#1292)
|
|
15
33
|
|
|
16
34
|
## [integrations/langfuse-v0.6.2] - 2025-01-02
|
|
17
35
|
|
|
@@ -25,7 +43,7 @@
|
|
|
25
43
|
|
|
26
44
|
### 🌀 Miscellaneous
|
|
27
45
|
|
|
28
|
-
-
|
|
46
|
+
- Fix tracing_context_var lint errors (#1220)
|
|
29
47
|
- Fix messages conversion to OpenAI format (#1272)
|
|
30
48
|
|
|
31
49
|
## [integrations/langfuse-v0.6.0] - 2024-11-18
|
|
@@ -42,6 +60,25 @@
|
|
|
42
60
|
|
|
43
61
|
- Fixed TypeError in LangfuseTrace (#1184)
|
|
44
62
|
|
|
63
|
+
* Added parent_span functionality in trace method
|
|
64
|
+
|
|
65
|
+
* solved PR comments
|
|
66
|
+
|
|
67
|
+
* Readded "end()" for solving Latency issues
|
|
68
|
+
|
|
69
|
+
* chore: fix ruff linting
|
|
70
|
+
|
|
71
|
+
* Handle multiple runs
|
|
72
|
+
|
|
73
|
+
* Fix indentation and span closing
|
|
74
|
+
|
|
75
|
+
* Fix tests
|
|
76
|
+
|
|
77
|
+
---------
|
|
78
|
+
|
|
79
|
+
Co-authored-by: Vladimir Blagojevic <dovlex@gmail.com>
|
|
80
|
+
Co-authored-by: Silvano Cerza <silvanocerza@gmail.com>
|
|
81
|
+
|
|
45
82
|
## [integrations/langfuse-v0.5.0] - 2024-10-01
|
|
46
83
|
|
|
47
84
|
### 🧹 Chores
|
|
@@ -50,7 +87,7 @@
|
|
|
50
87
|
|
|
51
88
|
### 🌀 Miscellaneous
|
|
52
89
|
|
|
53
|
-
-
|
|
90
|
+
- Add delay to flush the Langfuse traces (#1091)
|
|
54
91
|
- Add invocation_context to identify traces (#1089)
|
|
55
92
|
|
|
56
93
|
## [integrations/langfuse-v0.4.0] - 2024-09-17
|
|
@@ -82,15 +119,15 @@
|
|
|
82
119
|
|
|
83
120
|
### 🌀 Miscellaneous
|
|
84
121
|
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
122
|
+
- Install pytest-rerunfailures; change test-cov script (#845)
|
|
123
|
+
- Update Langfuse README to avoid common initialization issues (#952)
|
|
124
|
+
- Ruff update, don't ruff tests (#992)
|
|
88
125
|
|
|
89
126
|
## [integrations/langfuse-v0.2.0] - 2024-06-18
|
|
90
127
|
|
|
91
128
|
### 🌀 Miscellaneous
|
|
92
129
|
|
|
93
|
-
-
|
|
130
|
+
- Add support for Azure generators (#815)
|
|
94
131
|
|
|
95
132
|
## [integrations/langfuse-v0.1.0] - 2024-06-13
|
|
96
133
|
|
|
@@ -108,7 +145,7 @@
|
|
|
108
145
|
|
|
109
146
|
### 🌀 Miscellaneous
|
|
110
147
|
|
|
111
|
-
-
|
|
112
|
-
-
|
|
148
|
+
- Change the pydoc renderer class (#718)
|
|
149
|
+
- Missing api references (#728)
|
|
113
150
|
|
|
114
151
|
<!-- generated by git-cliff -->
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langfuse-haystack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -9,14 +9,14 @@ License-Expression: Apache-2.0
|
|
|
9
9
|
License-File: LICENSE.txt
|
|
10
10
|
Classifier: Development Status :: 4 - Beta
|
|
11
11
|
Classifier: Programming Language :: Python
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.9
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.10
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
17
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
18
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
19
|
-
Requires-Python: >=3.
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
20
|
Requires-Dist: haystack-ai>=2.9.0
|
|
21
21
|
Requires-Dist: langfuse
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
@@ -7,18 +7,18 @@ name = "langfuse-haystack"
|
|
|
7
7
|
dynamic = ["version"]
|
|
8
8
|
description = ''
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
11
|
license = "Apache-2.0"
|
|
12
12
|
keywords = []
|
|
13
13
|
authors = [{ name = "deepset GmbH", email = "info@deepset.ai" }]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 4 - Beta",
|
|
16
16
|
"Programming Language :: Python",
|
|
17
|
-
"Programming Language :: Python :: 3.8",
|
|
18
17
|
"Programming Language :: Python :: 3.9",
|
|
19
18
|
"Programming Language :: Python :: 3.10",
|
|
20
19
|
"Programming Language :: Python :: 3.11",
|
|
21
20
|
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
22
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
23
23
|
"Programming Language :: Python :: Implementation :: PyPy",
|
|
24
24
|
]
|
|
@@ -60,10 +60,6 @@ cov = ["test-cov", "cov-report"]
|
|
|
60
60
|
cov-retry = ["test-cov-retry", "cov-report"]
|
|
61
61
|
docs = ["pydoc-markdown pydoc/config.yml"]
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
[[tool.hatch.envs.all.matrix]]
|
|
65
|
-
python = ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
|
66
|
-
|
|
67
63
|
[tool.hatch.envs.lint]
|
|
68
64
|
installer = "uv"
|
|
69
65
|
detached = true
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
1
5
|
from typing import Any, Dict, Optional
|
|
2
6
|
|
|
3
7
|
import httpx
|
|
4
8
|
from haystack import component, default_from_dict, default_to_dict, logging, tracing
|
|
5
9
|
from haystack.utils import Secret, deserialize_secrets_inplace
|
|
6
|
-
|
|
7
|
-
from haystack_integrations.tracing.langfuse import LangfuseTracer
|
|
10
|
+
from haystack.utils.base_serialization import deserialize_class_instance, serialize_class_instance
|
|
8
11
|
from langfuse import Langfuse
|
|
9
12
|
|
|
13
|
+
from haystack_integrations.tracing.langfuse import LangfuseTracer, SpanHandler
|
|
14
|
+
|
|
10
15
|
logger = logging.getLogger(__name__)
|
|
11
16
|
|
|
12
17
|
|
|
@@ -27,7 +32,7 @@ class LangfuseConnector:
|
|
|
27
32
|
enable Haystack tracing in your pipeline.
|
|
28
33
|
|
|
29
34
|
Lastly, you may disable flushing the data after each component by setting the `HAYSTACK_LANGFUSE_ENFORCE_FLUSH`
|
|
30
|
-
|
|
35
|
+
environment variable to `false`. By default, the data is flushed after each component and blocks the thread until
|
|
31
36
|
the data is sent to Langfuse. **Caution**: Disabling this feature may result in data loss if the program crashes
|
|
32
37
|
before the data is sent to Langfuse. Make sure you will call langfuse.flush() explicitly before the program exits.
|
|
33
38
|
E.g. by using tracer.actual_tracer.flush():
|
|
@@ -94,6 +99,24 @@ class LangfuseConnector:
|
|
|
94
99
|
print(response["tracer"]["trace_url"])
|
|
95
100
|
```
|
|
96
101
|
|
|
102
|
+
For advanced use cases, you can also customize how spans are created and processed by
|
|
103
|
+
providing a custom SpanHandler. This allows you to add custom metrics, set warning levels,
|
|
104
|
+
or attach additional metadata to your Langfuse traces:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from haystack_integrations.tracing.langfuse import DefaultSpanHandler, LangfuseSpan
|
|
108
|
+
from typing import Optional
|
|
109
|
+
|
|
110
|
+
class CustomSpanHandler(DefaultSpanHandler):
|
|
111
|
+
|
|
112
|
+
def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
|
|
113
|
+
# Custom span handling logic, customize Langfuse spans however it fits you
|
|
114
|
+
# see DefaultSpanHandler for how we create and process spans by default
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
connector = LangfuseConnector(span_handler=CustomSpanHandler())
|
|
118
|
+
```
|
|
119
|
+
|
|
97
120
|
"""
|
|
98
121
|
|
|
99
122
|
def __init__(
|
|
@@ -103,6 +126,7 @@ class LangfuseConnector:
|
|
|
103
126
|
public_key: Optional[Secret] = Secret.from_env_var("LANGFUSE_PUBLIC_KEY"), # noqa: B008
|
|
104
127
|
secret_key: Optional[Secret] = Secret.from_env_var("LANGFUSE_SECRET_KEY"), # noqa: B008
|
|
105
128
|
httpx_client: Optional[httpx.Client] = None,
|
|
129
|
+
span_handler: Optional[SpanHandler] = None,
|
|
106
130
|
):
|
|
107
131
|
"""
|
|
108
132
|
Initialize the LangfuseConnector component.
|
|
@@ -117,11 +141,16 @@ class LangfuseConnector:
|
|
|
117
141
|
:param httpx_client: Optional custom httpx.Client instance to use for Langfuse API calls. Note that when
|
|
118
142
|
deserializing a pipeline from YAML, any custom client is discarded and Langfuse will create its own default
|
|
119
143
|
client, since HTTPX clients cannot be serialized.
|
|
144
|
+
:param span_handler: Optional custom handler for processing spans. If None, uses DefaultSpanHandler.
|
|
145
|
+
The span handler controls how spans are created and processed, allowing customization of span types
|
|
146
|
+
based on component types and additional processing after spans are yielded. See SpanHandler class for
|
|
147
|
+
details on implementing custom handlers.
|
|
120
148
|
"""
|
|
121
149
|
self.name = name
|
|
122
150
|
self.public = public
|
|
123
151
|
self.secret_key = secret_key
|
|
124
152
|
self.public_key = public_key
|
|
153
|
+
self.span_handler = span_handler
|
|
125
154
|
self.tracer = LangfuseTracer(
|
|
126
155
|
tracer=Langfuse(
|
|
127
156
|
secret_key=secret_key.resolve_value() if secret_key else None,
|
|
@@ -130,10 +159,11 @@ class LangfuseConnector:
|
|
|
130
159
|
),
|
|
131
160
|
name=name,
|
|
132
161
|
public=public,
|
|
162
|
+
span_handler=span_handler,
|
|
133
163
|
)
|
|
134
164
|
tracing.enable_tracing(self.tracer)
|
|
135
165
|
|
|
136
|
-
@component.output_types(name=str, trace_url=str)
|
|
166
|
+
@component.output_types(name=str, trace_url=str, trace_id=str)
|
|
137
167
|
def run(self, invocation_context: Optional[Dict[str, Any]] = None):
|
|
138
168
|
"""
|
|
139
169
|
Runs the LangfuseConnector component.
|
|
@@ -150,7 +180,7 @@ class LangfuseConnector:
|
|
|
150
180
|
"Langfuse tracer invoked with the following context: '{invocation_context}'",
|
|
151
181
|
invocation_context=invocation_context,
|
|
152
182
|
)
|
|
153
|
-
return {"name": self.name, "trace_url": self.tracer.get_trace_url()}
|
|
183
|
+
return {"name": self.name, "trace_url": self.tracer.get_trace_url(), "trace_id": self.tracer.get_trace_id()}
|
|
154
184
|
|
|
155
185
|
def to_dict(self) -> Dict[str, Any]:
|
|
156
186
|
"""
|
|
@@ -158,6 +188,7 @@ class LangfuseConnector:
|
|
|
158
188
|
|
|
159
189
|
:returns: The serialized component as a dictionary.
|
|
160
190
|
"""
|
|
191
|
+
span_handler = serialize_class_instance(self.span_handler) if self.span_handler else None
|
|
161
192
|
return default_to_dict(
|
|
162
193
|
self,
|
|
163
194
|
name=self.name,
|
|
@@ -165,6 +196,7 @@ class LangfuseConnector:
|
|
|
165
196
|
secret_key=self.secret_key.to_dict() if self.secret_key else None,
|
|
166
197
|
public_key=self.public_key.to_dict() if self.public_key else None,
|
|
167
198
|
# Note: httpx_client is not serialized as it's not serializable
|
|
199
|
+
span_handler=span_handler,
|
|
168
200
|
)
|
|
169
201
|
|
|
170
202
|
@classmethod
|
|
@@ -175,5 +207,9 @@ class LangfuseConnector:
|
|
|
175
207
|
:param data: The dictionary representation of this component.
|
|
176
208
|
:returns: The deserialized component instance.
|
|
177
209
|
"""
|
|
178
|
-
|
|
210
|
+
init_params = data["init_parameters"]
|
|
211
|
+
deserialize_secrets_inplace(init_params, keys=["secret_key", "public_key"])
|
|
212
|
+
init_params["span_handler"] = (
|
|
213
|
+
deserialize_class_instance(init_params["span_handler"]) if init_params["span_handler"] else None
|
|
214
|
+
)
|
|
179
215
|
return default_from_dict(cls, data)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
from .tracer import DefaultSpanHandler, LangfuseSpan, LangfuseTracer, SpanContext, SpanHandler
|
|
6
|
+
|
|
7
|
+
__all__ = ["DefaultSpanHandler", "LangfuseSpan", "LangfuseTracer", "SpanContext", "SpanHandler"]
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Dict, Iterator, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
from haystack import default_from_dict, default_to_dict, logging
|
|
14
|
+
from haystack.dataclasses import ChatMessage
|
|
15
|
+
from haystack.tracing import Span, Tracer
|
|
16
|
+
from haystack.tracing import tracer as proxy_tracer
|
|
17
|
+
from haystack.tracing import utils as tracing_utils
|
|
18
|
+
from typing_extensions import TypeAlias
|
|
19
|
+
|
|
20
|
+
import langfuse
|
|
21
|
+
from langfuse.client import StatefulGenerationClient, StatefulSpanClient, StatefulTraceClient
|
|
22
|
+
|
|
23
|
+
# Type alias for Langfuse stateful clients
|
|
24
|
+
LangfuseStatefulClient: TypeAlias = Union[StatefulTraceClient, StatefulSpanClient, StatefulGenerationClient]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR = "HAYSTACK_LANGFUSE_ENFORCE_FLUSH"
|
|
30
|
+
_SUPPORTED_GENERATORS = [
|
|
31
|
+
"AzureOpenAIGenerator",
|
|
32
|
+
"OpenAIGenerator",
|
|
33
|
+
"AnthropicGenerator",
|
|
34
|
+
"HuggingFaceAPIGenerator",
|
|
35
|
+
"HuggingFaceLocalGenerator",
|
|
36
|
+
"CohereGenerator",
|
|
37
|
+
]
|
|
38
|
+
_SUPPORTED_CHAT_GENERATORS = [
|
|
39
|
+
"AzureOpenAIChatGenerator",
|
|
40
|
+
"OpenAIChatGenerator",
|
|
41
|
+
"AnthropicChatGenerator",
|
|
42
|
+
"HuggingFaceAPIChatGenerator",
|
|
43
|
+
"HuggingFaceLocalChatGenerator",
|
|
44
|
+
"CohereChatGenerator",
|
|
45
|
+
]
|
|
46
|
+
_ALL_SUPPORTED_GENERATORS = _SUPPORTED_GENERATORS + _SUPPORTED_CHAT_GENERATORS
|
|
47
|
+
|
|
48
|
+
# These are the keys used by Haystack for traces and span.
|
|
49
|
+
# We keep them here to avoid making typos when using them.
|
|
50
|
+
_PIPELINE_INPUT_KEY = "haystack.pipeline.input_data"
|
|
51
|
+
_PIPELINE_OUTPUT_KEY = "haystack.pipeline.output_data"
|
|
52
|
+
_PIPELINE_RUN_KEY = "haystack.pipeline.run"
|
|
53
|
+
_COMPONENT_NAME_KEY = "haystack.component.name"
|
|
54
|
+
_COMPONENT_TYPE_KEY = "haystack.component.type"
|
|
55
|
+
_COMPONENT_OUTPUT_KEY = "haystack.component.output"
|
|
56
|
+
|
|
57
|
+
# Context var used to keep track of tracing related info.
|
|
58
|
+
# This mainly useful for parents spans.
|
|
59
|
+
tracing_context_var: ContextVar[Dict[Any, Any]] = ContextVar("tracing_context")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LangfuseSpan(Span):
|
|
63
|
+
"""
|
|
64
|
+
Internal class representing a bridge between the Haystack span tracing API and Langfuse.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, span: LangfuseStatefulClient) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Initialize a LangfuseSpan instance.
|
|
70
|
+
|
|
71
|
+
:param span: The span instance managed by Langfuse.
|
|
72
|
+
"""
|
|
73
|
+
self._span = span
|
|
74
|
+
# locally cache tags
|
|
75
|
+
self._data: Dict[str, Any] = {}
|
|
76
|
+
|
|
77
|
+
def set_tag(self, key: str, value: Any) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Set a generic tag for this span.
|
|
80
|
+
|
|
81
|
+
:param key: The tag key.
|
|
82
|
+
:param value: The tag value.
|
|
83
|
+
"""
|
|
84
|
+
coerced_value = tracing_utils.coerce_tag_value(value)
|
|
85
|
+
self._span.update(metadata={key: coerced_value})
|
|
86
|
+
self._data[key] = value
|
|
87
|
+
|
|
88
|
+
def set_content_tag(self, key: str, value: Any) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Set a content-specific tag for this span.
|
|
91
|
+
|
|
92
|
+
:param key: The content tag key.
|
|
93
|
+
:param value: The content tag value.
|
|
94
|
+
"""
|
|
95
|
+
if not proxy_tracer.is_content_tracing_enabled:
|
|
96
|
+
return
|
|
97
|
+
if key.endswith(".input"):
|
|
98
|
+
if "messages" in value:
|
|
99
|
+
messages = [m.to_openai_dict_format() for m in value["messages"]]
|
|
100
|
+
self._span.update(input=messages)
|
|
101
|
+
else:
|
|
102
|
+
self._span.update(input=value)
|
|
103
|
+
elif key.endswith(".output"):
|
|
104
|
+
if "replies" in value:
|
|
105
|
+
if all(isinstance(r, ChatMessage) for r in value["replies"]):
|
|
106
|
+
replies = [m.to_openai_dict_format() for m in value["replies"]]
|
|
107
|
+
else:
|
|
108
|
+
replies = value["replies"]
|
|
109
|
+
self._span.update(output=replies)
|
|
110
|
+
else:
|
|
111
|
+
self._span.update(output=value)
|
|
112
|
+
|
|
113
|
+
self._data[key] = value
|
|
114
|
+
|
|
115
|
+
def raw_span(self) -> LangfuseStatefulClient:
|
|
116
|
+
"""
|
|
117
|
+
Return the underlying span instance.
|
|
118
|
+
|
|
119
|
+
:return: The Langfuse span instance.
|
|
120
|
+
"""
|
|
121
|
+
return self._span
|
|
122
|
+
|
|
123
|
+
def get_data(self) -> Dict[str, Any]:
|
|
124
|
+
"""
|
|
125
|
+
Return the data associated with the span.
|
|
126
|
+
|
|
127
|
+
:return: The data associated with the span.
|
|
128
|
+
"""
|
|
129
|
+
return self._data
|
|
130
|
+
|
|
131
|
+
def get_correlation_data_for_logs(self) -> Dict[str, Any]:
|
|
132
|
+
return {}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class SpanContext:
|
|
137
|
+
"""
|
|
138
|
+
Context for creating spans in Langfuse.
|
|
139
|
+
|
|
140
|
+
Encapsulates the information needed to create and configure a span in Langfuse tracing.
|
|
141
|
+
Used by SpanHandler to determine the span type (trace, generation, or default) and its configuration.
|
|
142
|
+
|
|
143
|
+
:param name: The name of the span to create. For components, this is typically the component name.
|
|
144
|
+
:param operation_name: The operation being traced (e.g. "haystack.pipeline.run"). Used to determine
|
|
145
|
+
if a new trace should be created without warning.
|
|
146
|
+
:param component_type: The type of component creating the span (e.g. "OpenAIChatGenerator").
|
|
147
|
+
Can be used to determine the type of span to create.
|
|
148
|
+
:param tags: Additional metadata to attach to the span. Contains component input/output data
|
|
149
|
+
and other trace information.
|
|
150
|
+
:param parent_span: The parent span if this is a child span. If None, a new trace will be created.
|
|
151
|
+
:param trace_name: The name to use for the trace when creating a parent span. Defaults to "Haystack".
|
|
152
|
+
:param public: Whether traces should be publicly accessible. Defaults to False.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
name: str
|
|
156
|
+
operation_name: str
|
|
157
|
+
component_type: Optional[str]
|
|
158
|
+
tags: Dict[str, Any]
|
|
159
|
+
parent_span: Optional[Span]
|
|
160
|
+
trace_name: str = "Haystack"
|
|
161
|
+
public: bool = False
|
|
162
|
+
|
|
163
|
+
def __post_init__(self) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Validate the span context attributes.
|
|
166
|
+
|
|
167
|
+
:raises ValueError: If name, operation_name or trace_name are empty
|
|
168
|
+
:raises TypeError: If tags is not a dictionary
|
|
169
|
+
"""
|
|
170
|
+
if not self.name:
|
|
171
|
+
msg = "Span name cannot be empty"
|
|
172
|
+
raise ValueError(msg)
|
|
173
|
+
if not self.operation_name:
|
|
174
|
+
msg = "Operation name cannot be empty"
|
|
175
|
+
raise ValueError(msg)
|
|
176
|
+
if not self.trace_name:
|
|
177
|
+
msg = "Trace name cannot be empty"
|
|
178
|
+
raise ValueError(msg)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class SpanHandler(ABC):
|
|
182
|
+
"""
|
|
183
|
+
Abstract base class for customizing how Langfuse spans are created and processed.
|
|
184
|
+
|
|
185
|
+
This class defines two key extension points:
|
|
186
|
+
1. create_span: Controls what type of span to create (default or generation)
|
|
187
|
+
2. handle: Processes the span after component execution (adding metadata, metrics, etc.)
|
|
188
|
+
|
|
189
|
+
To implement a custom handler:
|
|
190
|
+
- Extend this class or DefaultSpanHandler
|
|
191
|
+
- Override create_span and handle methods. It is more common to override handle.
|
|
192
|
+
- Pass your handler to LangfuseConnector init method
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(self):
|
|
196
|
+
self.tracer: Optional[langfuse.Langfuse] = None
|
|
197
|
+
|
|
198
|
+
def init_tracer(self, tracer: langfuse.Langfuse) -> None:
|
|
199
|
+
"""
|
|
200
|
+
Initialize with Langfuse tracer. Called internally by LangfuseTracer.
|
|
201
|
+
|
|
202
|
+
:param tracer: The Langfuse client instance to use for creating spans
|
|
203
|
+
"""
|
|
204
|
+
self.tracer = tracer
|
|
205
|
+
|
|
206
|
+
@abstractmethod
|
|
207
|
+
def create_span(self, context: SpanContext) -> LangfuseSpan:
|
|
208
|
+
"""
|
|
209
|
+
Create a span of appropriate type based on the context.
|
|
210
|
+
|
|
211
|
+
This method determines what kind of span to create:
|
|
212
|
+
- A new trace if there's no parent span
|
|
213
|
+
- A generation span for LLM components
|
|
214
|
+
- A default span for other components
|
|
215
|
+
|
|
216
|
+
:param context: The context containing all information needed to create the span
|
|
217
|
+
:returns: A new LangfuseSpan instance configured according to the context
|
|
218
|
+
"""
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
@abstractmethod
|
|
222
|
+
def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
|
|
223
|
+
"""
|
|
224
|
+
Process a span after component execution by attaching metadata and metrics.
|
|
225
|
+
|
|
226
|
+
This method is called after the component or pipeline yields its span, allowing you to:
|
|
227
|
+
- Extract and attach token usage statistics
|
|
228
|
+
- Add model information
|
|
229
|
+
- Record timing data (e.g., time-to-first-token)
|
|
230
|
+
- Set log levels for quality monitoring
|
|
231
|
+
- Add custom metrics and observations
|
|
232
|
+
|
|
233
|
+
:param span: The span that was yielded by the component
|
|
234
|
+
:param component_type: The type of component that created the span, used to determine
|
|
235
|
+
what metadata to extract and how to process it
|
|
236
|
+
"""
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def from_dict(cls, data: Dict[str, Any]) -> "SpanHandler":
|
|
241
|
+
return default_from_dict(cls, data)
|
|
242
|
+
|
|
243
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
244
|
+
return default_to_dict(self)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class DefaultSpanHandler(SpanHandler):
|
|
248
|
+
"""DefaultSpanHandler provides the default Langfuse tracing behavior for Haystack."""
|
|
249
|
+
|
|
250
|
+
def create_span(self, context: SpanContext) -> LangfuseSpan:
|
|
251
|
+
if self.tracer is None:
|
|
252
|
+
message = "Tracer is not initialized"
|
|
253
|
+
raise RuntimeError(message)
|
|
254
|
+
|
|
255
|
+
tracing_ctx = tracing_context_var.get({})
|
|
256
|
+
if not context.parent_span:
|
|
257
|
+
if context.operation_name != _PIPELINE_RUN_KEY:
|
|
258
|
+
logger.warning(
|
|
259
|
+
"Creating a new trace without a parent span is not recommended for operation '{operation_name}'.",
|
|
260
|
+
operation_name=context.operation_name,
|
|
261
|
+
)
|
|
262
|
+
# Create a new trace when there's no parent span
|
|
263
|
+
return LangfuseSpan(
|
|
264
|
+
self.tracer.trace(
|
|
265
|
+
name=context.trace_name,
|
|
266
|
+
public=context.public,
|
|
267
|
+
id=tracing_ctx.get("trace_id"),
|
|
268
|
+
user_id=tracing_ctx.get("user_id"),
|
|
269
|
+
session_id=tracing_ctx.get("session_id"),
|
|
270
|
+
tags=tracing_ctx.get("tags"),
|
|
271
|
+
version=tracing_ctx.get("version"),
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
elif context.component_type in _ALL_SUPPORTED_GENERATORS:
|
|
275
|
+
return LangfuseSpan(context.parent_span.raw_span().generation(name=context.name))
|
|
276
|
+
else:
|
|
277
|
+
return LangfuseSpan(context.parent_span.raw_span().span(name=context.name))
|
|
278
|
+
|
|
279
|
+
def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
|
|
280
|
+
# If the span is at the pipeline level, we add input and output keys to the span
|
|
281
|
+
at_pipeline_level = span.get_data().get(_PIPELINE_INPUT_KEY) is not None
|
|
282
|
+
if at_pipeline_level:
|
|
283
|
+
span.raw_span().update(
|
|
284
|
+
input=span.get_data().get(_PIPELINE_INPUT_KEY), output=span.get_data().get(_PIPELINE_OUTPUT_KEY)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if component_type in _SUPPORTED_GENERATORS:
|
|
288
|
+
meta = span.get_data().get(_COMPONENT_OUTPUT_KEY, {}).get("meta")
|
|
289
|
+
if meta:
|
|
290
|
+
span.raw_span().update(usage=meta[0].get("usage") or None, model=meta[0].get("model"))
|
|
291
|
+
|
|
292
|
+
if component_type in _SUPPORTED_CHAT_GENERATORS:
|
|
293
|
+
replies = span.get_data().get(_COMPONENT_OUTPUT_KEY, {}).get("replies")
|
|
294
|
+
if replies:
|
|
295
|
+
meta = replies[0].meta
|
|
296
|
+
completion_start_time = meta.get("completion_start_time")
|
|
297
|
+
if completion_start_time:
|
|
298
|
+
try:
|
|
299
|
+
completion_start_time = datetime.fromisoformat(completion_start_time)
|
|
300
|
+
except ValueError:
|
|
301
|
+
logger.error(f"Failed to parse completion_start_time: {completion_start_time}")
|
|
302
|
+
completion_start_time = None
|
|
303
|
+
span.raw_span().update(
|
|
304
|
+
usage=meta.get("usage") or None,
|
|
305
|
+
model=meta.get("model"),
|
|
306
|
+
completion_start_time=completion_start_time,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class LangfuseTracer(Tracer):
|
|
311
|
+
"""
|
|
312
|
+
Internal class representing a bridge between the Haystack tracer and Langfuse.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(
|
|
316
|
+
self,
|
|
317
|
+
tracer: langfuse.Langfuse,
|
|
318
|
+
name: str = "Haystack",
|
|
319
|
+
public: bool = False,
|
|
320
|
+
span_handler: Optional[SpanHandler] = None,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""
|
|
323
|
+
Initialize a LangfuseTracer instance.
|
|
324
|
+
|
|
325
|
+
:param tracer: The Langfuse tracer instance.
|
|
326
|
+
:param name: The name of the pipeline or component. This name will be used to identify the tracing run on the
|
|
327
|
+
Langfuse dashboard.
|
|
328
|
+
:param public: Whether the tracing data should be public or private. If set to `True`, the tracing data will
|
|
329
|
+
be publicly accessible to anyone with the tracing URL. If set to `False`, the tracing data will be private
|
|
330
|
+
and only accessible to the Langfuse account owner.
|
|
331
|
+
:param span_handler: Custom handler for processing spans. If None, uses DefaultSpanHandler.
|
|
332
|
+
"""
|
|
333
|
+
if not proxy_tracer.is_content_tracing_enabled:
|
|
334
|
+
logger.warning(
|
|
335
|
+
"Traces will not be logged to Langfuse because Haystack tracing is disabled. "
|
|
336
|
+
"To enable, set the HAYSTACK_CONTENT_TRACING_ENABLED environment variable to true "
|
|
337
|
+
"before importing Haystack."
|
|
338
|
+
)
|
|
339
|
+
self._tracer = tracer
|
|
340
|
+
self._context: List[LangfuseSpan] = []
|
|
341
|
+
self._name = name
|
|
342
|
+
self._public = public
|
|
343
|
+
self.enforce_flush = os.getenv(HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR, "true").lower() == "true"
|
|
344
|
+
self._span_handler = span_handler or DefaultSpanHandler()
|
|
345
|
+
self._span_handler.init_tracer(tracer)
|
|
346
|
+
|
|
347
|
+
@contextlib.contextmanager
|
|
348
|
+
def trace(
|
|
349
|
+
self, operation_name: str, tags: Optional[Dict[str, Any]] = None, parent_span: Optional[Span] = None
|
|
350
|
+
) -> Iterator[Span]:
|
|
351
|
+
tags = tags or {}
|
|
352
|
+
span_name = tags.get(_COMPONENT_NAME_KEY, operation_name)
|
|
353
|
+
component_type = tags.get(_COMPONENT_TYPE_KEY)
|
|
354
|
+
|
|
355
|
+
# Create span using the handler
|
|
356
|
+
span = self._span_handler.create_span(
|
|
357
|
+
SpanContext(
|
|
358
|
+
name=span_name,
|
|
359
|
+
operation_name=operation_name,
|
|
360
|
+
component_type=component_type,
|
|
361
|
+
tags=tags,
|
|
362
|
+
parent_span=parent_span,
|
|
363
|
+
trace_name=self._name,
|
|
364
|
+
public=self._public,
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
self._context.append(span)
|
|
369
|
+
span.set_tags(tags)
|
|
370
|
+
|
|
371
|
+
yield span
|
|
372
|
+
|
|
373
|
+
# Let the span handler process the span
|
|
374
|
+
self._span_handler.handle(span, component_type)
|
|
375
|
+
|
|
376
|
+
raw_span = span.raw_span()
|
|
377
|
+
|
|
378
|
+
# In this section, we finalize both regular spans and generation spans created using the LangfuseSpan class.
|
|
379
|
+
# It's important to end() these spans to ensure they are properly closed and all relevant data is recorded.
|
|
380
|
+
# Note that we do not call end() on the main trace span itself, as its lifecycle is managed differently.
|
|
381
|
+
if isinstance(raw_span, (StatefulSpanClient, StatefulGenerationClient)):
|
|
382
|
+
raw_span.end()
|
|
383
|
+
self._context.pop()
|
|
384
|
+
|
|
385
|
+
if self.enforce_flush:
|
|
386
|
+
self.flush()
|
|
387
|
+
|
|
388
|
+
def flush(self):
|
|
389
|
+
self._tracer.flush()
|
|
390
|
+
|
|
391
|
+
def current_span(self) -> Optional[Span]:
|
|
392
|
+
"""
|
|
393
|
+
Return the current active span.
|
|
394
|
+
|
|
395
|
+
:return: The current span if available, else None.
|
|
396
|
+
"""
|
|
397
|
+
return self._context[-1] if self._context else None
|
|
398
|
+
|
|
399
|
+
def get_trace_url(self) -> str:
|
|
400
|
+
"""
|
|
401
|
+
Return the URL to the tracing data.
|
|
402
|
+
:return: The URL to the tracing data.
|
|
403
|
+
"""
|
|
404
|
+
return self._tracer.get_trace_url()
|
|
405
|
+
|
|
406
|
+
def get_trace_id(self) -> str:
|
|
407
|
+
"""
|
|
408
|
+
Return the trace ID.
|
|
409
|
+
:return: The trace ID.
|
|
410
|
+
"""
|
|
411
|
+
return self._tracer.get_trace_id()
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
1
5
|
import os
|
|
2
6
|
import time
|
|
3
7
|
from urllib.parse import urlparse
|
|
8
|
+
from typing import Optional
|
|
4
9
|
|
|
5
10
|
import pytest
|
|
6
11
|
import requests
|
|
@@ -10,61 +15,45 @@ from haystack.components.generators.chat import OpenAIChatGenerator
|
|
|
10
15
|
from haystack.dataclasses import ChatMessage
|
|
11
16
|
from haystack.utils import Secret
|
|
12
17
|
from requests.auth import HTTPBasicAuth
|
|
13
|
-
import httpx
|
|
14
18
|
|
|
15
19
|
from haystack_integrations.components.connectors.langfuse import LangfuseConnector
|
|
16
20
|
from haystack_integrations.components.generators.anthropic import AnthropicChatGenerator
|
|
17
21
|
from haystack_integrations.components.generators.cohere import CohereChatGenerator
|
|
22
|
+
from haystack_integrations.tracing.langfuse import LangfuseSpan, DefaultSpanHandler
|
|
23
|
+
from haystack_integrations.tracing.langfuse.tracer import _COMPONENT_OUTPUT_KEY
|
|
18
24
|
|
|
19
25
|
# don't remove (or move) this env var setting from here, it's needed to turn tracing on
|
|
20
26
|
os.environ["HAYSTACK_CONTENT_TRACING_ENABLED"] = "true"
|
|
21
27
|
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
pipe.add_component("tracer", LangfuseConnector(name=f"Chat example - {expected_trace}", public=True))
|
|
28
|
-
pipe.add_component("prompt_builder", ChatPromptBuilder())
|
|
29
|
-
pipe.add_component("llm", llm_class())
|
|
30
|
-
pipe.connect("prompt_builder.prompt", "llm.messages")
|
|
31
|
-
return pipe
|
|
29
|
+
def poll_langfuse(url: str):
|
|
30
|
+
"""Utility function to poll Langfuse API until the trace is ready"""
|
|
31
|
+
# Initial wait for trace creation
|
|
32
|
+
time.sleep(10)
|
|
32
33
|
|
|
34
|
+
auth = HTTPBasicAuth(os.environ["LANGFUSE_PUBLIC_KEY"], os.environ["LANGFUSE_SECRET_KEY"])
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
pipe.connect("prompt_builder.prompt", "llm.messages")
|
|
50
|
-
return pipe
|
|
36
|
+
attempts = 5
|
|
37
|
+
delay = 1
|
|
38
|
+
|
|
39
|
+
res = None
|
|
40
|
+
while attempts > 0:
|
|
41
|
+
res = requests.get(url, auth=auth)
|
|
42
|
+
if res.status_code == 200:
|
|
43
|
+
return res
|
|
44
|
+
|
|
45
|
+
attempts -= 1
|
|
46
|
+
if attempts > 0:
|
|
47
|
+
time.sleep(delay)
|
|
48
|
+
delay *= 2
|
|
49
|
+
|
|
50
|
+
return res
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
@pytest.fixture
|
|
54
|
-
def
|
|
55
|
-
"""Pipeline factory using custom httpx client for Langfuse"""
|
|
54
|
+
def pipeline_with_env_vars(llm_class, expected_trace):
|
|
56
55
|
pipe = Pipeline()
|
|
57
|
-
|
|
58
|
-
pipe.add_component(
|
|
59
|
-
"tracer",
|
|
60
|
-
LangfuseConnector(
|
|
61
|
-
name=f"Chat example - {expected_trace}",
|
|
62
|
-
public=True,
|
|
63
|
-
secret_key=Secret.from_env_var("LANGFUSE_SECRET_KEY"),
|
|
64
|
-
public_key=Secret.from_env_var("LANGFUSE_PUBLIC_KEY"),
|
|
65
|
-
httpx_client=custom_client,
|
|
66
|
-
),
|
|
67
|
-
)
|
|
56
|
+
pipe.add_component("tracer", LangfuseConnector(name=f"Chat example - {expected_trace}", public=True))
|
|
68
57
|
pipe.add_component("prompt_builder", ChatPromptBuilder())
|
|
69
58
|
pipe.add_component("llm", llm_class())
|
|
70
59
|
pipe.connect("prompt_builder.prompt", "llm.messages")
|
|
@@ -80,9 +69,7 @@ def pipeline_with_custom_client(llm_class, expected_trace):
|
|
|
80
69
|
(CohereChatGenerator, "COHERE_API_KEY", "Cohere"),
|
|
81
70
|
],
|
|
82
71
|
)
|
|
83
|
-
@pytest.mark.parametrize(
|
|
84
|
-
"pipeline_fixture", ["pipeline_with_env_vars", "pipeline_with_secrets", "pipeline_with_custom_client"]
|
|
85
|
-
)
|
|
72
|
+
@pytest.mark.parametrize("pipeline_fixture", ["pipeline_with_env_vars"])
|
|
86
73
|
def test_tracing_integration(llm_class, env_var, expected_trace, pipeline_fixture, request):
|
|
87
74
|
if not all([os.environ.get("LANGFUSE_SECRET_KEY"), os.environ.get("LANGFUSE_PUBLIC_KEY"), os.environ.get(env_var)]):
|
|
88
75
|
pytest.skip(f"Missing required environment variables: LANGFUSE_SECRET_KEY, LANGFUSE_PUBLIC_KEY, or {env_var}")
|
|
@@ -101,32 +88,23 @@ def test_tracing_integration(llm_class, env_var, expected_trace, pipeline_fixtur
|
|
|
101
88
|
)
|
|
102
89
|
assert "Berlin" in response["llm"]["replies"][0].text
|
|
103
90
|
assert response["tracer"]["trace_url"]
|
|
91
|
+
assert response["tracer"]["trace_id"]
|
|
104
92
|
|
|
105
93
|
trace_url = response["tracer"]["trace_url"]
|
|
106
94
|
uuid = os.path.basename(urlparse(trace_url).path)
|
|
107
95
|
url = f"https://cloud.langfuse.com/api/public/traces/{uuid}"
|
|
108
96
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
delay = 1
|
|
112
|
-
while attempts >= 0:
|
|
113
|
-
res = requests.get(
|
|
114
|
-
url, auth=HTTPBasicAuth(os.environ["LANGFUSE_PUBLIC_KEY"], os.environ["LANGFUSE_SECRET_KEY"])
|
|
115
|
-
)
|
|
116
|
-
if attempts > 0 and res.status_code != 200:
|
|
117
|
-
attempts -= 1
|
|
118
|
-
time.sleep(delay)
|
|
119
|
-
delay *= 2
|
|
120
|
-
continue
|
|
121
|
-
assert res.status_code == 200, f"Failed to retrieve data from Langfuse API: {res.status_code}"
|
|
97
|
+
res = poll_langfuse(url)
|
|
98
|
+
assert res.status_code == 200, f"Failed to retrieve data from Langfuse API: {res.status_code}"
|
|
122
99
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
100
|
+
res_json = res.json()
|
|
101
|
+
assert res_json["name"] == f"Chat example - {expected_trace}"
|
|
102
|
+
assert isinstance(res_json["input"], dict)
|
|
103
|
+
assert res_json["input"]["tracer"]["invocation_context"]["user_id"] == "user_42"
|
|
104
|
+
assert isinstance(res_json["output"], dict)
|
|
105
|
+
assert isinstance(res_json["metadata"], dict)
|
|
106
|
+
assert isinstance(res_json["observations"], list)
|
|
107
|
+
assert res_json["observations"][0]["type"] == "GENERATION"
|
|
130
108
|
|
|
131
109
|
|
|
132
110
|
def test_pipeline_serialization(monkeypatch):
|
|
@@ -169,3 +147,74 @@ def test_pipeline_serialization(monkeypatch):
|
|
|
169
147
|
|
|
170
148
|
# Verify pipeline is the same
|
|
171
149
|
assert new_pipe == pipe
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class QualityCheckSpanHandler(DefaultSpanHandler):
|
|
153
|
+
"""Extends default handler to add quality checks with warning levels."""
|
|
154
|
+
|
|
155
|
+
def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
|
|
156
|
+
# First do the default handling (model, usage, etc.)
|
|
157
|
+
super().handle(span, component_type)
|
|
158
|
+
|
|
159
|
+
# Then add our custom quality checks
|
|
160
|
+
if component_type == "OpenAIChatGenerator":
|
|
161
|
+
output = span._data.get(_COMPONENT_OUTPUT_KEY, {})
|
|
162
|
+
replies = output.get("replies", [])
|
|
163
|
+
|
|
164
|
+
if not replies:
|
|
165
|
+
span._span.update(level="ERROR", status_message="No response received")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
reply = replies[0]
|
|
169
|
+
if "error" in reply.meta:
|
|
170
|
+
span._span.update(level="ERROR", status_message=f"OpenAI error: {reply.meta['error']}")
|
|
171
|
+
elif len(reply.text) > 10:
|
|
172
|
+
span._span.update(level="WARNING", status_message="Response too long (> 10 chars)")
|
|
173
|
+
else:
|
|
174
|
+
span._span.update(level="DEFAULT", status_message="Success")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pytest.mark.integration
|
|
178
|
+
def test_custom_span_handler():
|
|
179
|
+
"""Test that custom span handler properly sets Langfuse levels."""
|
|
180
|
+
if not all(
|
|
181
|
+
[os.environ.get("LANGFUSE_SECRET_KEY"), os.environ.get("LANGFUSE_PUBLIC_KEY"), os.environ.get("OPENAI_API_KEY")]
|
|
182
|
+
):
|
|
183
|
+
pytest.skip("Missing required environment variables")
|
|
184
|
+
|
|
185
|
+
pipe = Pipeline()
|
|
186
|
+
pipe.add_component(
|
|
187
|
+
"tracer",
|
|
188
|
+
LangfuseConnector(
|
|
189
|
+
name="Quality Check Example",
|
|
190
|
+
public=True,
|
|
191
|
+
span_handler=QualityCheckSpanHandler(),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
pipe.add_component("prompt_builder", ChatPromptBuilder())
|
|
195
|
+
pipe.add_component("llm", OpenAIChatGenerator())
|
|
196
|
+
pipe.connect("prompt_builder.prompt", "llm.messages")
|
|
197
|
+
|
|
198
|
+
# Test short response
|
|
199
|
+
messages = [
|
|
200
|
+
ChatMessage.from_system("Respond with exactly 3 words."),
|
|
201
|
+
ChatMessage.from_user("What is Berlin?"),
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
response = pipe.run(
|
|
205
|
+
data={
|
|
206
|
+
"prompt_builder": {"template_variables": {}, "template": messages},
|
|
207
|
+
"tracer": {"invocation_context": {"user_id": "test_user"}},
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
trace_url = response["tracer"]["trace_url"]
|
|
212
|
+
uuid = os.path.basename(urlparse(trace_url).path)
|
|
213
|
+
url = f"https://cloud.langfuse.com/api/public/traces/{uuid}"
|
|
214
|
+
|
|
215
|
+
res = poll_langfuse(url)
|
|
216
|
+
assert res.status_code == 200, f"Failed to retrieve data from Langfuse API: {res.status_code}"
|
|
217
|
+
|
|
218
|
+
content = str(res.content)
|
|
219
|
+
assert "WARNING" in content
|
|
220
|
+
assert "Response too long" in content
|
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import contextlib
|
|
2
|
-
import os
|
|
3
|
-
from contextvars import ContextVar
|
|
4
|
-
from datetime import datetime
|
|
5
|
-
from typing import Any, Dict, Iterator, List, Optional, Union
|
|
6
|
-
|
|
7
|
-
from haystack import logging
|
|
8
|
-
from haystack.dataclasses import ChatMessage
|
|
9
|
-
from haystack.tracing import Span, Tracer
|
|
10
|
-
from haystack.tracing import tracer as proxy_tracer
|
|
11
|
-
from haystack.tracing import utils as tracing_utils
|
|
12
|
-
|
|
13
|
-
import langfuse
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR = "HAYSTACK_LANGFUSE_ENFORCE_FLUSH"
|
|
18
|
-
_SUPPORTED_GENERATORS = [
|
|
19
|
-
"AzureOpenAIGenerator",
|
|
20
|
-
"OpenAIGenerator",
|
|
21
|
-
"AnthropicGenerator",
|
|
22
|
-
"HuggingFaceAPIGenerator",
|
|
23
|
-
"HuggingFaceLocalGenerator",
|
|
24
|
-
"CohereGenerator",
|
|
25
|
-
]
|
|
26
|
-
_SUPPORTED_CHAT_GENERATORS = [
|
|
27
|
-
"AzureOpenAIChatGenerator",
|
|
28
|
-
"OpenAIChatGenerator",
|
|
29
|
-
"AnthropicChatGenerator",
|
|
30
|
-
"HuggingFaceAPIChatGenerator",
|
|
31
|
-
"HuggingFaceLocalChatGenerator",
|
|
32
|
-
"CohereChatGenerator",
|
|
33
|
-
]
|
|
34
|
-
_ALL_SUPPORTED_GENERATORS = _SUPPORTED_GENERATORS + _SUPPORTED_CHAT_GENERATORS
|
|
35
|
-
|
|
36
|
-
# These are the keys used by Haystack for traces and span.
|
|
37
|
-
# We keep them here to avoid making typos when using them.
|
|
38
|
-
_PIPELINE_RUN_KEY = "haystack.pipeline.run"
|
|
39
|
-
_COMPONENT_NAME_KEY = "haystack.component.name"
|
|
40
|
-
_COMPONENT_TYPE_KEY = "haystack.component.type"
|
|
41
|
-
_COMPONENT_OUTPUT_KEY = "haystack.component.output"
|
|
42
|
-
|
|
43
|
-
# Context var used to keep track of tracing related info.
|
|
44
|
-
# This mainly useful for parents spans.
|
|
45
|
-
tracing_context_var: ContextVar[Dict[Any, Any]] = ContextVar("tracing_context")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class LangfuseSpan(Span):
|
|
49
|
-
"""
|
|
50
|
-
Internal class representing a bridge between the Haystack span tracing API and Langfuse.
|
|
51
|
-
"""
|
|
52
|
-
|
|
53
|
-
def __init__(self, span: "Union[langfuse.client.StatefulSpanClient, langfuse.client.StatefulTraceClient]") -> None:
|
|
54
|
-
"""
|
|
55
|
-
Initialize a LangfuseSpan instance.
|
|
56
|
-
|
|
57
|
-
:param span: The span instance managed by Langfuse.
|
|
58
|
-
"""
|
|
59
|
-
self._span = span
|
|
60
|
-
# locally cache tags
|
|
61
|
-
self._data: Dict[str, Any] = {}
|
|
62
|
-
|
|
63
|
-
def set_tag(self, key: str, value: Any) -> None:
|
|
64
|
-
"""
|
|
65
|
-
Set a generic tag for this span.
|
|
66
|
-
|
|
67
|
-
:param key: The tag key.
|
|
68
|
-
:param value: The tag value.
|
|
69
|
-
"""
|
|
70
|
-
coerced_value = tracing_utils.coerce_tag_value(value)
|
|
71
|
-
self._span.update(metadata={key: coerced_value})
|
|
72
|
-
self._data[key] = value
|
|
73
|
-
|
|
74
|
-
def set_content_tag(self, key: str, value: Any) -> None:
|
|
75
|
-
"""
|
|
76
|
-
Set a content-specific tag for this span.
|
|
77
|
-
|
|
78
|
-
:param key: The content tag key.
|
|
79
|
-
:param value: The content tag value.
|
|
80
|
-
"""
|
|
81
|
-
if not proxy_tracer.is_content_tracing_enabled:
|
|
82
|
-
return
|
|
83
|
-
if key.endswith(".input"):
|
|
84
|
-
if "messages" in value:
|
|
85
|
-
messages = [m.to_openai_dict_format() for m in value["messages"]]
|
|
86
|
-
self._span.update(input=messages)
|
|
87
|
-
else:
|
|
88
|
-
self._span.update(input=value)
|
|
89
|
-
elif key.endswith(".output"):
|
|
90
|
-
if "replies" in value:
|
|
91
|
-
if all(isinstance(r, ChatMessage) for r in value["replies"]):
|
|
92
|
-
replies = [m.to_openai_dict_format() for m in value["replies"]]
|
|
93
|
-
else:
|
|
94
|
-
replies = value["replies"]
|
|
95
|
-
self._span.update(output=replies)
|
|
96
|
-
else:
|
|
97
|
-
self._span.update(output=value)
|
|
98
|
-
|
|
99
|
-
self._data[key] = value
|
|
100
|
-
|
|
101
|
-
def raw_span(self) -> "Union[langfuse.client.StatefulSpanClient, langfuse.client.StatefulTraceClient]":
|
|
102
|
-
"""
|
|
103
|
-
Return the underlying span instance.
|
|
104
|
-
|
|
105
|
-
:return: The Langfuse span instance.
|
|
106
|
-
"""
|
|
107
|
-
return self._span
|
|
108
|
-
|
|
109
|
-
def get_correlation_data_for_logs(self) -> Dict[str, Any]:
|
|
110
|
-
return {}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
class LangfuseTracer(Tracer):
|
|
114
|
-
"""
|
|
115
|
-
Internal class representing a bridge between the Haystack tracer and Langfuse.
|
|
116
|
-
"""
|
|
117
|
-
|
|
118
|
-
def __init__(self, tracer: "langfuse.Langfuse", name: str = "Haystack", public: bool = False) -> None:
|
|
119
|
-
"""
|
|
120
|
-
Initialize a LangfuseTracer instance.
|
|
121
|
-
|
|
122
|
-
:param tracer: The Langfuse tracer instance.
|
|
123
|
-
:param name: The name of the pipeline or component. This name will be used to identify the tracing run on the
|
|
124
|
-
Langfuse dashboard.
|
|
125
|
-
:param public: Whether the tracing data should be public or private. If set to `True`, the tracing data will
|
|
126
|
-
be publicly accessible to anyone with the tracing URL. If set to `False`, the tracing data will be private
|
|
127
|
-
and only accessible to the Langfuse account owner.
|
|
128
|
-
"""
|
|
129
|
-
if not proxy_tracer.is_content_tracing_enabled:
|
|
130
|
-
logger.warning(
|
|
131
|
-
"Traces will not be logged to Langfuse because Haystack tracing is disabled. "
|
|
132
|
-
"To enable, set the HAYSTACK_CONTENT_TRACING_ENABLED environment variable to true "
|
|
133
|
-
"before importing Haystack."
|
|
134
|
-
)
|
|
135
|
-
self._tracer = tracer
|
|
136
|
-
self._context: List[LangfuseSpan] = []
|
|
137
|
-
self._name = name
|
|
138
|
-
self._public = public
|
|
139
|
-
self.enforce_flush = os.getenv(HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR, "true").lower() == "true"
|
|
140
|
-
|
|
141
|
-
@contextlib.contextmanager
|
|
142
|
-
def trace(
|
|
143
|
-
self, operation_name: str, tags: Optional[Dict[str, Any]] = None, parent_span: Optional[Span] = None
|
|
144
|
-
) -> Iterator[Span]:
|
|
145
|
-
tags = tags or {}
|
|
146
|
-
span_name = tags.get(_COMPONENT_NAME_KEY, operation_name)
|
|
147
|
-
|
|
148
|
-
# Create new span depending whether there's a parent span or not
|
|
149
|
-
if not parent_span:
|
|
150
|
-
if operation_name != _PIPELINE_RUN_KEY:
|
|
151
|
-
logger.warning(
|
|
152
|
-
"Creating a new trace without a parent span is not recommended for operation '{operation_name}'.",
|
|
153
|
-
operation_name=operation_name,
|
|
154
|
-
)
|
|
155
|
-
# Create a new trace if no parent span is provided
|
|
156
|
-
context = tracing_context_var.get({})
|
|
157
|
-
span = LangfuseSpan(
|
|
158
|
-
self._tracer.trace(
|
|
159
|
-
name=self._name,
|
|
160
|
-
public=self._public,
|
|
161
|
-
id=context.get("trace_id"),
|
|
162
|
-
user_id=context.get("user_id"),
|
|
163
|
-
session_id=context.get("session_id"),
|
|
164
|
-
tags=context.get("tags"),
|
|
165
|
-
version=context.get("version"),
|
|
166
|
-
)
|
|
167
|
-
)
|
|
168
|
-
elif tags.get(_COMPONENT_TYPE_KEY) in _ALL_SUPPORTED_GENERATORS:
|
|
169
|
-
span = LangfuseSpan(parent_span.raw_span().generation(name=span_name))
|
|
170
|
-
else:
|
|
171
|
-
span = LangfuseSpan(parent_span.raw_span().span(name=span_name))
|
|
172
|
-
|
|
173
|
-
self._context.append(span)
|
|
174
|
-
span.set_tags(tags)
|
|
175
|
-
|
|
176
|
-
yield span
|
|
177
|
-
|
|
178
|
-
# Update span metadata based on component type
|
|
179
|
-
if tags.get(_COMPONENT_TYPE_KEY) in _SUPPORTED_GENERATORS:
|
|
180
|
-
# Haystack returns one meta dict for each message, but the 'usage' value
|
|
181
|
-
# is always the same, let's just pick the first item
|
|
182
|
-
meta = span._data.get(_COMPONENT_OUTPUT_KEY, {}).get("meta")
|
|
183
|
-
if meta:
|
|
184
|
-
m = meta[0]
|
|
185
|
-
span._span.update(usage=m.get("usage") or None, model=m.get("model"))
|
|
186
|
-
elif tags.get(_COMPONENT_TYPE_KEY) in _SUPPORTED_CHAT_GENERATORS:
|
|
187
|
-
replies = span._data.get(_COMPONENT_OUTPUT_KEY, {}).get("replies")
|
|
188
|
-
if replies:
|
|
189
|
-
meta = replies[0].meta
|
|
190
|
-
completion_start_time = meta.get("completion_start_time")
|
|
191
|
-
if completion_start_time:
|
|
192
|
-
try:
|
|
193
|
-
completion_start_time = datetime.fromisoformat(completion_start_time)
|
|
194
|
-
except ValueError:
|
|
195
|
-
logger.error(f"Failed to parse completion_start_time: {completion_start_time}")
|
|
196
|
-
completion_start_time = None
|
|
197
|
-
span._span.update(
|
|
198
|
-
usage=meta.get("usage") or None,
|
|
199
|
-
model=meta.get("model"),
|
|
200
|
-
completion_start_time=completion_start_time,
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
raw_span = span.raw_span()
|
|
204
|
-
|
|
205
|
-
# In this section, we finalize both regular spans and generation spans created using the LangfuseSpan class.
|
|
206
|
-
# It's important to end() these spans to ensure they are properly closed and all relevant data is recorded.
|
|
207
|
-
# Note that we do not call end() on the main trace span itself, as its lifecycle is managed differently.
|
|
208
|
-
if isinstance(raw_span, (langfuse.client.StatefulSpanClient, langfuse.client.StatefulGenerationClient)):
|
|
209
|
-
raw_span.end()
|
|
210
|
-
self._context.pop()
|
|
211
|
-
|
|
212
|
-
if self.enforce_flush:
|
|
213
|
-
self.flush()
|
|
214
|
-
|
|
215
|
-
def flush(self):
|
|
216
|
-
self._tracer.flush()
|
|
217
|
-
|
|
218
|
-
def current_span(self) -> Optional[Span]:
|
|
219
|
-
"""
|
|
220
|
-
Return the current active span.
|
|
221
|
-
|
|
222
|
-
:return: The current span if available, else None.
|
|
223
|
-
"""
|
|
224
|
-
return self._context[-1] if self._context else None
|
|
225
|
-
|
|
226
|
-
def get_trace_url(self) -> str:
|
|
227
|
-
"""
|
|
228
|
-
Return the URL to the tracing data.
|
|
229
|
-
:return: The URL to the tracing data.
|
|
230
|
-
"""
|
|
231
|
-
return self._tracer.get_trace_url()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|