langfuse-haystack 4.0.1__tar.gz → 5.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/CHANGELOG.md +28 -0
  2. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/PKG-INFO +2 -2
  3. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/example/basic_rag.py +2 -2
  4. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/example/chat.py +2 -2
  5. langfuse_haystack-5.0.0/pydoc/config_docusaurus.yml +14 -0
  6. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/pyproject.toml +20 -7
  7. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/src/haystack_integrations/components/connectors/langfuse/langfuse_connector.py +1 -0
  8. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/src/haystack_integrations/tracing/langfuse/tracer.py +101 -87
  9. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/tests/test_tracer.py +114 -66
  10. langfuse_haystack-4.0.1/pydoc/config_docusaurus.yml +0 -29
  11. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/.gitignore +0 -0
  12. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/LICENSE.txt +0 -0
  13. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/README.md +0 -0
  14. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/example/requirements.txt +0 -0
  15. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/src/haystack_integrations/components/connectors/__init__.py +0 -0
  16. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/src/haystack_integrations/components/connectors/langfuse/__init__.py +0 -0
  17. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/src/haystack_integrations/components/connectors/py.typed +0 -0
  18. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/src/haystack_integrations/tracing/langfuse/__init__.py +0 -0
  19. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/src/haystack_integrations/tracing/py.typed +0 -0
  20. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/tests/__init__.py +0 -0
  21. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/tests/test_langfuse_connector.py +0 -0
  22. {langfuse_haystack-4.0.1 → langfuse_haystack-5.0.0}/tests/test_tracing.py +0 -0
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/langfuse-v4.1.0] - 2026-03-26
4
+
5
+ ### 🚀 Features
6
+
7
+ - Log generation_kwargs in Langfuse spans (#3017)
8
+
9
+ ### 🐛 Bug Fixes
10
+
11
+ - *(langfuse)* Replace MagicMock with Mock to prevent RAM exhaustion (#2543) (#2798)
12
+
13
+ ### 📚 Documentation
14
+
15
+ - Simplify pydoc configs (#2855)
16
+
17
+ ### 🧹 Chores
18
+
19
+ - Remove unused allow-direct-references (#2866)
20
+ - Add ANN type annotations to google_genai, hanlp, jina, langfuse, lara (#2990)
21
+ - Enforce ruff docstring rules (D102/D103/D205/D209/D213/D417/D419) in integrations 21-30 (#3010)
22
+
23
+
24
+ ## [integrations/langfuse-v4.0.1] - 2026-01-27
25
+
26
+ ### 🐛 Bug Fixes
27
+
28
+ - *(langfuse)* Apply trace_id from tracing context to Langfuse traces (#2781)
29
+
30
+
3
31
  ## [integrations/langfuse-v4.0.0] - 2026-01-12
4
32
 
5
33
  ### 🧹 Chores
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langfuse-haystack
3
- Version: 4.0.1
3
+ Version: 5.0.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 :: 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<4.0.0,>=3.3.1
21
+ Requires-Dist: langfuse>=4.0.0
22
22
  Description-Content-Type: text/markdown
23
23
 
24
24
  # langfuse-haystack
@@ -62,5 +62,5 @@ if __name__ == "__main__":
62
62
  question = "What does Rhodes Statue look like?"
63
63
  response = pipeline.run({"text_embedder": {"text": question}, "prompt_builder": {"question": question}})
64
64
 
65
- print(response["llm"]["replies"][0]) # noqa: T201
66
- print(response["tracer"]["trace_url"]) # noqa: T201
65
+ print(response["llm"]["replies"][0])
66
+ print(response["tracer"]["trace_url"])
@@ -59,5 +59,5 @@ if __name__ == "__main__":
59
59
  },
60
60
  }
61
61
  )
62
- print(response["llm"]["replies"][0]) # noqa: T201
63
- print(response["tracer"]["trace_url"]) # noqa: T201
62
+ print(response["llm"]["replies"][0])
63
+ print(response["tracer"]["trace_url"])
@@ -0,0 +1,14 @@
1
+ loaders:
2
+ - modules:
3
+ - haystack_integrations.components.connectors.langfuse.langfuse_connector
4
+ - haystack_integrations.tracing.langfuse.tracer
5
+ search_path: [../src]
6
+ processors:
7
+ - type: filter
8
+ documented_only: true
9
+ skip_empty_modules: true
10
+ renderer:
11
+ description: Langfuse integration for Haystack
12
+ id: integrations-langfuse
13
+ filename: langfuse.md
14
+ title: langfuse
@@ -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>=3.3.1, <4.0.0"]
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"
@@ -45,7 +45,7 @@ installer = "uv"
45
45
  dependencies = ["haystack-pydoc-tools", "ruff"]
46
46
 
47
47
  [tool.hatch.envs.default.scripts]
48
- docs = ["pydoc-markdown pydoc/config_docusaurus.yml"]
48
+ docs = ["haystack-pydoc pydoc/config_docusaurus.yml"]
49
49
  fmt = "ruff check --fix {args}; ruff format {args}"
50
50
  fmt-check = "ruff check {args} && ruff format --check {args}"
51
51
 
@@ -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
 
@@ -76,8 +77,6 @@ non_interactive = true
76
77
  check_untyped_defs = true
77
78
  disallow_incomplete_defs = true
78
79
 
79
- [tool.hatch.metadata]
80
- allow-direct-references = true
81
80
 
82
81
 
83
82
  [tool.ruff]
@@ -86,9 +85,17 @@ line-length = 120
86
85
  [tool.ruff.lint]
87
86
  select = [
88
87
  "A",
88
+ "ANN",
89
89
  "ARG",
90
90
  "B",
91
91
  "C",
92
+ "D102", # Missing docstring in public method
93
+ "D103", # Missing docstring in public function
94
+ "D205", # 1 blank line required between summary line and description
95
+ "D209", # Closing triple quotes go to new line
96
+ "D213", # summary lines must be positioned on the second physical line of the docstring
97
+ "D417", # Missing argument descriptions in the docstring
98
+ "D419", # Docstring is empty
92
99
  "DTZ",
93
100
  "E",
94
101
  "EM",
@@ -125,6 +132,8 @@ ignore = [
125
132
  "PLR0915",
126
133
  # Asserts
127
134
  "S101",
135
+ # Allow `Any` - used legitimately for dynamic types and SDK boundaries
136
+ "ANN401",
128
137
  ]
129
138
 
130
139
  [tool.ruff.lint.isort]
@@ -135,14 +144,16 @@ ban-relative-imports = "parents"
135
144
 
136
145
  [tool.ruff.lint.per-file-ignores]
137
146
  # Tests can use magic values, assertions, and relative imports
138
- "tests/**/*" = ["PLR2004", "S101", "TID252"]
147
+ "tests/**/*" = ["D", "PLR2004", "S101", "TID252", "ANN"]
139
148
  # Examples can print their output
140
- "examples/**" = ["T201"]
149
+ "examples/**" = ["T201", "ANN", "D"]
150
+ "example/**" = ["T201", "ANN", "D"]
141
151
  "tests/**" = ["T201"]
142
152
 
143
153
  [tool.coverage.run]
144
154
  source = ["haystack_integrations"]
145
155
  branch = true
156
+ relative_files = true
146
157
  parallel = false
147
158
 
148
159
 
@@ -150,6 +161,8 @@ parallel = false
150
161
  omit = ["*/tests/*", "*/__init__.py"]
151
162
  show_missing = true
152
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
153
166
 
154
167
  [tool.pytest.ini_options]
155
168
  addopts = "--strict-markers"
@@ -19,6 +19,7 @@ logger = logging.getLogger(__name__)
19
19
  class LangfuseConnector:
20
20
  """
21
21
  LangfuseConnector connects Haystack LLM framework with [Langfuse](https://langfuse.com) in order to enable the
22
+
22
23
  tracing of operations and data flow within various components of a pipeline.
23
24
 
24
25
  To use LangfuseConnector, add it to your pipeline without connecting it to any other components.
@@ -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.types import TraceContext, TraceMetadata
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__()
@@ -91,7 +91,10 @@ class LangfuseSpan(Span):
91
91
  if key.endswith(".input"):
92
92
  if "messages" in value:
93
93
  messages = [m.to_openai_dict_format(require_tool_call_ids=False) for m in value["messages"]]
94
- self._span.update(input=messages)
94
+ if isinstance(gen_kwargs := value.get("generation_kwargs"), dict):
95
+ self._span.update(input={"messages": messages, "generation_kwargs": gen_kwargs})
96
+ else:
97
+ self._span.update(input=messages)
95
98
  else:
96
99
  coerced_value = tracing_utils.coerce_tag_value(value)
97
100
  self._span.update(input=coerced_value)
@@ -125,6 +128,7 @@ class LangfuseSpan(Span):
125
128
  return self._data
126
129
 
127
130
  def get_correlation_data_for_logs(self) -> dict[str, Any]:
131
+ """Return correlation data for log enrichment."""
128
132
  return {}
129
133
 
130
134
 
@@ -234,9 +238,11 @@ class SpanHandler(ABC):
234
238
 
235
239
  @classmethod
236
240
  def from_dict(cls, data: dict[str, Any]) -> "SpanHandler":
241
+ """Deserialize a SpanHandler from a dictionary."""
237
242
  return default_from_dict(cls, data)
238
243
 
239
244
  def to_dict(self) -> dict[str, Any]:
245
+ """Serialize this SpanHandler to a dictionary."""
240
246
  return default_to_dict(self)
241
247
 
242
248
 
@@ -273,6 +279,7 @@ class DefaultSpanHandler(SpanHandler):
273
279
  """DefaultSpanHandler provides the default Langfuse tracing behavior for Haystack."""
274
280
 
275
281
  def create_span(self, context: SpanContext) -> LangfuseSpan:
282
+ """Create a Langfuse span based on the given context."""
276
283
  if self.tracer is None:
277
284
  message = (
278
285
  "Tracer is not initialized. "
@@ -290,33 +297,17 @@ class DefaultSpanHandler(SpanHandler):
290
297
  trace_context: TraceContext | None = cast(
291
298
  TraceContext, {"trace_id": tracing_ctx.get("trace_id")} if tracing_ctx.get("trace_id") else None
292
299
  )
293
- # Create a new trace when there's no parent span
300
+
294
301
  span_context_manager = self.tracer.start_as_current_observation(
295
302
  trace_context=trace_context,
296
303
  name=context.trace_name,
297
304
  version=tracing_ctx.get("version"),
298
305
  as_type=root_span_type,
299
306
  )
300
-
301
- # Create LangfuseSpan which will handle entering the context manager
302
307
  span = LangfuseSpan(span_context_manager)
303
308
 
304
- # Build trace metadata from context
305
- trace_metadata: TraceMetadata = {
306
- "name": context.trace_name,
307
- "user_id": tracing_ctx.get("user_id"),
308
- "session_id": tracing_ctx.get("session_id"),
309
- "version": tracing_ctx.get("version"),
310
- "metadata": None,
311
- "tags": tracing_ctx.get("tags"),
312
- "public": context.public,
313
- "release": None,
314
- }
315
-
316
- # Filter out None values and apply trace attributes
317
- trace_attrs = {k: v for k, v in trace_metadata.items() if v is not None}
318
- if trace_attrs:
319
- span._span.update_trace(**trace_attrs)
309
+ if context.public:
310
+ span.raw_span().set_trace_as_public()
320
311
 
321
312
  return span
322
313
 
@@ -340,15 +331,16 @@ class DefaultSpanHandler(SpanHandler):
340
331
  )
341
332
  )
342
333
  else:
343
- return LangfuseSpan(self.tracer.start_as_current_span(name=context.name))
334
+ return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name))
344
335
 
345
336
  def handle(self, span: LangfuseSpan, component_type: str | None) -> None:
337
+ """Process and enrich a span after component execution."""
346
338
  # If the span is at the pipeline level, we add input and output keys to the span
347
339
  at_pipeline_level = span.get_data().get(_PIPELINE_INPUT_KEY) is not None
348
340
  if at_pipeline_level:
349
341
  coerced_input = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_INPUT_KEY))
350
342
  coerced_output = tracing_utils.coerce_tag_value(span.get_data().get(_PIPELINE_OUTPUT_KEY))
351
- span.raw_span().update_trace(input=coerced_input, output=coerced_output)
343
+ span.raw_span().update(input=coerced_input, output=coerced_output)
352
344
  # special case for ToolInvoker (to update the span name to be: `original_component_name - [tool_names]`)
353
345
  if component_type == "ToolInvoker":
354
346
  tool_names: list[str] = []
@@ -456,9 +448,11 @@ class LangfuseTracer(Tracer):
456
448
  def trace(
457
449
  self, operation_name: str, tags: dict[str, Any] | None = None, parent_span: Span | None = None
458
450
  ) -> Iterator[Span]:
451
+ """Create and manage a tracing span as a context manager."""
459
452
  tags = tags or {}
460
453
  span_name = tags.get(_COMPONENT_NAME_KEY, operation_name)
461
454
  component_type = tags.get(_COMPONENT_TYPE_KEY)
455
+ is_root_span = not (parent_span or self.current_span())
462
456
 
463
457
  # Create a new span context
464
458
  span_context = SpanContext(
@@ -473,76 +467,94 @@ class LangfuseTracer(Tracer):
473
467
  public=self._public,
474
468
  )
475
469
 
476
- # Create span using the handler
477
- span = self._span_handler.create_span(span_context)
470
+ tracing_ctx = tracing_context_var.get({})
478
471
 
479
- # Build new span hierarchy: copy existing stack, add new span, save for restoration
480
- prev_stack = span_stack_var.get()
481
- new_stack = (prev_stack or []).copy()
482
- new_stack.append(span)
483
- token = span_stack_var.set(new_stack)
472
+ # we need to properly exit context managers
473
+ with contextlib.ExitStack() as stack:
474
+ if is_root_span:
475
+ # propagate_attributes sets trace_name and other trace-level metadata on the root
476
+ # span and ALL child spans created within this context (full trace lifetime)
477
+ stack.enter_context(
478
+ propagate_attributes(
479
+ trace_name=self._name,
480
+ user_id=tracing_ctx.get("user_id") if tracing_ctx.get("user_id") is not None else None,
481
+ session_id=tracing_ctx.get("session_id") if tracing_ctx.get("session_id") is not None else None,
482
+ version=tracing_ctx.get("version") if tracing_ctx.get("version") is not None else None,
483
+ tags=tracing_ctx.get("tags") if tracing_ctx.get("tags") is not None else None,
484
+ )
485
+ )
484
486
 
485
- span.set_tags(tags)
487
+ # Create span using the handler
488
+ span = self._span_handler.create_span(span_context)
486
489
 
487
- try:
488
- yield span
489
- except Exception:
490
- # Exception occurred - capture exception info and pass to __exit__
491
- # This allows Langfuse/OpenTelemetry to properly mark the span with ERROR level
492
- exc_info = sys.exc_info()
493
- try:
494
- # Process span data (may fail with nested pipeline exceptions)
495
- self._span_handler.handle(span, component_type)
496
-
497
- # End span with exception info (may fail if span data is corrupted)
498
- raw_span = span.raw_span()
499
- if span._context_manager is not None:
500
- # Pass actual exception info to mark span as failed with ERROR level
501
- span._context_manager.__exit__(*exc_info)
502
- elif hasattr(raw_span, "end"):
503
- # Only call end() if it's not a context manager
504
- raw_span.end()
505
- except Exception as cleanup_error:
506
- # Log cleanup errors but don't let them corrupt context
507
- logger.warning(
508
- "Error during span cleanup for {operation_name}: {cleanup_error}",
509
- operation_name=operation_name,
510
- cleanup_error=cleanup_error,
511
- )
490
+ # Build new span hierarchy: copy existing stack, add new span, save for restoration
491
+ prev_stack = span_stack_var.get()
492
+ new_stack = (prev_stack or []).copy()
493
+ new_stack.append(span)
494
+ token = span_stack_var.set(new_stack)
512
495
 
513
- # Re-raise the original exception
514
- raise
515
- else:
516
- # No exception - clean exit with success status
517
- # This preserves any manually-set log levels (WARNING, DEBUG)
518
- try:
519
- # Process span data
520
- self._span_handler.handle(span, component_type)
521
-
522
- # End span successfully
523
- raw_span = span.raw_span()
524
- # In v3, we need to properly exit context managers
525
- if span._context_manager is not None:
526
- # No exception - pass None to indicate success
527
- span._context_manager.__exit__(None, None, None)
528
- elif hasattr(raw_span, "end"):
529
- # Only call end() if it's not a context manager
530
- raw_span.end()
531
- except Exception as cleanup_error:
532
- # Log cleanup errors but don't let them corrupt context
533
- logger.warning(
534
- "Error during span cleanup for {operation_name}: {cleanup_error}",
535
- operation_name=operation_name,
536
- cleanup_error=cleanup_error,
537
- )
538
- finally:
539
- # Restore previous span stack using saved token
540
- span_stack_var.reset(token)
496
+ span.set_tags(tags)
541
497
 
542
- if self.enforce_flush:
543
- self.flush()
498
+ try:
499
+ yield span
500
+ except Exception:
501
+ # Exception occurred - capture exception info and pass to __exit__
502
+ # This allows Langfuse/OpenTelemetry to properly mark the span with ERROR level
503
+ exc_info = sys.exc_info()
504
+ try:
505
+ # Process span data (may fail with nested pipeline exceptions)
506
+ self._span_handler.handle(span, component_type)
507
+
508
+ # End span with exception info (may fail if span data is corrupted)
509
+ raw_span = span.raw_span()
510
+ if span._context_manager is not None:
511
+ # Pass actual exception info to mark span as failed with ERROR level
512
+ span._context_manager.__exit__(*exc_info)
513
+ elif hasattr(raw_span, "end"):
514
+ # Only call end() if it's not a context manager
515
+ raw_span.end()
516
+ except Exception as cleanup_error:
517
+ # Log cleanup errors but don't let them corrupt context
518
+ logger.warning(
519
+ "Error during span cleanup for {operation_name}: {cleanup_error}",
520
+ operation_name=operation_name,
521
+ cleanup_error=cleanup_error,
522
+ )
523
+
524
+ # Re-raise the original exception
525
+ raise
526
+ else:
527
+ # No exception - clean exit with success status
528
+ # This preserves any manually-set log levels (WARNING, DEBUG)
529
+ try:
530
+ # Process span data
531
+ self._span_handler.handle(span, component_type)
532
+
533
+ # End span successfully
534
+ raw_span = span.raw_span()
535
+ # In v3, we need to properly exit context managers
536
+ if span._context_manager is not None:
537
+ # No exception - pass None to indicate success
538
+ span._context_manager.__exit__(None, None, None)
539
+ elif hasattr(raw_span, "end"):
540
+ # Only call end() if it's not a context manager
541
+ raw_span.end()
542
+ except Exception as cleanup_error:
543
+ # Log cleanup errors but don't let them corrupt context
544
+ logger.warning(
545
+ "Error during span cleanup for {operation_name}: {cleanup_error}",
546
+ operation_name=operation_name,
547
+ cleanup_error=cleanup_error,
548
+ )
549
+ finally:
550
+ # Restore previous span stack using saved token
551
+ span_stack_var.reset(token)
552
+
553
+ if self.enforce_flush:
554
+ self.flush()
544
555
 
545
556
  def flush(self) -> None:
557
+ """Flush all pending spans to Langfuse."""
546
558
  self._tracer.flush()
547
559
 
548
560
  def current_span(self) -> Span | None:
@@ -558,6 +570,7 @@ class LangfuseTracer(Tracer):
558
570
  def get_trace_url(self) -> str:
559
571
  """
560
572
  Return the URL to the tracing data.
573
+
561
574
  :return: The URL to the tracing data.
562
575
  """
563
576
  return self._tracer.get_trace_url() or ""
@@ -565,6 +578,7 @@ class LangfuseTracer(Tracer):
565
578
  def get_trace_id(self) -> str:
566
579
  """
567
580
  Return the trace ID.
581
+
568
582
  :return: The trace ID.
569
583
  """
570
584
  return self._tracer.get_current_trace_id() or ""
@@ -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 v3 context managers"""
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 update_trace(self, **kwargs):
67
- # v3 API method for updating trace-level data
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,44 +128,57 @@ 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
- mock_context_manager._span.update.reset_mock()
144
- span.set_content_tag("test.output", "output_value")
145
- # Check that the span.update method was called with output parameter
146
- mock_context_manager._span.update.assert_called_with(output="output_value")
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
- # test message input
153
144
  span = LangfuseSpan(mock_context_manager)
154
- span.set_content_tag("key.input", {"messages": [ChatMessage.from_user("message")]})
155
- assert mock_context_manager._span.update.call_count == 1
156
- # check we converted ChatMessage to OpenAI format
157
- assert mock_context_manager._span.update.call_args_list[0][1] == {
158
- "input": [{"role": "user", "content": "message"}]
159
- }
160
- # test replies ChatMessage list
161
- mock_context_manager._span.update.reset_mock()
162
- span.set_content_tag("key.output", {"replies": [ChatMessage.from_system("reply")]})
163
- assert mock_context_manager._span.update.call_count == 1
164
- # check we converted ChatMessage to OpenAI format
165
- assert mock_context_manager._span.update.call_args_list[0][1] == {
166
- "output": [{"role": "system", "content": "reply"}]
167
- }
168
145
 
169
- # test replies string list
170
- mock_context_manager._span.update.reset_mock()
171
- span.set_content_tag("key.output", {"replies": ["reply1", "reply2"]})
172
- assert mock_context_manager._span.update.call_count == 1
173
- # check we handle properly string list replies
174
- assert mock_context_manager._span.update.call_args_list[0][1] == {"output": ["reply1", "reply2"]}
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"}]
152
+ }
153
+
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"]}
175
182
 
176
183
 
177
184
  class TestSpanContext:
@@ -226,7 +233,6 @@ class TestDefaultSpanHandler:
226
233
  def test_create_span_root_trace(self):
227
234
  """Test creating a root span (no parent)."""
228
235
  mock_client = Mock()
229
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
230
236
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
231
237
 
232
238
  handler = DefaultSpanHandler()
@@ -261,6 +267,24 @@ class TestDefaultSpanHandler:
261
267
  finally:
262
268
  tracing_context_var.reset(token)
263
269
 
270
+ def test_create_span_root_trace_public(self):
271
+ """Test that public=True calls set_trace_as_public on the span."""
272
+ mock_client = Mock()
273
+ mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
274
+
275
+ handler = DefaultSpanHandler()
276
+ handler.init_tracer(mock_client)
277
+
278
+ context = MagicMock(spec=SpanContext)
279
+ context.parent_span = None
280
+ context.trace_name = "public_trace"
281
+ context.operation_name = "haystack.pipeline.run"
282
+ context.public = True
283
+
284
+ result = handler.create_span(context)
285
+
286
+ assert result.raw_span()._data.get("public") is True
287
+
264
288
  def test_handle_generator(self):
265
289
  mock_span = Mock()
266
290
  mock_span.raw_span.return_value = mock_span
@@ -332,7 +356,6 @@ class TestDefaultSpanHandler:
332
356
  def test_create_span_custom_chat_generator(self):
333
357
  """Test that custom chat generators create 'generation' span type."""
334
358
  mock_client = Mock()
335
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
336
359
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
337
360
 
338
361
  handler = DefaultSpanHandler()
@@ -343,8 +366,9 @@ class TestDefaultSpanHandler:
343
366
  operation_name="haystack.component.run",
344
367
  component_type="MistralChatGenerator",
345
368
  tags={},
346
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
369
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
347
370
  )
371
+ mock_client.start_as_current_observation.reset_mock()
348
372
 
349
373
  span = handler.create_span(context)
350
374
  assert isinstance(span, LangfuseSpan)
@@ -355,7 +379,6 @@ class TestDefaultSpanHandler:
355
379
  def test_create_span_custom_generator(self):
356
380
  """Test that custom generators create 'generation' span type."""
357
381
  mock_client = Mock()
358
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
359
382
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
360
383
 
361
384
  handler = DefaultSpanHandler()
@@ -366,8 +389,9 @@ class TestDefaultSpanHandler:
366
389
  operation_name="haystack.component.run",
367
390
  component_type="CustomAPIGenerator",
368
391
  tags={},
369
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
392
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
370
393
  )
394
+ mock_client.start_as_current_observation.reset_mock()
371
395
 
372
396
  span = handler.create_span(context)
373
397
  assert isinstance(span, LangfuseSpan)
@@ -378,7 +402,6 @@ class TestDefaultSpanHandler:
378
402
  def test_create_span_retriever(self):
379
403
  """Test that retrievers create 'retriever' span type."""
380
404
  mock_client = Mock()
381
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
382
405
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
383
406
 
384
407
  handler = DefaultSpanHandler()
@@ -389,8 +412,9 @@ class TestDefaultSpanHandler:
389
412
  operation_name="haystack.component.run",
390
413
  component_type="InMemoryBM25Retriever",
391
414
  tags={},
392
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
415
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
393
416
  )
417
+ mock_client.start_as_current_observation.reset_mock()
394
418
 
395
419
  span = handler.create_span(context)
396
420
  assert isinstance(span, LangfuseSpan)
@@ -401,7 +425,6 @@ class TestDefaultSpanHandler:
401
425
  def test_create_span_embedder(self):
402
426
  """Test that embedders create 'embedding' span type."""
403
427
  mock_client = Mock()
404
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
405
428
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
406
429
 
407
430
  handler = DefaultSpanHandler()
@@ -412,8 +435,9 @@ class TestDefaultSpanHandler:
412
435
  operation_name="haystack.component.run",
413
436
  component_type="SentenceTransformersDocumentEmbedder",
414
437
  tags={},
415
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
438
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
416
439
  )
