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.
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/CHANGELOG.md +21 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/PKG-INFO +2 -2
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/pyproject.toml +2 -2
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +5 -5
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/tracing/langfuse/tracer.py +83 -56
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/tests/test_tracer.py +167 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/tests/test_tracing.py +5 -5
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/.gitignore +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/LICENSE.txt +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/README.md +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/example/basic_rag.py +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/example/chat.py +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/example/requirements.txt +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/pydoc/config.yml +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/pydoc/config_docusaurus.yml +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/components/connectors/__init__.py +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/components/connectors/py.typed +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/tracing/py.typed +0 -0
- {langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/tests/__init__.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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 = "
|
|
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,
|
|
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[
|
|
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[
|
|
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) ->
|
|
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:
|
|
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,
|
|
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[
|
|
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[
|
|
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:
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
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:
|
|
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) ->
|
|
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:
|
|
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:
|
|
258
|
+
sanitized: dict[str, Any] = {}
|
|
275
259
|
|
|
276
|
-
def _flatten(data:
|
|
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,
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
elif context.component_type
|
|
348
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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[
|
|
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
|
-
|
|
474
|
-
#
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
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=
|
|
141
|
-
def run(self, messages:
|
|
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=
|
|
153
|
-
def run(self, messages:
|
|
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"}}}
|
|
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
|
{langfuse_haystack-3.2.0 → langfuse_haystack-3.3.0}/src/haystack_integrations/tracing/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|