Skip to content

Commit 500f10e

Browse files
fix(boto3): Finish StreamingBody span correctly (#7540)
### Description Finish boto streaming spans when a `StreamingBody` is consumed, closed or fails. Previously, streaming spans where only finished when a read returned no data or `StreamingBody.close()` was called.
1 parent 505c6db commit 500f10e

2 files changed

Lines changed: 235 additions & 32 deletions

File tree

‎sentry_sdk/integrations/boto3/_instrumentation.py‎

Lines changed: 107 additions & 32 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.consts import IDENTIFIER, ORIGIN
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,
@@ -162,26 +162,44 @@ def _replace_header(request: "AWSRequest", key: str, value: str) -> None:
162162
)
163163

164164

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

170-
# Span could be absent if the integration is disabled.
171-
if span is None:
172-
return
176+
if error is None:
177+
span.end()
178+
else:
179+
span.__exit__(type(error), error, error.__traceback__)
173180

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

176188
body = parsed.get("Body")
177189
if not isinstance(body, StreamingBody):
178-
return
190+
return False
179191

180192
streaming_span: "Union[Span, StreamedSpan]"
181193
if isinstance(span, StreamedSpan):
182194
streaming_span = sentry_sdk.traces.start_span(
183195
name=span.name,
196+
# `parent_span` is set explicitly to the boto span.
184197
parent_span=span,
198+
# avoid making the streaming span the current span on the scope since the application might
199+
# keep `StreamingBody` open before reading it. Otherwise: 1. when the streamingspan ends it
200+
# could restore the parent span on the scope, breaking the parent-child relation of newly
201+
# created spans; 2. newly created spans would be attached to the streaming span.
202+
active=False,
185203
attributes={
186204
"sentry.op": OP.HTTP_CLIENT_STREAM,
187205
"sentry.origin": ORIGIN,
@@ -196,35 +214,92 @@ def _sentry_after_call(
196214

197215
orig_read = body.read
198216
orig_close = body.close
217+
raw_stream = body._raw_stream # type: ignore[attr-defined]
218+
orig_raw_close = raw_stream.close
219+
finished = False
220+
read_in_progress = False
221+
222+
def finish_span(error: "Optional[BaseException]" = None) -> None:
223+
nonlocal finished
224+
if finished:
225+
return
226+
227+
finished = True
228+
_finish_span(streaming_span, error)
229+
230+
def content_length_reached() -> bool:
231+
content_length = getattr(body, "_content_length", None)
232+
amount_read = getattr(body, "_amount_read", None)
233+
return (
234+
content_length is not None
235+
and amount_read is not None
236+
and amount_read >= int(content_length)
237+
)
199238

200239
def sentry_streaming_body_read(*args: "Any", **kwargs: "Any") -> bytes:
240+
nonlocal read_in_progress
241+
read_in_progress = True
201242
try:
202-
ret = orig_read(*args, **kwargs)
203-
if ret:
204-
return ret
205-
206-
if isinstance(streaming_span, StreamedSpan):
207-
streaming_span.end()
208-
else:
209-
streaming_span.finish()
210-
return ret
211-
except Exception:
212-
if isinstance(streaming_span, StreamedSpan):
213-
streaming_span.end()
214-
else:
215-
streaming_span.finish()
243+
read_return_value = orig_read(*args, **kwargs)
244+
with capture_internal_exceptions():
245+
amount_of_bytes_requested = args[0] if args else kwargs.get("amt")
246+
if (
247+
amount_of_bytes_requested is None
248+
or amount_of_bytes_requested < 0
249+
or (amount_of_bytes_requested > 0 and not read_return_value)
250+
or content_length_reached()
251+
):
252+
finish_span()
253+
return read_return_value
254+
except BaseException as error:
255+
finish_span(error)
216256
raise
217-
218-
body.read = sentry_streaming_body_read # type: ignore
257+
finally:
258+
read_in_progress = False
219259

220260
def sentry_streaming_body_close(*args: "Any", **kwargs: "Any") -> None:
221-
if isinstance(streaming_span, StreamedSpan):
222-
streaming_span.end()
223-
else:
224-
streaming_span.finish()
225-
orig_close(*args, **kwargs)
261+
try:
262+
orig_close(*args, **kwargs)
263+
finish_span()
264+
except BaseException as error:
265+
finish_span(error)
266+
raise
267+
268+
def sentry_raw_stream_close(*args: "Any", **kwargs: "Any") -> None:
269+
try:
270+
orig_raw_close(*args, **kwargs)
271+
if not read_in_progress:
272+
finish_span()
273+
except BaseException as error:
274+
finish_span(error)
275+
raise
276+
277+
try:
278+
# StreamingBody.__exit__ closes `_raw_stream` directly, bypassing
279+
# StreamingBody.close(), so both levels need to be instrumented.
280+
raw_stream.close = sentry_raw_stream_close
281+
body.read = sentry_streaming_body_read # type: ignore
282+
body.close = sentry_streaming_body_close # type: ignore
283+
except Exception:
284+
finish_span()
285+
raise
226286

227-
body.close = sentry_streaming_body_close # type: ignore
287+
return True
288+
289+
290+
def _sentry_after_call(
291+
context: "Dict[str, Any]", parsed: "Dict[str, Any]", **kwargs: "Any"
292+
) -> None:
293+
span: "Optional[Union[Span, StreamedSpan]]" = context.pop("_sentrysdk_span", None)
294+
295+
# Span could be absent if the integration is disabled.
296+
if span is None:
297+
return
298+
299+
span.__exit__(None, None, None)
300+
301+
with capture_internal_exceptions():
302+
_instrument_streaming_body(span, parsed)
228303

229304

230305
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 = span["attributes"].get("sentry.op") if span_streaming else span["op"]
77+
spans_by_op.setdefault(op, []).append(span)
78+
return spans_by_op
79+
80+
81+
def _assert_span_finished(span, span_streaming):
82+
finished_timestamp = "end_timestamp" if span_streaming else "timestamp"
83+
assert span[finished_timestamp] is not None
84+
85+
86+
def _assert_one_failed_span(spans, span_streaming):
87+
assert len(spans) == 1
88+
assert spans[0]["status"] in ("error", "internal_error")
89+
_assert_span_finished(spans[0], span_streaming)
90+
91+
92+
@pytest.mark.parametrize("span_streaming", [True, False])
93+
def test_streaming_body_read_failure_finishes_stream_span(
94+
capture_items,
95+
client_factory,
96+
span_streaming,
97+
):
98+
client = client_factory()
99+
original_exception = OSError("stream read failed")
100+
101+
class _FailingBody(Body):
102+
def __init__(self, exception):
103+
super().__init__(b"")
104+
self._exception = exception
105+
106+
def read(self, *args, **kwargs):
107+
# urllib3 closes the response before propagating some read failures.
108+
self.close()
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)