440
+ mock_client.start_as_current_observation.reset_mock()
417
441
 
418
442
  span = handler.create_span(context)
419
443
  assert isinstance(span, LangfuseSpan)
@@ -422,9 +446,8 @@ class TestDefaultSpanHandler:
422
446
  )
423
447
 
424
448
  def test_create_span_non_component(self):
425
- """Test that non-matching components create regular spans."""
449
+ """Test that non-matching components create default span type."""
426
450
  mock_client = Mock()
427
- mock_client.start_as_current_span = Mock(return_value=MockContextManager())
428
451
  mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
429
452
 
430
453
  handler = DefaultSpanHandler()
@@ -435,15 +458,14 @@ class TestDefaultSpanHandler:
435
458
  operation_name="haystack.component.run",
436
459
  component_type="DocumentJoiner",
437
460
  tags={},
438
- parent_span=LangfuseSpan(mock_client.start_as_current_span()),
461
+ parent_span=LangfuseSpan(mock_client.start_as_current_observation()),
439
462
  )
463
+ mock_client.start_as_current_observation.reset_mock()
440
464
 
441
465
  span = handler.create_span(context)
442
466
  assert isinstance(span, LangfuseSpan)
443
- # Non-matching components should use start_as_current_span, not start_as_current_observation
444
- mock_client.start_as_current_observation.assert_not_called()
445
- # Verify start_as_current_span was called for the actual span creation (not just parent)
446
- assert mock_client.start_as_current_span.call_count == 2 # Once for parent, once for the span
467
+ # Non-matching components use start_as_current_observation without explicit as_type
468
+ mock_client.start_as_current_observation.assert_called_once_with(name="DocumentJoiner")
447
469
 
