Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
471 changes: 286 additions & 185 deletions sentry_sdk/integrations/openai.py

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions sentry_sdk/integrations/openai_agents/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,14 @@ def _set_output_data(

for output in result.output:
if output.type == "function_call":
output_messages["tool"].append(output.dict())
output_messages["tool"].append(output.model_dump())
elif output.type == "message":
for output_message in output.content:
try:
output_messages["response"].append(output_message.text)
except AttributeError:
# Unknown output message type, just return the json
output_messages["response"].append(output_message.dict())
output_messages["response"].append(output_message.model_dump())

if record_outputs and len(output_messages["tool"]) > 0:
if isinstance(span, StreamedSpan):
Expand Down
6 changes: 6 additions & 0 deletions sentry_sdk/scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,12 @@ def span(self, span: "Optional[Span]") -> None:
self._transaction = transaction.name
if transaction.source:
self._transaction_info["source"] = transaction.source
elif span is None:
self._transaction = None
# The transaction source describes the transaction name; leaving
# it behind would stamp stale transaction_info on later events
# that no longer carry a transaction.
self._transaction_info = {}

@property
def streamed_span(self) -> "Optional[StreamedSpan]":
Expand Down
16 changes: 16 additions & 0 deletions sentry_sdk/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,15 @@ def _parse_rate_limits(
continue


class TransportResponseError(Exception):
"""Raised when the HTTP transport receives an unexpected response status.

A non-2xx status (other than 429 rate limiting) means the envelope was not
accepted. Raising lets custom/wrapper transports detect the failed
delivery and fail over to another route instead of assuming success.
"""


class HttpTransportCore(Transport):
"""Shared base class for sync and async transports."""

Expand Down Expand Up @@ -404,6 +413,13 @@ def _handle_response(
self._handle_request_error(
envelope=envelope, loss_reason="status_{}".format(response.status)
)
# Surface the failed delivery so custom/wrapper transports can
# catch it and fail over (e.g. a 403/404 from a network middlebox).
# 429 is handled via rate limits above and 413 is a deliberate
# drop, so neither raises here.
raise TransportResponseError(
"Unexpected status code: {}".format(response.status)
)

def _update_headers(
self: "Self",
Expand Down
80 changes: 80 additions & 0 deletions tests/integrations/openai/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -2568,6 +2568,86 @@ def test_streaming_chat_completion(
pass # if tiktoken is not installed, we can't guarantee token usage will be calculated properly


@pytest.mark.parametrize("consume", ["break_then_close", "with_block"])
def test_streaming_chat_completion_span_finished_on_early_exit(
sentry_init,
capture_events,
get_model_response,
server_side_event_chunks,
consume,
):
# https://github.com/getsentry/sentry-python/issues/7847
# Closing a stream early (or leaving a `with` block after one chunk) must
# still finish the gen_ai.chat span.
sentry_init(
integrations=[OpenAIIntegration()],
disabled_integrations=[StdlibIntegration],
traces_sample_rate=1.0,
# Keep gen_ai spans on the transaction so the assertion below sees them.
stream_gen_ai_spans=False,
)

client = OpenAI(api_key="z")
returned_stream = get_model_response(
server_side_event_chunks(
[
ChatCompletionChunk(
id="1",
choices=[
DeltaChoice(
index=0,
delta=ChoiceDelta(content="hel"),
finish_reason=None,
)
],
created=100000,
model="model-id",
object="chat.completion.chunk",
),
ChatCompletionChunk(
id="1",
choices=[
DeltaChoice(
index=0,
delta=ChoiceDelta(content="lo"),
finish_reason=None,
)
],
created=100000,
model="model-id",
object="chat.completion.chunk",
),
],
include_event_type=False,
)
)

with mock.patch.object(
client.chat._client._client,
"send",
return_value=returned_stream,
):
events = capture_events()
with start_transaction(name="openai tx"):
stream = client.chat.completions.create(
model="some-model",
messages=[{"role": "user", "content": "hello"}],
stream=True,
)
if consume == "break_then_close":
for _ in stream:
break
stream.close()
else:
with stream:
next(iter(stream))

transactions = [event for event in events if event.get("type") == "transaction"]
assert len(transactions) == 1
span_ops = [span["op"] for span in transactions[0]["spans"]]
assert "gen_ai.chat" in span_ops


# noinspection PyTypeChecker
@pytest.mark.parametrize("span_streaming", [True, False])
@pytest.mark.parametrize("stream_gen_ai_spans", [True, False])
Expand Down
51 changes: 50 additions & 1 deletion tests/integrations/openai_agents/test_openai_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import json
import logging
import os
import warnings
from types import SimpleNamespace
from unittest import mock
from unittest.mock import MagicMock, patch

Expand All @@ -20,6 +22,7 @@
from agents.items import (
ResponseFunctionToolCall,
ResponseOutputMessage,
ResponseOutputRefusal,
ResponseOutputText,
)
from agents.models.openai_responses import OpenAIResponsesModel
Expand Down Expand Up @@ -72,7 +75,11 @@
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.openai_agents import OpenAIAgentsIntegration
from sentry_sdk.integrations.openai_agents.utils import _set_input_data, safe_serialize
from sentry_sdk.integrations.openai_agents.utils import (
_set_input_data,
_set_output_data,
safe_serialize,
)
from sentry_sdk.integrations.stdlib import StdlibIntegration
from sentry_sdk.utils import package_version, parse_version

Expand Down Expand Up @@ -7483,3 +7490,45 @@ async def test_runner_run_streamed_with_starting_agent_kwarg(

(transaction,) = events
assert transaction["transaction"] == "test_agent workflow"


def test_set_output_data_tool_calls_no_pydantic_deprecation(sentry_init):
"""Serializing tool-call output must not use the deprecated pydantic v1 API.

Regression test for GH-7827: _set_output_data called BaseModel.dict(),
which raises PydanticDeprecatedSince20 when DeprecationWarnings are errors.
"""
sentry_init(
integrations=[OpenAIAgentsIntegration()],
send_default_pii=True,
traces_sample_rate=1.0,
)

tool_call = ResponseFunctionToolCall(
type="function_call",
id="call_1",
call_id="call_1",
name="get_weather",
arguments='{"city": "Berlin"}',
status="completed",
)
refusal = ResponseOutputRefusal(type="refusal", refusal="I cannot help.")
message = ResponseOutputMessage(
type="message",
id="msg_1",
role="assistant",
content=[refusal],
status="completed",
)
result = SimpleNamespace(output=[tool_call, message])

with start_span(op="test") as span:
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
_set_output_data(span, result)

tool_calls = json.loads(span._data[SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS])
assert tool_calls[0]["name"] == "get_weather"
assert tool_calls[0]["arguments"] == '{"city": "Berlin"}'

assert "I cannot help." in span._data[SPANDATA.GEN_AI_RESPONSE_TEXT]
42 changes: 42 additions & 0 deletions tests/test_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -1110,3 +1110,45 @@ def test_conversation_id_clear():

scope.clear()
assert scope.get_conversation_id() is None


def test_transaction_name_cleared_when_span_unset():
"""Scope._transaction follows the span lifecycle (GH-7774).

Set by a Transaction, preserved while a child span is active, cleared
when the span is set back to None (top-level transaction exit).
"""
from sentry_sdk.tracing import Transaction

scope = Scope()

transaction = Transaction(name="my-transaction")
scope.span = transaction
assert scope._transaction == "my-transaction"
assert scope._transaction_info.get("source") # info follows the name

child = transaction.start_child(op="child")
scope.span = child
assert scope._transaction == "my-transaction"

scope.span = None
assert scope._transaction is None
assert scope._transaction_info == {} # no stale source left behind


def test_transaction_name_survives_nested_span_exit():
"""Exiting a child span restores the transaction on the scope.

The transaction name must not be dropped when an inner span finishes
while the transaction is still live.
"""
scope = Scope()

with sentry_sdk.start_transaction(name="my-transaction", scope=scope):
assert scope._transaction == "my-transaction"
with sentry_sdk.start_span(op="child", scope=scope):
assert scope._transaction == "my-transaction"
# span restored to the transaction, not None
assert scope._transaction == "my-transaction"

assert scope._transaction is None
37 changes: 37 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -1110,3 +1110,40 @@ async def send_message(i):
# New request should be dropped due to rate limiting
assert len(capturing_server.captured) == 0
await client.close_async(timeout=2.0)


from sentry_sdk.consts import EndpointType
from sentry_sdk.transport import TransportResponseError


@pytest.mark.parametrize("status_code", [403, 404, 500])
def test_unexpected_status_code_raises(capturing_server, make_client, status_code):
# https://github.com/getsentry/sentry-python/issues/7844
# A non-2xx response (e.g. 403/404 from a network middlebox) is a failed
# delivery and must raise, so wrapper transports can catch it and fail
# over instead of assuming the event was sent.
client = make_client()
capturing_server.respond_with(code=status_code)

with pytest.raises(TransportResponseError, match="Unexpected status code"):
client.transport._send_request(
body=b"{}",
headers={},
endpoint_type=EndpointType.ENVELOPE,
)


@pytest.mark.parametrize("status_code", [413, 429])
def test_expected_error_status_codes_do_not_raise(
capturing_server, make_client, status_code
):
# 413 (envelope too large) is a deliberate drop and 429 is handled via
# rate limits; neither should raise.
client = make_client()
capturing_server.respond_with(code=status_code)

client.transport._send_request(
body=b"{}",
headers={},
endpoint_type=EndpointType.ENVELOPE,
)