langfuse-haystack 2.1.0__tar.gz → 2.2.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 (19) hide show
  1. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/CHANGELOG.md +10 -1
  2. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/PKG-INFO +2 -2
  3. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/pyproject.toml +1 -6
  4. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/src/haystack_integrations/tracing/langfuse/tracer.py +29 -74
  5. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/tests/test_tracer.py +65 -4
  6. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/.gitignore +0 -0
  7. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/LICENSE.txt +0 -0
  8. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/README.md +0 -0
  9. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/example/basic_rag.py +0 -0
  10. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/example/chat.py +0 -0
  11. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/example/requirements.txt +0 -0
  12. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/pydoc/config.yml +0 -0
  13. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/src/haystack_integrations/components/connectors/__init__.py +0 -0
  14. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
  15. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +0 -0
  16. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
  17. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/tests/__init__.py +0 -0
  18. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/tests/test_langfuse_connector.py +0 -0
  19. {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.0}/tests/test_tracing.py +0 -0
@@ -1,10 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [unreleased]
4
+
5
+
6
+ ### 🧹 Chores
7
+
8
+ - Pin langfuse<3.0.0 (#1904)
9
+ - Align core-integrations Hatch scripts (#1898)
10
+ - Update md files for new hatch scripts (#1911)
11
+
3
12
  ## [integrations/langfuse-v2.0.1] - 2025-06-02
4
13
 
5
14
  ### 🚀 Features
6
15
 
7
- - Use Langfuse local _to_openai_dict_format function to serialize messages (#1885)
16
+ - Use Langfuse local to_openai_dict_format function to serialize messages (#1885)
8
17
 
9
18
  ### 🌀 Miscellaneous
10
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langfuse-haystack
3
- Version: 2.1.0
3
+ Version: 2.2.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
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: Implementation :: CPython
19
19
  Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Requires-Python: >=3.9
21
- Requires-Dist: haystack-ai>=2.13.0
21
+ Requires-Dist: haystack-ai>=2.15.1
22
22
  Requires-Dist: langfuse<3.0.0,>=2.9.0
23
23
  Description-Content-Type: text/markdown
24
24
 
@@ -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.13.0", "langfuse>=2.9.0, <3.0.0"]
25
+ dependencies = ["haystack-ai>=2.15.1", "langfuse>=2.9.0, <3.0.0"]
26
26
 
27
27
  [project.urls]
28
28
  Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse#readme"
@@ -79,7 +79,6 @@ installer = "uv"
79
79
  detached = true
80
80
  dependencies = [
81
81
  "pip",
82
- "black>=23.1.0",
83
82
  "mypy>=1.0.0",
84
83
  "ruff>=0.0.243",
85
84
  ]
@@ -90,10 +89,6 @@ typing = "mypy --install-types --non-interactive --explicit-package-bases {args:
90
89
  [tool.hatch.metadata]
91
90
  allow-direct-references = true
92
91
 
93
- [tool.black]
94
- target-version = ["py38"]
95
- line-length = 120
96
- skip-string-normalization = true
97
92
 
98
93
  [tool.ruff]
99
94
  target-version = "py38"
@@ -3,7 +3,6 @@
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
5
  import contextlib
6
- import json
7
6
  import os
8
7
  from abc import ABC, abstractmethod
9
8
  from collections import Counter
@@ -66,61 +65,6 @@ _COMPONENT_INPUT_KEY = "haystack.component.input"
66
65
  tracing_context_var: ContextVar[Dict[Any, Any]] = ContextVar("tracing_context")
67
66
 
68
67
 
69
- def _to_openai_dict_format(chat_message: ChatMessage) -> Dict[str, Any]:
70
- """
71
- Convert a ChatMessage to the dictionary format expected by OpenAI's chat completion API.
72
-
73
- Note: We already have such a method in Haystack's ChatMessage class.
74
- However, the original method doesn't tolerate None values for ids of ToolCall and ToolCallResult.
75
- Some generators, like GoogleGenAIChatGenerator, return None values for ids of ToolCall and ToolCallResult.
76
- To seamlessly support these generators, we use this, Langfuse local, version of the method.
77
-
78
- :param chat_message: The ChatMessage instance to convert.
79
- :return: Dictionary in OpenAI Chat API format.
80
- """
81
- text_contents = chat_message.texts
82
- tool_calls = chat_message.tool_calls
83
- tool_call_results = chat_message.tool_call_results
84
-
85
- if not text_contents and not tool_calls and not tool_call_results:
86
- message = "A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, or `ToolCallResult`."
87
- logger.error(message)
88
- raise ValueError(message)
89
- if len(text_contents) + len(tool_call_results) > 1:
90
- message = "A `ChatMessage` can only contain one `TextContent` or one `ToolCallResult`."
91
- logger.error(message)
92
- raise ValueError(message)
93
-
94
- openai_msg: Dict[str, Any] = {"role": chat_message._role.value}
95
-
96
- # Add name field if present
97
- if chat_message._name is not None:
98
- openai_msg["name"] = chat_message._name
99
-
100
- if tool_call_results:
101
- result = tool_call_results[0]
102
- openai_msg["content"] = result.result
103
- openai_msg["tool_call_id"] = result.origin.id
104
- # OpenAI does not provide a way to communicate errors in tool invocations, so we ignore the error field
105
- return openai_msg
106
-
107
- if text_contents:
108
- openai_msg["content"] = text_contents[0]
109
- if tool_calls:
110
- openai_tool_calls = []
111
- for tc in tool_calls:
112
- openai_tool_calls.append(
113
- {
114
- "id": tc.id,
115
- "type": "function",
116
- # We disable ensure_ascii so special chars like emojis are not converted
117
- "function": {"name": tc.tool_name, "arguments": json.dumps(tc.arguments, ensure_ascii=False)},
118
- }
119
- )
120
- openai_msg["tool_calls"] = openai_tool_calls
121
- return openai_msg
122
-
123
-
124
68
  class LangfuseSpan(Span):
125
69
  """
126
70
  Internal class representing a bridge between the Haystack span tracing API and Langfuse.
@@ -158,7 +102,7 @@ class LangfuseSpan(Span):
158
102
  return
159
103
  if key.endswith(".input"):
160
104
  if "messages" in value:
161
- messages = [_to_openai_dict_format(m) for m in value["messages"]]
105
+ messages = [m.to_openai_dict_format(require_tool_call_ids=False) for m in value["messages"]]
162
106
  self._span.update(input=messages)
163
107
  else:
164
108
  coerced_value = tracing_utils.coerce_tag_value(value)
@@ -166,7 +110,7 @@ class LangfuseSpan(Span):
166
110
  elif key.endswith(".output"):
167
111
  if "replies" in value:
168
112
  if all(isinstance(r, ChatMessage) for r in value["replies"]):
169
- replies = [_to_openai_dict_format(m) for m in value["replies"]]
113
+ replies = [m.to_openai_dict_format(require_tool_call_ids=False) for m in value["replies"]]
170
114
  else:
171
115
  replies = value["replies"]
172
116
  self._span.update(output=replies)
@@ -449,22 +393,33 @@ class LangfuseTracer(Tracer):
449
393
  self._context.append(span)
450
394
  span.set_tags(tags)
451
395
 
452
- yield span
453
-
454
- # Let the span handler process the span
455
- self._span_handler.handle(span, component_type)
456
-
457
- # In this section, we finalize both regular spans and generation spans created using the LangfuseSpan class.
458
- # It's important to end() these spans to ensure they are properly closed and all relevant data is recorded.
459
- # Note that we do not call end() on the main trace span itself (StatefulTraceClient), as its lifecycle is
460
- # managed differently.
461
- raw_span = span.raw_span()
462
- if isinstance(raw_span, (StatefulSpanClient, StatefulGenerationClient)):
463
- raw_span.end()
464
- self._context.pop()
465
-
466
- if self.enforce_flush:
467
- self.flush()
396
+ try:
397
+ yield span
398
+ finally:
399
+ # Always clean up context, even if nested operations fail
400
+ try:
401
+ # Process span data (may fail with nested pipeline exceptions)
402
+ self._span_handler.handle(span, component_type)
403
+
404
+ # End span (may fail if span data is corrupted)
405
+ raw_span = span.raw_span()
406
+ if isinstance(raw_span, (StatefulSpanClient, StatefulGenerationClient)):
407
+ raw_span.end()
408
+ except Exception as cleanup_error:
409
+ # Log cleanup errors but don't let them corrupt context
410
+ logger.warning(
411
+ "Error during span cleanup for {operation_name}: {cleanup_error}",
412
+ operation_name=operation_name,
413
+ cleanup_error=cleanup_error,
414
+ )
415
+ finally:
416
+ # CRITICAL: Always pop context to prevent corruption
417
+ # This is especially important for nested pipeline scenarios
418
+ if self._context and self._context[-1] == span:
419
+ self._context.pop()
420
+
421
+ if self.enforce_flush:
422
+ self.flush()
468
423
 
469
424
  def flush(self) -> None:
470
425
  self._tracer.flush()
@@ -3,15 +3,20 @@
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
5
  import datetime
6
+ import json
6
7
  import logging
7
8
  import sys
8
- from unittest.mock import MagicMock, Mock, patch
9
9
  from typing import Optional
10
+ from unittest.mock import MagicMock, Mock, patch
10
11
 
11
12
  import pytest
13
+ from haystack import Pipeline, component
12
14
  from haystack.dataclasses import ChatMessage, ToolCall
13
- from haystack_integrations.tracing.langfuse.tracer import LangfuseTracer, LangfuseSpan, SpanContext, DefaultSpanHandler
14
- from haystack_integrations.tracing.langfuse.tracer import _COMPONENT_OUTPUT_KEY
15
+
16
+ from haystack_integrations.components.connectors.langfuse import LangfuseConnector
17
+ from haystack_integrations.tracing.langfuse.tracer import (
18
+ _COMPONENT_OUTPUT_KEY, DefaultSpanHandler, LangfuseSpan, LangfuseTracer,
19
+ SpanContext)
15
20
 
16
21
 
17
22
  class MockSpan:
@@ -367,7 +372,8 @@ class TestLangfuseTracer:
367
372
  monkeypatch.setenv("HAYSTACK_LANGFUSE_ENFORCE_FLUSH", "false")
368
373
  tracer_mock = Mock()
369
374
 
370
- from haystack_integrations.tracing.langfuse.tracer import LangfuseTracer
375
+ from haystack_integrations.tracing.langfuse.tracer import \
376
+ LangfuseTracer
371
377
 
372
378
  tracer = LangfuseTracer(tracer=tracer_mock, name="Haystack", public=False)
373
379
  with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
@@ -397,3 +403,58 @@ class TestLangfuseTracer:
397
403
 
398
404
  LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False)
399
405
  assert "tracing is disabled" in caplog.text
406
+
407
+ def test_context_cleanup_after_nested_failures(self):
408
+ """
409
+ Test that tracer context is properly cleaned up even when nested operations fail.
410
+
411
+ This test addresses a critical bug where failing nested operations (like inner pipelines)
412
+ could corrupt the tracing context, leaving stale spans that affect subsequent operations.
413
+ The fix ensures proper cleanup through try/finally blocks.
414
+
415
+ Before the fix: context would retain spans after failures (length > 0)
416
+ After the fix: context is always cleaned up (length == 0)
417
+ """
418
+
419
+
420
+ @component
421
+ class FailingParser:
422
+ @component.output_types(result=str)
423
+ def run(self, data: str):
424
+ # This will fail with ValueError when data is not valid JSON
425
+ parsed = json.loads(data)
426
+ return {"result": parsed["key"]}
427
+
428
+ @component
429
+ class ComponentWithNestedPipeline:
430
+ def __init__(self):
431
+ # This simulates IntentClassifier's internal pipeline
432
+ self.internal_pipeline = Pipeline()
433
+ self.internal_pipeline.add_component("parser", FailingParser())
434
+
435
+ @component.output_types(result=str)
436
+ def run(self, input_data: str):
437
+ # Run nested pipeline - this is where corruption occurs
438
+ result = self.internal_pipeline.run({"parser": {"data": input_data}})
439
+ return {"result": result["parser"]["result"]}
440
+
441
+ tracer = LangfuseConnector("test")
442
+
443
+ main_pipeline = Pipeline()
444
+ main_pipeline.add_component("nested_component", ComponentWithNestedPipeline())
445
+ main_pipeline.add_component("tracer", tracer)
446
+
447
+ # Test 1: First run will fail and should clean up context
448
+ try:
449
+ main_pipeline.run({"nested_component": {"input_data": "invalid json"}})
450
+ except Exception:
451
+ pass # Expected to fail
452
+
453
+ # Critical assertion: context should be empty after failed operation
454
+ assert len(tracer.tracer._context) == 0
455
+
456
+ # Test 2: Second run should work normally with clean context
457
+ main_pipeline.run({"nested_component": {"input_data": '{"key": "valid"}'}})
458
+
459
+ # Critical assertion: context should be empty after successful operation
460
+ assert len(tracer.tracer._context) == 0