448
470
  def test_handle_embedder_with_openai_format(self):
449
471
  """Test that embedder usage is extracted in OpenAI format."""
@@ -568,7 +590,7 @@ class TestLangfuseTracer:
568
590
  assert tracer._public
569
591
 
570
592
  def test_create_new_span(self):
571
- mock_raw_span = MagicMock()
593
+ mock_raw_span = Mock()
572
594
  mock_raw_span.operation_name = "operation_name"
573
595
  mock_raw_span.metadata = {"tag1": "value1", "tag2": "value2"}
574
596
 
@@ -586,8 +608,7 @@ class TestLangfuseTracer:
586
608
  mock_span_instance.get_data.return_value = {}
587
609
  mock_span_instance._context_manager = mock_context_manager
588
610
 
589
- mock_tracer = MagicMock()
590
- mock_tracer.start_as_current_span.return_value = mock_context_manager
611
+ mock_tracer = Mock()
591
612
  mock_tracer.start_as_current_observation.return_value = mock_context_manager
592
613
 
593
614
  tracer = LangfuseTracer(tracer=mock_tracer, name="Haystack", public=False)
@@ -603,15 +624,42 @@ class TestLangfuseTracer:
603
624
  # Check that the span is cleaned up after tracing
604
625
  assert tracer.current_span() is None, "There should be no active span after tracing completes"
