Skip to content

Commit fbf6167

Browse files
committed
Streaming
1 parent cc0c6bb commit fbf6167

3 files changed

Lines changed: 125 additions & 23 deletions

File tree

qtype/interpreter/base/stream_emitter.py

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ async def process_message(self, message: FlowMessage):
3535

3636
from typing import Any
3737

38+
from opentelemetry import trace
39+
3840
from qtype.interpreter.types import (
3941
ErrorEvent,
4042
ReasoningStreamDeltaEvent,
@@ -54,6 +56,24 @@ async def process_message(self, message: FlowMessage):
5456
from qtype.semantic.model import Step
5557

5658

59+
def get_current_telemetry_metadata() -> dict[str, Any]:
60+
"""
61+
Get current OpenTelemetry span context as metadata dict.
62+
63+
Returns:
64+
Dictionary with span_id and trace_id if span is recording,
65+
empty dict otherwise
66+
"""
67+
span = trace.get_current_span()
68+
if span and span.is_recording():
69+
ctx = span.get_span_context()
70+
return {
71+
"span_id": format(ctx.span_id, "016x"),
72+
"trace_id": format(ctx.trace_id, "032x"),
73+
}
74+
return {}
75+
76+
5777
class TextStreamContext:
5878
"""
5979
Async context manager for text streaming.
@@ -83,7 +103,11 @@ async def __aenter__(self) -> TextStreamContext:
83103
"""Emit TextStreamStartEvent when entering context."""
84104
if self.on_stream_event:
85105
await self.on_stream_event(
86-
TextStreamStartEvent(step=self.step, stream_id=self.stream_id)
106+
TextStreamStartEvent(
107+
step=self.step,
108+
stream_id=self.stream_id,
109+
metadata=get_current_telemetry_metadata(),
110+
)
87111
)
88112
return self
89113

@@ -96,7 +120,11 @@ async def __aexit__(
96120
"""Emit TextStreamEndEvent when exiting context."""
97121
if self.on_stream_event:
98122
await self.on_stream_event(
99-
TextStreamEndEvent(step=self.step, stream_id=self.stream_id)
123+
TextStreamEndEvent(
124+
step=self.step,
125+
stream_id=self.stream_id,
126+
metadata=get_current_telemetry_metadata(),
127+
)
100128
)
101129
return False
102130

@@ -113,6 +141,7 @@ async def delta(self, text: str) -> None:
113141
step=self.step,
114142
stream_id=self.stream_id,
115143
delta=text,
144+
metadata=get_current_telemetry_metadata(),
116145
)
117146
)
118147

@@ -158,7 +187,9 @@ async def __aexit__(
158187
if self._started and self.on_stream_event:
159188
await self.on_stream_event(
160189
ReasoningStreamEndEvent(
161-
step=self.step, stream_id=self.stream_id
190+
step=self.step,
191+
stream_id=self.stream_id,
192+
metadata=get_current_telemetry_metadata(),
162193
)
163194
)
164195
return False
@@ -173,11 +204,15 @@ async def delta(self, text: str) -> None:
173204
text: The incremental reasoning content to append to the stream
174205
"""
175206
if self.on_stream_event:
207+
metadata = get_current_telemetry_metadata()
208+
176209
# Emit start event on first delta
177210
if not self._started:
178211
await self.on_stream_event(
179212
ReasoningStreamStartEvent(
180-
step=self.step, stream_id=self.stream_id
213+
step=self.step,
214+
stream_id=self.stream_id,
215+
metadata=metadata,
181216
)
182217
)
183218
self._started = True
@@ -187,6 +222,7 @@ async def delta(self, text: str) -> None:
187222
step=self.step,
188223
stream_id=self.stream_id,
189224
delta=text,
225+
metadata=metadata,
190226
)
191227
)
192228

