langfuse-haystack 2.1.0__tar.gz → 2.2.1__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.
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/CHANGELOG.md +20 -1
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/PKG-INFO +2 -2
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/pyproject.toml +1 -6
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/src/haystack_integrations/tracing/langfuse/tracer.py +30 -74
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/tests/test_tracer.py +65 -4
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/.gitignore +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/LICENSE.txt +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/README.md +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/example/basic_rag.py +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/example/chat.py +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/example/requirements.txt +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/pydoc/config.yml +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/src/haystack_integrations/components/connectors/__init__.py +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/tests/__init__.py +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/tests/test_langfuse_connector.py +0 -0
- {langfuse_haystack-2.1.0 → langfuse_haystack-2.2.1}/tests/test_tracing.py +0 -0
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [integrations/langfuse-v2.2.0] - 2025-07-03
|
|
4
|
+
|
|
5
|
+
### 🚀 Features
|
|
6
|
+
|
|
7
|
+
- Simpler generation spans, use Haystack's to_openai_dict_format (#2044)
|
|
8
|
+
|
|
9
|
+
### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- Properly cleanup Langfuse tracing context after pipeline run failures (#1999)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### 🧹 Chores
|
|
15
|
+
|
|
16
|
+
- Pin langfuse<3.0.0 (#1904)
|
|
17
|
+
- Align core-integrations Hatch scripts (#1898)
|
|
18
|
+
- Update md files for new hatch scripts (#1911)
|
|
19
|
+
- Remove black (#1985)
|
|
20
|
+
|
|
21
|
+
|
|
3
22
|
## [integrations/langfuse-v2.0.1] - 2025-06-02
|
|
4
23
|
|
|
5
24
|
### 🚀 Features
|
|
6
25
|
|
|
7
|
-
- Use Langfuse local
|
|
26
|
+
- Use Langfuse local to_openai_dict_format function to serialize messages (#1885)
|
|
8
27
|
|
|
9
28
|
### 🌀 Miscellaneous
|
|
10
29
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langfuse-haystack
|
|
3
|
-
Version: 2.1
|
|
3
|
+
Version: 2.2.1
|
|
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.
|
|
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.
|
|
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
|
|
@@ -39,6 +38,7 @@ _SUPPORTED_GENERATORS = [
|
|
|
39
38
|
"OllamaGenerator",
|
|
40
39
|
]
|
|
41
40
|
_SUPPORTED_CHAT_GENERATORS = [
|
|
41
|
+
"AmazonBedrockChatGenerator",
|
|
42
42
|
"AzureOpenAIChatGenerator",
|
|
43
43
|
"OpenAIChatGenerator",
|
|
44
44
|
"AnthropicChatGenerator",
|
|
@@ -66,61 +66,6 @@ _COMPONENT_INPUT_KEY = "haystack.component.input"
|
|
|
66
66
|
tracing_context_var: ContextVar[Dict[Any, Any]] = ContextVar("tracing_context")
|
|
67
67
|
|
|
68
68
|
|
|
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
69
|
class LangfuseSpan(Span):
|
|
125
70
|
"""
|
|
126
71
|
Internal class representing a bridge between the Haystack span tracing API and Langfuse.
|
|
@@ -158,7 +103,7 @@ class LangfuseSpan(Span):
|
|
|
158
103
|
return
|
|
159
104
|
if key.endswith(".input"):
|
|
160
105
|
if "messages" in value:
|
|
161
|
-
messages = [
|
|
106
|
+
messages = [m.to_openai_dict_format(require_tool_call_ids=False) for m in value["messages"]]
|
|
162
107
|
self._span.update(input=messages)
|
|
163
108
|
else:
|
|
164
109
|
coerced_value = tracing_utils.coerce_tag_value(value)
|
|
@@ -166,7 +111,7 @@ class LangfuseSpan(Span):
|
|
|
166
111
|
elif key.endswith(".output"):
|
|
167
112
|
if "replies" in value:
|
|
168
113
|
if all(isinstance(r, ChatMessage) for r in value["replies"]):
|
|
169
|
-
replies = [
|
|
114
|
+
replies = [m.to_openai_dict_format(require_tool_call_ids=False) for m in value["replies"]]
|
|
170
115
|
else:
|
|
171
116
|
replies = value["replies"]
|
|
172
117
|
self._span.update(output=replies)
|
|
@@ -449,22 +394,33 @@ class LangfuseTracer(Tracer):
|
|
|
449
394
|
self._context.append(span)
|
|
450
395
|
span.set_tags(tags)
|
|
451
396
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
397
|
+
try:
|
|
398
|
+
yield span
|
|
399
|
+
finally:
|
|
400
|
+
# Always clean up context, even if nested operations fail
|
|
401
|
+
try:
|
|
402
|
+
# Process span data (may fail with nested pipeline exceptions)
|
|
403
|
+
self._span_handler.handle(span, component_type)
|
|
404
|
+
|
|
405
|
+
# End span (may fail if span data is corrupted)
|
|
406
|
+
raw_span = span.raw_span()
|
|
407
|
+
if isinstance(raw_span, (StatefulSpanClient, StatefulGenerationClient)):
|
|
408
|
+
raw_span.end()
|
|
409
|
+
except Exception as cleanup_error:
|
|
410
|
+
# Log cleanup errors but don't let them corrupt context
|
|
411
|
+
logger.warning(
|
|
412
|
+
"Error during span cleanup for {operation_name}: {cleanup_error}",
|
|
413
|
+
operation_name=operation_name,
|
|
414
|
+
cleanup_error=cleanup_error,
|
|
415
|
+
)
|
|
416
|
+
finally:
|
|
417
|
+
# CRITICAL: Always pop context to prevent corruption
|
|
418
|
+
# This is especially important for nested pipeline scenarios
|
|
419
|
+
if self._context and self._context[-1] == span:
|
|
420
|
+
self._context.pop()
|
|
421
|
+
|
|
422
|
+
if self.enforce_flush:
|
|
423
|
+
self.flush()
|
|
468
424
|
|
|
469
425
|
def flush(self) -> None:
|
|
470
426
|
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
|
-
|
|
14
|
-
from haystack_integrations.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|