langfuse-haystack 3.2.0__tar.gz → 3.3.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 (22) hide show
  1. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/CHANGELOG.md +21 -0
  2. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/PKG-INFO +2 -2
  3. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/pyproject.toml +2 -2
  4. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +5 -5
  5. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/tracing/langfuse/tracer.py +83 -56
  6. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/tests/test_tracer.py +167 -0
  7. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/tests/test_tracing.py +5 -5
  8. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/.gitignore +0 -0
  9. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/LICENSE.txt +0 -0
  10. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/README.md +0 -0
  11. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/example/basic_rag.py +0 -0
  12. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/example/chat.py +0 -0
  13. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/example/requirements.txt +0 -0
  14. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/pydoc/config.yml +0 -0
  15. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/pydoc/config_docusaurus.yml +0 -0
  16. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/components/connectors/__init__.py +0 -0
  17. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
  18. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/components/connectors/py.typed +0 -0
  19. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
  20. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/tracing/py.typed +0 -0
  21. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/tests/__init__.py +0 -0
  22. {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/tests/test_langfuse_connector.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/langfuse-v3.2.1] - 2025-11-07
4
+
5
+ ### 🌀 Miscellaneous
6
+
7
+ - Chore: Upgrade langfuse dep, observation types require version>=3.3.1 (#2493)
8
+
9
+ ## [integrations/langfuse-v3.2.0] - 2025-11-07
10
+
11
+ ### 🐛 Bug Fixes
12
+
13
+ - Flatten usage_details dict (#2491)
14
+
15
+ ### ⚙️ CI
16
+
17
+ - Change pytest command (#2475)
18
+
19
+ ### 🌀 Miscellaneous
20
+
21
+ - Usage_details instead usage (#2481)
22
+ - Feat: Langfuse - add support for tool and agent observation types (#2490)
23
+
3
24
  ## [integrations/langfuse-v3.1.0] - 2025-10-24
4
25
 
5
26
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langfuse-haystack
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Langfuse integration for Haystack
5
5
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse#readme
6
6
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: Implementation :: CPython
19
19
  Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Requires-Python: >=3.9
21
21
  Requires-Dist: haystack-ai>=2.17.1
22
- Requires-Dist: langfuse<4.0.0,>=3.3.0
22
+ Requires-Dist: langfuse<4.0.0,>=3.3.1
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  # langfuse-haystack
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Programming Language :: Python :: Implementation :: CPython",
23
23
  "Programming Language :: Python :: Implementation :: PyPy",
24
24
  ]
25
- dependencies = ["haystack-ai>=2.17.1", "langfuse>=3.3.0, <4.0.0"]
25
+ dependencies = ["haystack-ai>=2.17.1", "langfuse>=3.3.1, <4.0.0"]
26
26
 
27
27
  [project.urls]
28
28
  Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse#readme"
@@ -82,7 +82,7 @@ allow-direct-references = true
82
82
 
83
83
 
84
84
  [tool.ruff]
85
- target-version = "py38"
85
+ target-version = "py39"
86
86
  line-length = 120
87
87
 
88
88
  [tool.ruff.lint]
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
- from typing import Any, Dict, Optional
5
+ from typing import Any, Optional
6
6
 
7
7
  import httpx
8
8
  from haystack import component, default_from_dict, default_to_dict, logging, tracing
@@ -124,7 +124,7 @@ class LangfuseConnector:
124
124
  span_handler: Optional[SpanHandler] = None,
125
125
  *,
126
126
  host: Optional[str] = None,
127
- langfuse_client_kwargs: Optional[Dict[str, Any]] = None,
127
+ langfuse_client_kwargs: Optional[dict[str, Any]] = None,
128
128
  ) -> None:
129
129
  """
130
130
  Initialize the LangfuseConnector component.
@@ -172,7 +172,7 @@ class LangfuseConnector:
172
172
  tracing.enable_tracing(self.tracer)
173
173
 
174
174
  @component.output_types(name=str, trace_url=str, trace_id=str)
175
- def run(self, invocation_context: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
175
+ def run(self, invocation_context: Optional[dict[str, Any]] = None) -> dict[str, str]:
176
176
  """
177
177
  Runs the LangfuseConnector component.
178
178
 
@@ -191,7 +191,7 @@ class LangfuseConnector:
191
191
  )
192
192
  return {"name": self.name, "trace_url": self.tracer.get_trace_url(), "trace_id": self.tracer.get_trace_id()}
193
193
 
194
- def to_dict(self) -> Dict[str, Any]:
194
+ def to_dict(self) -> dict[str, Any]:
195
195
  """
196
196
  Serialize this component to a dictionary.
197
197
 
@@ -218,7 +218,7 @@ class LangfuseConnector:
218
218
  )
219
219
 
220
220
  @classmethod
221
- def from_dict(cls, data: Dict[str, Any]) -> "LangfuseConnector":
221
+ def from_dict(cls, data: dict[str, Any]) -> "LangfuseConnector":
222
222
  """
223
223
  Deserialize this component from a dictionary.
224
224
 
@@ -4,13 +4,15 @@
4
4
 
5
5
  import contextlib
6
6
  import os
7
+ import sys
7
8
  from abc import ABC, abstractmethod
8
9
  from collections import Counter
10
+ from collections.abc import Iterator
9
11
  from contextlib import AbstractContextManager
10
12
  from contextvars import ContextVar
11
13
  from dataclasses import dataclass
12
14
  from datetime import datetime
13
- from typing import Any, Dict, Iterator, List, Literal, Optional
15
+ from typing import Any, Literal, Optional, cast
14
16
 
15
17
  from haystack import default_from_dict, default_to_dict, logging
16
18
  from haystack.dataclasses import ChatMessage
@@ -25,27 +27,6 @@ from langfuse.types import TraceMetadata
25
27
  logger = logging.getLogger(__name__)
26
28
 
27
29
  HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR = "HAYSTACK_LANGFUSE_ENFORCE_FLUSH"
28
- _SUPPORTED_GENERATORS = [
29
- "AzureOpenAIGenerator",
30
- "OpenAIGenerator",
31
- "AnthropicGenerator",
32
- "HuggingFaceAPIGenerator",
33
- "HuggingFaceLocalGenerator",
34
- "CohereGenerator",
35
- "OllamaGenerator",
36
- ]
37
- _SUPPORTED_CHAT_GENERATORS = [
38
- "AmazonBedrockChatGenerator",
39
- "AzureOpenAIChatGenerator",
40
- "OpenAIChatGenerator",
41
- "AnthropicChatGenerator",
42
- "HuggingFaceAPIChatGenerator",
43
- "HuggingFaceLocalChatGenerator",
44
- "CohereChatGenerator",
45
- "OllamaChatGenerator",
46
- "GoogleGenAIChatGenerator",
47
- ]
48
- _ALL_SUPPORTED_GENERATORS = _SUPPORTED_GENERATORS + _SUPPORTED_CHAT_GENERATORS
49
30
 
50
31
  # These are the keys used by Haystack for traces and span.
51
32
  # We keep them here to avoid making typos when using them.
@@ -58,13 +39,16 @@ _COMPONENT_TYPE_KEY = "haystack.component.type"
58
39
  _COMPONENT_OUTPUT_KEY = "haystack.component.output"
59
40
  _COMPONENT_INPUT_KEY = "haystack.component.input"
60
41
 
42
+ # Type alias for observation span types
43
+ ObservationSpanType = Literal["tool", "agent", "retriever", "embedding", "generation"]
44
+
61
45
  # External session metadata for trace correlation (Haystack system)
62
46
  # Stores trace_id, user_id, session_id, tags, version for root trace creation
63
- tracing_context_var: ContextVar[Dict[Any, Any]] = ContextVar("tracing_context")
47
+ tracing_context_var: ContextVar[dict[Any, Any]] = ContextVar("tracing_context")
64
48
 
65
49
  # Internal span execution hierarchy for our tracer
66
50
  # Manages parent-child relationships and prevents cross-request span interleaving
67
- span_stack_var: ContextVar[Optional[List["LangfuseSpan"]]] = ContextVar("span_stack", default=None)
51
+ span_stack_var: ContextVar[Optional[list["LangfuseSpan"]]] = ContextVar("span_stack", default=None)
68
52
 
69
53
 
70
54
  class LangfuseSpan(Span):
@@ -81,7 +65,7 @@ class LangfuseSpan(Span):
81
65
  `langfuse.get_client().start_as_current_observation`.
82
66
  """
83
67
  self._span = context_manager.__enter__()
84
- self._data: Dict[str, Any] = {}
68
+ self._data: dict[str, Any] = {}
85
69
  self._context_manager = context_manager
86
70
 
87
71
  def set_tag(self, key: str, value: Any) -> None:
@@ -132,7 +116,7 @@ class LangfuseSpan(Span):
132
116
  """
133
117
  return self._span
134
118
 
135
- def get_data(self) -> Dict[str, Any]:
119
+ def get_data(self) -> dict[str, Any]:
136
120
  """
137
121
  Return the data associated with the span.
138
122
 
@@ -140,7 +124,7 @@ class LangfuseSpan(Span):
140
124
  """
141
125
  return self._data
142
126
 
143
- def get_correlation_data_for_logs(self) -> Dict[str, Any]:
127
+ def get_correlation_data_for_logs(self) -> dict[str, Any]:
144
128
  return {}
145
129
 
146
130
 
@@ -167,7 +151,7 @@ class SpanContext:
167
151
  name: str
168
152
  operation_name: str
169
153
  component_type: Optional[str]
170
- tags: Dict[str, Any]
154
+ tags: dict[str, Any]
171
155
  parent_span: Optional[Span]
172
156
  trace_name: str = "Haystack"
173
157
  public: bool = False
@@ -249,14 +233,14 @@ class SpanHandler(ABC):
249
233
  pass
250
234
 
251
235
  @classmethod
252
- def from_dict(cls, data: Dict[str, Any]) -> "SpanHandler":
236
+ def from_dict(cls, data: dict[str, Any]) -> "SpanHandler":
253
237
  return default_from_dict(cls, data)
254
238
 
255
- def to_dict(self) -> Dict[str, Any]:
239
+ def to_dict(self) -> dict[str, Any]:
256
240
  return default_to_dict(self)
257
241
 
258
242
 
259
- def _sanitize_usage_data(usage: Dict[str, Any]) -> Dict[str, Any]:
243
+ def _sanitize_usage_data(usage: dict[str, Any]) -> dict[str, Any]:
260
244
  """
261
245
  Sanitize usage data for Langfuse by flattening to a single-level dictionary.
262
246
 
@@ -271,9 +255,9 @@ def _sanitize_usage_data(usage: Dict[str, Any]) -> Dict[str, Any]:
271
255
  if not isinstance(usage, dict):
272
256
  return {}
273
257
 
274
- sanitized: Dict[str, Any] = {}
258
+ sanitized: dict[str, Any] = {}
275
259
 
276
- def _flatten(data: Dict[str, Any], prefix: str = "") -> None:
260
+ def _flatten(data: dict[str, Any], prefix: str = "") -> None:
277
261
  """Recursively flatten nested dictionaries."""
278
262
  for key, value in data.items():
279
263
  full_key = f"{prefix}.{key}" if prefix else key
@@ -316,7 +300,9 @@ class DefaultSpanHandler(SpanHandler):
316
300
  )
317
301
  # Create a new trace when there's no parent span
318
302
  span_context_manager = self.tracer.start_as_current_observation(
319
- name=context.trace_name, version=tracing_ctx.get("version"), as_type=root_span_type
303
+ name=context.trace_name,
304
+ version=tracing_ctx.get("version"),
305
+ as_type=root_span_type,
320
306
  )
321
307
 
322
308
  # Create LangfuseSpan which will handle entering the context manager
@@ -340,12 +326,26 @@ class DefaultSpanHandler(SpanHandler):
340
326
  span._span.update_trace(**trace_attrs)
341
327
 
342
328
  return span
343
- elif context.component_type == "ToolInvoker":
344
- return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name, as_type="tool"))
329
+
330
+ span_type = None
331
+
332
+ if context.component_type == "ToolInvoker":
333
+ span_type = "tool"
345
334
  elif context.operation_name == "haystack.agent.run":
346
- return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name, as_type="agent"))
347
- elif context.component_type in _ALL_SUPPORTED_GENERATORS:
348
- return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name, as_type="generation"))
335
+ span_type = "agent"
336
+ elif context.component_type and context.component_type.endswith("Retriever"):
337
+ span_type = "retriever"
338
+ elif context.component_type and context.component_type.endswith("Embedder"):
339
+ span_type = "embedding"
340
+ elif context.component_type and context.component_type.endswith("Generator"):
341
+ span_type = "generation"
342
+
343
+ if span_type:
344
+ return LangfuseSpan(
345
+ self.tracer.start_as_current_observation(
346
+ name=context.name, as_type=cast(ObservationSpanType, span_type)
347
+ )
348
+ )
349
349
  else:
350
350
  return LangfuseSpan(self.tracer.start_as_current_span(name=context.name))
351
351
 
@@ -358,7 +358,7 @@ class DefaultSpanHandler(SpanHandler):
358
358
  span.raw_span().update_trace(input=coerced_input, output=coerced_output)
359
359
  # special case for ToolInvoker (to update the span name to be: `original_component_name - [tool_names]`)
360
360
  if component_type == "ToolInvoker":
361
- tool_names: List[str] = []
361
+ tool_names: list[str] = []
362
362
  messages = span.get_data().get(_COMPONENT_INPUT_KEY, {}).get("messages", [])
363
363
  for message in messages:
364
364
  if isinstance(message, ChatMessage) and message.tool_calls:
@@ -371,14 +371,7 @@ class DefaultSpanHandler(SpanHandler):
371
371
  formatted_names = [f"{name} (x{count})" if count > 1 else name for name, count in tool_counts.items()]
372
372
  span.raw_span().update(name=f"{tool_invoker_name} - {sorted(formatted_names)}")
373
373
 
374
- if component_type in _SUPPORTED_GENERATORS:
375
- meta = span.get_data().get(_COMPONENT_OUTPUT_KEY, {}).get("meta")
376
- if meta:
377
- usage = meta[0].get("usage")
378
- sanitized_usage = _sanitize_usage_data(usage) if usage else None
379
- span.raw_span().update(usage_details=sanitized_usage, model=meta[0].get("model"))
380
-
381
- if component_type in _SUPPORTED_CHAT_GENERATORS:
374
+ if component_type and component_type.endswith("ChatGenerator"):
382
375
  replies = span.get_data().get(_COMPONENT_OUTPUT_KEY, {}).get("replies")
383
376
  if replies:
384
377
  meta = replies[0].meta
@@ -396,6 +389,12 @@ class DefaultSpanHandler(SpanHandler):
396
389
  model=meta.get("model"),
397
390
  completion_start_time=completion_start_time,
398
391
  )
392
+ elif component_type and component_type.endswith("Generator"):
393
+ meta = span.get_data().get(_COMPONENT_OUTPUT_KEY, {}).get("meta")
394
+ if meta:
395
+ usage = meta[0].get("usage")
396
+ sanitized_usage = _sanitize_usage_data(usage) if usage else None
397
+ span.raw_span().update(usage_details=sanitized_usage, model=meta[0].get("model"))
399
398
 
400
399
 
401
400
  class LangfuseTracer(Tracer):
@@ -429,7 +428,7 @@ class LangfuseTracer(Tracer):
429
428
  )
430
429
  self._tracer = tracer
431
430
  # Keep _context as deprecated shim to avoid AttributeError if anyone uses it
432
- self._context: List[LangfuseSpan] = []
431
+ self._context: list[LangfuseSpan] = []
433
432
  self._name = name
434
433
  self._public = public
435
434
  self.enforce_flush = os.getenv(HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR, "true").lower() == "true"
@@ -438,7 +437,7 @@ class LangfuseTracer(Tracer):
438
437
 
439
438
  @contextlib.contextmanager
440
439
  def trace(
441
- self, operation_name: str, tags: Optional[Dict[str, Any]] = None, parent_span: Optional[Span] = None
440
+ self, operation_name: str, tags: Optional[dict[str, Any]] = None, parent_span: Optional[Span] = None
442
441
  ) -> Iterator[Span]:
443
442
  tags = tags or {}
444
443
  span_name = tags.get(_COMPONENT_NAME_KEY, operation_name)
@@ -470,16 +469,44 @@ class LangfuseTracer(Tracer):
470
469
 
471
470
  try:
472
471
  yield span
473
- finally:
474
- # Always clean up context, even if nested operations fail
472
+ except Exception:
473
+ # Exception occurred - capture exception info and pass to __exit__
474
+ # This allows Langfuse/OpenTelemetry to properly mark the span with ERROR level
475
+ exc_info = sys.exc_info()
475
476
  try:
476
477
  # Process span data (may fail with nested pipeline exceptions)
477
478
  self._span_handler.handle(span, component_type)
478
479
 
479
- # End span (may fail if span data is corrupted)
480
+ # End span with exception info (may fail if span data is corrupted)
481
+ raw_span = span.raw_span()
482
+ if span._context_manager is not None:
483
+ # Pass actual exception info to mark span as failed with ERROR level
484
+ span._context_manager.__exit__(*exc_info)
485
+ elif hasattr(raw_span, "end"):
486
+ # Only call end() if it's not a context manager
487
+ raw_span.end()
488
+ except Exception as cleanup_error:
489
+ # Log cleanup errors but don't let them corrupt context
490
+ logger.warning(
491
+ "Error during span cleanup for {operation_name}: {cleanup_error}",
492
+ operation_name=operation_name,
493
+ cleanup_error=cleanup_error,
494
+ )
495
+
496
+ # Re-raise the original exception
497
+ raise
498
+ else:
499
+ # No exception - clean exit with success status
500
+ # This preserves any manually-set log levels (WARNING, DEBUG)
501
+ try:
502
+ # Process span data
503
+ self._span_handler.handle(span, component_type)
504
+
505
+ # End span successfully
480
506
  raw_span = span.raw_span()
481
507
  # In v3, we need to properly exit context managers
482
508
  if span._context_manager is not None:
509
+ # No exception - pass None to indicate success
483
510
  span._context_manager.__exit__(None, None, None)
484
511
  elif hasattr(raw_span, "end"):
485
512
  # Only call end() if it's not a context manager
@@ -491,9 +518,9 @@ class LangfuseTracer(Tracer):
491
518
  operation_name=operation_name,
492
519
  cleanup_error=cleanup_error,
493
520
  )
494
- finally:
495
- # Restore previous span stack using saved token - ensures proper cleanup
496
- span_stack_var.reset(token)
521
+ finally:
522
+ # Restore previous span stack using saved token
523
+ span_stack_var.reset(token)
497
524
 
498
525
  if self.enforce_flush:
499
526
  self.flush()
@@ -295,6 +295,122 @@ class TestDefaultSpanHandler:
295
295
  "completion_start_time": None,
296
296
  }
297
297
 
298
+ def test_create_span_custom_chat_generator(self):
299
+ """Test that custom chat generators create 'generation' span type."""
300
+ mock_client = Mock()
301
+ mock_client.start_as_current_span = Mock(return_value=MockContextManager())
302
+ mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
303
+
304
+ handler = DefaultSpanHandler()
305
+ handler.init_tracer(mock_client)
306
+
307
+ context = SpanContext(
308
+ name="MistralChatGenerator",
309
+ operation_name="haystack.component.run",
310
+ component_type="MistralChatGenerator",
311
+ tags={},
312
+ parent_span=LangfuseSpan(mock_client.start_as_current_span()),
313
+ )
314
+
315
+ span = handler.create_span(context)
316
+ assert isinstance(span, LangfuseSpan)
317
+ mock_client.start_as_current_observation.assert_called_once_with(
318
+ name="MistralChatGenerator", as_type="generation"
319
+ )
320
+
321
+ def test_create_span_custom_generator(self):
322
+ """Test that custom generators create 'generation' span type."""
323
+ mock_client = Mock()
324
+ mock_client.start_as_current_span = Mock(return_value=MockContextManager())
325
+ mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
326
+
327
+ handler = DefaultSpanHandler()
328
+ handler.init_tracer(mock_client)
329
+
330
+ context = SpanContext(
331
+ name="CustomAPIGenerator",
332
+ operation_name="haystack.component.run",
333
+ component_type="CustomAPIGenerator",
334
+ tags={},
335
+ parent_span=LangfuseSpan(mock_client.start_as_current_span()),
336
+ )
337
+
338
+ span = handler.create_span(context)
339
+ assert isinstance(span, LangfuseSpan)
340
+ mock_client.start_as_current_observation.assert_called_once_with(
341
+ name="CustomAPIGenerator", as_type="generation"
342
+ )
343
+
344
+ def test_create_span_retriever(self):
345
+ """Test that retrievers create 'retriever' span type."""
346
+ mock_client = Mock()
347
+ mock_client.start_as_current_span = Mock(return_value=MockContextManager())
348
+ mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
349
+
350
+ handler = DefaultSpanHandler()
351
+ handler.init_tracer(mock_client)
352
+
353
+ context = SpanContext(
354
+ name="InMemoryBM25Retriever",
355
+ operation_name="haystack.component.run",
356
+ component_type="InMemoryBM25Retriever",
357
+ tags={},
358
+ parent_span=LangfuseSpan(mock_client.start_as_current_span()),
359
+ )
360
+
361
+ span = handler.create_span(context)
362
+ assert isinstance(span, LangfuseSpan)
363
+ mock_client.start_as_current_observation.assert_called_once_with(
364
+ name="InMemoryBM25Retriever", as_type="retriever"
365
+ )
366
+
367
+ def test_create_span_embedder(self):
368
+ """Test that embedders create 'embedding' span type."""
369
+ mock_client = Mock()
370
+ mock_client.start_as_current_span = Mock(return_value=MockContextManager())
371
+ mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
372
+
373
+ handler = DefaultSpanHandler()
374
+ handler.init_tracer(mock_client)
375
+
376
+ context = SpanContext(
377
+ name="SentenceTransformersDocumentEmbedder",
378
+ operation_name="haystack.component.run",
379
+ component_type="SentenceTransformersDocumentEmbedder",
380
+ tags={},
381
+ parent_span=LangfuseSpan(mock_client.start_as_current_span()),
382
+ )
383
+
384
+ span = handler.create_span(context)
385
+ assert isinstance(span, LangfuseSpan)
386
+ mock_client.start_as_current_observation.assert_called_once_with(
387
+ name="SentenceTransformersDocumentEmbedder", as_type="embedding"
388
+ )
389
+
390
+ def test_create_span_non_component(self):
391
+ """Test that non-matching components create regular spans."""
392
+ mock_client = Mock()
393
+ mock_client.start_as_current_span = Mock(return_value=MockContextManager())
394
+ mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
395
+
396
+ handler = DefaultSpanHandler()
397
+ handler.init_tracer(mock_client)
398
+
399
+ context = SpanContext(
400
+ name="DocumentJoiner",
401
+ operation_name="haystack.component.run",
402
+ component_type="DocumentJoiner",
403
+ tags={},
404
+ parent_span=LangfuseSpan(mock_client.start_as_current_span()),
405
+ )
406
+
407
+ span = handler.create_span(context)
408
+ assert isinstance(span, LangfuseSpan)
409
+ # Non-matching components should use start_as_current_span, not start_as_current_observation
410
+ mock_client.start_as_current_observation.assert_not_called()
411
+ # Verify start_as_current_span was called for the actual span creation (not just parent)
412
+ assert mock_client.start_as_current_span.call_count == 2 # Once for parent, once for the span
413
+
298
414
 
299
415
  class TestCustomSpanHandler:
300
416
  def test_handle(self):
@@ -574,3 +690,54 @@ class TestLangfuseTracer:
574
690
  assert task2_spans[1][2] == task2_inner # current_span during inner
575
691
  assert task2_spans[2][2] == task2_outer # current_span after inner
576
692
  assert task2_spans[3][2] is None # current_span after outer
693
+
694
+ def test_trace_exception_handling(self):
695
+ """
696
+ Test that exceptions are properly captured and passed to span __exit__.
697
+
698
+ This verifies the new exception handling behavior where:
699
+ - Exception case: __exit__() receives (exc_type, exc_val, exc_tb)
700
+ - Success case: __exit__() receives (None, None, None)
701
+ """
702
+ # Create a mock context manager that tracks how __exit__ was called
703
+ mock_exit_calls = []
704
+
705
+ class TrackingContextManager:
706
+ def __init__(self):
707
+ self._span = MockSpan()
708
+
709
+ def __enter__(self):
710
+ return self._span
711
+
712
+ def __exit__(self, exc_type, exc_val, exc_tb):
713
+ # Track what was passed to __exit__
714
+ mock_exit_calls.append((exc_type, exc_val, exc_tb))
715
+ return False # Don't suppress exceptions
716
+
717
+ mock_client = MockLangfuseClient()
718
+ mock_client._mock_context_manager = TrackingContextManager()
719
+
720
+ tracer = LangfuseTracer(tracer=mock_client, name="Test", public=False)
721
+
722
+ # Test 1: Exception case - __exit__ should receive exception info
723
+ mock_exit_calls.clear()
724
+ error_msg = "test error"
725
+ with pytest.raises(ValueError, match="test error"):
726
+ with tracer.trace("test_operation"):
727
+ raise ValueError(error_msg)
728
+
729
+ assert len(mock_exit_calls) == 1
730
+ assert mock_exit_calls[0][0] is ValueError # exc_type
731
+ assert str(mock_exit_calls[0][1]) == error_msg # exc_val
732
+ assert mock_exit_calls[0][2] is not None # exc_tb (traceback)
733
+
734
+ # Test 2: Success case - __exit__ should receive (None, None, None)
735
+ mock_exit_calls.clear()
736
+ with tracer.trace("test_operation"):
737
+ pass # No exception
738
+
739
+ assert len(mock_exit_calls) == 1
740
+ assert mock_exit_calls[0] == (None, None, None)
741
+
742
+ # Test 3: Verify span stack is cleaned up after exception
743
+ assert tracer.current_span() is None
@@ -5,7 +5,7 @@
5
5
  import json
6
6
  import os
7
7
  import time
8
- from typing import Any, Dict, List
8
+ from typing import Any
9
9
  from urllib.parse import urlparse
10
10
 
11
11
  import pytest
@@ -137,8 +137,8 @@ def test_tracing_with_sub_pipelines():
137
137
  self.sub_pipeline = Pipeline()
138
138
  self.sub_pipeline.add_component("llm", OpenAIChatGenerator())
139
139
 
140
- @component.output_types(replies=List[ChatMessage])
141
- def run(self, messages: List[ChatMessage]) -> Dict[str, Any]:
140
+ @component.output_types(replies=list[ChatMessage])
141
+ def run(self, messages: list[ChatMessage]) -> dict[str, Any]:
142
142
  return {"replies": self.sub_pipeline.run(data={"llm": {"messages": messages}})["llm"]["replies"]}
143
143
 
144
144
  @component
@@ -149,8 +149,8 @@ def test_tracing_with_sub_pipelines():
149
149
  self.sub_pipeline.add_component("sub_llm", SubGenerator())
150
150
  self.sub_pipeline.connect("prompt_builder.prompt", "sub_llm.messages")
151
151
 
152
- @component.output_types(replies=List[ChatMessage])
153
- def run(self, messages: List[ChatMessage]) -> Dict[str, Any]:
152
+ @component.output_types(replies=list[ChatMessage])
153
+ def run(self, messages: list[ChatMessage]) -> dict[str, Any]:
154
154
  return {
155
155
  "replies": self.sub_pipeline.run(
156
156
  data={"prompt_builder": {"template": messages, "template_variables": {"location": "Berlin"}}}