Skip to content

Commit b949974

Browse files
feat(pydantic-ai): Support span streaming
1 parent b1e999d commit b949974

6 files changed

Lines changed: 732 additions & 140 deletions

File tree

sentry_sdk/integrations/pydantic_ai/spans/ai_client.py

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
truncate_and_annotate_messages,
99
)
1010
from sentry_sdk.consts import OP, SPANDATA
11+
from sentry_sdk.traces import StreamedSpan
12+
from sentry_sdk.tracing_utils import has_span_streaming_enabled
1113
from sentry_sdk.utils import safe_serialize
1214

1315
from ..consts import SPAN_ORIGIN
@@ -27,7 +29,7 @@
2729
)
2830

2931
if TYPE_CHECKING:
30-
from typing import Any, Dict, List
32+
from typing import Any, Dict, List, Union
3133

3234
from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore
3335

@@ -97,7 +99,9 @@ def _get_system_instructions(
9799
return permanent_instructions, current_instructions
98100

99101

100-
def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None:
102+
def _set_input_messages(
103+
span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", messages: "Any"
104+
) -> None:
101105
"""Set input messages data on a span."""
102106
if not _should_send_prompts():
103107
return
@@ -107,14 +111,24 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non
107111

108112
permanent_instructions, current_instructions = _get_system_instructions(messages)
109113
if len(permanent_instructions) > 0 or len(current_instructions) > 0:
110-
span.set_data(
111-
SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS,
112-
json.dumps(
113-
_transform_system_instructions(
114-
permanent_instructions, current_instructions
115-
)
116-
),
117-
)
114+
if isinstance(span, StreamedSpan):
115+
span.set_attribute(
116+
SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS,
117+
json.dumps(
118+
_transform_system_instructions(
119+
permanent_instructions, current_instructions
120+
)
121+
),
122+
)
123+
else:
124+
span.set_data(
125+
SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS,
126+
json.dumps(
127+
_transform_system_instructions(
128+
permanent_instructions, current_instructions
129+
)
130+
),
131+
)
118132

119133
try:
120134
formatted_messages = []
@@ -198,15 +212,21 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non
198212
pass
199213

200214

201-
def _set_output_data(span: "sentry_sdk.tracing.Span", response: "Any") -> None:
215+
def _set_output_data(
216+
span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", response: "Any"
217+
) -> None:
202218
"""Set output data on a span."""
203219
if not _should_send_prompts():
204220
return
205221

206222
if not response:
207223
return
208224

209-
span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_name)
225+
set_on_span = (
226+
span.set_attribute if isinstance(span, StreamedSpan) else span.set_data
227+
)
228+
set_on_span(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_name)
229+
210230
try:
211231
# Extract text from ModelResponse
212232
if hasattr(response, "parts"):
@@ -230,7 +250,7 @@ def _set_output_data(span: "sentry_sdk.tracing.Span", response: "Any") -> None:
230250
set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, texts)
231251

232252
if tool_calls:
233-
span.set_data(
253+
set_on_span(
234254
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls)
235255
)
236256

@@ -257,20 +277,31 @@ def ai_client_span(
257277

258278
model_name = _get_model_name(model_obj) or "unknown"
259279

260-
span = sentry_sdk.start_span(
261-
op=OP.GEN_AI_CHAT,
262-
name=f"chat {model_name}",
263-
origin=SPAN_ORIGIN,
264-
)
280+
span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options)
281+
if span_streaming:
282+
span = sentry_sdk.traces.start_span(
283+
name=f"chat {model_name}",
284+
attributes={
285+
"sentry.op": OP.GEN_AI_CHAT,
286+
"sentry.origin": SPAN_ORIGIN,
287+
SPANDATA.GEN_AI_OPERATION_NAME: "chat",
288+
SPANDATA.GEN_AI_RESPONSE_STREAMING: get_is_streaming(),
289+
},
290+
)
291+
else:
292+
span = sentry_sdk.start_span(
293+
op=OP.GEN_AI_CHAT,
294+
name=f"chat {model_name}",
295+
origin=SPAN_ORIGIN,
296+
)
265297

