Skip to content

Commit 43ca991

Browse files
authored
feat(profiling): Enable profiling for ASGI frameworks (#1824)
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 ffe7737 commit 43ca991

File tree

14 files changed

+249
-39
lines changed

14 files changed

+249
-39
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: 23 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,26 @@ 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 (
70+
dependant
71+
and dependant.call is not None
72+
and not asyncio.iscoroutinefunction(dependant.call)
73+
):
74+
old_call = dependant.call
75+
76+
def _sentry_call(*args, **kwargs):
77+
# type: (*Any, **Any) -> Any
78+
hub = Hub.current
79+
with hub.configure_scope() as sentry_scope:
80+
if sentry_scope.profile is not None:
81+
sentry_scope.profile.active_thread_id = (
82+
threading.current_thread().ident
83+
)
84+
return old_call(*args, **kwargs)
85+
86+
dependant.call = _sentry_call
87+
6588
old_app = old_get_request_handler(*args, **kwargs)
6689

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

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: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646
from typing import Sequence
4747
from typing import Tuple
4848
from typing_extensions import TypedDict
49-
import sentry_sdk.scope
5049
import sentry_sdk.tracing
5150

5251
ThreadId = str
@@ -329,10 +328,13 @@ def __init__(
329328
self,
330329
scheduler, # type: Scheduler
331330
transaction, # type: sentry_sdk.tracing.Transaction
331+
hub=None, # type: Optional[sentry_sdk.Hub]
332332
):
333333
# type: (...) -> None
334334
self.scheduler = scheduler
335335
self.transaction = transaction
336+
self.hub = hub
337+
self.active_thread_id = None # type: Optional[int]
336338
self.start_ns = 0 # type: int
337339
self.stop_ns = 0 # type: int
338340
self.active = False # type: bool
@@ -347,6 +349,14 @@ def __init__(
347349

348350
def __enter__(self):
349351
# type: () -> None
352+
hub = self.hub or sentry_sdk.Hub.current
353+
354+
_, scope = hub._stack[-1]
355+
old_profile = scope.profile
356+
scope.profile = self
357+
358+
self._context_manager_state = (hub, scope, old_profile)
359+
350360
self.start_ns = nanosecond_time()
351361
self.scheduler.start_profiling(self)
352362

@@ -355,6 +365,11 @@ def __exit__(self, ty, value, tb):
355365
self.scheduler.stop_profiling(self)
356366
self.stop_ns = nanosecond_time()
357367

368+
_, scope, old_profile = self._context_manager_state
369+
del self._context_manager_state
370+
371+
scope.profile = old_profile
372+
358373
def write(self, ts, sample):
359374
# type: (int, RawSample) -> None
360375
if ts < self.start_ns:
@@ -414,18 +429,14 @@ def process(self):
414429
"thread_metadata": thread_metadata,
415430
}
416431

417-
def to_json(self, event_opt, options, scope):
418-
# type: (Any, Dict[str, Any], Optional[sentry_sdk.scope.Scope]) -> Dict[str, Any]
419-
432+
def to_json(self, event_opt, options):
433+
# type: (Any, Dict[str, Any]) -> Dict[str, Any]
420434
profile = self.process()
421435

422436
handle_in_app_impl(
423437
profile["frames"], options["in_app_exclude"], options["in_app_include"]
424438
)
425439

426-
# the active thread id from the scope always take priorty if it exists
427-
active_thread_id = None if scope is None else scope.active_thread_id
428-
429440
return {
430441
"environment": event_opt.get("environment"),
431442
"event_id": uuid.uuid4().hex,
@@ -459,8 +470,8 @@ def to_json(self, event_opt, options, scope):
459470
"trace_id": self.transaction.trace_id,
460471
"active_thread_id": str(
461472
self.transaction._active_thread_id
462-
if active_thread_id is None
463-
else active_thread_id
473+
if self.active_thread_id is None
474+
else self.active_thread_id
464475
),
465476
}
466477
],
@@ -739,7 +750,7 @@ def start_profiling(transaction, hub=None):
739750
# if profiling was not enabled, this should be a noop
740751
if _should_profile(transaction, hub):
741752
assert _scheduler is not None
742-
with Profile(_scheduler, transaction):
753+
with Profile(_scheduler, transaction, hub):
743754
yield
744755
else:
745756
yield

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

tests/integrations/django/asgi/test_asgi.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
13
import django
24
import pytest
35
from channels.testing import HttpCommunicator
@@ -70,6 +72,41 @@ async def test_async_views(sentry_init, capture_events, application):
7072
}
7173

7274

75+
@pytest.mark.parametrize("application", APPS)
76+
@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"])
77+
@pytest.mark.asyncio
78+
@pytest.mark.skipif(
79+
django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
80+
)
81+
async def test_active_thread_id(sentry_init, capture_envelopes, endpoint, application):
82+
sentry_init(
83+
integrations=[DjangoIntegration()],
84+
traces_sample_rate=1.0,
85+
_experiments={"profiles_sample_rate": 1.0},
86+
)
87+
88+
envelopes = capture_envelopes()
89+
90+
comm = HttpCommunicator(application, "GET", endpoint)
91+
response = await comm.get_response()
92+
assert response["status"] == 200, response["body"]
93+
94+
await comm.wait()
95+
96+
data = json.loads(response["body"])
97+
98+
envelopes = [envelope for envelope in envelopes]
99+
assert len(envelopes) == 1
100+
101+
profiles = [item for item in envelopes[0].items if item.type == "profile"]
102+
assert len(profiles) == 1
103+
104+
for profile in profiles:
105+
transactions = profile.payload.json["transactions"]
106+
assert len(transactions) == 1
107+
assert str(data["active"]) == transactions[0]["active_thread_id"]
108+
109+
73110
@pytest.mark.asyncio
74111
@pytest.mark.skipif(
75112
django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"

tests/integrations/django/myapp/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def path(path, *args, **kwargs):
5858
views.csrf_hello_not_exempt,
5959
name="csrf_hello_not_exempt",
6060
),
61+
path("sync/thread_ids", views.thread_ids_sync, name="thread_ids_sync"),
6162
]
6263

6364
# async views
@@ -67,6 +68,11 @@ def path(path, *args, **kwargs):
6768
if views.my_async_view is not None:
6869
urlpatterns.append(path("my_async_view", views.my_async_view, name="my_async_view"))
6970

71+
if views.thread_ids_async is not None:
72+
urlpatterns.append(
73+
path("async/thread_ids", views.thread_ids_async, name="thread_ids_async")
74+
)
75+
7076
# rest framework
7177
try:
7278
urlpatterns.append(

0 commit comments

Comments
 (0)