langfuse-haystack 0.7.0__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.7.0 → langfuse_haystack-0.8.0}/CHANGELOG.md +11 -0
  2. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/PKG-INFO +1 -1
  3. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +34 -2
  4. langfuse_haystack-0.8.0/src/haystack_integrations/tracing/langfuse/__init__.py +6 -0
  5. langfuse_haystack-0.8.0/src/haystack_integrations/tracing/langfuse/tracer.py +382 -0
  6. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/tests/test_tracing.py +86 -0
  7. langfuse_haystack-0.7.0/src/haystack_integrations/tracing/langfuse/__init__.py +0 -6
  8. langfuse_haystack-0.7.0/src/haystack_integrations/tracing/langfuse/tracer.py +0 -231
  9. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/.gitignore +0 -0
  10. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/LICENSE.txt +0 -0
  11. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/README.md +0 -0
  12. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/example/basic_rag.py +0 -0
  13. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/example/chat.py +0 -0
  14. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/example/requirements.txt +0 -0
  15. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/pydoc/config.yml +0 -0
  16. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/pyproject.toml +0 -0
  17. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
  18. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/tests/__init__.py +0 -0
  19. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/tests/test_langfuse_span.py +0 -0
  20. {langfuse_haystack-0.7.0 → langfuse_haystack-0.8.0}/tests/test_tracer.py +0 -0
@@ -1,5 +1,16 @@
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
+
3
14
  ## [integrations/langfuse-v0.6.4] - 2025-01-17
4
15
 
5
16
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langfuse-haystack
3
- Version: 0.7.0
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
@@ -3,8 +3,9 @@ from typing import Any, Dict, Optional
3
3
  import httpx
4
4
  from haystack import component, default_from_dict, default_to_dict, logging, tracing
5
5
  from haystack.utils import Secret, deserialize_secrets_inplace
6
+ from haystack.utils.base_serialization import deserialize_class_instance, serialize_class_instance
6
7
 
7
- from haystack_integrations.tracing.langfuse import LangfuseTracer
8
+ from haystack_integrations.tracing.langfuse import LangfuseTracer, SpanHandler
8
9
  from langfuse import Langfuse
9
10
 
10
11
  logger = logging.getLogger(__name__)
@@ -94,6 +95,24 @@ class LangfuseConnector:
94
95
  print(response["tracer"]["trace_url"])
95
96
  ```
96
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
+
97
116
  """
98
117
 
99
118
  def __init__(
@@ -103,6 +122,7 @@ class LangfuseConnector:
103
122
  public_key: Optional[Secret] = Secret.from_env_var("LANGFUSE_PUBLIC_KEY"), # noqa: B008
104
123
  secret_key: Optional[Secret] = Secret.from_env_var("LANGFUSE_SECRET_KEY"), # noqa: B008
105
124
  httpx_client: Optional[httpx.Client] = None,
125
+ span_handler: Optional[SpanHandler] = None,
106
126
  ):
107
127
  """
108
128
  Initialize the LangfuseConnector component.
@@ -117,11 +137,16 @@ class LangfuseConnector:
117
137
  :param httpx_client: Optional custom httpx.Client instance to use for Langfuse API calls. Note that when
118
138
  deserializing a pipeline from YAML, any custom client is discarded and Langfuse will create its own default
119
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.
120
144
  """
121
145
  self.name = name
122
146
  self.public = public
123
147
  self.secret_key = secret_key
124
148
  self.public_key = public_key
149
+ self.span_handler = span_handler
125
150
  self.tracer = LangfuseTracer(
126
151
  tracer=Langfuse(
127
152
  secret_key=secret_key.resolve_value() if secret_key else None,
@@ -130,6 +155,7 @@ class LangfuseConnector:
130
155
  ),
131
156
  name=name,
132
157
  public=public,
158
+ span_handler=span_handler,
133
159
  )
134
160
  tracing.enable_tracing(self.tracer)
135
161
 
@@ -158,6 +184,7 @@ class LangfuseConnector:
158
184
 
159
185
  :returns: The serialized component as a dictionary.
160
186
  """
187
+ span_handler = serialize_class_instance(self.span_handler) if self.span_handler else None
161
188
  return default_to_dict(
162
189
  self,
163
190
  name=self.name,
@@ -165,6 +192,7 @@ class LangfuseConnector:
165
192
  secret_key=self.secret_key.to_dict() if self.secret_key else None,
166
193
  public_key=self.public_key.to_dict() if self.public_key else None,
167
194
  # Note: httpx_client is not serialized as it's not serializable
195
+ span_handler=span_handler,
168
196
  )
169
197
 
170
198
  @classmethod
@@ -175,5 +203,9 @@ class LangfuseConnector:
175
203
  :param data: The dictionary representation of this component.
176
204
  :returns: The deserialized component instance.
177
205
  """
178
- 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
+ )
179
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
@@ -15,6 +16,8 @@ import httpx
15
16
  from haystack_integrations.components.connectors.langfuse import LangfuseConnector
16
17
  from haystack_integrations.components.generators.anthropic import AnthropicChatGenerator
17
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
18
21
 
19
22
  # don't remove (or move) this env var setting from here, it's needed to turn tracing on
20
23
  os.environ["HAYSTACK_CONTENT_TRACING_ENABLED"] = "true"
@@ -169,3 +172,86 @@ def test_pipeline_serialization(monkeypatch):
169
172
 
170
173
  # Verify pipeline is the same
171
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,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()