266-
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat")
298+
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat")
299+
# Set streaming flag from contextvar
300+
span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, get_is_streaming())
267301

268302
_set_agent_data(span, agent)
269303
_set_model_data(span, model, model_settings)
270304

271-
# Set streaming flag from contextvar
272-
span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, get_is_streaming())
273-
274305
# Add available tools if agent is available
275306
agent_obj = agent or get_current_agent()
276307
_set_available_tools(span, agent_obj)

sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import sentry_sdk
44
from sentry_sdk.consts import OP, SPANDATA
5+
from sentry_sdk.traces import StreamedSpan
6+
from sentry_sdk.tracing_utils import has_span_streaming_enabled
57
from sentry_sdk.utils import safe_serialize
68

79
from ..consts import SPAN_ORIGIN
810
from ..utils import _set_agent_data, _should_send_prompts
911

1012
if TYPE_CHECKING:
11-
from typing import Any, Optional
13+
from typing import Any, Optional, Union
1214

1315
from pydantic_ai._tool_manager import ToolDefinition # type: ignore
1416

@@ -27,33 +29,56 @@ def execute_tool_span(
2729
agent: The agent executing the tool
2830
tool_definition: The definition of the tool, if available
2931
"""
30-
span = sentry_sdk.start_span(
31-
op=OP.GEN_AI_EXECUTE_TOOL,
32-
name=f"execute_tool {tool_name}",
33-
origin=SPAN_ORIGIN,
34-
)
32+
span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options)
33+
if span_streaming:
34+
span = sentry_sdk.traces.start_span(
35+
name=f"execute_tool {tool_name}",
36+
attributes={
37+
"sentry.op": OP.GEN_AI_EXECUTE_TOOL,
38+
"sentry.origin": SPAN_ORIGIN,
39+
SPANDATA.GEN_AI_OPERATION_NAME: "execute_tool",
40+
SPANDATA.GEN_AI_TOOL_NAME: tool_name,
41+
},
42+
)
43+
44+
set_on_span = span.set_attribute
45+
else:
46+
span = sentry_sdk.start_span(
47+
op=OP.GEN_AI_EXECUTE_TOOL,
48+
name=f"execute_tool {tool_name}",
49+
origin=SPAN_ORIGIN,
50+
)
3551

36-
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "execute_tool")
37-
span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name)
52+
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "execute_tool")
53+
span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name)
54+
55+
set_on_span = span.set_data
3856

3957
if tool_definition is not None and hasattr(tool_definition, "description"):
40-
span.set_data(
58+
set_on_span(
4159
SPANDATA.GEN_AI_TOOL_DESCRIPTION,
4260
tool_definition.description,
4361
)
4462

4563
_set_agent_data(span, agent)
4664

4765
if _should_send_prompts() and tool_args is not None:
48-
span.set_data(SPANDATA.GEN_AI_TOOL_INPUT, safe_serialize(tool_args))
66+
set_on_span(SPANDATA.GEN_AI_TOOL_INPUT, safe_serialize(tool_args))
4967

5068
return span
5169

5270

53-
def update_execute_tool_span(span: "sentry_sdk.tracing.Span", result: "Any") -> None:
71+
def update_execute_tool_span(
72+
span: "Union[sentry_sdk.tracing.Span, StreamedSpan]", result: "Any"
73+
) -> None:
5474
"""Update the execute tool span with the result."""
5575
if not span:
5676
return
5777

58-
if _should_send_prompts() and result is not None:
78+
if not _should_send_prompts() or result is None:
79+
return
80+
81+
if isinstance(span, StreamedSpan):
82+
span.set_attribute(SPANDATA.GEN_AI_TOOL_OUTPUT, safe_serialize(result))
83+
else:
5984
span.set_data(SPANDATA.GEN_AI_TOOL_OUTPUT, safe_serialize(result))

sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
truncate_and_annotate_messages,
99
)
1010
from sentry_sdk.consts import OP, SPANDATA
11+
from sentry_sdk.tracing_utils import has_span_streaming_enabled
1112

1213
from ..consts import SPAN_ORIGIN
1314
from ..utils import (
@@ -44,13 +45,24 @@ def invoke_agent_span(
4445
if agent and getattr(agent, "name", None):
4546
name = agent.name
4647

47-
span = get_start_span_function()(
48-
op=OP.GEN_AI_INVOKE_AGENT,
49-
name=f"invoke_agent {name}",
50-
origin=SPAN_ORIGIN,
51-
)
48+
span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options)
49+
if span_streaming:
50+
span = sentry_sdk.traces.start_span(
51+
name=f"invoke_agent {name}",
52+
attributes={
53+
"sentry.op": OP.GEN_AI_INVOKE_AGENT,
54+
"sentry.origin": SPAN_ORIGIN,
55+
SPANDATA.GEN_AI_OPERATION_NAME: "invoke_agent",
56+
},
57+
)
58+
else:
59+
span = get_start_span_function()(
60+
op=OP.GEN_AI_INVOKE_AGENT,
61+
name=f"invoke_agent {name}",
62+
origin=SPAN_ORIGIN,
63+
)
5264

53-
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent")
65+
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent")
5466

5567
_set_agent_data(span, agent)
5668
_set_model_data(span, model, model_settings)

sentry_sdk/integrations/pydantic_ai/spans/utils.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sentry_sdk.ai.consts import DATA_URL_BASE64_REGEX
88
from sentry_sdk.ai.utils import get_modality_from_mime_type
99
from sentry_sdk.consts import SPANDATA
10+
from sentry_sdk.traces import StreamedSpan
1011

1112
if TYPE_CHECKING:
1213
from typing import Any, Dict, Union
@@ -46,7 +47,8 @@ def _serialize_binary_content_item(item: "Any") -> "Dict[str, Any]":
4647

4748

4849
def _set_usage_data(
49-
span: "sentry_sdk.tracing.Span", usage: "Union[RequestUsage, RunUsage]"
50+
span: "Union[sentry_sdk.tracing.Span, StreamedSpan]",
51+
usage: "Union[RequestUsage, RunUsage]",
5052
) -> None:
5153
"""Set token usage data on a span.
5254
@@ -60,24 +62,26 @@ def _set_usage_data(
6062
if usage is None:
6163
return
6264

65+
set_on_span = (
66+
span.set_attribute if isinstance(span, StreamedSpan) else span.set_data
67+
)
68+
6369
if hasattr(usage, "input_tokens") and usage.input_tokens is not None:
64-
span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, usage.input_tokens)
70+
set_on_span(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, usage.input_tokens)
6571

6672
# Pydantic AI uses cache_read_tokens (not input_tokens_cached)
6773
if hasattr(usage, "cache_read_tokens") and usage.cache_read_tokens is not None:
68-
span.set_data(
69-
SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, usage.cache_read_tokens
70-
)
74+
set_on_span(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, usage.cache_read_tokens)
7175

7276
# Pydantic AI uses cache_write_tokens (not input_tokens_cache_write)
7377
if hasattr(usage, "cache_write_tokens") and usage.cache_write_tokens is not None:
74-
span.set_data(
78+
set_on_span(
7579
SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE,
7680
usage.cache_write_tokens,
7781
)
7882

7983
if hasattr(usage, "output_tokens") and usage.output_tokens is not None:
80-
span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage.output_tokens)
84+
set_on_span(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage.output_tokens)
8185

8286
if hasattr(usage, "total_tokens") and usage.total_tokens is not None:
83-
span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage.total_tokens)
87+
set_on_span(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage.total_tokens)

0 commit comments

Comments
 (0)