@@ -218,7 +254,11 @@ def __init__(
218254
async def __aenter__(self) -> StepBoundaryContext:
219255
"""Emit StepStartEvent when entering context."""
220256
if self.on_stream_event:
221-
await self.on_stream_event(StepStartEvent(step=self.step))
257+
await self.on_stream_event(
258+
StepStartEvent(
259+
step=self.step, metadata=get_current_telemetry_metadata()
260+
)
261+
)
222262
return self
223263

224264
async def __aexit__(
@@ -229,7 +269,11 @@ async def __aexit__(
229269
) -> bool:
230270
"""Emit StepEndEvent when exiting context."""
231271
if self.on_stream_event:
232-
await self.on_stream_event(StepEndEvent(step=self.step))
272+
await self.on_stream_event(
273+
StepEndEvent(
274+
step=self.step, metadata=get_current_telemetry_metadata()
275+
)
276+
)
233277
return False
234278

235279

@@ -277,6 +321,7 @@ async def __aenter__(self) -> ToolExecutionContext:
277321
tool_call_id=self.tool_call_id,
278322
tool_name=self.tool_name,
279323
tool_input=self.tool_input,
324+
metadata=get_current_telemetry_metadata(),
280325
)
281326
)
282327
return self
@@ -299,6 +344,7 @@ async def __aexit__(
299344
step=self.step,
300345
tool_call_id=self.tool_call_id,
301346
error_message=str(exc_val),
347+
metadata=get_current_telemetry_metadata(),
302348
)
303349
)
304350
self._completed = True
@@ -319,6 +365,7 @@ async def complete(self, output: Any) -> None:
319365
step=self.step,
320366
tool_call_id=self.tool_call_id,
321367
tool_output=output,
368+
metadata=get_current_telemetry_metadata(),
322369
)
323370
)
324371
self._completed = True
@@ -338,6 +385,7 @@ async def error(self, error_message: str) -> None:
338385
step=self.step,
339386
tool_call_id=self.tool_call_id,
340387
error_message=error_message,
388+
metadata=get_current_telemetry_metadata(),
341389
)
342390
)
343391
self._completed = True
@@ -387,6 +435,18 @@ def __init__(
387435
self.step = step
388436
self.on_stream_event = on_stream_event
389437

438+
def _get_span_context(self) -> tuple[str | None, str | None]:
439+
"""
440+
Get current OpenTelemetry span context.
441+
442+
Returns:
443+
Tuple of (span_id, trace_id) as hex strings, or (None, None)
444+
445+
Deprecated: Use get_current_telemetry_metadata() instead.
446+
"""
447+
metadata = get_current_telemetry_metadata()
448+
return metadata.get("span_id"), metadata.get("trace_id")
449+
390450
def text_stream(self, stream_id: str) -> TextStreamContext:
391451
"""
392452
Create a context manager for text streaming.
@@ -459,7 +519,11 @@ async def status(self, message: str) -> None:
459519
"""
460520
if self.on_stream_event:
461521
await self.on_stream_event(
462-
StatusEvent(step=self.step, message=message)
522+
StatusEvent(
523+
step=self.step,
524+
message=message,
525+
metadata=get_current_telemetry_metadata(),
526+
)
463527
)
464528

465529
async def error(self, error_message: str) -> None:
@@ -471,5 +535,9 @@ async def error(self, error_message: str) -> None:
471535
"""
472536
if self.on_stream_event:
473537
await self.on_stream_event(
474-
ErrorEvent(step=self.step, error_message=error_message)
538+
ErrorEvent(
539+
step=self.step,
540+
error_message=error_message,
541+
metadata=get_current_telemetry_metadata(),
542+
)
475543
)

qtype/interpreter/stream/chat/converter.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,11 +377,22 @@ async def stream_events():
377377
# Create converter for stateful event-to-chunk conversion
378378
converter = StreamEventConverter()
379379

380+
# Track telemetry metadata from events
381+
telemetry_metadata: dict[str, str] = {}
382+
380383
# Process events and convert to chunks
381384
async for event in event_stream:
382385
if event is None:
383386
break # End of stream
384387

388+
# Extract telemetry metadata from first event that has it
389+
if not telemetry_metadata and event.metadata:
390+
if "span_id" in event.metadata and "trace_id" in event.metadata:
391+
telemetry_metadata = {
392+
"span_id": event.metadata["span_id"],
393+
"trace_id": event.metadata["trace_id"],
394+
}
395+
385396
# Convert event to chunks and yield as SSE
386397
for chunk in converter.convert(event):
387398
yield (
@@ -390,8 +401,15 @@ async def stream_events():
390401
f"\n\n"
391402
)
392403

393-
# End message stream with optional metadata
394-
finish_chunk = FinishChunk(messageMetadata=output_metadata) # type: ignore[arg-type]
404+
# Merge telemetry metadata with output_metadata for FinishChunk
405+
final_metadata = {**telemetry_metadata}
406+
if output_metadata:
407+
final_metadata.update(output_metadata)
408+
409+
# End message stream with metadata (includes telemetry)
410+
finish_chunk = FinishChunk(
411+
messageMetadata=final_metadata if final_metadata else None
412+
)
395413
yield (
396414
f"data: "
397415
f"{finish_chunk.model_dump_json(by_alias=True, exclude_none=True)}"

qtype/interpreter/types.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,23 @@ def __bool__(self) -> bool:
3838
# and can be converted to Vercel UI chunks for frontend display
3939

4040

41-
class TextStreamStartEvent(BaseModel):
41+
class BaseStreamEvent(BaseModel):
42+
"""
43+
Base class for all stream events.
44+
45+
Provides common metadata field for telemetry and other contextual data.
46+
The metadata dict typically contains:
47+
- span_id: OpenTelemetry span ID (16 hex chars)
48+
- trace_id: OpenTelemetry trace ID (32 hex chars)
49+
"""
50+
51+
metadata: dict[str, Any] = Field(
52+
default_factory=dict,
53+
description="Metadata for telemetry and context tracking",
54+
)
55+
56+
57+
class TextStreamStartEvent(BaseStreamEvent):
4258
"""Signals the start of incremental text streaming.
4359
4460
Use this when beginning to stream LLM-generated content or other
@@ -55,7 +71,7 @@ class TextStreamStartEvent(BaseModel):
5571
)
5672

