Skip to content

Commit 2c9d119

Browse files
committed
feat(profiling): Enable profiling for ASGI frameworks
This enables profiling for ASGI frameworks. When running in ASGI sync views, the transaction gets started in the main thread then the request is dispatched to a handler thread. We want to set the handler thread as the active thread id to ensure that profiles will show it on first render.
1 parent 2f916d3 commit 2c9d119

File tree

16 files changed

+345
-46
lines changed

16 files changed

+345
-46
lines changed

sentry_sdk/client.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -433,9 +433,7 @@ def capture_event(
433433

434434
if is_transaction:
435435
if profile is not None:
436-
envelope.add_profile(
437-
profile.to_json(event_opt, self.options, scope)
438-
)
436+
envelope.add_profile(profile.to_json(event_opt, self.options))
439437
envelope.add_transaction(event_opt)
440438
else:
441439
envelope.add_event(event_opt)

sentry_sdk/integrations/asgi.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from sentry_sdk.hub import Hub, _should_send_default_pii
1515
from sentry_sdk.integrations._wsgi_common import _filter_headers
1616
from sentry_sdk.integrations.modules import _get_installed_modules
17+
from sentry_sdk.profiler import start_profiling
1718
from sentry_sdk.sessions import auto_session_tracking
1819
from sentry_sdk.tracing import (
1920
SOURCE_FOR_STYLE,
@@ -175,7 +176,7 @@ async def _run_app(self, scope, callback):
175176

176177
with hub.start_transaction(
177178
transaction, custom_sampling_context={"asgi_scope": scope}
178-
):
179+
), start_profiling(transaction, hub):
179180
# XXX: Would be cool to have correct span status, but we
180181
# would have to wrap send(). That is a bit hard to do with
181182
# the current abstraction over ASGI 2/3.

sentry_sdk/integrations/django/asgi.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import asyncio
10+
import threading
1011

1112
from sentry_sdk import Hub, _functools
1213
from sentry_sdk._types import MYPY
@@ -89,10 +90,14 @@ def wrap_async_view(hub, callback):
8990
async def sentry_wrapped_callback(request, *args, **kwargs):
9091
# type: (Any, *Any, **Any) -> Any
9192

92-
with hub.start_span(
93-
op=OP.VIEW_RENDER, description=request.resolver_match.view_name
94-
):
95-
return await callback(request, *args, **kwargs)
93+
with hub.configure_scope() as sentry_scope:
94+
if sentry_scope.profile is not None:
95+
sentry_scope.profile.active_thread_id = threading.current_thread().ident
96+
97+
with hub.start_span(
98+
op=OP.VIEW_RENDER, description=request.resolver_match.view_name
99+
):
100+
return await callback(request, *args, **kwargs)
96101

97102
return sentry_wrapped_callback
98103

sentry_sdk/integrations/django/views.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import threading
2+
13
from sentry_sdk.consts import OP
24
from sentry_sdk.hub import Hub
35
from sentry_sdk._types import MYPY
@@ -73,9 +75,15 @@ def _wrap_sync_view(hub, callback):
7375
@_functools.wraps(callback)
7476
def sentry_wrapped_callback(request, *args, **kwargs):
7577
# type: (Any, *Any, **Any) -> Any
76-
with hub.start_span(
77-
op=OP.VIEW_RENDER, description=request.resolver_match.view_name
78-
):
79-
return callback(request, *args, **kwargs)
78+
with hub.configure_scope() as sentry_scope:
79+
# set the active thread id to the handler thread for sync views
80+
# this isn't necessary for async views since that runs on main
81+
if sentry_scope.profile is not None:
82+
sentry_scope.profile.active_thread_id = threading.current_thread().ident
83+
84+
with hub.start_span(
85+
op=OP.VIEW_RENDER, description=request.resolver_match.view_name
86+
):
87+
return callback(request, *args, **kwargs)
8088

8189
return sentry_wrapped_callback

sentry_sdk/integrations/fastapi.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import asyncio
2+
import threading
3+
14
from sentry_sdk._types import MYPY
25
from sentry_sdk.hub import Hub, _should_send_default_pii
36
from sentry_sdk.integrations import DidNotEnable
@@ -62,6 +65,22 @@ def patch_get_request_handler():
6265

6366
def _sentry_get_request_handler(*args, **kwargs):
6467
# type: (*Any, **Any) -> Any
68+
dependant = kwargs.get("dependant")
69+
if dependant and not asyncio.iscoroutinefunction(dependant.call):
70+
old_call = dependant.call
71+
72+
def _sentry_call(*args, **kwargs):
73+
# type: (*Any, **Any) -> Any
74+
hub = Hub.current
75+
with hub.configure_scope() as sentry_scope:
76+
if sentry_scope.profile is not None:
77+
sentry_scope.profile.active_thread_id = (
78+
threading.current_thread().ident
79+
)
80+
return old_call(*args, **kwargs)
81+
82+
dependant.call = _sentry_call
83+
6584
old_app = old_get_request_handler(*args, **kwargs)
6685

6786
async def _sentry_app(*args, **kwargs):

sentry_sdk/integrations/quart.py

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from __future__ import absolute_import
22

3+
import inspect
4+
import threading
5+
36
from sentry_sdk.hub import _should_send_default_pii, Hub
47
from sentry_sdk.integrations import DidNotEnable, Integration
58
from sentry_sdk.integrations._wsgi_common import _filter_headers
@@ -11,6 +14,7 @@
1114
event_from_exception,
1215
)
1316

