66import json
77from concurrent .futures import ThreadPoolExecutor
88from dataclasses import replace
9+ from functools import partial
910from typing import Any , Literal
1011
1112from jinja2 import meta
1213from jinja2 .sandbox import SandboxedEnvironment
1314
14- from haystack import Document , component , default_from_dict , default_to_dict , logging
15+ from haystack import Document , component , default_from_dict , default_to_dict , logging , tracing
1516from haystack .components .converters .image .document_to_image import DocumentToImageContent
1617from haystack .components .generators .chat .types import ChatGenerator
18+ from haystack .components .generators .utils import _trace_chat_generator_run
1719from haystack .core .serialization import component_to_dict
1820from haystack .dataclasses import ImageContent , TextContent
1921from haystack .dataclasses .chat_message import ChatMessage
@@ -267,11 +269,14 @@ def _process_response(response_text: str) -> tuple[str | None, dict[str, Any], s
267269 meta_updates = {k : v for k , v in parsed .items () if k != DOCUMENT_CONTENT_KEY }
268270 return content , meta_updates , None
269271
270- def _run_on_thread (self , image_content : ImageContent | None ) -> dict [str , Any ]:
272+ def _run_on_thread (
273+ self , image_content : ImageContent | None , parent_span : tracing .Span | None = None
274+ ) -> dict [str , Any ]:
271275 """
272276 Execute the LLM inference in a separate thread for each document.
273277
274278 :param image_content: The image content for one document, or None if conversion failed.
279+ :param parent_span: Span to nest the generator span under, captured on the calling thread.
275280 :returns:
276281 The LLM response if successful, or a dictionary with an "error" key on failure.
277282 """
@@ -282,7 +287,11 @@ def _run_on_thread(self, image_content: ImageContent | None) -> dict[str, Any]:
282287 message = ChatMessage .from_user (content_parts = [TextContent (text = self .prompt ), image_content ])
283288
284289 try :
285- result = self ._chat_generator .run (messages = [message ])
290+ with _trace_chat_generator_run (
291+ self ._chat_generator , {"messages" : [message ]}, parent_span = parent_span
292+ ) as span :
293+ result = self ._chat_generator .run (messages = [message ])
294+ span .set_content_tag ("haystack.component.output" , result )
286295 except Exception as e :
287296 if self .raise_on_failure :
288297 raise e
@@ -295,11 +304,14 @@ def _run_on_thread(self, image_content: ImageContent | None) -> dict[str, Any]:
295304
296305 return result
297306
298- async def _run_async (self , image_content : ImageContent | None ) -> dict [str , Any ]:
307+ async def _run_async (
308+ self , image_content : ImageContent | None , parent_span : tracing .Span | None = None
309+ ) -> dict [str , Any ]:
299310 """
300311 Execute the LLM inference asynchronously for each document.
301312
302313 :param image_content: The image content for one document, or None if conversion failed.
314+ :param parent_span: Span to nest the generator span under, captured on the calling task.
303315 :returns:
304316 The LLM response if successful, or a dictionary with an "error" key on failure.
305317 """
@@ -310,7 +322,11 @@ async def _run_async(self, image_content: ImageContent | None) -> dict[str, Any]
310322 message = ChatMessage .from_user (content_parts = [TextContent (text = self .prompt ), image_content ])
311323
312324 try :
313- result = await _execute_component_async (self ._chat_generator , messages = [message ])
325+ with _trace_chat_generator_run (
326+ self ._chat_generator , {"messages" : [message ]}, parent_span = parent_span
327+ ) as span :
328+ result = await _execute_component_async (self ._chat_generator , messages = [message ])
329+ span .set_content_tag ("haystack.component.output" , result )
314330 except Exception as e :
315331 if self .raise_on_failure :
316332 raise e
@@ -366,8 +382,11 @@ def run(self, documents: list[Document]) -> dict[str, list[Document]]:
366382
367383 image_contents = self ._document_to_image_content .run (documents = documents )["image_contents" ]
368384
385+ # Capture the current span here so worker threads nest their generator spans under the component span.
386+ parent_span = tracing .tracer .current_span ()
387+
369388 with ThreadPoolExecutor (max_workers = self .max_workers ) as executor :
370- results = executor .map (self ._run_on_thread , image_contents )
389+ results = executor .map (partial ( self ._run_on_thread , parent_span = parent_span ) , image_contents )
371390
372391 successful_documents = []
373392 failed_documents = []
@@ -401,12 +420,15 @@ async def run_async(self, documents: list[Document]) -> dict[str, list[Document]
401420
402421 image_contents = self ._document_to_image_content .run (documents = documents )["image_contents" ]
403422
423+ # Capture the current span here so concurrent tasks nest their generator spans under the component span.
424+ parent_span = tracing .tracer .current_span ()
425+
404426 # Run the LLM on each image content, bounding concurrency per task so max_workers is enforced.
405427 sem = asyncio .Semaphore (max (1 , self .max_workers ))
406428
407429 async def _bounded_run (image_content : ImageContent | None ) -> dict [str , Any ]:
408430 async with sem :
409- return await self ._run_async (image_content )
431+ return await self ._run_async (image_content , parent_span = parent_span )
410432
411433 results = await asyncio .gather (* [_bounded_run (image_content ) for image_content in image_contents ])
412434
0 commit comments