1- from typing import Sequence
1+ import logging
2+ from typing import Any , Callable , Generator , Sequence
23
4+ import hayhooks
5+ from haystack import Pipeline
36from haystack .dataclasses .chat_message import ChatMessage
7+ from openinference .instrumentation import using_metadata
8+ from opentelemetry .trace import Span
9+ from opentelemetry .trace .status import Status , StatusCode
410from phoenix .client .__generated__ import v1
511
612from src .common import phoenix_utils
@@ -12,7 +18,7 @@ def get_phoenix_prompt(prompt_name: str, prompt_version_id: str = "") -> list[Ch
1218
1319
1420def to_chat_messages (
15- msg_list : Sequence [dict | v1 .PromptMessage | ChatMessage ],
21+ msg_list : Sequence [str | dict | v1 .PromptMessage | ChatMessage ],
1622) -> list [ChatMessage ]:
1723 """Convert a list of dicts or Phoenix PromptMessage to a list of Haystack ChatMessage."""
1824 messages = []
@@ -26,11 +32,17 @@ def to_chat_messages(
2632 role = msg ["role" ]
2733 content = msg ["content" ]
2834
29- assert isinstance (content , list ), f"Expected list content, got { type (content )} : { content } "
30- assert len (content ) == 1 , f"Expected single content, got { len (content )} items: { content } "
31- assert content [0 ]["type" ] == "text" , f"Expected text content, got { content [0 ]['type' ]} "
32- assert "text" in content [0 ], f"Expected 'text' in content[0], got { content [0 ]} "
33- text = content [0 ]["text" ]
35+ if isinstance (content , str ):
36+ text = content
37+ elif isinstance (content , list ):
38+ assert (
39+ len (content ) == 1
40+ ), f"Expected single content, got { len (content )} items: { content } "
41+ assert content [0 ]["type" ] == "text" , f"Expected text content, got { content [0 ]['type' ]} "
42+ assert "text" in content [0 ], f"Expected 'text' in content[0], got { content [0 ]} "
43+ text = content [0 ]["text" ]
44+ else :
45+ raise ValueError (f"Unexpected content type: { type (content )} for message { msg } " )
3446
3547 if role == "system" :
3648 assert isinstance (text , str ), f"Expected string, got { type (text )} "
@@ -46,3 +58,90 @@ def to_chat_messages(
4658 messages .append (chat_msg )
4759
4860 return messages
61+
62+
63+ logger = logging .getLogger (__name__ )
64+
65+
66+ class TracedPipelineRunner :
67+ """Helper class to run Haystack pipelines with OpenInference tracing."""
68+
69+ def __init__ (self , parent_span_name : str , pipeline : Pipeline ) -> None :
70+ self .parent_span_name = parent_span_name
71+ self .pipeline = pipeline
72+
73+ def stream_response (
74+ self ,
75+ pipeline_run_args : dict ,
76+ * ,
77+ metadata : dict [str , Any ],
78+ input_ : Any | None = None ,
79+ shorten_output : Callable [[str ], str ] = lambda resp : resp ,
80+ ) -> Generator :
81+ with using_metadata (metadata ):
82+ # Must set using_metadata context before calling tracer.start_as_current_span()
83+ with phoenix_utils .tracer ().start_as_current_span ( # pylint: disable=not-context-manager,unexpected-keyword-arg
84+ self .parent_span_name , openinference_span_kind = "chain"
85+ ) as span :
86+ assert isinstance (span , Span ), f"Got unexpected { type (span )} "
87+ try :
88+ span .set_input (input_ )
89+
90+ # hayhooks.streaming_generator() creates a thread that now inherits OpenTelemetry context
91+ # thanks to ThreadingInstrumentor enabled in phoenix_utils.py
92+ # streaming_generator() must be run inside the span context to inherit correctly
93+ generator = hayhooks .streaming_generator (
94+ pipeline = self .pipeline ,
95+ pipeline_run_args = pipeline_run_args ,
96+ )
97+
98+ # Stream chunks with each 'yield' call
99+ chunk_count = 0
100+ full_response = []
101+ for chunk in generator :
102+ chunk_count += 1
103+ if hasattr (chunk , "content" ):
104+ full_response .append (chunk .content )
105+ # Must yield chunks one by one for SSE
106+ # Must yield in the tracer span context to have spans linked as child spans
107+ yield chunk
108+
109+ logger .info ("Successfully streamed %d chunks" , chunk_count )
110+ response_text = "" .join (full_response ) if full_response else ""
111+ span .set_output (shorten_output (response_text ))
112+ span .set_status (Status (StatusCode .OK ))
113+ except Exception as e :
114+ logger .error ("Error during streaming: %s" , e , exc_info = True )
115+ span .set_status (Status (StatusCode .ERROR , str (e )))
116+ span .record_exception (e )
117+ raise
118+
119+ def return_response (
120+ self ,
121+ pipeline_run_args : dict ,
122+ * ,
123+ metadata : dict [str , Any ],
124+ input_ : Any | None = None ,
125+ include_outputs_from : set [str ] | None = None ,
126+ extract_output : Callable [[Any ], Any ] = lambda resp : resp ,
127+ ) -> dict :
128+ # Must set using_metadata context before calling tracer.start_as_current_span()
129+ with using_metadata (metadata ):
130+ with phoenix_utils .tracer ().start_as_current_span ( # pylint: disable=not-context-manager,unexpected-keyword-arg
131+ self .parent_span_name , openinference_span_kind = "chain"
132+ ) as span :
133+ try :
134+ result = self .pipeline .run (
135+ pipeline_run_args ,
136+ include_outputs_from = include_outputs_from ,
137+ )
138+ # Shorter than span.set_attribute(SpanAttributes.INPUT_VALUE, ...)
139+ span .set_input (input_ )
140+ span .set_output (extract_output (result ))
141+ span .set_status (Status (StatusCode .OK ))
142+ return result
143+ except Exception as e :
144+ logger .error ("Error during pipeline run: %s" , e , exc_info = True )
145+ span .set_status (Status (StatusCode .ERROR , str (e )))
146+ span .record_exception (e )
147+ raise
0 commit comments