langfuse-haystack 4.1.0__tar.gz → 5.0.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-4.1.0 → langfuse_haystack-5.0.1}/CHANGELOG.md +32 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/PKG-INFO +2 -2
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/pyproject.toml +6 -2
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/tracing/langfuse/tracer.py +93 -90
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/tests/test_tracer.py +128 -75
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/.gitignore +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/LICENSE.txt +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/README.md +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/example/basic_rag.py +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/example/chat.py +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/example/requirements.txt +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/pydoc/config_docusaurus.yml +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/components/connectors/__init__.py +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/components/connectors/py.typed +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/tracing/py.typed +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/tests/__init__.py +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/tests/test_langfuse_connector.py +0 -0
- {langfuse_haystack-4.1.0 → langfuse_haystack-5.0.1}/tests/test_tracing.py +0 -0
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [integrations/langfuse-v5.0.0] - 2026-04-17
|
|
4
|
+
|
|
5
|
+
### 🚀 Features
|
|
6
|
+
|
|
7
|
+
- [**breaking**] Migrate to Langfuse Python SDK v4 (#3126)
|
|
8
|
+
|
|
9
|
+
### 🧪 Testing
|
|
10
|
+
|
|
11
|
+
- Track test coverage for all integrations (#3065)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [integrations/langfuse-v4.1.0] - 2026-03-26
|
|
15
|
+
|
|
16
|
+
### 🚀 Features
|
|
17
|
+
|
|
18
|
+
- Log generation_kwargs in Langfuse spans (#3017)
|
|
19
|
+
|
|
20
|
+
### 🐛 Bug Fixes
|
|
21
|
+
|
|
22
|
+
- *(langfuse)* Replace MagicMock with Mock to prevent RAM exhaustion (#2543) (#2798)
|
|
23
|
+
|
|
24
|
+
### 📚 Documentation
|
|
25
|
+
|
|
26
|
+
- Simplify pydoc configs (#2855)
|
|
27
|
+
|
|
28
|
+
### 🧹 Chores
|
|
29
|
+
|
|
30
|
+
- Remove unused allow-direct-references (#2866)
|
|
31
|
+
- Add ANN type annotations to google_genai, hanlp, jina, langfuse, lara (#2990)
|
|
32
|
+
- Enforce ruff docstring rules (D102/D103/D205/D209/D213/D417/D419) in integrations 21-30 (#3010)
|
|
33
|
+
|
|
34
|
+
|
|
3
35
|
## [integrations/langfuse-v4.0.1] - 2026-01-27
|
|
4
36
|
|
|
5
37
|
### 🐛 Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langfuse-haystack
|
|
3
|
-
Version:
|
|
3
|
+
Version: 5.0.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 :: Implementation :: CPython
|
|
|
18
18
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
20
|
Requires-Dist: haystack-ai>=2.22.0
|
|
21
|
-
Requires-Dist: langfuse
|
|
21
|
+
Requires-Dist: langfuse>=4.0.0
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
|
|
24
24
|
# langfuse-haystack
|
|
@@ -21,7 +21,7 @@ classifiers = [
|
|
|
21
21
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
22
22
|
"Programming Language :: Python :: Implementation :: PyPy",
|
|
23
23
|
]
|
|
24
|
-
dependencies = ["haystack-ai>=2.22.0", "langfuse>=
|
|
24
|
+
dependencies = ["haystack-ai>=2.22.0", "langfuse>=4.0.0"]
|
|
25
25
|
|
|
26
26
|
[project.urls]
|
|
27
27
|
Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/langfuse#readme"
|
|
@@ -66,7 +66,8 @@ dependencies = [
|
|
|
66
66
|
unit = 'pytest -m "not integration" {args:tests}'
|
|
67
67
|
integration = 'pytest -m "integration" {args:tests}'
|
|
68
68
|
all = 'pytest {args:tests}'
|
|
69
|
-
cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x {args:tests}'
|
|
69
|
+
unit-cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x -m "not integration" {args:tests}'
|
|
70
|
+
integration-cov-append-retry = 'pytest --cov=haystack_integrations --cov-append --reruns 3 --reruns-delay 30 -x -m "integration" {args:tests}'
|
|
70
71
|
|
|
71
72
|
types = "mypy -p haystack_integrations.components.connectors.langfuse -p haystack_integrations.tracing.langfuse {args}"
|
|
72
73
|
|
|
@@ -152,6 +153,7 @@ ban-relative-imports = "parents"
|
|
|
152
153
|
[tool.coverage.run]
|
|
153
154
|
source = ["haystack_integrations"]
|
|
154
155
|
branch = true
|
|
156
|
+
relative_files = true
|
|
155
157
|
parallel = false
|
|
156
158
|
|
|
157
159
|
|
|
@@ -159,6 +161,8 @@ parallel = false
|
|
|
159
161
|
omit = ["*/tests/*", "*/__init__.py"]
|
|
160
162
|
show_missing = true
|
|
161
163
|
exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
|
|
164
|
+
# coverage automatically detects some of the test dependencies but cannot find the source, so we ignore the errors
|
|
165
|
+
ignore_errors = true
|
|
162
166
|
|
|
163
167
|
[tool.pytest.ini_options]
|
|
164
168
|
addopts = "--strict-markers"
|
|
@@ -22,7 +22,8 @@ from haystack.tracing import utils as tracing_utils
|
|
|
22
22
|
|
|
23
23
|
import langfuse
|
|
24
24
|
from langfuse import LangfuseSpan as LangfuseClientSpan
|
|
25
|
-
from langfuse
|
|
25
|
+
from langfuse import propagate_attributes
|
|
26
|
+
from langfuse.types import TraceContext
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
28
29
|
|
|
@@ -61,7 +62,6 @@ class LangfuseSpan(Span):
|
|
|
61
62
|
Initialize a LangfuseSpan instance.
|
|
62
63
|
|
|
63
64
|
:param context_manager: The context manager from Langfuse created with
|
|
64
|
-
`langfuse.get_client().start_as_current_span` or
|
|
65
65
|
`langfuse.get_client().start_as_current_observation`.
|
|
66
66
|
"""
|
|
67
67
|
self._span = context_manager.__enter__()
|
|
@@ -90,7 +90,7 @@ class LangfuseSpan(Span):
|
|
|
90
90
|
return
|
|
91
91
|
if key.endswith(".input"):
|
|
92
92
|
if "messages" in value:
|
|
93
|
-
messages = [m.to_openai_dict_format(require_tool_call_ids=False) for m in value
|
|
93
|
+
messages = [m.to_openai_dict_format(require_tool_call_ids=False) for m in (value.get("messages") or [])]
|
|
94
94
|
if isinstance(gen_kwargs := value.get("generation_kwargs"), dict):
|
|
95
95
|
self._span.update(input={"messages": messages, "generation_kwargs": gen_kwargs})
|
|
96
96
|
else:
|
|
@@ -100,10 +100,11 @@ class LangfuseSpan(Span):
|
|
|
100
100
|
self._span.update(input=coerced_value)
|
|
101
101
|
elif key.endswith(".output"):
|
|
102
102
|
if "replies" in value:
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
replies_list = value.get("replies") or []
|
|
104
|
+
if all(isinstance(r, ChatMessage) for r in replies_list):
|
|
105
|
+
replies = [m.to_openai_dict_format(require_tool_call_ids=False) for m in replies_list]
|
|
105
106
|
else:
|
|
106
|
-
replies =
|
|
107
|
+
replies = replies_list
|
|
107
108
|
self._span.update(output=replies)
|
|
108
109
|
else:
|
|
109
110
|
coerced_value = tracing_utils.coerce_tag_value(value)
|
|
@@ -297,33 +298,17 @@ class DefaultSpanHandler(SpanHandler):
|
|
|
297
298
|
trace_context: TraceContext | None = cast(
|
|
298
299
|
TraceContext, {"trace_id": tracing_ctx.get("trace_id")} if tracing_ctx.get("trace_id") else None
|
|
299
300
|
)
|
|
300
|
-
|
|
301
|
+
|
|
301
302
|
span_context_manager = self.tracer.start_as_current_observation(
|
|
302
303
|
trace_context=trace_context,
|
|
303
304
|
name=context.trace_name,
|
|
304
305
|
version=tracing_ctx.get("version"),
|
|
305
306
|
as_type=root_span_type,
|
|
306
307
|
)
|
|
307
|
-
|
|
308
|
-
# Create LangfuseSpan which will handle entering the context manager
|
|
309
308
|
span = LangfuseSpan(span_context_manager)
|
|
310
309
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
"name": context.trace_name,
|
|
314
|
-
"user_id": tracing_ctx.get("user_id"),
|
|
315
|
-
"session_id": tracing_ctx.get("session_id"),
|
|
316
|
-
"version": tracing_ctx.get("version"),
|
|
317
|
-
"metadata": None,
|
|
318
|
-
"tags": tracing_ctx.get("tags"),
|
|
319
|
-
"public": context.public,
|
|
320
|
-
"release": None,
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
# Filter out None values and apply trace attributes
|
|
324
|
-
trace_attrs = {k: v for k, v in trace_metadata.items() if v is not None}
|
|
325
|
-
if trace_attrs:
|
|
326
|
-
span._span.update_trace(**trace_attrs)
|
|
310
|
+
if context.public:
|
|
311
|
+
span.raw_span().set_trace_as_public()
|
|
327
312
|
|
|
328
313
|
return span
|
|
329
314
|
|
|
@@ -347,7 +332,7 @@ class DefaultSpanHandler(SpanHandler):
|
|
|
347
332
|
)
|
|
348
333
|
)
|
|
349
334
|
else:
|
|
350
|
-
return LangfuseSpan(self.tracer.
|
|
335
|
+
return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name))
|
|
351
336
|
|
|
352
337
|
def handle(self, span: LangfuseSpan, component_type: str | None) -> None:
|
|
353
338
|
"""Process and enrich a span after component execution."""
|
|
@@ -356,7 +341,7 @@ class DefaultSpanHandler(SpanHandler):
|
|
|
356
341
|
if at_pipeline_level:
|
|
357
342
|
coerced_input = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_INPUT_KEY))
|
|
358
343
|
coerced_output = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_OUTPUT_KEY))
|
|
359
|
-
span.raw_span().
|
|
344
|
+
span.raw_span().update(input=coerced_input, output=coerced_output)
|
|
360
345
|
# special case for ToolInvoker (to update the span name to be: `original_component_name - [tool_names]`)
|
|
361
346
|
if component_type == "ToolInvoker":
|
|
362
347
|
tool_names: list[str] = []
|
|
@@ -468,6 +453,7 @@ class LangfuseTracer(Tracer):
|
|
|
468
453
|
tags = tags or {}
|
|
469
454
|
span_name = tags.get(_COMPONENT_NAME_KEY, operation_name)
|
|
470
455
|
component_type = tags.get(_COMPONENT_TYPE_KEY)
|
|
456
|
+
is_root_span = not (parent_span or self.current_span())
|
|
471
457
|
|
|
472
458
|
# Create a new span context
|
|
473
459
|
span_context = SpanContext(
|
|
@@ -482,74 +468,91 @@ class LangfuseTracer(Tracer):
|
|
|
482
468
|
public=self._public,
|
|
483
469
|
)
|
|
484
470
|
|
|
485
|
-
|
|
486
|
-
span = self._span_handler.create_span(span_context)
|
|
471
|
+
tracing_ctx = tracing_context_var.get({})
|
|
487
472
|
|
|
488
|
-
#
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
473
|
+
# we need to properly exit context managers
|
|
474
|
+
with contextlib.ExitStack() as stack:
|
|
475
|
+
if is_root_span:
|
|
476
|
+
# propagate_attributes sets trace_name and other trace-level metadata on the root
|
|
477
|
+
# span and ALL child spans created within this context (full trace lifetime)
|
|
478
|
+
stack.enter_context(
|
|
479
|
+
propagate_attributes(
|
|
480
|
+
trace_name=self._name,
|
|
481
|
+
user_id=tracing_ctx.get("user_id") if tracing_ctx.get("user_id") is not None else None,
|
|
482
|
+
session_id=tracing_ctx.get("session_id") if tracing_ctx.get("session_id") is not None else None,
|
|
483
|
+
version=tracing_ctx.get("version") if tracing_ctx.get("version") is not None else None,
|
|
484
|
+
tags=tracing_ctx.get("tags") if tracing_ctx.get("tags") is not None else None,
|
|
485
|
+
)
|
|
486
|
+
)
|
|
493
487
|
|
|
494
|
-
|
|
488
|
+
# Create span using the handler
|
|
489
|
+
span = self._span_handler.create_span(span_context)
|
|
495
490
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
exc_info = sys.exc_info()
|
|
502
|
-
try:
|
|
503
|
-
# Process span data (may fail with nested pipeline exceptions)
|
|
504
|
-
self._span_handler.handle(span, component_type)
|
|
505
|
-
|
|
506
|
-
# End span with exception info (may fail if span data is corrupted)
|
|
507
|
-
raw_span = span.raw_span()
|
|
508
|
-
if span._context_manager is not None:
|
|
509
|
-
# Pass actual exception info to mark span as failed with ERROR level
|
|
510
|
-
span._context_manager.__exit__(*exc_info)
|
|
511
|
-
elif hasattr(raw_span, "end"):
|
|
512
|
-
# Only call end() if it's not a context manager
|
|
513
|
-
raw_span.end()
|
|
514
|
-
except Exception as cleanup_error:
|
|
515
|
-
# Log cleanup errors but don't let them corrupt context
|
|
516
|
-
logger.warning(
|
|
517
|
-
"Error during span cleanup for {operation_name}: {cleanup_error}",
|
|
518
|
-
operation_name=operation_name,
|
|
519
|
-
cleanup_error=cleanup_error,
|
|
520
|
-
)
|
|
491
|
+
# Build new span hierarchy: copy existing stack, add new span, save for restoration
|
|
492
|
+
prev_stack = span_stack_var.get()
|
|
493
|
+
new_stack = (prev_stack or []).copy()
|
|
494
|
+
new_stack.append(span)
|
|
495
|
+
token = span_stack_var.set(new_stack)
|
|
521
496
|
|
|
522
|
-
|
|
523
|
-
raise
|
|
524
|
-
else:
|
|
525
|
-
# No exception - clean exit with success status
|
|
526
|
-
# This preserves any manually-set log levels (WARNING, DEBUG)
|
|
527
|
-
try:
|
|
528
|
-
# Process span data
|
|
529
|
-
self._span_handler.handle(span, component_type)
|
|
530
|
-
|
|
531
|
-
# End span successfully
|
|
532
|
-
raw_span = span.raw_span()
|
|
533
|
-
# In v3, we need to properly exit context managers
|
|
534
|
-
if span._context_manager is not None:
|
|
535
|
-
# No exception - pass None to indicate success
|
|
536
|
-
span._context_manager.__exit__(None, None, None)
|
|
537
|
-
elif hasattr(raw_span, "end"):
|
|
538
|
-
# Only call end() if it's not a context manager
|
|
539
|
-
raw_span.end()
|
|
540
|
-
except Exception as cleanup_error:
|
|
541
|
-
# Log cleanup errors but don't let them corrupt context
|
|
542
|
-
logger.warning(
|
|
543
|
-
"Error during span cleanup for {operation_name}: {cleanup_error}",
|
|
544
|
-
operation_name=operation_name,
|
|
545
|
-
cleanup_error=cleanup_error,
|
|
546
|
-
)
|
|
547
|
-
finally:
|
|
548
|
-
# Restore previous span stack using saved token
|
|
549
|
-
span_stack_var.reset(token)
|
|
497
|
+
span.set_tags(tags)
|
|
550
498
|
|
|
551
|
-
|
|
552
|
-
|
|
499
|
+
try:
|
|
500
|
+
yield span
|
|
501
|
+
except Exception:
|
|
502
|
+
# Exception occurred - capture exception info and pass to __exit__
|
|
503
|
+
# This allows Langfuse/OpenTelemetry to properly mark the span with ERROR level
|
|
504
|
+
exc_info = sys.exc_info()
|
|
505
|
+
try:
|
|
506
|
+
# Process span data (may fail with nested pipeline exceptions)
|
|
507
|
+
self._span_handler.handle(span, component_type)
|
|
508
|
+
|
|
509
|
+
# End span with exception info (may fail if span data is corrupted)
|
|
510
|
+
raw_span = span.raw_span()
|
|
511
|
+
if span._context_manager is not None:
|
|
512
|
+
# Pass actual exception info to mark span as failed with ERROR level
|
|
513
|
+
span._context_manager.__exit__(*exc_info)
|
|
514
|
+
elif hasattr(raw_span, "end"):
|
|
515
|
+
# Only call end() if it's not a context manager
|
|
516
|
+
raw_span.end()
|
|
517
|
+
except Exception as cleanup_error:
|
|
518
|
+
# Log cleanup errors but don't let them corrupt context
|
|
519
|
+
logger.warning(
|
|
520
|
+
"Error during span cleanup for {operation_name}: {cleanup_error}",
|
|
521
|
+
operation_name=operation_name,
|
|
522
|
+
cleanup_error=cleanup_error,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Re-raise the original exception
|
|
526
|
+
raise
|
|
527
|
+
else:
|
|
528
|
+
# No exception - clean exit with success status
|
|
529
|
+
# This preserves any manually-set log levels (WARNING, DEBUG)
|
|
530
|
+
try:
|
|
531
|
+
# Process span data
|
|
532
|
+
self._span_handler.handle(span, component_type)
|
|
533
|
+
|
|
534
|
+
# End span successfully
|
|
535
|
+
raw_span = span.raw_span()
|
|
536
|
+
# In v3, we need to properly exit context managers
|
|
537
|
+
if span._context_manager is not None:
|
|
538
|
+
# No exception - pass None to indicate success
|
|
539
|
+
span._context_manager.__exit__(None, None, None)
|
|
540
|
+
elif hasattr(raw_span, "end"):
|
|
541
|
+
# Only call end() if it's not a context manager
|
|
542
|
+
raw_span.end()
|
|
543
|
+
except Exception as cleanup_error:
|
|
544
|
+
# Log cleanup errors but don't let them corrupt context
|
|
545
|
+
logger.warning(
|
|
546
|
+
"Error during span cleanup for {operation_name}: {cleanup_error}",
|
|
547
|
+
operation_name=operation_name,
|
|
548
|
+
cleanup_error=cleanup_error,
|
|
549
|
+
)
|
|
550
|
+
finally:
|
|
551
|
+
# Restore previous span stack using saved token
|
|
552
|
+
span_stack_var.reset(token)
|
|
553
|
+
|
|
554
|
+
if self.enforce_flush:
|
|
555
|
+
self.flush()
|
|
553
556
|
|
|
554
557
|
def flush(self) -> None:
|
|
555
558
|
"""Flush all pending spans to Langfuse."""
|
|
@@ -22,17 +22,15 @@ from haystack_integrations.tracing.langfuse.tracer import (
|
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
# Mock functions for Langfuse v3 API
|
|
26
25
|
def mock_get_client():
|
|
27
26
|
mock_client = Mock()
|
|
28
|
-
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
|
|
29
27
|
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
|
|
30
28
|
mock_client.get_current_trace_id = Mock(return_value="mock_trace_id_123")
|
|
31
29
|
return mock_client
|
|
32
30
|
|
|
33
31
|
|
|
34
32
|
class MockContextManager:
|
|
35
|
-
"""Mock context manager that simulates Langfuse
|
|
33
|
+
"""Mock context manager that simulates Langfuse v4 context managers"""
|
|
36
34
|
|
|
37
35
|
def __init__(self, name="mock_span"):
|
|
38
36
|
self._span = MockSpan(name)
|
|
@@ -63,9 +61,8 @@ class MockSpan:
|
|
|
63
61
|
# Return a new mock span for child spans
|
|
64
62
|
return MockSpan(name=name or "child_span")
|
|
65
63
|
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
self._data.update(kwargs)
|
|
64
|
+
def set_trace_as_public(self):
|
|
65
|
+
self._data["public"] = True
|
|
69
66
|
|
|
70
67
|
def generation(self, name=None):
|
|
71
68
|
# Return a new mock span for generation spans
|
|
@@ -90,9 +87,6 @@ class MockLangfuseClient:
|
|
|
90
87
|
def __init__(self):
|
|
91
88
|
self._mock_context_manager = MockContextManager()
|
|
92
89
|
|
|
93
|
-
def start_as_current_span(self, _name=None, **_kwargs):
|
|
94
|
-
return self._mock_context_manager
|
|
95
|
-
|
|
96
90
|
def start_as_current_observation(self, _name=None, _as_type=None, **_kwargs):
|
|
97
91
|
return self._mock_context_manager
|
|
98
92
|
|
|
@@ -134,57 +128,75 @@ class TestLangfuseSpan:
|
|
|
134
128
|
# set_content_tag method can update input and output of the span object
|
|
135
129
|
def test_set_content_tag_updates_input_and_output(self):
|
|
136
130
|
mock_context_manager = MockContextManager()
|
|
137
|
-
|
|
138
131
|
span = LangfuseSpan(mock_context_manager)
|
|
139
|
-
span.set_content_tag("test.input", "input_value")
|
|
140
|
-
# Check that the span.update method was called with input parameter
|
|
141
|
-
mock_context_manager._span.update.assert_called_with(input="input_value")
|
|
142
132
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
133
|
+
with patch("haystack_integrations.tracing.langfuse.tracer.proxy_tracer.is_content_tracing_enabled", True):
|
|
134
|
+
span.set_content_tag("test.input", "input_value")
|
|
135
|
+
mock_context_manager._span.update.assert_called_with(input="input_value")
|
|
136
|
+
|
|
137
|
+
mock_context_manager._span.update.reset_mock()
|
|
138
|
+
span.set_content_tag("test.output", "output_value")
|
|
139
|
+
mock_context_manager._span.update.assert_called_with(output="output_value")
|
|
147
140
|
|
|
148
141
|
# set_content_tag method can update input and output of the span object with messages/replies
|
|
149
142
|
def test_set_content_tag_updates_input_and_output_with_messages(self):
|
|
150
143
|
mock_context_manager = MockContextManager()
|
|
151
|
-
|
|
152
144
|
span = LangfuseSpan(mock_context_manager)
|
|
153
|
-
span.set_content_tag("key.input", {"messages": [ChatMessage.from_user("message")]})
|
|
154
|
-
assert mock_context_manager._span.update.call_count == 1
|
|
155
|
-
# check we converted ChatMessage to OpenAI format
|
|
156
|
-
assert mock_context_manager._span.update.call_args_list[0][1] == {
|
|
157
|
-
"input": [{"role": "user", "content": "message"}]
|
|
158
|
-
}
|
|
159
145
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
assert mock_context_manager._span.update.call_count == 1
|
|
167
|
-
assert mock_context_manager._span.update.call_args_list[0][1] == {
|
|
168
|
-
"input": {
|
|
169
|
-
"messages": [{"role": "user", "content": "message"}],
|
|
170
|
-
"generation_kwargs": {"n": 2, "temperature": 0.7},
|
|
146
|
+
with patch("haystack_integrations.tracing.langfuse.tracer.proxy_tracer.is_content_tracing_enabled", True):
|
|
147
|
+
span.set_content_tag("key.input", {"messages": [ChatMessage.from_user("message")]})
|
|
148
|
+
assert mock_context_manager._span.update.call_count == 1
|
|
149
|
+
# check we converted ChatMessage to OpenAI format
|
|
150
|
+
assert mock_context_manager._span.update.call_args_list[0][1] == {
|
|
151
|
+
"input": [{"role": "user", "content": "message"}]
|
|
171
152
|
}
|
|
172
|
-
}
|
|
173
|
-
# test replies ChatMessage list
|
|
174
|
-
mock_context_manager._span.update.reset_mock()
|
|
175
|
-
span.set_content_tag("key.output", {"replies": [ChatMessage.from_system("reply")]})
|
|
176
|
-
assert mock_context_manager._span.update.call_count == 1
|
|
177
|
-
# check we converted ChatMessage to OpenAI format
|
|
178
|
-
assert mock_context_manager._span.update.call_args_list[0][1] == {
|
|
179
|
-
"output": [{"role": "system", "content": "reply"}]
|
|
180
|
-
}
|
|
181
153
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
154
|
+
# test generation_kwargs are captured
|
|
155
|
+
mock_context_manager._span.update.reset_mock()
|
|
156
|
+
span.set_content_tag(
|
|
157
|
+
"key.input",
|
|
158
|
+
{"messages": [ChatMessage.from_user("message")], "generation_kwargs": {"n": 2, "temperature": 0.7}},
|
|
159
|
+
)
|
|
160
|
+
assert mock_context_manager._span.update.call_count == 1
|
|
161
|
+
assert mock_context_manager._span.update.call_args_list[0][1] == {
|
|
162
|
+
"input": {
|
|
163
|
+
"messages": [{"role": "user", "content": "message"}],
|
|
164
|
+
"generation_kwargs": {"n": 2, "temperature": 0.7},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
# test replies ChatMessage list
|
|
168
|
+
mock_context_manager._span.update.reset_mock()
|
|
169
|
+
span.set_content_tag("key.output", {"replies": [ChatMessage.from_system("reply")]})
|
|
170
|
+
assert mock_context_manager._span.update.call_count == 1
|
|
171
|
+
# check we converted ChatMessage to OpenAI format
|
|
172
|
+
assert mock_context_manager._span.update.call_args_list[0][1] == {
|
|
173
|
+
"output": [{"role": "system", "content": "reply"}]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# test replies string list
|
|
177
|
+
mock_context_manager._span.update.reset_mock()
|
|
178
|
+
span.set_content_tag("key.output", {"replies": ["reply1", "reply2"]})
|
|
179
|
+
assert mock_context_manager._span.update.call_count == 1
|
|
180
|
+
# check we handle properly string list replies
|
|
181
|
+
assert mock_context_manager._span.update.call_args_list[0][1] == {"output": ["reply1", "reply2"]}
|
|
182
|
+
|
|
183
|
+
def test_set_content_tag_messages_none_does_not_raise(self):
|
|
184
|
+
mock_context_manager = MockContextManager()
|
|
185
|
+
span = LangfuseSpan(mock_context_manager)
|
|
186
|
+
|
|
187
|
+
with patch("haystack_integrations.tracing.langfuse.tracer.proxy_tracer.is_content_tracing_enabled", True):
|
|
188
|
+
span.set_content_tag("key.input", {"messages": None})
|
|
189
|
+
assert mock_context_manager._span.update.call_count == 1
|
|
190
|
+
assert mock_context_manager._span.update.call_args_list[0][1] == {"input": []}
|
|
191
|
+
|
|
192
|
+
def test_set_content_tag_replies_none_does_not_raise(self):
|
|
193
|
+
mock_context_manager = MockContextManager()
|
|
194
|
+
span = LangfuseSpan(mock_context_manager)
|
|
195
|
+
|
|
196
|
+
with patch("haystack_integrations.tracing.langfuse.tracer.proxy_tracer.is_content_tracing_enabled", True):
|
|
197
|
+
span.set_content_tag("key.output", {"replies": None})
|
|
198
|
+
assert mock_context_manager._span.update.call_count == 1
|
|
199
|
+
assert mock_context_manager._span.update.call_args_list[0][1] == {"output": []}
|
|
188
200
|
|
|
189
201
|
|
|
190
202
|
class TestSpanContext:
|
|
@@ -239,7 +251,6 @@ class TestDefaultSpanHandler:
|
|
|
239
251
|
def test_create_span_root_trace(self):
|
|
240
252
|
"""Test creating a root span (no parent)."""
|
|
241
253
|
mock_client = Mock()
|
|
242
|
-
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
|
|
243
254
|
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
|
|
244
255
|
|
|
245
256
|
handler = DefaultSpanHandler()
|
|
@@ -274,6 +285,24 @@ class TestDefaultSpanHandler:
|
|
|
274
285
|
finally:
|
|
275
286
|
tracing_context_var.reset(token)
|
|
276
287
|
|
|
288
|
+
def test_create_span_root_trace_public(self):
|
|
289
|
+
"""Test that public=True calls set_trace_as_public on the span."""
|
|
290
|
+
mock_client = Mock()
|
|
291
|
+
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
|
|
292
|
+
|
|
293
|
+
handler = DefaultSpanHandler()
|
|
294
|
+
handler.init_tracer(mock_client)
|
|
295
|
+
|
|
296
|
+
context = MagicMock(spec=SpanContext)
|
|
297
|
+
context.parent_span = None
|
|
298
|
+
context.trace_name = "public_trace"
|
|
299
|
+
context.operation_name = "haystack.pipeline.run"
|
|
300
|
+
context.public = True
|
|
301
|
+
|
|
302
|
+
result = handler.create_span(context)
|
|
303
|
+
|
|
304
|
+
assert result.raw_span()._data.get("public") is True
|
|
305
|
+
|
|
277
306
|
def test_handle_generator(self):
|
|
278
307
|
mock_span = Mock()
|
|
279
308
|
mock_span.raw_span.return_value = mock_span
|
|
@@ -345,7 +374,6 @@ class TestDefaultSpanHandler:
|
|
|
345
374
|
def test_create_span_custom_chat_generator(self):
|
|
346
375
|
"""Test that custom chat generators create 'generation' span type."""
|
|
347
376
|
mock_client = Mock()
|
|
348
|
-
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
|
|
349
377
|
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
|
|
350
378
|
|
|
351
379
|
handler = DefaultSpanHandler()
|
|
@@ -356,8 +384,9 @@ class TestDefaultSpanHandler:
|
|
|
356
384
|
operation_name="haystack.component.run",
|
|
357
385
|
component_type="MistralChatGenerator",
|
|
358
386
|
tags={},
|
|
359
|
-
parent_span=LangfuseSpan(mock_client.
|
|
387
|
+
parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
|
|
360
388
|
)
|
|
389
|
+
mock_client.start_as_current_observation.reset_mock()
|
|
361
390
|
|
|
362
391
|
span = handler.create_span(context)
|
|
363
392
|
assert isinstance(span, LangfuseSpan)
|
|
@@ -368,7 +397,6 @@ class TestDefaultSpanHandler:
|
|
|
368
397
|
def test_create_span_custom_generator(self):
|
|
369
398
|
"""Test that custom generators create 'generation' span type."""
|
|
370
399
|
mock_client = Mock()
|
|
371
|
-
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
|
|
372
400
|
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
|
|
373
401
|
|
|
374
402
|
handler = DefaultSpanHandler()
|
|
@@ -379,8 +407,9 @@ class TestDefaultSpanHandler:
|
|
|
379
407
|
operation_name="haystack.component.run",
|
|
380
408
|
component_type="CustomAPIGenerator",
|
|
381
409
|
tags={},
|
|
382
|
-
parent_span=LangfuseSpan(mock_client.
|
|
410
|
+
parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
|
|
383
411
|
)
|
|
412
|
+
mock_client.start_as_current_observation.reset_mock()
|
|
384
413
|
|
|
385
414
|
span = handler.create_span(context)
|
|
386
415
|
assert isinstance(span, LangfuseSpan)
|
|
@@ -391,7 +420,6 @@ class TestDefaultSpanHandler:
|
|
|
391
420
|
def test_create_span_retriever(self):
|
|
392
421
|
"""Test that retrievers create 'retriever' span type."""
|
|
393
422
|
mock_client = Mock()
|
|
394
|
-
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
|
|
395
423
|
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
|
|
396
424
|
|
|
397
425
|
handler = DefaultSpanHandler()
|
|
@@ -402,8 +430,9 @@ class TestDefaultSpanHandler:
|
|
|
402
430
|
operation_name="haystack.component.run",
|
|
403
431
|
component_type="InMemoryBM25Retriever",
|
|
404
432
|
tags={},
|
|
405
|
-
parent_span=LangfuseSpan(mock_client.
|
|
433
|
+
parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
|
|
406
434
|
)
|
|
435
|
+
mock_client.start_as_current_observation.reset_mock()
|
|
407
436
|
|
|
408
437
|
span = handler.create_span(context)
|
|
409
438
|
assert isinstance(span, LangfuseSpan)
|
|
@@ -414,7 +443,6 @@ class TestDefaultSpanHandler:
|
|
|
414
443
|
def test_create_span_embedder(self):
|
|
415
444
|
"""Test that embedders create 'embedding' span type."""
|
|
416
445
|
mock_client = Mock()
|
|
417
|
-
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
|
|
418
446
|
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
|
|
419
447
|
|
|
420
448
|
handler = DefaultSpanHandler()
|
|
@@ -425,8 +453,9 @@ class TestDefaultSpanHandler:
|
|
|
425
453
|
operation_name="haystack.component.run",
|
|
426
454
|
component_type="SentenceTransformersDocumentEmbedder",
|
|
427
455
|
tags={},
|
|
428
|
-
parent_span=LangfuseSpan(mock_client.
|
|
456
|
+
parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
|
|
429
457
|
)
|
|
458
|
+
mock_client.start_as_current_observation.reset_mock()
|
|
430
459
|
|
|
431
460
|
span = handler.create_span(context)
|
|
432
461
|
assert isinstance(span, LangfuseSpan)
|
|
@@ -435,9 +464,8 @@ class TestDefaultSpanHandler:
|
|
|
435
464
|
)
|
|
436
465
|
|
|
437
466
|
def test_create_span_non_component(self):
|
|
438
|
-
"""Test that non-matching components create
|
|
467
|
+
"""Test that non-matching components create default span type."""
|
|
439
468
|
mock_client = Mock()
|
|
440
|
-
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
|
|
441
469
|
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
|
|
442
470
|
|
|
443
471
|
handler = DefaultSpanHandler()
|
|
@@ -448,15 +476,14 @@ class TestDefaultSpanHandler:
|
|
|
448
476
|
operation_name="haystack.component.run",
|
|
449
477
|
component_type="DocumentJoiner",
|
|
450
478
|
tags={},
|
|
451
|
-
parent_span=LangfuseSpan(mock_client.
|
|
479
|
+
parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
|
|
452
480
|
)
|
|
481
|
+
mock_client.start_as_current_observation.reset_mock()
|
|
453
482
|
|
|
454
483
|
span = handler.create_span(context)
|
|
455
484
|
assert isinstance(span, LangfuseSpan)
|
|
456
|
-
# Non-matching components
|
|
457
|
-
mock_client.start_as_current_observation.
|
|
458
|
-
# Verify start_as_current_span was called for the actual span creation (not just parent)
|
|
459
|
-
assert mock_client.start_as_current_span.call_count == 2 # Once for parent, once for the span
|
|
485
|
+
# Non-matching components use start_as_current_observation without explicit as_type
|
|
486
|
+
mock_client.start_as_current_observation.assert_called_once_with(name="DocumentJoiner")
|
|
460
487
|
|
|
461
488
|
def test_handle_embedder_with_openai_format(self):
|
|
462
489
|
"""Test that embedder usage is extracted in OpenAI format."""
|
|
@@ -600,7 +627,6 @@ class TestLangfuseTracer:
|
|
|
600
627
|
mock_span_instance._context_manager = mock_context_manager
|
|
601
628
|
|
|
602
629
|
mock_tracer = Mock()
|
|
603
|
-
mock_tracer.start_as_current_span.return_value = mock_context_manager
|
|
604
630
|
mock_tracer.start_as_current_observation.return_value = mock_context_manager
|
|
605
631
|
|
|
606
632
|
tracer = LangfuseTracer(tracer=mock_tracer, name="Haystack", public=False)
|
|
@@ -616,15 +642,42 @@ class TestLangfuseTracer:
|
|
|
616
642
|
# Check that the span is cleaned up after tracing
|
|
617
643
|
assert tracer.current_span() is None, "There should be no active span after tracing completes"
|
|
618
644
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
with
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
645
|
+
def test_pipeline_input_output_written_to_root_observation(self):
|
|
646
|
+
tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
|
|
647
|
+
with tracer.trace(
|
|
648
|
+
operation_name="operation_name",
|
|
649
|
+
tags={
|
|
650
|
+
"haystack.pipeline.input_data": "hello",
|
|
651
|
+
"haystack.pipeline.output_data": "bye",
|
|
652
|
+
},
|
|
653
|
+
) as span:
|
|
654
|
+
pass
|
|
655
|
+
|
|
656
|
+
# MockSpan._update_data mirrors kwargs into _data, so after the context exits
|
|
657
|
+
# we can verify update(input=..., output=...) was actually called.
|
|
658
|
+
assert span.raw_span()._data["input"] == "hello"
|
|
659
|
+
assert span.raw_span()._data["output"] == "bye"
|
|
625
660
|
|
|
626
|
-
|
|
627
|
-
|
|
661
|
+
def test_propagate_attributes_called_with_tracing_ctx_vars(self):
|
|
662
|
+
token = tracing_context_var.set(
|
|
663
|
+
{"user_id": "user123", "session_id": "sess456", "version": "v1", "tags": ["tag1"]}
|
|
664
|
+
)
|
|
665
|
+
try:
|
|
666
|
+
with patch("haystack_integrations.tracing.langfuse.tracer.propagate_attributes") as mock_propagate:
|
|
667
|
+
mock_propagate.return_value.__enter__ = Mock(return_value=None)
|
|
668
|
+
mock_propagate.return_value.__exit__ = Mock(return_value=False)
|
|
669
|
+
tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="MyPipeline", public=False)
|
|
670
|
+
with tracer.trace(operation_name="haystack.pipeline.run"):
|
|
671
|
+
pass
|
|
672
|
+
mock_propagate.assert_called_once_with(
|
|
673
|
+
trace_name="MyPipeline",
|
|
674
|
+
user_id="user123",
|
|
675
|
+
session_id="sess456",
|
|
676
|
+
version="v1",
|
|
677
|
+
tags=["tag1"],
|
|
678
|
+
)
|
|
679
|
+
finally:
|
|
680
|
+
tracing_context_var.reset(token)
|
|
628
681
|
|
|
629
682
|
def test_trace_generation(self):
|
|
630
683
|
with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client"):
|
|
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-4.1.0 → langfuse_haystack-5.0.1}/src/haystack_integrations/tracing/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|