|
3 | 3 | # SPDX-License-Identifier: Apache-2.0 |
4 | 4 |
|
5 | 5 | import datetime |
| 6 | +import json |
6 | 7 | import logging |
7 | 8 | import sys |
8 | | -from unittest.mock import MagicMock, Mock, patch |
9 | 9 | from typing import Optional |
| 10 | +from unittest.mock import MagicMock, Mock, patch |
10 | 11 |
|
11 | 12 | import pytest |
| 13 | +from haystack import Pipeline, component |
12 | 14 | from haystack.dataclasses import ChatMessage, ToolCall |
13 | | -from haystack_integrations.tracing.langfuse.tracer import LangfuseTracer, LangfuseSpan, SpanContext, DefaultSpanHandler |
14 | | -from haystack_integrations.tracing.langfuse.tracer import _COMPONENT_OUTPUT_KEY |
| 15 | + |
| 16 | +from haystack_integrations.components.connectors.langfuse import LangfuseConnector |
| 17 | +from haystack_integrations.tracing.langfuse.tracer import ( |
| 18 | + _COMPONENT_OUTPUT_KEY, DefaultSpanHandler, LangfuseSpan, LangfuseTracer, |
| 19 | + SpanContext) |
15 | 20 |
|
16 | 21 |
|
17 | 22 | class MockSpan: |
@@ -367,7 +372,8 @@ def test_update_span_flush_disable(self, monkeypatch): |
367 | 372 | monkeypatch.setenv("HAYSTACK_LANGFUSE_ENFORCE_FLUSH", "false") |
368 | 373 | tracer_mock = Mock() |
369 | 374 |
|
370 | | - from haystack_integrations.tracing.langfuse.tracer import LangfuseTracer |
| 375 | + from haystack_integrations.tracing.langfuse.tracer import \ |
| 376 | + LangfuseTracer |
371 | 377 |
|
372 | 378 | tracer = LangfuseTracer(tracer=tracer_mock, name="Haystack", public=False) |
373 | 379 | with tracer.trace(operation_name="operation_name", tags={"haystack.pipeline.input_data": "hello"}) as span: |
@@ -397,3 +403,58 @@ def test_init_with_tracing_disabled(self, monkeypatch, caplog): |
397 | 403 |
|
398 | 404 | LangfuseTracer(tracer=MockTracer(), name="Haystack", public=False) |
399 | 405 | assert "tracing is disabled" in caplog.text |
| 406 | + |
| 407 | + def test_context_cleanup_after_nested_failures(self): |
| 408 | + """ |
| 409 | + Test that tracer context is properly cleaned up even when nested operations fail. |
| 410 | +
|
| 411 | + This test addresses a critical bug where failing nested operations (like inner pipelines) |
| 412 | + could corrupt the tracing context, leaving stale spans that affect subsequent operations. |
| 413 | + The fix ensures proper cleanup through try/finally blocks. |
| 414 | +
|
| 415 | + Before the fix: context would retain spans after failures (length > 0) |
| 416 | + After the fix: context is always cleaned up (length == 0) |
| 417 | + """ |
| 418 | + |
| 419 | + |
| 420 | + @component |
| 421 | + class FailingParser: |
| 422 | + @component.output_types(result=str) |
| 423 | + def run(self, data: str): |
| 424 | + # This will fail with ValueError when data is not valid JSON |
| 425 | + parsed = json.loads(data) |
| 426 | + return {"result": parsed["key"]} |
| 427 | + |
| 428 | + @component |
| 429 | + class ComponentWithNestedPipeline: |
| 430 | + def __init__(self): |
| 431 | + # This simulates IntentClassifier's internal pipeline |
| 432 | + self.internal_pipeline = Pipeline() |
| 433 | + self.internal_pipeline.add_component("parser", FailingParser()) |
| 434 | + |
| 435 | + @component.output_types(result=str) |
| 436 | + def run(self, input_data: str): |
| 437 | + # Run nested pipeline - this is where corruption occurs |
| 438 | + result = self.internal_pipeline.run({"parser": {"data": input_data}}) |
| 439 | + return {"result": result["parser"]["result"]} |
| 440 | + |
| 441 | + tracer = LangfuseConnector("test") |
| 442 | + |
| 443 | + main_pipeline = Pipeline() |
| 444 | + main_pipeline.add_component("nested_component", ComponentWithNestedPipeline()) |
| 445 | + main_pipeline.add_component("tracer", tracer) |
| 446 | + |
| 447 | + # Test 1: First run will fail and should clean up context |
| 448 | + try: |
| 449 | + main_pipeline.run({"nested_component": {"input_data": "invalid json"}}) |
| 450 | + except Exception: |
| 451 | + pass # Expected to fail |
| 452 | + |
| 453 | + # Critical assertion: context should be empty after failed operation |
| 454 | + assert len(tracer.tracer._context) == 0 |
| 455 | + |
| 456 | + # Test 2: Second run should work normally with clean context |
| 457 | + main_pipeline.run({"nested_component": {"input_data": '{"key": "valid"}'}}) |
| 458 | + |
| 459 | + # Critical assertion: context should be empty after successful operation |
| 460 | + assert len(tracer.tracer._context) == 0 |
0 commit comments