Skip to content

Commit f32e391

Browse files
feat: Add concurrent.futures patch to threading integration (#4770)
Automatically fork isolation and current scopes when running tasks with `concurrent.future`. Packages the implementation from #4508 (comment) as an integration. Closes #4565 --------- Co-authored-by: Anton Pirker <[email protected]>
1 parent b1dd2dc commit f32e391

File tree

2 files changed

+113
-8
lines changed

2 files changed

+113
-8
lines changed

sentry_sdk/integrations/threading.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import warnings
33
from functools import wraps
44
from threading import Thread, current_thread
5+
from concurrent.futures import ThreadPoolExecutor, Future
56

67
import sentry_sdk
78
from sentry_sdk.integrations import Integration
@@ -24,6 +25,7 @@
2425
from sentry_sdk._types import ExcInfo
2526

2627
F = TypeVar("F", bound=Callable[..., Any])
28+
T = TypeVar("T", bound=Any)
2729

2830

2931
class ThreadingIntegration(Integration):
@@ -59,6 +61,15 @@ def setup_once():
5961
django_version = None
6062
channels_version = None
6163

64+
is_async_emulated_with_threads = (
65+
sys.version_info < (3, 9)
66+
and channels_version is not None
67+
and channels_version < "4.0.0"
68+
and django_version is not None
69+
and django_version >= (3, 0)
70+
and django_version < (4, 0)
71+
)
72+
6273
@wraps(old_start)
6374
def sentry_start(self, *a, **kw):
6475
# type: (Thread, *Any, **Any) -> Any
@@ -67,14 +78,7 @@ def sentry_start(self, *a, **kw):
6778
return old_start(self, *a, **kw)
6879

6980
if integration.propagate_scope:
70-
if (
71-
sys.version_info < (3, 9)
72-
and channels_version is not None
73-
and channels_version < "4.0.0"
74-
and django_version is not None
75-
and django_version >= (3, 0)
76-
and django_version < (4, 0)
77-
):
81+
if is_async_emulated_with_threads:
7882
warnings.warn(
7983
"There is a known issue with Django channels 2.x and 3.x when using Python 3.8 or older. "
8084
"(Async support is emulated using threads and some Sentry data may be leaked between those threads.) "
@@ -109,6 +113,9 @@ def sentry_start(self, *a, **kw):
109113
return old_start(self, *a, **kw)
110114

111115
Thread.start = sentry_start # type: ignore
116+
ThreadPoolExecutor.submit = _wrap_threadpool_executor_submit( # type: ignore
117+
ThreadPoolExecutor.submit, is_async_emulated_with_threads
118+
)
112119

113120

114121
def _wrap_run(isolation_scope_to_use, current_scope_to_use, old_run_func):
@@ -134,6 +141,43 @@ def _run_old_run_func():
134141
return run # type: ignore
135142

136143

144+
def _wrap_threadpool_executor_submit(func, is_async_emulated_with_threads):
145+
# type: (Callable[..., Future[T]], bool) -> Callable[..., Future[T]]
146+
"""
147+
Wrap submit call to propagate scopes on task submission.
148+
"""
149+
150+
@wraps(func)
151+
def sentry_submit(self, fn, *args, **kwargs):
152+
# type: (ThreadPoolExecutor, Callable[..., T], *Any, **Any) -> Future[T]
153+
integration = sentry_sdk.get_client().get_integration(ThreadingIntegration)
154+
if integration is None:
155+
return func(self, fn, *args, **kwargs)
156+
157+
if integration.propagate_scope and is_async_emulated_with_threads:
158+
isolation_scope = sentry_sdk.get_isolation_scope()
159+
current_scope = sentry_sdk.get_current_scope()
160+
elif integration.propagate_scope:
161+
isolation_scope = sentry_sdk.get_isolation_scope().fork()
162+
current_scope = sentry_sdk.get_current_scope().fork()
163+
else:
164+
isolation_scope = None
165+
current_scope = None
166+
167+
def wrapped_fn(*args, **kwargs):
168+
# type: (*Any, **Any) -> Any
169+
if isolation_scope is not None and current_scope is not None:
170+
with use_isolation_scope(isolation_scope):
171+
with use_scope(current_scope):
172+
return fn(*args, **kwargs)
173+
174+
return fn(*args, **kwargs)
175+
176+
return func(self, wrapped_fn, *args, **kwargs)
177+
178+
return sentry_submit
179+
180+
137181
def _capture_exception():
138182
# type: () -> ExcInfo
139183
exc_info = sys.exc_info()

tests/integrations/threading/test_threading.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,64 @@ def do_some_work(number):
276276
- op="outer-submit-4": description="Thread: main"\
277277
"""
278278
)
279+
280+
281+
@pytest.mark.parametrize(
282+
"propagate_scope",
283+
(True, False),
284+
ids=["propagate_scope=True", "propagate_scope=False"],
285+
)
286+
def test_spans_from_threadpool(
287+
sentry_init, capture_events, render_span_tree, propagate_scope
288+
):
289+
sentry_init(
290+
traces_sample_rate=1.0,
291+
integrations=[ThreadingIntegration(propagate_scope=propagate_scope)],
292+
)
293+
events = capture_events()
294+
295+
def do_some_work(number):
296+
with sentry_sdk.start_span(
297+
op=f"inner-run-{number}", name=f"Thread: child-{number}"
298+
):
299+
pass
300+
301+
with sentry_sdk.start_transaction(op="outer-trx"):
302+
with futures.ThreadPoolExecutor(max_workers=1) as executor:
303+
for number in range(5):
304+
with sentry_sdk.start_span(
305+
op=f"outer-submit-{number}", name="Thread: main"
306+
):
307+
future = executor.submit(do_some_work, number)
308+
future.result()
309+
310+
(event,) = events
311+
312+
if propagate_scope:
313+
assert render_span_tree(event) == dedent(
314+
"""\
315+
- op="outer-trx": description=null
316+
- op="outer-submit-0": description="Thread: main"
317+
- op="inner-run-0": description="Thread: child-0"
318+
- op="outer-submit-1": description="Thread: main"
319+
- op="inner-run-1": description="Thread: child-1"
320+
- op="outer-submit-2": description="Thread: main"
321+
- op="inner-run-2": description="Thread: child-2"
322+
- op="outer-submit-3": description="Thread: main"
323+
- op="inner-run-3": description="Thread: child-3"
324+
- op="outer-submit-4": description="Thread: main"
325+
- op="inner-run-4": description="Thread: child-4"\
326+
"""
327+
)
328+
329+
elif not propagate_scope:
330+
assert render_span_tree(event) == dedent(
331+
"""\
332+
- op="outer-trx": description=null
333+
- op="outer-submit-0": description="Thread: main"
334+
- op="outer-submit-1": description="Thread: main"
335+
- op="outer-submit-2": description="Thread: main"
336+
- op="outer-submit-3": description="Thread: main"
337+
- op="outer-submit-4": description="Thread: main"\
338+
"""
339+
)

0 commit comments

Comments
 (0)