Skip to content

Commit 8a31eeb

Browse files
committed
Merge branch 'master' into ivana/span-first-10-random-improvements
2 parents a9b33a9 + e2ddbab commit 8a31eeb

File tree

12 files changed

+286
-190
lines changed

12 files changed

+286
-190
lines changed

.github/workflows/ai-integration-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
token: ${{ secrets.GITHUB_TOKEN }}
3535

3636
- name: Run Python SDK Tests
37-
uses: getsentry/testing-ai-sdk-integrations@121da677853244cedfe11e95184b2b431af102eb
37+
uses: getsentry/testing-ai-sdk-integrations@285c012e522f241581534dfc89bd99ec3b1da4f6
3838
env:
3939
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4040
with:

scripts/populate_tox/package_dependencies.jsonl

Lines changed: 17 additions & 16 deletions
Large diffs are not rendered by default.

scripts/populate_tox/populate_tox.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
See scripts/populate_tox/README.md for more info.
55
"""
66

7+
import re
78
import functools
89
import hashlib
910
import json
@@ -872,7 +873,10 @@ def get_last_updated() -> Optional[datetime]:
872873

873874

874875
def _normalize_name(package: str) -> str:
875-
return package.lower().replace("-", "_")
876+
# From https://peps.python.org/pep-0503/#normalized-names
877+
# but normalizing to underscores instead of hyphens since tox-formatted packages
878+
# use underscores.
879+
return re.sub(r"[-_.]+", "_", package).lower()
876880

877881

878882
def _extract_wheel_info_to_cache(wheel: dict):

scripts/populate_tox/releases.jsonl

Lines changed: 44 additions & 43 deletions
Large diffs are not rendered by default.

sentry_sdk/integrations/httpx.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424

2525
try:
26-
from httpx import AsyncClient, Client, Request, Response # type: ignore
26+
from httpx import AsyncClient, Client, Request, Response
2727
except ImportError:
2828
raise DidNotEnable("httpx is not installed")
2929

@@ -94,7 +94,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response":
9494

9595
return rv
9696

97-
Client.send = send
97+
Client.send = send # type: ignore
9898

9999

100100
def _install_httpx_async_client() -> None:
@@ -150,4 +150,4 @@ async def send(
150150

151151
return rv
152152

153-
AsyncClient.send = send
153+
AsyncClient.send = send # type: ignore

sentry_sdk/integrations/pydantic_ai/patches/tools.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async def wrapped_execute_tool_call(
5050
call = validated.call
5151
name = call.tool_name
5252
tool = self.tools.get(name) if self.tools else None
53+
selected_tool_definition = getattr(tool, "tool_def", None)
5354

5455
# Determine tool type by checking tool.toolset
5556
tool_type = "function"
@@ -73,6 +74,7 @@ async def wrapped_execute_tool_call(
7374
args_dict,
7475
agent,
7576
tool_type=tool_type,
77+
tool_definition=selected_tool_definition,
7678
) as span:
7779
try:
7880
result = await original_execute_tool_call(
@@ -127,6 +129,7 @@ async def wrapped_call_tool(
127129
# Extract tool info before calling original
128130
name = call.tool_name
129131
tool = self.tools.get(name) if self.tools else None
132+
selected_tool_definition = getattr(tool, "tool_def", None)
130133

131134
# Determine tool type by checking tool.toolset
132135
tool_type = "function" # default
@@ -150,6 +153,7 @@ async def wrapped_call_tool(
150153
args_dict,
151154
agent,
152155
tool_type=tool_type,
156+
tool_definition=selected_tool_definition,
153157
) as span:
154158
try:
155159
result = await original_call_tool(

sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@
99

1010
if TYPE_CHECKING:
1111
from typing import Any, Optional
12+
from pydantic_ai._tool_manager import ToolDefinition # type: ignore
1213

1314

1415
def execute_tool_span(
15-
tool_name: str, tool_args: "Any", agent: "Any", tool_type: str = "function"
16+
tool_name: str,
17+
tool_args: "Any",
18+
agent: "Any",
19+
tool_type: str = "function",
20+
tool_definition: "Optional[ToolDefinition]" = None,
1621
) -> "sentry_sdk.tracing.Span":
1722
"""Create a span for tool execution.
1823
@@ -21,6 +26,7 @@ def execute_tool_span(
2126
tool_args: The arguments passed to the tool
2227
agent: The agent executing the tool
2328
tool_type: The type of tool ("function" for regular tools, "mcp" for MCP services)
29+
tool_definition: The definition of the tool, if available
2430
"""
2531
span = sentry_sdk.start_span(
2632
op=OP.GEN_AI_EXECUTE_TOOL,
@@ -32,6 +38,12 @@ def execute_tool_span(
3238
span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, tool_type)
3339
span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name)
3440

41+
if tool_definition is not None and hasattr(tool_definition, "description"):
42+
span.set_data(
43+
SPANDATA.GEN_AI_TOOL_DESCRIPTION,
44+
tool_definition.description,
45+
)
46+
3547
_set_agent_data(span, agent)
3648

3749
if _should_send_prompts() and tool_args is not None:

sentry_sdk/traces.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,12 @@ def __enter__(self) -> "StreamedSpan":
288288
def __exit__(
289289
self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]"
290290
) -> None:
291+
if self._timestamp is not None:
292+
# This span is already finished, ignore
293+
return
294+
291295
if value is not None and should_be_treated_as_error(ty, value):
292-
self.status = SpanStatus.ERROR
296+
self.status = SpanStatus.ERROR.value
293297

294298
self._end()
295299

@@ -329,7 +333,9 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None
329333
del self._previous_span_on_scope
330334
self._scope.span = old_span
331335

332-
# Set attributes from the segment
336+
# Set attributes from the segment. These are set on span end on purpose
337+
# so that we have the best chance to capture the segment's final name
338+
# (since it might change during its lifetime)
333339
self.set_attribute("sentry.segment.id", self._segment.span_id)
334340
self.set_attribute("sentry.segment.name", self._segment.name)
335341

tests/integrations/openai_agents/test_openai_agents.py

Lines changed: 63 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,55 +1220,57 @@ def simple_test_tool(message: str) -> str:
12201220
)
12211221
tool_span = next(span for span in spans if span["op"] == OP.GEN_AI_EXECUTE_TOOL)
12221222

1223-
available_tools = [
1224-
{
1225-
"name": "simple_test_tool",
1226-
"description": "A simple tool",
1227-
"params_json_schema": {
1228-
"properties": {"message": {"title": "Message", "type": "string"}},
1229-
"required": ["message"],
1230-
"title": "simple_test_tool_args",
1231-
"type": "object",
1232-
"additionalProperties": False,
1233-
},
1234-
"on_invoke_tool": "<function agents.tool.function_tool.<locals>._create_function_tool.<locals>._on_invoke_tool>",
1235-
"strict_json_schema": True,
1236-
"is_enabled": True,
1237-
}
1238-
]
1223+
available_tool = {
1224+
"name": "simple_test_tool",
1225+
"description": "A simple tool",
1226+
"params_json_schema": {
1227+
"properties": {"message": {"title": "Message", "type": "string"}},
1228+
"required": ["message"],
1229+
"title": "simple_test_tool_args",
1230+
"type": "object",
1231+
"additionalProperties": False,
1232+
},
1233+
"on_invoke_tool": mock.ANY,
1234+
"strict_json_schema": True,
1235+
"is_enabled": True,
1236+
}
1237+
12391238
if parse_version(OPENAI_AGENTS_VERSION) >= (0, 3, 3):
1240-
available_tools[0].update(
1239+
available_tool.update(
12411240
{"tool_input_guardrails": None, "tool_output_guardrails": None}
12421241
)
12431242

12441243
if parse_version(OPENAI_AGENTS_VERSION) >= (
12451244
0,
12461245
8,
12471246
):
1248-
available_tools[0]["needs_approval"] = False
1247+
available_tool["needs_approval"] = False
12491248
if parse_version(OPENAI_AGENTS_VERSION) >= (
12501249
0,
12511250
9,
12521251
0,
12531252
):
1254-
available_tools[0].update(
1253+
available_tool.update(
12551254
{
12561255
"timeout_seconds": None,
12571256
"timeout_behavior": "error_as_result",
12581257
"timeout_error_function": None,
12591258
}
12601259
)
12611260

1262-
available_tools = safe_serialize(available_tools)
1263-
12641261
assert transaction["transaction"] == "test_agent workflow"
12651262
assert transaction["contexts"]["trace"]["origin"] == "auto.ai.openai_agents"
12661263

12671264
assert agent_span["description"] == "invoke_agent test_agent"
12681265
assert agent_span["origin"] == "auto.ai.openai_agents"
12691266
assert agent_span["data"]["gen_ai.agent.name"] == "test_agent"
12701267
assert agent_span["data"]["gen_ai.operation.name"] == "invoke_agent"
1271-
assert agent_span["data"]["gen_ai.request.available_tools"] == available_tools
1268+
1269+
agent_span_available_tool = json.loads(
1270+
agent_span["data"]["gen_ai.request.available_tools"]
1271+
)[0]
1272+
assert all(agent_span_available_tool[k] == v for k, v in available_tool.items())
1273+
12721274
assert agent_span["data"]["gen_ai.request.max_tokens"] == 100
12731275
assert agent_span["data"]["gen_ai.request.model"] == "gpt-4"
12741276
assert agent_span["data"]["gen_ai.request.temperature"] == 0.7
@@ -1279,7 +1281,14 @@ def simple_test_tool(message: str) -> str:
12791281
assert ai_client_span1["data"]["gen_ai.operation.name"] == "chat"
12801282
assert ai_client_span1["data"]["gen_ai.system"] == "openai"
12811283
assert ai_client_span1["data"]["gen_ai.agent.name"] == "test_agent"
1282-
assert ai_client_span1["data"]["gen_ai.request.available_tools"] == available_tools
1284+
1285+
ai_client_span1_available_tool = json.loads(
1286+
ai_client_span1["data"]["gen_ai.request.available_tools"]
1287+
)[0]
1288+
assert all(
1289+
ai_client_span1_available_tool[k] == v for k, v in available_tool.items()
1290+
)
1291+
12831292
assert ai_client_span1["data"]["gen_ai.request.max_tokens"] == 100
12841293
assert ai_client_span1["data"]["gen_ai.request.messages"] == safe_serialize(
12851294
[
@@ -1319,14 +1328,12 @@ def simple_test_tool(message: str) -> str:
13191328
assert tool_span["description"] == "execute_tool simple_test_tool"
13201329
assert tool_span["data"]["gen_ai.agent.name"] == "test_agent"
13211330
assert tool_span["data"]["gen_ai.operation.name"] == "execute_tool"
1322-
assert (
1323-
re.sub(
1324-
"<.*>(,)",
1325-
r"'NOT_CHECKED'\1",
1326-
agent_span["data"]["gen_ai.request.available_tools"],
1327-
)
1328-
== available_tools
1329-
)
1331+
1332+
tool_span_available_tool = json.loads(
1333+
tool_span["data"]["gen_ai.request.available_tools"]
1334+
)[0]
1335+
assert all(tool_span_available_tool[k] == v for k, v in available_tool.items())
1336+
13301337
assert tool_span["data"]["gen_ai.request.max_tokens"] == 100
13311338
assert tool_span["data"]["gen_ai.request.model"] == "gpt-4"
13321339
assert tool_span["data"]["gen_ai.request.temperature"] == 0.7
@@ -1341,14 +1348,14 @@ def simple_test_tool(message: str) -> str:
13411348
assert ai_client_span2["description"] == "chat gpt-4"
13421349
assert ai_client_span2["data"]["gen_ai.agent.name"] == "test_agent"
13431350
assert ai_client_span2["data"]["gen_ai.operation.name"] == "chat"
1344-
assert (
1345-
re.sub(
1346-
"<.*>(,)",
1347-
r"'NOT_CHECKED'\1",
1348-
agent_span["data"]["gen_ai.request.available_tools"],
1349-
)
1350-
== available_tools
1351+
1352+
ai_client_span2_available_tool = json.loads(
1353+
ai_client_span2["data"]["gen_ai.request.available_tools"]
1354+
)[0]
1355+
assert all(
1356+
ai_client_span2_available_tool[k] == v for k, v in available_tool.items()
13511357
)
1358+
13521359
assert ai_client_span2["data"]["gen_ai.request.max_tokens"] == 100
13531360
assert ai_client_span2["data"]["gen_ai.request.messages"] == safe_serialize(
13541361
[
@@ -1425,6 +1432,15 @@ async def test_hosted_mcp_tool_propagation_header_streamed(
14251432
"/responses",
14261433
)
14271434

1435+
# openai-agents calls with_streaming_response() if available starting with
1436+
# https://github.com/openai/openai-agents-python/commit/159beb56130f7d85192acfd593c9168757984dc0.
1437+
# When using with_streaming_response() the header set below changes the response type:
1438+
# https://github.com/openai/openai-python/blob/656e3cab4a18262a49b961d41293367e45ee71b9/src/openai/_response.py#L67.
1439+
if parse_version(OPENAI_AGENTS_VERSION) >= (0, 10, 3) and hasattr(
1440+
agent_with_tool.model._client.responses, "with_streaming_response"
1441+
):
1442+
request.headers["X-Stainless-Raw-Response"] = "stream"
1443+
14281444
response = httpx.Response(
14291445
200,
14301446
request=request,
@@ -3178,6 +3194,15 @@ async def test_streaming_ttft_on_chat_span(sentry_init, test_agent, async_iterat
31783194
"/responses",
31793195
)
31803196

3197+
# openai-agents calls with_streaming_response() if available starting with
3198+
# https://github.com/openai/openai-agents-python/commit/159beb56130f7d85192acfd593c9168757984dc0.
3199+
# When using with_streaming_response() the header set below changes the response type:
3200+
# https://github.com/openai/openai-python/blob/656e3cab4a18262a49b961d41293367e45ee71b9/src/openai/_response.py#L67.
3201+
if parse_version(OPENAI_AGENTS_VERSION) >= (0, 10, 3) and hasattr(
3202+
agent_with_tool.model._client.responses, "with_streaming_response"
3203+
):
3204+
request.headers["X-Stainless-Raw-Response"] = "stream"
3205+
31813206
response = httpx.Response(
31823207
200,
31833208
request=request,

tests/integrations/pydantic_ai/test_pydantic_ai.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from pydantic_ai import Agent
1717
from pydantic_ai.messages import BinaryContent, UserPromptPart
1818
from pydantic_ai.usage import RequestUsage
19-
from pydantic_ai.models.test import TestModel
2019
from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior
2120

2221

@@ -2386,7 +2385,9 @@ async def test_execute_tool_span_with_mcp_type(sentry_init, capture_events):
23862385
Test execute_tool span with MCP tool type.
23872386
"""
23882387
import sentry_sdk
2389-
from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import execute_tool_span
2388+
from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import (
2389+
execute_tool_span,
2390+
)
23902391

23912392
sentry_init(
23922393
integrations=[PydanticAIIntegration()],
@@ -2794,3 +2795,42 @@ async def test_set_usage_data_with_cache_tokens(sentry_init, capture_events):
27942795
(span_data,) = event["spans"]
27952796
assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 80
27962797
assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE] == 20
2798+
2799+
2800+
@pytest.mark.asyncio
2801+
async def test_tool_description_in_execute_tool_span(sentry_init, capture_events):
2802+
"""
2803+
Test that tool description from the tool's docstring is included in execute_tool spans.
2804+
"""
2805+
agent = Agent(
2806+
"test",
2807+
name="test_agent",
2808+
system_prompt="You are a helpful test assistant.",
2809+
)
2810+
2811+
@agent.tool_plain
2812+
def multiply_numbers(a: int, b: int) -> int:
2813+
"""Multiply two numbers and return the product."""
2814+
return a * b
2815+
2816+
sentry_init(
2817+
integrations=[PydanticAIIntegration()],
2818+
traces_sample_rate=1.0,
2819+
send_default_pii=True,
2820+
)
2821+
2822+
events = capture_events()
2823+
2824+
result = await agent.run("What is 5 times 3?")
2825+
assert result is not None
2826+
2827+
(transaction,) = events
2828+
spans = transaction["spans"]
2829+
2830+
tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"]
2831+
assert len(tool_spans) >= 1
2832+
2833+
tool_span = tool_spans[0]
2834+
assert tool_span["data"]["gen_ai.tool.name"] == "multiply_numbers"
2835+
assert SPANDATA.GEN_AI_TOOL_DESCRIPTION in tool_span["data"]
2836+
assert "Multiply two numbers" in tool_span["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION]

0 commit comments

Comments
 (0)