Skip to content

Commit a5514e7

Browse files
authored
fix: Fix tracing of a pipelines outputs (#12090)
1 parent d6d08e1 commit a5514e7

5 files changed

Lines changed: 91 additions & 3 deletions

File tree

haystack/core/pipeline/pipeline.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,6 @@ def run( # noqa: PLR0915, PLR0912, C901
386386
"haystack.pipeline.run",
387387
tags={
388388
"haystack.pipeline.input_data": data,
389-
"haystack.pipeline.output_data": pipeline_outputs,
390389
"haystack.pipeline.metadata": self.metadata,
391390
"haystack.pipeline.max_runs_per_component": self._max_runs_per_component,
392391
"haystack.pipeline.execution_mode": "sync",
@@ -517,6 +516,9 @@ def run( # noqa: PLR0915, PLR0912, C901
517516
break_point=break_point,
518517
)
519518

519+
# Set here so the tag reflects the final outputs.
520+
span.set_content_tag("haystack.pipeline.output_data", pipeline_outputs)
521+
520522
return pipeline_outputs
521523

522524
@staticmethod
@@ -907,7 +909,6 @@ async def process_results():
907909
"haystack.pipeline.run",
908910
tags={
909911
"haystack.pipeline.input_data": data,
910-
"haystack.pipeline.output_data": pipeline_outputs,
911912
"haystack.pipeline.metadata": self.metadata,
912913
"haystack.pipeline.max_runs_per_component": self._max_runs_per_component,
913914
"haystack.pipeline.execution_mode": "async",
@@ -1051,6 +1052,9 @@ async def process_results():
10511052
):
10521053
yield partial_outputs
10531054

1055+
# Set here so the tag reflects the final outputs.
1056+
parent_span.set_content_tag("haystack.pipeline.output_data", pipeline_outputs)
1057+
10541058
# Yield the final pipeline outputs.
10551059
yield pipeline_outputs
10561060
finally:
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed the ``haystack.pipeline.output_data`` tracing tag being empty. The tag was set at the start of
5+
``Pipeline.run``/``run_async`` from the still-empty outputs, and since tracing backends coerce a tag value
6+
when it is set, the recorded output was always an empty dictionary. It is now set once the run completes so
7+
it reflects the final pipeline outputs. The tag is also gated behind content tracing
8+
(``HAYSTACK_CONTENT_TRACING_ENABLED``), consistent with the component-level input/output tags.

test/conftest.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from haystack.core.serialization import allow_deserialization_module
1616
from haystack.document_stores.in_memory import InMemoryDocumentStore
1717
from haystack.testing.test_utils import set_all_seeds
18-
from test.tracing.utils import SpyingTracer
18+
from test.tracing.utils import EagerSpyingTracer, SpyingTracer
1919

2020
set_all_seeds(0)
2121

@@ -108,6 +108,18 @@ def spying_tracer() -> Generator[SpyingTracer, None, None]:
108108
tracing.disable_tracing()
109109

110110

111+
@pytest.fixture()
112+
def eager_spying_tracer() -> Generator[EagerSpyingTracer, None, None]:
113+
# Coerces tags when set, mirroring real backends. Content tracing is left to the test to toggle.
114+
tracer = EagerSpyingTracer()
115+
tracing.enable_tracing(tracer)
116+
117+
yield tracer
118+
119+
# Make sure to disable tracing after the test to avoid affecting other tests
120+
tracing.disable_tracing()
121+
122+
111123
@pytest.fixture()
112124
def base64_image_string():
113125
return "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="

test/core/pipeline/test_tracing.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# SPDX-License-Identifier: Apache-2.0
44

55
import asyncio
6+
import json
67
from dataclasses import replace
78
from unittest.mock import ANY
89

@@ -156,6 +157,30 @@ def test_with_enabled_content_tracing(
156157
),
157158
]
158159

160+
@pytest.mark.parametrize("run_async", [False, True])
161+
@pytest.mark.parametrize("content_tracing", [True, False])
162+
def test_pipeline_output_data_trace_tag(
163+
self, pipeline, run_async, content_tracing, eager_spying_tracer, monkeypatch
164+
):
165+
"""
166+
output_data holds the final outputs and is gated behind content tracing.
167+
168+
EagerSpyingTracer coerces tags when set (like real backends), so it catches output_data being set from the
169+
still-empty outputs dict at span start.
170+
"""
171+
monkeypatch.setattr(tracer, "is_content_tracing_enabled", content_tracing)
172+
if run_async:
173+
asyncio.run(pipeline.run_async(data={"word": "world"}))
174+
else:
175+
pipeline.run(data={"word": "world"})
176+
177+
tags = eager_spying_tracer.spans[0].tags
178+
assert json.loads(tags["haystack.pipeline.input_data"]) == {"hello": {"word": "world"}}
179+
if content_tracing:
180+
assert json.loads(tags["haystack.pipeline.output_data"]) == {"hello2": {"output": "Hello, Hello, world!!"}}
181+
else:
182+
assert "haystack.pipeline.output_data" not in tags
183+
159184
@pytest.mark.parametrize("run_async", [False, True])
160185
def test_span_input_not_affected_by_component_mutation(self, run_async, spying_tracer, monkeypatch):
161186
"""

test/tracing/utils.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Any
1010

1111
from haystack.tracing import Span, Tracer
12+
from haystack.tracing.utils import coerce_tag_value
1213

1314

1415
@dataclasses.dataclass
@@ -52,3 +53,41 @@ def trace(
5253
self.spans.append(new_span)
5354

5455
yield new_span
56+
57+
58+
@dataclasses.dataclass
59+
class EagerSpyingSpan(Span):
60+
"""
61+
A span that coerces tag values when `set_tag` is called.
62+
63+
This mirrors real tracing backends (e.g. OpenTelemetry, Datadog), which serialize a tag value eagerly
64+
when it is set instead of holding a live reference to it. It is useful to catch tags that are set from a
65+
mutable object before that object has been populated.
66+
"""
67+
68+
operation_name: str
69+
parent_span: Span | None = None
70+
tags: dict[str, Any] = dataclasses.field(default_factory=dict)
71+
72+
def set_tag(self, key: str, value: Any) -> None:
73+
self.tags[key] = coerce_tag_value(value)
74+
75+
76+
class EagerSpyingTracer(Tracer):
77+
def current_span(self) -> Span | None:
78+
return self.spans[-1] if self.spans else None
79+
80+
def __init__(self) -> None:
81+
self.spans: list[EagerSpyingSpan] = []
82+
83+
@contextlib.contextmanager
84+
def trace(
85+
self, operation_name: str, tags: dict[str, Any] | None = None, parent_span: Span | None = None
86+
) -> Iterator[Span]:
87+
new_span = EagerSpyingSpan(operation_name, parent_span)
88+
for key, value in (tags or {}).items():
89+
new_span.set_tag(key, value)
90+
91+
self.spans.append(new_span)
92+
93+
yield new_span

0 commit comments

Comments
 (0)