Skip to content

Commit 98b3fe3

Browse files
committed
Merge branch 'master' into feat/span-first
2 parents f0e7ef5 + 26c95f7 commit 98b3fe3

File tree

8 files changed

+95
-106
lines changed

8 files changed

+95
-106
lines changed

sentry_sdk/integrations/openai_agents/patches/models.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,6 @@ async def wrapped_get_response(*args: "Any", **kwargs: "Any") -> "Any":
133133

134134
@wraps(original_stream_response)
135135
async def wrapped_stream_response(*args: "Any", **kwargs: "Any") -> "Any":
136-
# Uses explicit try/finally instead of context manager to ensure cleanup
137-
# even if the consumer abandons the stream (GeneratorExit).
138136
span_kwargs = dict(kwargs)
139137
if len(args) > 0:
140138
span_kwargs["system_instructions"] = args[0]

sentry_sdk/scope.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
make_sampling_decision,
3535
PropagationContext,
3636
)
37-
from sentry_sdk.traces import StreamedSpan, NoOpStreamedSpan
37+
from sentry_sdk.traces import _DEFAULT_PARENT_SPAN, StreamedSpan, NoOpStreamedSpan
3838
from sentry_sdk.tracing import (
3939
BAGGAGE_HEADER_NAME,
4040
SENTRY_TRACE_HEADER_NAME,
@@ -1224,9 +1224,9 @@ def start_span(
12241224
def start_streamed_span(
12251225
self,
12261226
name: str,
1227-
attributes: "Optional[Attributes]" = None,
1228-
parent_span: "Optional[StreamedSpan]" = None,
1229-
active: bool = True,
1227+
attributes: "Optional[Attributes]",
1228+
parent_span: "Optional[StreamedSpan]",
1229+
active: bool,
12301230
) -> "StreamedSpan":
12311231
# TODO: rename to start_span once we drop the old API
12321232
if isinstance(parent_span, NoOpStreamedSpan):
@@ -1236,7 +1236,9 @@ def start_streamed_span(
12361236
"currently active span instead."
12371237
)
12381238

1239-
if parent_span is None or isinstance(parent_span, NoOpStreamedSpan):
1239+
if parent_span is _DEFAULT_PARENT_SPAN or isinstance(
1240+
parent_span, NoOpStreamedSpan
1241+
):
12401242
parent_span = self.span # type: ignore
12411243

12421244
# If no eligible parent_span was provided and there is no currently

sentry_sdk/traces.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,24 @@ def __str__(self) -> str:
8484
}
8585

8686

87+
# Sentinel value for an unset parent_span to be able to distinguish it from
88+
# a None set by the user
89+
_DEFAULT_PARENT_SPAN = object()
90+
91+
8792
def start_span(
8893
name: str,
8994
attributes: "Optional[Attributes]" = None,
90-
parent_span: "Optional[StreamedSpan]" = None,
95+
parent_span: "Optional[StreamedSpan]" = _DEFAULT_PARENT_SPAN, # type: ignore[assignment]
9196
active: bool = True,
9297
) -> "StreamedSpan":
9398
"""
9499
Start a span.
95100
96101
The span's parent, unless provided explicitly via the `parent_span` argument,
97102
will be the current active span, if any. If there is none, this span will
98-
become the root of a new span tree.
103+
become the root of a new span tree. If you explicitly want this span to be
104+
top-level without a parent, set `parent_span=None`.
99105
100106
`start_span()` can either be used as context manager or you can use the span
101107
object it returns and explicitly end it via `span.end()`. The following is
@@ -130,7 +136,8 @@ def start_span(
130136
131137
:param parent_span: A span instance that the new span should consider its
132138
parent. If not provided, the parent will be set to the currently active
133-
span, if any.
139+
span, if any. If set to `None`, this span will become a new root-level
140+
span.
134141
:type parent_span: "Optional[StreamedSpan]"
135142
136143
:param active: Controls whether spans started while this span is running
@@ -593,6 +600,18 @@ def trace_id(self) -> str:
593600
def sampled(self) -> "Optional[bool]":
594601
return False
595602

603+
@property
604+
def status(self) -> "str":
605+
return SpanStatus.OK.value
606+
607+
@status.setter
608+
def status(self, status: "Union[SpanStatus, str]") -> None:
609+
pass
610+
611+
@property
612+
def active(self) -> bool:
613+
return True
614+
596615
def _start(self) -> None:
597616
if self._scope is None:
598617
return self
@@ -643,7 +662,7 @@ def remove_attribute(self, key: str) -> None:
643662
def is_segment(self) -> bool:
644663
return False
645664

646-
def to_traceparent(self) -> str:
665+
def _to_traceparent(self) -> str:
647666
propagation_context = (
648667
sentry_sdk.get_current_scope().get_active_propagation_context()
649668
)

sentry_sdk/utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2005,7 +2005,11 @@ def serialize_item(
20052005

20062006
try:
20072007
serialized = serialize_item(data)
2008-
return json.dumps(serialized, default=str)
2008+
return (
2009+
json.dumps(serialized, default=str)
2010+
if not isinstance(serialized, str)
2011+
else serialized
2012+
)
20092013
except Exception:
20102014
return str(data)
20112015

tests/integrations/google_genai/test_google_genai.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1625,7 +1625,7 @@ def test_generate_content_with_function_response(
16251625
assert messages[0]["role"] == "tool"
16261626
assert messages[0]["content"]["toolCallId"] == "call_123"
16271627
assert messages[0]["content"]["toolName"] == "get_weather"
1628-
assert messages[0]["content"]["output"] == '"Sunny, 72F"'
1628+
assert messages[0]["content"]["output"] == "Sunny, 72F"
16291629

16301630

16311631
def test_generate_content_with_mixed_string_and_content(
@@ -1891,7 +1891,7 @@ def test_extract_contents_messages_function_response():
18911891
assert result[0]["role"] == "tool"
18921892
assert result[0]["content"]["toolCallId"] == "call_123"
18931893
assert result[0]["content"]["toolName"] == "get_weather"
1894-
assert result[0]["content"]["output"] == '"sunny"'
1894+
assert result[0]["content"]["output"] == "sunny"
18951895

18961896

18971897
def test_extract_contents_messages_function_response_with_output_key():
@@ -1908,7 +1908,7 @@ def test_extract_contents_messages_function_response_with_output_key():
19081908
assert result[0]["content"]["toolCallId"] == "call_456"
19091909
assert result[0]["content"]["toolName"] == "get_time"
19101910
# Should prefer "output" key
1911-
assert result[0]["content"]["output"] == '"3:00 PM"'
1911+
assert result[0]["content"]["output"] == "3:00 PM"
19121912

19131913

19141914
def test_extract_contents_messages_mixed_parts():

tests/integrations/mcp/test_mcp.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -241,15 +241,12 @@ async def test_tool_async(tool_name, arguments):
241241
assert span["data"][SPANDATA.MCP_TRANSPORT] == "http"
242242
assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-456"
243243
assert span["data"][SPANDATA.MCP_SESSION_ID] == session_id
244-
assert span["data"]["mcp.request.argument.data"] == '"test"'
244+
assert span["data"]["mcp.request.argument.data"] == "test"
245245

246246
# Check PII-sensitive data
247247
if send_default_pii and include_prompts:
248-
# TODO: Investigate why tool result is double-serialized.
249248
assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps(
250-
json.dumps(
251-
{"status": "completed"},
252-
)
249+
{"status": "completed"}
253250
)
254251
else:
255252
assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]
@@ -366,8 +363,8 @@ async def test_prompt(name, arguments):
366363
assert span["data"][SPANDATA.MCP_METHOD_NAME] == "prompts/get"
367364
assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio"
368365
assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-prompt"
369-
assert span["data"]["mcp.request.argument.name"] == '"code_help"'
370-
assert span["data"]["mcp.request.argument.language"] == '"python"'
366+
assert span["data"]["mcp.request.argument.name"] == "code_help"
367+
assert span["data"]["mcp.request.argument.language"] == "python"
371368

372369
# Message count is always captured
373370
assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1
@@ -752,7 +749,7 @@ def test_tool_unstructured(tool_name, arguments):
752749
# Should extract and join text from content blocks only with PII
753750
if send_default_pii and include_prompts:
754751
assert (
755-
span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == '"First part Second part"'
752+
span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == "First part Second part"
756753
)
757754
else:
758755
assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]
@@ -959,7 +956,7 @@ def test_tool_complex(tool_name, arguments):
959956
assert span["data"]["mcp.request.argument.nested"] == json.dumps(
960957
{"key": "value", "list": [1, 2, 3]}
961958
)
962-
assert span["data"]["mcp.request.argument.string"] == '"test"'
959+
assert span["data"]["mcp.request.argument.string"] == "test"
963960
assert span["data"]["mcp.request.argument.number"] == "42"
964961

965962

tests/integrations/openai_agents/test_openai_agents.py

Lines changed: 19 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from sentry_sdk.integrations.logging import LoggingIntegration
1313
from sentry_sdk.integrations.openai_agents import OpenAIAgentsIntegration
1414
from sentry_sdk.integrations.openai_agents.utils import _set_input_data, safe_serialize
15-
from sentry_sdk.utils import parse_version
15+
from sentry_sdk.utils import parse_version, package_version
1616

1717
from openai import AsyncOpenAI
1818
from agents.models.openai_responses import OpenAIResponsesModel
@@ -37,6 +37,8 @@
3737
from agents.exceptions import MaxTurnsExceeded, ModelBehaviorError
3838
from agents.version import __version__ as OPENAI_AGENTS_VERSION
3939

40+
OPENAI_VERSION = package_version("openai")
41+
4042
from openai.types.responses import (
4143
ResponseCreatedEvent,
4244
ResponseTextDeltaEvent,
@@ -1256,18 +1258,22 @@ def simple_test_tool(message: str) -> str:
12561258
assert ai_client_span1["data"]["gen_ai.usage.output_tokens"] == 5
12571259
assert ai_client_span1["data"]["gen_ai.usage.output_tokens.reasoning"] == 0
12581260
assert ai_client_span1["data"]["gen_ai.usage.total_tokens"] == 15
1259-
assert ai_client_span1["data"]["gen_ai.response.tool_calls"] == safe_serialize(
1260-
[
1261-
{
1262-
"arguments": '{"message": "hello"}',
1263-
"call_id": "call_123",
1264-
"name": "simple_test_tool",
1265-
"type": "function_call",
1266-
"id": "call_123",
1267-
"status": None,
1268-
}
1269-
]
1270-
)
1261+
1262+
tool_call = {
1263+
"arguments": '{"message": "hello"}',
1264+
"call_id": "call_123",
1265+
"name": "simple_test_tool",
1266+
"type": "function_call",
1267+
"id": "call_123",
1268+
"status": None,
1269+
}
1270+
1271+
if OPENAI_VERSION >= (2, 25, 0):
1272+
tool_call["namespace"] = None
1273+
1274+
assert json.loads(ai_client_span1["data"]["gen_ai.response.tool_calls"]) == [
1275+
tool_call
1276+
]
12711277

12721278
assert tool_span["description"] == "execute_tool simple_test_tool"
12731279
assert tool_span["data"]["gen_ai.agent.name"] == "test_agent"
@@ -2507,75 +2513,6 @@ def calculator(a: int, b: int) -> int:
25072513
assert invoke_agent_span["data"]["gen_ai.usage.output_tokens.reasoning"] == 3
25082514

25092515

2510-
@pytest.mark.asyncio
2511-
async def test_response_model_not_set_when_unavailable(
2512-
sentry_init, capture_events, test_agent
2513-
):
2514-
"""
2515-
Test that response model is not set if the API response doesn't have a model field.
2516-
The request model should still be set correctly.
2517-
"""
2518-
2519-
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
2520-
with patch(
2521-
"agents.models.openai_responses.OpenAIResponsesModel._fetch_response"
2522-
) as mock_fetch_response:
2523-
# Create a mock response without a model field
2524-
mock_response = MagicMock()
2525-
mock_response.model = None # No model in response
2526-
mock_response.id = "resp_123"
2527-
mock_response.output = [
2528-
ResponseOutputMessage(
2529-
id="msg_123",
2530-
type="message",
2531-
status="completed",
2532-
content=[
2533-
ResponseOutputText(
2534-
text="Response without model field",
2535-
type="output_text",
2536-
annotations=[],
2537-
)
2538-
],
2539-
role="assistant",
2540-
)
2541-
]
2542-
mock_response.usage = MagicMock()
2543-
mock_response.usage.input_tokens = 10
2544-
mock_response.usage.output_tokens = 20
2545-
mock_response.usage.total_tokens = 30
2546-
mock_response.usage.input_tokens_details = InputTokensDetails(
2547-
cached_tokens=0
2548-
)
2549-
mock_response.usage.output_tokens_details = OutputTokensDetails(
2550-
reasoning_tokens=0
2551-
)
2552-
2553-
mock_fetch_response.return_value = mock_response
2554-
2555-
sentry_init(
2556-
integrations=[OpenAIAgentsIntegration()],
2557-
traces_sample_rate=1.0,
2558-
)
2559-
2560-
events = capture_events()
2561-
2562-
result = await agents.Runner.run(
2563-
test_agent, "Test input", run_config=test_run_config
2564-
)
2565-
2566-
assert result is not None
2567-
2568-
(transaction,) = events
2569-
spans = transaction["spans"]
2570-
_, ai_client_span = spans
2571-
2572-
# Response model should NOT be set when API doesn't return it
2573-
assert "gen_ai.response.model" not in ai_client_span["data"]
2574-
# But request model should still be set
2575-
assert "gen_ai.request.model" in ai_client_span["data"]
2576-
assert ai_client_span["data"]["gen_ai.request.model"] == "gpt-4"
2577-
2578-
25792516
@pytest.mark.asyncio
25802517
async def test_invoke_agent_span_includes_response_model(
25812518
sentry_init, capture_events, test_agent

tests/test_utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
exc_info_from_error,
3636
get_lines_from_file,
3737
package_version,
38+
safe_serialize,
3839
)
3940

4041

@@ -1062,5 +1063,36 @@ def fake_getlines(filename):
10621063
assert result == expected_result
10631064

10641065

1066+
def test_safe_serialize_plain_string():
1067+
assert safe_serialize("already a string") == "already a string"
1068+
1069+
1070+
def test_safe_serialize_json_string():
1071+
assert safe_serialize('{"key": "value"}') == '{"key": "value"}'
1072+
1073+
1074+
def test_safe_serialize_dict():
1075+
assert safe_serialize({"key": "value"}) == '{"key": "value"}'
1076+
1077+
1078+
def test_safe_serialize_callable():
1079+
def my_func():
1080+
pass
1081+
1082+
result = safe_serialize(my_func)
1083+
assert result.startswith("<function")
1084+
assert '"' not in result[:1] # no wrapping quotes from json.dumps
1085+
1086+
1087+
def test_safe_serialize_object():
1088+
class MyClass:
1089+
def __init__(self):
1090+
self.x = 1
1091+
1092+
result = safe_serialize(MyClass())
1093+
assert result.startswith("<MyClass")
1094+
assert '"' not in result[:1] # no wrapping quotes from json.dumps
1095+
1096+
10651097
def test_package_version_is_none():
10661098
assert package_version("non_existent_package") is None

0 commit comments

Comments
 (0)