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