Skip to content

Commit 13cab92

Browse files
sjrldavidsbatista
andauthored
feat: add internal tracing to components that use chat generators (#12075)
Co-authored-by: David S. Batista <dsbatista@gmail.com>
1 parent 4fed5ce commit 13cab92

14 files changed

Lines changed: 380 additions & 63 deletions

haystack/components/evaluators/llm_evaluator.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from haystack.components.builders import PromptBuilder
1414
from haystack.components.generators.chat.openai import OpenAIChatGenerator
1515
from haystack.components.generators.chat.types import ChatGenerator
16+
from haystack.components.generators.utils import _trace_chat_generator_run
1617
from haystack.core.serialization import component_to_dict
1718
from haystack.dataclasses.chat_message import ChatMessage
1819
from haystack.utils import deserialize_chatgenerator_inplace, deserialize_type, serialize_type
@@ -232,7 +233,9 @@ def run(self, **inputs: Any) -> dict[str, Any]:
232233
prompt = self.builder.run(**input_names_to_values)
233234
messages = [ChatMessage.from_user(prompt["prompt"])]
234235
try:
235-
result = self._chat_generator.run(messages=messages)
236+
with _trace_chat_generator_run(self._chat_generator, {"messages": messages}) as span:
237+
result = self._chat_generator.run(messages=messages)
238+
span.set_content_tag("haystack.component.output", result)
236239
except Exception as e:
237240
if self.raise_on_failure:
238241
raise ValueError(f"Error while generating response for prompt: {prompt}. Error: {e}") from e
@@ -301,15 +304,17 @@ async def run_async(self, **inputs: Any) -> dict[str, Any]:
301304
prompt = self.builder.run(**input_names_to_values)
302305
messages = [ChatMessage.from_user(prompt["prompt"])]
303306
try:
304-
if generator_has_async:
305-
result = await self._chat_generator.run_async(messages=messages) # type: ignore[attr-defined]
306-
else:
307-
logger.debug(
308-
"{generator_type} does not implement 'run_async'."
309-
" Running the synchronous 'run' method in a thread to avoid blocking the event loop.",
310-
generator_type=type(self._chat_generator).__name__,
311-
)
312-
result = await asyncio.to_thread(self._chat_generator.run, messages=messages)
307+
with _trace_chat_generator_run(self._chat_generator, {"messages": messages}) as span:
308+
if generator_has_async:
309+
result = await self._chat_generator.run_async(messages=messages) # type: ignore[attr-defined]
310+
else:
311+
logger.debug(
312+
"{generator_type} does not implement 'run_async'."
313+
" Running the synchronous 'run' method in a thread to avoid blocking the event loop.",
314+
generator_type=type(self._chat_generator).__name__,
315+
)
316+
result = await asyncio.to_thread(self._chat_generator.run, messages=messages)
317+
span.set_content_tag("haystack.component.output", result)
313318
except Exception as e:
314319
if self.raise_on_failure:
315320
raise ValueError(f"Error while generating response for prompt: {prompt}. Error: {e}") from e

haystack/components/extractors/image/llm_document_content_extractor.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@
66
import json
77
from concurrent.futures import ThreadPoolExecutor
88
from dataclasses import replace
9+
from functools import partial
910
from typing import Any, Literal
1011

1112
from jinja2 import meta
1213
from 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
1516
from haystack.components.converters.image.document_to_image import DocumentToImageContent
1617
from haystack.components.generators.chat.types import ChatGenerator
18+
from haystack.components.generators.utils import _trace_chat_generator_run
1719
from haystack.core.serialization import component_to_dict
1820
from haystack.dataclasses import ImageContent, TextContent
1921
from 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

haystack/components/extractors/llm_metadata_extractor.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
from collections.abc import Iterable
99
from concurrent.futures import ThreadPoolExecutor
1010
from dataclasses import replace
11+
from functools import partial
1112
from typing import Any
1213

1314
from jinja2 import meta
1415
from jinja2.sandbox import SandboxedEnvironment
1516

16-
from haystack import Document, component, default_from_dict, default_to_dict, logging
17+
from haystack import Document, component, default_from_dict, default_to_dict, logging, tracing
1718
from haystack.components.builders import PromptBuilder
1819
from haystack.components.generators.chat.types import ChatGenerator
20+
from haystack.components.generators.utils import _trace_chat_generator_run
1921
from haystack.components.preprocessors import DocumentSplitter
2022
from haystack.core.serialization import component_to_dict
2123
from haystack.dataclasses import ChatMessage
@@ -309,13 +311,17 @@ def _prepare_prompts(
309311

310312
return all_prompts
311313

312-
def _run_on_thread(self, prompt: ChatMessage | None) -> dict[str, Any]:
314+
def _run_on_thread(self, prompt: ChatMessage | None, parent_span: tracing.Span | None = None) -> dict[str, Any]:
313315
# If prompt is None, return an error dictionary
314316
if prompt is None:
315317
return {"error": "Document has no content, skipping LLM call."}
316318

317319
try:
318-
result = self._chat_generator.run(messages=[prompt])
320+
with _trace_chat_generator_run(
321+
self._chat_generator, {"messages": [prompt]}, parent_span=parent_span
322+
) as span:
323+
result = self._chat_generator.run(messages=[prompt])
324+
span.set_content_tag("haystack.component.output", result)
319325
except Exception as e:
320326
if self.raise_on_failure:
321327
raise e
@@ -327,13 +333,17 @@ def _run_on_thread(self, prompt: ChatMessage | None) -> dict[str, Any]:
327333
result = {"error": "LLM failed with exception: " + str(e)}
328334
return result
329335

330-
async def _run_async(self, prompt: ChatMessage | None) -> dict[str, Any]:
336+
async def _run_async(self, prompt: ChatMessage | None, parent_span: tracing.Span | None = None) -> dict[str, Any]:
331337
# If prompt is None, return an error dictionary
332338
if prompt is None:
333339
return {"error": "Document has no content, skipping LLM call."}
334340

335341
try:
336-
result = await _execute_component_async(self._chat_generator, messages=[prompt])
342+
with _trace_chat_generator_run(
343+
self._chat_generator, {"messages": [prompt]}, parent_span=parent_span
344+
) as span:
345+
result = await _execute_component_async(self._chat_generator, messages=[prompt])
346+
span.set_content_tag("haystack.component.output", result)
337347
except Exception as e:
338348
if self.raise_on_failure:
339349
raise e
@@ -411,9 +421,12 @@ def run(self, documents: list[Document], page_range: list[str | int] | None = No
411421
# Create ChatMessage prompts for each document
412422
all_prompts = self._prepare_prompts(documents=documents, expanded_range=expanded_range)
413423

424+
# Capture the current span here so worker threads nest their generator spans under the component span.
425+
parent_span = tracing.tracer.current_span()
426+
414427
# Run the LLM on each prompt
415428
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
416-
results = executor.map(self._run_on_thread, all_prompts)
429+
results = executor.map(partial(self._run_on_thread, parent_span=parent_span), all_prompts)
417430

418431
successful_documents, failed_documents = self._process_results(documents, results)
419432

@@ -460,12 +473,15 @@ async def run_async(self, documents: list[Document], page_range: list[str | int]
460473
# Create ChatMessage prompts for each document
461474
all_prompts = self._prepare_prompts(documents=documents, expanded_range=expanded_range)
462475

476+
# Capture the current span here so concurrent tasks nest their generator spans under the component span.
477+
parent_span = tracing.tracer.current_span()
478+
463479
# Run the LLM on each prompt, bounding concurrency per task so max_workers is enforced.
464480
sem = Semaphore(max(1, self.max_workers))
465481

466482
async def _bounded_run(prompt: ChatMessage | None) -> dict[str, Any]:
467483
async with sem:
468-
return await self._run_async(prompt)
484+
return await self._run_async(prompt, parent_span=parent_span)
469485

470486
results = await gather(*[_bounded_run(prompt) for prompt in all_prompts])
471487

haystack/components/generators/utils.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,53 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5+
import contextlib
56
import json
7+
from collections.abc import Iterator
68
from typing import Any
79

8-
from haystack import logging
10+
from haystack import logging, tracing
911
from haystack.dataclasses import ChatMessage, ReasoningContent, StreamingChunk, ToolCall
1012

1113
logger = logging.getLogger(__name__)
1214

1315

16+
@contextlib.contextmanager
17+
def _trace_chat_generator_run(
18+
chat_generator: Any, generator_inputs: dict[str, Any], parent_span: tracing.Span | None = None
19+
) -> Iterator[tracing.Span]:
20+
"""
21+
Open a tracing span around a ChatGenerator call made internally by another component.
22+
23+
Components that embed a ChatGenerator but do not return its `ChatMessage` replies (for example rankers,
24+
extractors or evaluators) would otherwise discard the LLM token usage carried in `reply.meta["usage"]`.
25+
Wrapping the internal call in this span re-exposes that usage to tracers via the `haystack.component.output`
26+
content tag, mirroring how the `Pipeline` traces its top-level components.
27+
28+
The caller is responsible for setting the output tag inside the context, so it is skipped when the call fails:
29+
30+
```python
31+
with _trace_chat_generator_run(self._chat_generator, {"messages": messages}) as span:
32+
result = self._chat_generator.run(messages=messages)
33+
span.set_content_tag("haystack.component.output", result)
34+
```
35+
36+
:param chat_generator: The ChatGenerator being invoked.
37+
:param generator_inputs: The inputs passed to the generator, recorded as the span input content tag.
38+
:param parent_span: Explicit parent span. Defaults to the current span. Pass it explicitly when the generator
39+
runs in a worker thread, where the ambient span context does not propagate.
40+
"""
41+
# Fall back to the active span so same-thread callers get correct nesting without passing a parent explicitly.
42+
parent_span = parent_span or tracing.tracer.current_span()
43+
with tracing.tracer.trace(
44+
"haystack.chat_generator.run",
45+
tags={"haystack.component.name": "chat_generator", "haystack.component.type": type(chat_generator).__name__},
46+
parent_span=parent_span,
47+
) as span:
48+
span.set_content_tag("haystack.component.input", generator_inputs)
49+
yield span
50+
51+
1452
def print_streaming_chunk(chunk: StreamingChunk) -> None:
1553
"""
1654
Callback function to handle and display streaming output chunks.

haystack/components/query/query_expander.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from haystack.components.builders.prompt_builder import PromptBuilder
99
from haystack.components.generators.chat.openai import OpenAIChatGenerator
1010
from haystack.components.generators.chat.types import ChatGenerator
11+
from haystack.components.generators.utils import _trace_chat_generator_run
1112
from haystack.core.component import component
1213
from haystack.core.serialization import component_to_dict
1314
from haystack.dataclasses.chat_message import ChatMessage
@@ -209,7 +210,10 @@ def run(self, query: str, n_expansions: int | None = None) -> dict[str, list[str
209210

210211
try:
211212
prompt_result = self._prompt_builder.run(query=query.strip(), n_expansions=expansion_count)
212-
generator_result = self.chat_generator.run(messages=[ChatMessage.from_user(prompt_result["prompt"])])
213+
messages = [ChatMessage.from_user(prompt_result["prompt"])]
214+
with _trace_chat_generator_run(self.chat_generator, {"messages": messages}) as span:
215+
generator_result = self.chat_generator.run(messages=messages)
216+
span.set_content_tag("haystack.component.output", generator_result)
213217

214218
if not generator_result.get("replies") or len(generator_result["replies"]) == 0:
215219
logger.warning("ChatGenerator returned no replies for query: {query}", query=query)
@@ -276,9 +280,10 @@ async def run_async(self, query: str, n_expansions: int | None = None) -> dict[s
276280

277281
try:
278282
prompt_result = self._prompt_builder.run(query=query.strip(), n_expansions=expansion_count)
279-
generator_result = await _execute_component_async(
280-
self.chat_generator, messages=[ChatMessage.from_user(prompt_result["prompt"])]
281-
)
283+
messages = [ChatMessage.from_user(prompt_result["prompt"])]
284+
with _trace_chat_generator_run(self.chat_generator, {"messages": messages}) as span:
285+
generator_result = await _execute_component_async(self.chat_generator, messages=messages)
286+
span.set_content_tag("haystack.component.output", generator_result)
282287

283288
if not generator_result.get("replies") or len(generator_result["replies"]) == 0:
284289
logger.warning("ChatGenerator returned no replies for query: {query}", query=query)

haystack/components/rankers/llm_ranker.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from haystack.components.builders import PromptBuilder
99
from haystack.components.generators.chat.openai import OpenAIChatGenerator
1010
from haystack.components.generators.chat.types import ChatGenerator
11+
from haystack.components.generators.utils import _trace_chat_generator_run
1112
from haystack.core.serialization import component_to_dict
1213
from haystack.dataclasses import ChatMessage
1314
from haystack.utils import deserialize_chatgenerator_inplace
@@ -259,9 +260,12 @@ def run(self, query: str, documents: list[Document], top_k: int | None = None) -
259260
self.warm_up()
260261

261262
prompt = self._prompt_builder.run(query=query.strip(), documents=deduplicated_documents)
263+
messages = [ChatMessage.from_user(prompt["prompt"])]
262264

263265
try:
264-
result = self._chat_generator.run(messages=[ChatMessage.from_user(prompt["prompt"])])
266+
with _trace_chat_generator_run(self._chat_generator, {"messages": messages}) as span:
267+
result = self._chat_generator.run(messages=messages)
268+
span.set_content_tag("haystack.component.output", result)
265269
except Exception as exc:
266270
if self.raise_on_failure:
267271
raise
@@ -323,11 +327,12 @@ async def run_async(
323327
await self.warm_up_async()
324328

325329
prompt = self._prompt_builder.run(query=query.strip(), documents=deduplicated_documents)
330+
messages = [ChatMessage.from_user(prompt["prompt"])]
326331

327332
try:
328-
result = await _execute_component_async(
329-
self._chat_generator, messages=[ChatMessage.from_user(prompt["prompt"])]
330-
)
333+
with _trace_chat_generator_run(self._chat_generator, {"messages": messages}) as span:
334+
result = await _execute_component_async(self._chat_generator, messages=messages)
335+
span.set_content_tag("haystack.component.output", result)
331336
except Exception as exc:
332337
if self.raise_on_failure:
333338
raise

0 commit comments

Comments
 (0)