Skip to content

Commit da20623

Browse files
authored
Fix spans for streaming responses in WSGI based frameworks (#3798)
Fixes spans in streaming responses when using WSGI based frameworks. Only close the transaction once the response was consumed. This way all the spans created during creation of the response will be recorded with the transaction: - The transaction stays open until all the streaming blocks are sent to the client. (because of this I had to update the tests, to make sure the tests, consume the response, because the Werkzeug test client (used by Flask and Django and our Strawberry tests) will not close the WSGI response) - A maximum runtime of 5 minutes for transactions is enforced. (like Javascript does it) - When using a generator to generate the streaming response, it uses the correct scopes to have correct parent-child relationship of spans created in the generator. People having Sentry in a streaming application will: - See an increase in their transaction duration to up to 5 minutes - Get the correct span tree for streaming responses generated by a generator Fixes #3736
1 parent a7c2d70 commit da20623

File tree

6 files changed

+270
-73
lines changed

6 files changed

+270
-73
lines changed

sentry_sdk/integrations/wsgi.py

+94-41
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import sys
22
from functools import partial
3+
from threading import Timer
34

45
import sentry_sdk
56
from sentry_sdk._werkzeug import get_host, _get_headers
67
from sentry_sdk.api import continue_trace
78
from sentry_sdk.consts import OP
8-
from sentry_sdk.scope import should_send_default_pii
9+
from sentry_sdk.scope import should_send_default_pii, use_isolation_scope, use_scope
910
from sentry_sdk.integrations._wsgi_common import (
1011
DEFAULT_HTTP_METHODS_TO_CAPTURE,
1112
_filter_headers,
12-
nullcontext,
1313
)
1414
from sentry_sdk.sessions import track_session
15-
from sentry_sdk.scope import use_isolation_scope
1615
from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_ROUTE
16+
from sentry_sdk.tracing_utils import finish_running_transaction
1717
from sentry_sdk.utils import (
1818
ContextVar,
1919
capture_internal_exceptions,
@@ -46,6 +46,9 @@ def __call__(self, status, response_headers, exc_info=None): # type: ignore
4646
pass
4747

4848

49+
MAX_TRANSACTION_DURATION_SECONDS = 5 * 60
50+
51+
4952
_wsgi_middleware_applied = ContextVar("sentry_wsgi_middleware_applied")
5053

5154

@@ -98,6 +101,7 @@ def __call__(self, environ, start_response):
98101
_wsgi_middleware_applied.set(True)
99102
try:
100103
with sentry_sdk.isolation_scope() as scope:
104+
current_scope = sentry_sdk.get_current_scope()
101105
with track_session(scope, session_mode="request"):
102106
with capture_internal_exceptions():
103107
scope.clear_breadcrumbs()
@@ -109,6 +113,7 @@ def __call__(self, environ, start_response):
109113
)
110114

111115
method = environ.get("REQUEST_METHOD", "").upper()
116+
112117
transaction = None
113118
if method in self.http_methods_to_capture:
114119
transaction = continue_trace(
@@ -119,27 +124,43 @@ def __call__(self, environ, start_response):
119124
origin=self.span_origin,
120125
)
121126

122-
with (
127+
timer = None
128+
if transaction is not None:
123129
sentry_sdk.start_transaction(
124130
transaction,
125131
custom_sampling_context={"wsgi_environ": environ},
132+
).__enter__()
133+
timer = Timer(
134+
MAX_TRANSACTION_DURATION_SECONDS,
135+
_finish_long_running_transaction,
136+
args=(current_scope, scope),
126137
)
127-
if transaction is not None
128-
else nullcontext()
129-
):
130-
try:
131-
response = self.app(
132-
environ,
133-
partial(
134-
_sentry_start_response, start_response, transaction
135-
),
136-
)
137-
except BaseException:
138-
reraise(*_capture_exception())
138+
timer.start()
139+
140+
try:
141+
response = self.app(
142+
environ,
143+
partial(
144+
_sentry_start_response,
145+
start_response,
146+
transaction,
147+
),
148+
)
149+
except BaseException:
150+
exc_info = sys.exc_info()
151+
_capture_exception(exc_info)
152+
finish_running_transaction(current_scope, exc_info, timer)
153+
reraise(*exc_info)
154+
139155
finally:
140156
_wsgi_middleware_applied.set(False)
141157

142-
return _ScopedResponse(scope, response)
158+
return _ScopedResponse(
159+
response=response,
160+
current_scope=current_scope,
161+
isolation_scope=scope,
162+
timer=timer,
163+
)
143164

144165

145166
def _sentry_start_response( # type: ignore
@@ -201,13 +222,13 @@ def get_client_ip(environ):
201222
return environ.get("REMOTE_ADDR")
202223

203224

204-
def _capture_exception():
205-
# type: () -> ExcInfo
225+
def _capture_exception(exc_info=None):
226+
# type: (Optional[ExcInfo]) -> ExcInfo
206227
"""
207228
Captures the current exception and sends it to Sentry.
208229
Returns the ExcInfo tuple to it can be reraised afterwards.
209230
"""
210-
exc_info = sys.exc_info()
231+
exc_info = exc_info or sys.exc_info()
211232
e = exc_info[1]
212233

213234
# SystemExit(0) is the only uncaught exception that is expected behavior
@@ -225,7 +246,7 @@ def _capture_exception():
225246

226247
class _ScopedResponse:
227248
"""
228-
Users a separate scope for each response chunk.
249+
Use separate scopes for each response chunk.
229250
230251
This will make WSGI apps more tolerant against:
231252
- WSGI servers streaming responses from a different thread/from
@@ -234,37 +255,54 @@ class _ScopedResponse:
234255
- WSGI servers streaming responses interleaved from the same thread
235256
"""
236257

237-
__slots__ = ("_response", "_scope")
258+
__slots__ = ("_response", "_current_scope", "_isolation_scope", "_timer")
238259

239-
def __init__(self, scope, response):
240-
# type: (sentry_sdk.scope.Scope, Iterator[bytes]) -> None
241-
self._scope = scope
260+
def __init__(
261+
self,
262+
response, # type: Iterator[bytes]
263+
current_scope, # type: sentry_sdk.scope.Scope
264+
isolation_scope, # type: sentry_sdk.scope.Scope
265+
timer=None, # type: Optional[Timer]
266+
):
267+
# type: (...) -> None
242268
self._response = response
269+
self._current_scope = current_scope
270+
self._isolation_scope = isolation_scope
271+
self._timer = timer
243272

244273
def __iter__(self):
245274
# type: () -> Iterator[bytes]
246275
iterator = iter(self._response)
247276

248-
while True:
249-
with use_isolation_scope(self._scope):
250-
try:
251-
chunk = next(iterator)
252-
except StopIteration:
253-
break
254-
except BaseException:
255-
reraise(*_capture_exception())
277+
try:
278+
while True:
279+
with use_isolation_scope(self._isolation_scope):
280+
with use_scope(self._current_scope):
281+
try:
282+
chunk = next(iterator)
283+
except StopIteration:
284+
break
285+
except BaseException:
286+
reraise(*_capture_exception())
287+
288+
yield chunk
256289

257-
yield chunk
290+
finally:
291+
with use_isolation_scope(self._isolation_scope):
292+
with use_scope(self._current_scope):
293+
finish_running_transaction(timer=self._timer)
258294

259295
def close(self):
260296
# type: () -> None
261-
with use_isolation_scope(self._scope):
262-
try:
263-
self._response.close() # type: ignore
264-
except AttributeError:
265-
pass
266-
except BaseException:
267-
reraise(*_capture_exception())
297+
with use_isolation_scope(self._isolation_scope):
298+
with use_scope(self._current_scope):
299+
try:
300+
finish_running_transaction(timer=self._timer)
301+
self._response.close() # type: ignore
302+
except AttributeError:
303+
pass
304+
except BaseException:
305+
reraise(*_capture_exception())
268306

269307

270308
def _make_wsgi_event_processor(environ, use_x_forwarded_for):
@@ -308,3 +346,18 @@ def event_processor(event, hint):
308346
return event
309347

310348
return event_processor
349+
350+
351+
def _finish_long_running_transaction(current_scope, isolation_scope):
352+
# type: (sentry_sdk.scope.Scope, sentry_sdk.scope.Scope) -> None
353+
"""
354+
Make sure we don't keep transactions open for too long.
355+
Triggered after MAX_TRANSACTION_DURATION_SECONDS have passed.
356+
"""
357+
try:
358+
with use_isolation_scope(isolation_scope):
359+
with use_scope(current_scope):
360+
finish_running_transaction()
361+
except AttributeError:
362+
# transaction is not there anymore
363+
pass

sentry_sdk/tracing_utils.py

+18
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636

3737
from types import FrameType
3838

39+
from sentry_sdk._types import ExcInfo
40+
from threading import Timer
41+
3942

4043
SENTRY_TRACE_REGEX = re.compile(
4144
"^[ \t]*" # whitespace
@@ -739,3 +742,18 @@ def get_current_span(scope=None):
739742

740743
if TYPE_CHECKING:
741744
from sentry_sdk.tracing import Span
745+
746+
747+
def finish_running_transaction(scope=None, exc_info=None, timer=None):
748+
# type: (Optional[sentry_sdk.Scope], Optional[ExcInfo], Optional[Timer]) -> None
749+
if timer is not None:
750+
timer.cancel()
751+
752+
current_scope = scope or sentry_sdk.get_current_scope()
753+
if current_scope.transaction is not None and hasattr(
754+
current_scope.transaction, "_context_manager_state"
755+
):
756+
if exc_info is not None:
757+
current_scope.transaction.__exit__(*exc_info)
758+
else:
759+
current_scope.transaction.__exit__(None, None, None)

0 commit comments

Comments
 (0)