5773

58-
class TextStreamDeltaEvent(BaseModel):
74+
class TextStreamDeltaEvent(BaseStreamEvent):
5975
"""Carries an incremental chunk of text content.
6076
6177
Use this for streaming LLM responses or other incremental text.
@@ -72,7 +88,7 @@ class TextStreamDeltaEvent(BaseModel):
7288
delta: str = Field(description="Incremental text content to append")
7389

7490

75-
class TextStreamEndEvent(BaseModel):
91+
class TextStreamEndEvent(BaseStreamEvent):
7692
"""Signals the completion of incremental text streaming.
7793
7894
Use this to mark the end of a text stream. After this event,
@@ -88,7 +104,7 @@ class TextStreamEndEvent(BaseModel):
88104
)
89105

90106

91-
class ReasoningStreamStartEvent(BaseModel):
107+
class ReasoningStreamStartEvent(BaseStreamEvent):
92108
"""Signals the start of incremental reasoning streaming.
93109
94110
Use this when an agent begins outputting reasoning/thinking steps.
@@ -105,7 +121,7 @@ class ReasoningStreamStartEvent(BaseModel):
105121
)
106122

107123

108-
class ReasoningStreamDeltaEvent(BaseModel):
124+
class ReasoningStreamDeltaEvent(BaseStreamEvent):
109125
"""Carries an incremental chunk of reasoning content.
110126
111127
Use this for streaming agent reasoning/thinking steps.
@@ -122,7 +138,7 @@ class ReasoningStreamDeltaEvent(BaseModel):
122138
delta: str = Field(description="Incremental reasoning content to append")
123139

124140

125-
class ReasoningStreamEndEvent(BaseModel):
141+
class ReasoningStreamEndEvent(BaseStreamEvent):
126142
"""Signals the completion of incremental reasoning streaming.
127143
128144
Use this to mark the end of a reasoning stream. After this event,
@@ -138,7 +154,7 @@ class ReasoningStreamEndEvent(BaseModel):
138154
)
139155

140156

141-
class StatusEvent(BaseModel):
157+
class StatusEvent(BaseStreamEvent):
142158
"""Reports a complete status message from a step.
143159
144160
Use this for non-streaming status updates like:
@@ -155,7 +171,7 @@ class StatusEvent(BaseModel):
155171
message: str = Field(description="Complete status message to display")
156172

157173

158-
class StepStartEvent(BaseModel):
174+
class StepStartEvent(BaseStreamEvent):
159175
"""Marks the beginning of a logical step boundary.
160176
161177
Use this to group related events together visually in the UI.
@@ -168,7 +184,7 @@ class StepStartEvent(BaseModel):
168184
step: Step
169185

170186

171-
class StepEndEvent(BaseModel):
187+
class StepEndEvent(BaseStreamEvent):
172188
"""Marks the end of a logical step boundary.
173189
174190
Use this to close a step boundary opened by StepStartEvent.
@@ -180,7 +196,7 @@ class StepEndEvent(BaseModel):
180196
step: Step
181197

182198

183-
class ToolExecutionStartEvent(BaseModel):
199+
class ToolExecutionStartEvent(BaseStreamEvent):
184200
"""Signals the start of tool execution.
185201
186202
Use this when a tool is about to be invoked, either by an LLM
@@ -198,7 +214,7 @@ class ToolExecutionStartEvent(BaseModel):
198214
)
199215

200216

201-
class ToolExecutionEndEvent(BaseModel):
217+
class ToolExecutionEndEvent(BaseStreamEvent):
202218
"""Signals the completion of tool execution.
203219
204220
Use this when a tool has finished executing successfully.
@@ -214,7 +230,7 @@ class ToolExecutionEndEvent(BaseModel):
214230
tool_output: Any = Field(description="Output returned by the tool")
215231

216232

217-
class ToolExecutionErrorEvent(BaseModel):
233+
class ToolExecutionErrorEvent(BaseStreamEvent):
218234
"""Signals that tool execution failed.
219235
220236
Use this when a tool encounters an error during execution.
@@ -230,7 +246,7 @@ class ToolExecutionErrorEvent(BaseModel):
230246
error_message: str = Field(description="Description of the error")
231247

232248

233-
class ErrorEvent(BaseModel):
249+
class ErrorEvent(BaseStreamEvent):
234250
"""Signals a general error occurred during step execution.
235251
236252
Use this for errors that aren't specific to tool execution.

0 commit comments

Comments
 (0)