Skip to content

Commit 2522edd

Browse files
committed
fix(boto3): harden StreamingBody span finalization
1 parent 68c2877 commit 2522edd

2 files changed

Lines changed: 222 additions & 30 deletions

File tree

‎sentry_sdk/integrations/boto3/_instrumentation.py‎

Lines changed: 94 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from typing import TYPE_CHECKING
22

33
import sentry_sdk
4-
from sentry_sdk.consts import OP, SPANDATA
4+
from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS
55
from sentry_sdk.integrations import DidNotEnable
66
from sentry_sdk.integrations.boto3 import Boto3Integration
7-
from sentry_sdk.traces import StreamedSpan
7+
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
88
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, Span
99
from sentry_sdk.tracing_utils import (
1010
add_http_breadcrumb,
@@ -160,26 +160,39 @@ def _replace_header(request: "AWSRequest", key: str, value: str) -> None:
160160
)
161161

162162

163-
def _sentry_after_call(
164-
context: "Dict[str, Any]", parsed: "Dict[str, Any]", **kwargs: "Any"
163+
def _finish_span(
164+
span: "Union[Span, StreamedSpan]",
165+
error: "Optional[BaseException]" = None,
165166
) -> None:
166-
span: "Optional[Union[Span, StreamedSpan]]" = context.pop("_sentrysdk_span", None)
167+
with capture_internal_exceptions():
168+
if not isinstance(span, StreamedSpan):
169+
if error is not None:
170+
span.set_status(SPANSTATUS.INTERNAL_ERROR)
171+
span.finish()
172+
return
167173

168-
# Span could be absent if the integration is disabled.
169-
if span is None:
170-
return
174+
if error is None:
175+
span.end()
176+
else:
177+
span.__exit__(type(error), error, error.__traceback__)
171178

172-
span.__exit__(None, None, None)
179+
180+
def _instrument_streaming_body(
181+
span: "Union[Span, StreamedSpan]", parsed: "Dict[str, Any]"
182+
) -> bool:
183+
if isinstance(span, NoOpStreamedSpan):
184+
return False
173185

174186
body = parsed.get("Body")
175187
if not isinstance(body, StreamingBody):
176-
return
188+
return False
177189

178190
streaming_span: "Union[Span, StreamedSpan]"
179191
if isinstance(span, StreamedSpan):
180192
streaming_span = sentry_sdk.traces.start_span(
181193
name=span.name,
182194
parent_span=span,
195+
active=False,
183196
attributes={
184197
"sentry.op": OP.HTTP_CLIENT_STREAM,
185198
"sentry.origin": Boto3Integration.origin,
@@ -194,35 +207,86 @@ def _sentry_after_call(
194207

195208
orig_read = body.read
196209
orig_close = body.close
210+
raw_stream = body._raw_stream # type: ignore[attr-defined]
211+
orig_raw_close = raw_stream.close
212+
finished = False
213+
214+
def finish(error: "Optional[BaseException]" = None) -> None:
215+
nonlocal finished
216+
if finished:
217+
return
218+
219+
finished = True
220+
_finish_span(streaming_span, error)
221+
222+
def content_length_reached() -> bool:
223+
content_length = getattr(body, "_content_length", None)
224+
amount_read = getattr(body, "_amount_read", None)
225+
return (
226+
content_length is not None
227+
and amount_read is not None
228+
and amount_read >= int(content_length)
229+
)
197230

198231
def sentry_streaming_body_read(*args: "Any", **kwargs: "Any") -> bytes:
199232
try:
200233
ret = orig_read(*args, **kwargs)
201-
if ret:
202-
return ret
203-
204-
if isinstance(streaming_span, StreamedSpan):
205-
streaming_span.end()
206-
else:
207-
streaming_span.finish()
234+
with capture_internal_exceptions():
235+
amount = args[0] if args else kwargs.get("amt")
236+
if (
237+
amount is None
238+
or amount < 0
239+
or (amount > 0 and not ret)
240+
or content_length_reached()
241+
):
242+
finish()
208243
return ret
209-
except Exception:
210-
if isinstance(streaming_span, StreamedSpan):
211-
streaming_span.end()
212-
else:
213-
streaming_span.finish()
244+
except BaseException as error:
245+
finish(error)
214246
raise
215247

216-
body.read = sentry_streaming_body_read # type: ignore
217-
218248
def sentry_streaming_body_close(*args: "Any", **kwargs: "Any") -> None:
219-
if isinstance(streaming_span, StreamedSpan):
220-
streaming_span.end()
221-
else:
222-
streaming_span.finish()
223-
orig_close(*args, **kwargs)
249+
try:
250+
orig_close(*args, **kwargs)
251+
finish()
252+
except BaseException as error:
253+
finish(error)
254+
raise
255+
256+
def sentry_raw_stream_close(*args: "Any", **kwargs: "Any") -> None:
257+
try:
258+
orig_raw_close(*args, **kwargs)
259+
finish()
260+
except BaseException as error:
261+
finish(error)
262+
raise
263+
264+
try:
265+
# StreamingBody.__exit__ closes `_raw_stream` directly, bypassing
266+
# StreamingBody.close(), so both levels need to be instrumented.
267+
raw_stream.close = sentry_raw_stream_close
268+
body.read = sentry_streaming_body_read # type: ignore
269+
body.close = sentry_streaming_body_close # type: ignore
270+
except Exception:
271+
finish()
272+
raise
273+
274+
return True
275+
276+
277+
def _sentry_after_call(
278+
context: "Dict[str, Any]", parsed: "Dict[str, Any]", **kwargs: "Any"
279+
) -> None:
280+
span: "Optional[Union[Span, StreamedSpan]]" = context.pop("_sentrysdk_span", None)
281+
282+
# Span could be absent if the integration is disabled.
283+
if span is None:
284+
return
224285

225-
body.close = sentry_streaming_body_close # type: ignore
286+
span.__exit__(None, None, None)
287+
288+
with capture_internal_exceptions():
289+
_instrument_streaming_body(span, parsed)
226290

227291

228292
def _sentry_after_call_error(
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,134 @@
1+
import boto3
2+
import pytest
3+
from botocore.awsrequest import AWSResponse
4+
from botocore.config import Config
5+
6+
import sentry_sdk
7+
from sentry_sdk.consts import OP
18
from sentry_sdk.integrations.boto3 import Boto3Integration
9+
from tests.integrations.boto3.aws_mock import Body
10+
11+
session = boto3.Session( # type: ignore[attr-defined]
12+
aws_access_key_id="-",
13+
aws_secret_access_key="-",
14+
region_name="eu-north-1",
15+
)
216

317

418
def test_public_api():
519
assert Boto3Integration.__module__ == "sentry_sdk.integrations.boto3"
620
assert Boto3Integration.identifier == "boto3"
21+
22+
23+
@pytest.fixture
24+
def client_factory(sentry_init, monkeypatch, span_streaming):
25+
sentry_init(
26+
traces_sample_rate=1.0,
27+
integrations=[Boto3Integration()],
28+
trace_lifecycle="stream" if span_streaming else "static",
29+
# avoid SDK's machine hostname being used as server name.
30+
server_name="",
31+
)
32+
# remove retry delay to speed up tests
33+
monkeypatch.setattr("botocore.endpoint.time.sleep", lambda delay: None)
34+
35+
def make_client(service_name="s3", attempt_count=1, **client_kwargs):
36+
return session.client(
37+
service_name,
38+
config=Config(
39+
# `total_max_attempts` includes the initial request.
40+
retries={"total_max_attempts": attempt_count, "mode": "standard"}
41+
),
42+
**client_kwargs,
43+
)
44+
45+
return make_client
46+
47+
48+
def _capture_boto3_spans_by_op(invoke_client_method, capture_items, span_streaming):
49+
items = capture_items()
50+
51+
if span_streaming:
52+
with sentry_sdk.traces.start_span(name="parent"): # type: ignore[attr-defined]
53+
invoke_client_method()
54+
55+
sentry_sdk.flush()
56+
spans = [
57+
item.payload
58+
for item in items
59+
if item.type == "span"
60+
and item.payload["attributes"].get("sentry.origin")
61+
== Boto3Integration.origin
62+
]
63+
else:
64+
with sentry_sdk.start_transaction():
65+
invoke_client_method()
66+
67+
transaction = next(item.payload for item in items if item.type == "transaction")
68+
spans = [
69+
span
70+
for span in transaction["spans"]
71+
if span["origin"] == Boto3Integration.origin
72+
]
73+
74+
spans_by_op = {}
75+
for span in spans:
76+
op = (
77+
span["attributes"].get("sentry.op") if span_streaming else span["op"]
78+
)
79+
spans_by_op.setdefault(op, []).append(span)
80+
return spans_by_op
81+
82+
83+
def _assert_span_finished(span, span_streaming):
84+
finished_timestamp = "end_timestamp" if span_streaming else "timestamp"
85+
assert span[finished_timestamp] is not None
86+
87+
88+
def _assert_one_failed_span(spans, span_streaming):
89+
assert len(spans) == 1
90+
assert spans[0]["status"] in ("error", "internal_error")
91+
_assert_span_finished(spans[0], span_streaming)
92+
93+
94+
@pytest.mark.parametrize("span_streaming", [True, False])
95+
def test_streaming_body_read_failure_finishes_stream_span(
96+
capture_items,
97+
client_factory,
98+
span_streaming,
99+
):
100+
client = client_factory()
101+
original_exception = OSError("stream read failed")
102+
103+
class _FailingBody(Body):
104+
def __init__(self, exception):
105+
super().__init__(b"")
106+
self._exception = exception
107+
108+
def read(self, *args, **kwargs):
109+
raise self._exception
110+
111+
def respond(request, **kwargs):
112+
return AWSResponse(
113+
request.url,
114+
200,
115+
{"content-length": "1"},
116+
_FailingBody(original_exception),
117+
)
118+
119+
client.meta.events.register("before-send", respond)
120+
121+
def invoke_client_method_and_read_body():
122+
body = client.get_object(Bucket="bucket", Key="foo")["Body"]
123+
with pytest.raises(OSError) as exc_info:
124+
body.read()
125+
assert exc_info.value is original_exception
126+
127+
spans_by_op = _capture_boto3_spans_by_op(
128+
invoke_client_method_and_read_body, capture_items, span_streaming
129+
)
130+
client_spans = spans_by_op.get(OP.HTTP_CLIENT, [])
131+
stream_spans = spans_by_op.get(OP.HTTP_CLIENT_STREAM, [])
132+
133+
assert len(client_spans) == 1
134+
_assert_one_failed_span(stream_spans, span_streaming)

0 commit comments

Comments
 (0)