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.
Files changed (21) hide show
  1. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/CHANGELOG.md +46 -9
  2. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/PKG-INFO +3 -3
  3. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/pyproject.toml +2 -6
  4. langfuse_haystack-0.9.0/src/haystack_integrations/components/connectors/__init__.py +3 -0
  5. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +1 -0
  6. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +42 -6
  7. langfuse_haystack-0.9.0/src/haystack_integrations/tracing/langfuse/__init__.py +7 -0
  8. langfuse_haystack-0.9.0/src/haystack_integrations/tracing/langfuse/tracer.py +411 -0
  9. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/tests/test_langfuse_span.py +4 -0
  10. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/tests/test_tracer.py +4 -0
  11. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/tests/test_tracing.py +112 -63
  12. langfuse_haystack-0.7.0/src/haystack_integrations/tracing/langfuse/__init__.py +0 -6
  13. langfuse_haystack-0.7.0/src/haystack_integrations/tracing/langfuse/tracer.py +0 -231
  14. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/.gitignore +0 -0
  15. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/LICENSE.txt +0 -0
  16. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/README.md +0 -0
  17. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/example/basic_rag.py +0 -0
  18. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/example/chat.py +0 -0
  19. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/example/requirements.txt +0 -0
  20. {langfuse_haystack-0.7.0 → langfuse_haystack-0.9.0}/pydoc/config.yml +0 -0
  21. {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
- - Chore: Langfuse - pin `haystack-ai>=2.9.0` and simplify message conversion (#1292)
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
- - Chore: Fix tracing_context_var lint errors (#1220)
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
- - Fix: Add delay to flush the Langfuse traces (#1091)
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
- - Ci: install `pytest-rerunfailures` where needed; add retry config to `test-cov` script (#845)
86
- - Chore: Update Langfuse README to avoid common initialization issues (#952)
87
- - Chore: langfuse - ruff update, don't ruff tests (#992)
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
- - Feat: add support for Azure generators (#815)
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
- - Chore: change the pydoc renderer class (#718)
112
- - Docs: add missing api references (#728)
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.7.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.8
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.8"
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
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -1,6 +1,7 @@
1
1
  # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
+
4
5
  from .langfuse_connector import LangfuseConnector
5
6
 
6
7
  __all__ = ["LangfuseConnector"]
@@ -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
- environent variable to `false`. By default, the data is flushed after each component and blocks the thread until
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
- deserialize_secrets_inplace(data["init_parameters"], keys=["secret_key", "public_key"])
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,3 +1,7 @@
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
 
3
7
  os.environ["HAYSTACK_CONTENT_TRACING_ENABLED"] = "true"
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
1
5
  import datetime
2
6
  import logging
3
7
  import sys
@@ -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
- @pytest.fixture
24
- def pipeline_with_env_vars(llm_class, expected_trace):
25
- """Pipeline factory using environment variables for Langfuse authentication"""
26
- pipe = Pipeline()
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
- @pytest.fixture
35
- def pipeline_with_secrets(llm_class, expected_trace):
36
- """Pipeline factory using Secret objects for Langfuse authentication"""
37
- pipe = Pipeline()
38
- pipe.add_component(
39
- "tracer",
40
- LangfuseConnector(
41
- name=f"Chat example - {expected_trace}",
42
- public=True,
43
- secret_key=Secret.from_env_var("LANGFUSE_SECRET_KEY"),
44
- public_key=Secret.from_env_var("LANGFUSE_PUBLIC_KEY"),
45
- ),
46
- )
47
- pipe.add_component("prompt_builder", ChatPromptBuilder())
48
- pipe.add_component("llm", llm_class())
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 pipeline_with_custom_client(llm_class, expected_trace):
55
- """Pipeline factory using custom httpx client for Langfuse"""
54
+ def pipeline_with_env_vars(llm_class, expected_trace):
56
55
  pipe = Pipeline()
57
- custom_client = httpx.Client(timeout=30.0) # Custom timeout of 30 seconds
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
- # Poll the Langfuse API a bit as the trace might not be ready right away
110
- attempts = 5
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
- # check if the trace contains the expected LLM name
124
- assert expected_trace in str(res.content)
125
- # check if the trace contains the expected generation span
126
- assert "GENERATION" in str(res.content)
127
- # check if the trace contains the expected user_id
128
- assert "user_42" in str(res.content)
129
- break
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,6 +0,0 @@
1
- # SPDX-FileCopyrightText: 2024-present deepset GmbH <info@deepset.ai>
2
- #
3
- # SPDX-License-Identifier: Apache-2.0
4
- from .tracer import LangfuseTracer
5
-
6
- __all__ = ["LangfuseTracer"]
@@ -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()