17+
from sentry_sdk._functools import wraps
1418
from sentry_sdk._types import MYPY
1519

1620
if MYPY:
@@ -34,13 +38,15 @@
3438
request,
3539
websocket,
3640
)
41+
from quart.scaffold import Scaffold # type: ignore
3742
from quart.signals import ( # type: ignore
3843
got_background_exception,
3944
got_request_exception,
4045
got_websocket_exception,
4146
request_started,
4247
websocket_started,
4348
)
49+
from quart.utils import is_coroutine_function # type: ignore
4450
except ImportError:
4551
raise DidNotEnable("Quart is not installed")
4652

@@ -71,18 +77,62 @@ def setup_once():
7177
got_request_exception.connect(_capture_exception)
7278
got_websocket_exception.connect(_capture_exception)
7379

74-
old_app = Quart.__call__
80+
patch_asgi_app()
81+
patch_scaffold_route()
82+
83+
84+
def patch_asgi_app():
85+
# type: () -> None
86+
old_app = Quart.__call__
87+
88+
async def sentry_patched_asgi_app(self, scope, receive, send):
89+
# type: (Any, Any, Any, Any) -> Any
90+
if Hub.current.get_integration(QuartIntegration) is None:
91+
return await old_app(self, scope, receive, send)
92+
93+
middleware = SentryAsgiMiddleware(lambda *a, **kw: old_app(self, *a, **kw))
94+
middleware.__call__ = middleware._run_asgi3
95+
return await middleware(scope, receive, send)
96+
97+
Quart.__call__ = sentry_patched_asgi_app
98+
99+
100+
def patch_scaffold_route():
101+
# type: () -> None
102+
old_route = Scaffold.route
103+
104+
def _sentry_route(*args, **kwargs):
105+
# type: (*Any, **Any) -> Any
106+
old_decorator = old_route(*args, **kwargs)
107+
108+
def decorator(old_func):
109+
# type: (Any) -> Any
110+
111+
if inspect.isfunction(old_func) and not is_coroutine_function(old_func):
112+
113+
@wraps(old_func)
114+
def _sentry_func(*args, **kwargs):
115+
# type: (*Any, **Any) -> Any
116+
hub = Hub.current
117+
integration = hub.get_integration(QuartIntegration)
118+
if integration is None:
119+
return old_func(*args, **kwargs)
120+
121+
with hub.configure_scope() as sentry_scope:
122+
if sentry_scope.profile is not None:
123+
sentry_scope.profile.active_thread_id = (
124+
threading.current_thread().ident
125+
)
126+
127+
return old_func(*args, **kwargs)
128+
129+
return old_decorator(_sentry_func)
75130

76-
async def sentry_patched_asgi_app(self, scope, receive, send):
77-
# type: (Any, Any, Any, Any) -> Any
78-
if Hub.current.get_integration(QuartIntegration) is None:
79-
return await old_app(self, scope, receive, send)
131+
return old_decorator(old_func)
80132

81-
middleware = SentryAsgiMiddleware(lambda *a, **kw: old_app(self, *a, **kw))
82-
middleware.__call__ = middleware._run_asgi3
83-
return await middleware(scope, receive, send)
133+
return decorator
84134

85-
Quart.__call__ = sentry_patched_asgi_app
135+
Scaffold.route = _sentry_route
86136

87137

88138
def _set_transaction_name_and_source(scope, transaction_style, request):

sentry_sdk/integrations/starlette.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import functools
5+
import threading
56

67
from sentry_sdk._compat import iteritems
78
from sentry_sdk._types import MYPY
@@ -403,6 +404,11 @@ def _sentry_sync_func(*args, **kwargs):
403404
return old_func(*args, **kwargs)
404405

405406
with hub.configure_scope() as sentry_scope:
407+
if sentry_scope.profile is not None:
408+
sentry_scope.profile.active_thread_id = (
409+
threading.current_thread().ident
410+
)
411+
406412
request = args[0]
407413

