Skip to content

Commit 7bf4692

Browse files
authored
Merge branch 'main' into mHadfield/1383-stream-action-plan-response-from-backend
2 parents c37bb3f + 2e0cc86 commit 7bf4692

6 files changed

Lines changed: 252 additions & 42 deletions

File tree

app/poetry.lock

Lines changed: 18 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ haystack-ai = "^2.16.1"
2525
hayhooks = "1.0.1"
2626
openai = "^1.99.8"
2727
openinference-instrumentation-haystack = "^0.1.24"
28+
opentelemetry-instrumentation-threading = "^0.59b0"
2829
arize-phoenix-client = "^1.15.3"
2930
arize-phoenix-otel = "^0.13.0"
3031
amazon-bedrock-haystack = "^3.10.0"

app/src/common/components.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ def parse_json_if_possible(self, content: Any) -> Any:
435435
try:
436436
return json.loads(content.text)
437437
except JSONDecodeError:
438-
logger.warning("Failed to parse content as JSON: %s", content.text, exc_info=True)
438+
logger.warning("Failed to parse content as JSON: %s", content.text)
439439
return content.text
440440

441441
return content

app/src/common/haystack_utils.py

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
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
36
from 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
410
from phoenix.client.__generated__ import v1
511

612
from src.common import phoenix_utils
@@ -12,7 +18,7 @@ def get_phoenix_prompt(prompt_name: str, prompt_version_id: str = "") -> list[Ch
1218

1319

1420
def 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

app/src/common/phoenix_utils.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import httpx
66
import opentelemetry.exporter.otlp.proto.http.trace_exporter as otel_trace_exporter
77
import phoenix.otel
8+
from openinference.instrumentation import OITracer
9+
from opentelemetry.instrumentation.threading import ThreadingInstrumentor
810
from opentelemetry.sdk.trace.export import BatchSpanProcessor
911
from opentelemetry.trace import NoOpTracerProvider, TracerProvider
1012

@@ -19,6 +21,51 @@
1921
logger = logging.getLogger(__name__)
2022

2123

24+
class ContextDetachErrorFilter(logging.Filter):
25+
"""Filter to suppress harmless OpenTelemetry context detach errors in threaded environments.
26+
27+
When using OpenInference instrumentation with threading (e.g., hayhooks.streaming_generator),
28+
context tokens may be created in one thread and detached in another during cleanup,
29+
causing harmless "Failed to detach context" or "was created in a different Context" errors.
30+
31+
These errors don't affect functionality - the spans are created correctly and traces work as expected.
32+
This filter suppresses these specific error messages to reduce log noise.
33+
34+
Related issue: https://github.com/Arize-ai/openinference/issues/306
35+
This is the recommended pattern until OpenInference fixes the upstream issue.
36+
Similar approaches are used by other projects facing this issue:
37+
- Google ADK (https://github.com/google/adk-python/issues/1670)
38+
- Agno framework (https://github.com/agno-agi/agno/issues/5208)
39+
- Langfuse (https://github.com/langfuse/langfuse/issues/8316)
40+
"""
41+
42+
def filter(self, record: logging.LogRecord) -> bool:
43+
message = record.getMessage()
44+
45+
# Suppress "Failed to detach context" errors
46+
if "Failed to detach context" in message:
47+
return False
48+
49+
# Suppress "was created in a different Context" errors
50+
if "was created in a different Context" in message:
51+
return False
52+
53+
# Also check exception info if present
54+
if record.exc_info and record.exc_info[0] is ValueError:
55+
exc_str = str(record.exc_info[1])
56+
if "was created in a different Context" in exc_str:
57+
return False
58+
59+
return True
60+
61+
62+
def _suppress_context_detach_errors() -> None:
63+
"""Add filter to suppress harmless OpenTelemetry context detach errors."""
64+
otel_context_logger = logging.getLogger("opentelemetry.context")
65+
otel_context_logger.addFilter(ContextDetachErrorFilter())
66+
logger.info("Added filter to suppress harmless OpenTelemetry context detach errors")
67+
68+
2269
def _create_client(
2370
url: str = config.phoenix_collector_endpoint, api_key: str | None = None
2471
) -> Client:
@@ -66,11 +113,25 @@ def configure_phoenix(only_if_alive: bool = True) -> None:
66113
auto_instrument=True,
67114
)
68115

116+
# Enable threading instrumentation to propagate OpenTelemetry context across threads
117+
# This fixes the issue where hayhooks.streaming_generator() creates threads without
118+
# inheriting the parent span context, causing orphaned spans in Phoenix traces.
119+
# https://github.com/langfuse/langfuse/issues/8316#issuecomment-3154235201
120+
ThreadingInstrumentor().instrument()
121+
logger.info(
122+
"Threading instrumentation enabled for OpenTelemetry context propagation during streaming"
123+
)
124+
125+
# Suppress harmless context detach errors that occur when OpenInference instrumentation
126+
# tries to clean up context tokens in different threads during streaming.
127+
_suppress_context_detach_errors()
128+
69129
if config.redact_pii:
70130
phoenix_api_key = os.environ.get("PHOENIX_API_KEY")
71131
span_exporter = otel_trace_exporter.OTLPSpanExporter(
72132
endpoint=trace_endpoint, headers={"Authorization": f"Bearer {phoenix_api_key}"}
73133
)
134+
74135
# Create the PII redacting processor with the OTLP exporter
75136
pii_processor = PresidioRedactionSpanProcessor(span_exporter)
76137
# Add the pii processor to the otel instance
@@ -79,6 +140,18 @@ def configure_phoenix(only_if_alive: bool = True) -> None:
79140
tracer_provider.add_span_processor(pii_processor)
80141

81142

143+
_tracer: OITracer | None = None
144+
145+
146+
def tracer() -> OITracer:
147+
global _tracer
148+
if _tracer is None:
149+
new_tracer = tracer_provider.get_tracer(__name__)
150+
assert isinstance(new_tracer, OITracer), f"Got unexpected {type(new_tracer)}"
151+
_tracer = new_tracer
152+
return _tracer
153+
154+
82155
def get_prompt_template(prompt_name: str, prompt_version_id: str = "") -> PromptVersion:
83156
"""Retrieve a prompt template from Phoenix by name.
84157
https://arize.com/docs/phoenix/sdk-api-reference/python/overview#prompt-management

0 commit comments

Comments
 (0)