605
626
 
606
- # check that update method is called on the span instance with the provided key value pairs
607
- def test_update_span_with_pipeline_input_output_data(self):
608
- with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client"):
609
- tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
610
- with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span:
611
- assert span.raw_span()._data["metadata"] == {"haystack.pipeline.input_data": "hello"}
627
+ def test_pipeline_input_output_written_to_root_observation(self):
628
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="Haystack", public=False)
629
+ with tracer.trace(
630
+ operation_name="operation_name",
631
+ tags={
632
+ "haystack.pipeline.input_data": "hello",
633
+ "haystack.pipeline.output_data": "bye",
634
+ },
635
+ ) as span:
636
+ pass
612
637
 
613
- with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.output_data": "bye"}) as span:
614
- assert span.raw_span()._data["metadata"] == {"haystack.pipeline.output_data": "bye"}
638
+ # MockSpan._update_data mirrors kwargs into _data, so after the context exits
639
+ # we can verify update(input=..., output=...) was actually called.
640
+ assert span.raw_span()._data["input"] == "hello"
641
+ assert span.raw_span()._data["output"] == "bye"
642
+
643
+ def test_propagate_attributes_called_with_tracing_ctx_vars(self):
644
+ token = tracing_context_var.set(
645
+ {"user_id": "user123", "session_id": "sess456", "version": "v1", "tags": ["tag1"]}
646
+ )
647
+ try:
648
+ with patch("haystack_integrations.tracing.langfuse.tracer.propagate_attributes") as mock_propagate:
649
+ mock_propagate.return_value.__enter__ = Mock(return_value=None)
650
+ mock_propagate.return_value.__exit__ = Mock(return_value=False)
651
+ tracer = LangfuseTracer(tracer=MockLangfuseClient(), name="MyPipeline", public=False)
652
+ with tracer.trace(operation_name="haystack.pipeline.run"):
653
+ pass
654
+ mock_propagate.assert_called_once_with(
655
+ trace_name="MyPipeline",
656
+ user_id="user123",
657
+ session_id="sess456",
658
+ version="v1",
659
+ tags=["tag1"],
660
+ )
661
+ finally:
662
+ tracing_context_var.reset(token)
615
663
 
616
664
  def test_trace_generation(self):
617
665
  with patch("haystack_integrations.tracing.langfuse.tracer.langfuse.get_client"):
@@ -1,29 +0,0 @@
1
- loaders:
2
- - ignore_when_discovered:
3
- - __init__
4
- modules:
5
- - haystack_integrations.components.connectors.langfuse.langfuse_connector
6
- - haystack_integrations.tracing.langfuse.tracer
7
- search_path:
8
- - ../src
9
- type: haystack_pydoc_tools.loaders.CustomPythonLoader
10
- processors:
11
- - do_not_filter_modules: false
12
- documented_only: true
13
- expression: null
14
- skip_empty_modules: true
15
- type: filter
16
- - type: smart
17
- - type: crossref
18
- renderer:
19
- description: Langfuse integration for Haystack
20
- id: integrations-langfuse
21
- markdown:
22
- add_member_class_prefix: false
23
- add_method_class_prefix: true
24
- classdef_code_block: false
25
- descriptive_class_title: false
26
- descriptive_module_title: true
27
- filename: langfuse.md
28
- title: langfuse
29
- type: haystack_pydoc_tools.renderers.DocusaurusRenderer