408414
_set_transaction_name_and_source(

sentry_sdk/profiler.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@
4747
from typing import Sequence
4848
from typing import Tuple
4949
from typing_extensions import TypedDict
50-
import sentry_sdk.scope
5150
import sentry_sdk.tracing
5251

5352
StackId = int
@@ -305,13 +304,22 @@ def __init__(
305304
self.scheduler = scheduler
306305
self.transaction = transaction
307306
self.hub = hub
307+
self.active_thread_id = None # type: Optional[int]
308308
self._start_ns = None # type: Optional[int]
309309
self._stop_ns = None # type: Optional[int]
310310

311311
transaction._profile = self
312312

313313
def __enter__(self):
314314
# type: () -> None
315+
hub = self.hub or sentry_sdk.Hub.current
316+
317+
_, scope = hub._stack[-1]
318+
old_profile = scope.profile
319+
scope.profile = self
320+
321+
self._context_manager_state = (hub, scope, old_profile)
322+
315323
self._start_ns = nanosecond_time()
316324
self.scheduler.start_profiling()
317325

@@ -320,8 +328,13 @@ def __exit__(self, ty, value, tb):
320328
self.scheduler.stop_profiling()
321329
self._stop_ns = nanosecond_time()
322330

323-
def to_json(self, event_opt, options, scope):
324-
# type: (Any, Dict[str, Any], Optional[sentry_sdk.scope.Scope]) -> Dict[str, Any]
331+
_, scope, old_profile = self._context_manager_state
332+
del self._context_manager_state
333+
334+
scope.profile = old_profile
335+
336+
def to_json(self, event_opt, options):
337+
# type: (Any, Dict[str, Any]) -> Dict[str, Any]
325338
assert self._start_ns is not None
326339
assert self._stop_ns is not None
327340

@@ -333,9 +346,6 @@ def to_json(self, event_opt, options, scope):
333346
profile["frames"], options["in_app_exclude"], options["in_app_include"]
334347
)
335348

336-
# the active thread id from the scope always take priorty if it exists
337-
active_thread_id = None if scope is None else scope.active_thread_id
338-
339349
return {
340350
"environment": event_opt.get("environment"),
341351
"event_id": uuid.uuid4().hex,
@@ -369,8 +379,8 @@ def to_json(self, event_opt, options, scope):
369379
"trace_id": self.transaction.trace_id,
370380
"active_thread_id": str(
371381
self.transaction._active_thread_id
372-
if active_thread_id is None
373-
else active_thread_id
382+
if self.active_thread_id is None
383+
else self.active_thread_id
374384
),
375385
}
376386
],

sentry_sdk/scope.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
Type,
2828
)
2929

30+
from sentry_sdk.profiler import Profile
3031
from sentry_sdk.tracing import Span
3132
from sentry_sdk.session import Session
3233

@@ -94,10 +95,7 @@ class Scope(object):
9495
"_session",
9596
"_attachments",
9697
"_force_auto_session_tracking",
97-
# The thread that is handling the bulk of the work. This can just
98-
# be the main thread, but that's not always true. For web frameworks,
99-
# this would be the thread handling the request.
100-
"_active_thread_id",
98+
"_profile",
10199
)
102100

103101
def __init__(self):
@@ -129,7 +127,7 @@ def clear(self):
129127
self._session = None # type: Optional[Session]
130128
self._force_auto_session_tracking = None # type: Optional[bool]
131129

132-
self._active_thread_id = None # type: Optional[int]
130+
self._profile = None # type: Optional[Profile]
133131

134132
@_attr_setter
135133
def level(self, value):
@@ -235,15 +233,15 @@ def span(self, span):
235233
self._transaction = transaction.name
236234

237235
@property
238-
def active_thread_id(self):
239-
# type: () -> Optional[int]
240-
"""Get/set the current active thread id."""
241-
return self._active_thread_id
236+
def profile(self):
237+
# type: () -> Optional[Profile]
238+
return self._profile
242239

243-
def set_active_thread_id(self, active_thread_id):
244-
# type: (Optional[int]) -> None
245-
"""Set the current active thread id."""
246-
self._active_thread_id = active_thread_id
240+
@profile.setter
241+
def profile(self, profile):
242+
# type: (Optional[Profile]) -> None
243+
244+
self._profile = profile
247245

248246
def set_tag(
249247
self,
@@ -464,8 +462,8 @@ def update_from_scope(self, scope):
464462
self._span = scope._span
465463
if scope._attachments:
466464
self._attachments.extend(scope._attachments)
467-
if scope._active_thread_id is not None:
468-
self._active_thread_id = scope._active_thread_id
465+
if scope._profile:
466+
self._profile = scope._profile
469467

470468
def update_from_kwargs(
471469
self,
@@ -515,7 +513,7 @@ def __copy__(self):
515513
rv._force_auto_session_tracking = self._force_auto_session_tracking
516514
rv._attachments = list(self._attachments)
517515

518-
rv._active_thread_id = self._active_thread_id
516+
rv._profile = self._profile
519517

520518
return rv
521519

0 commit comments

Comments
 (0)