Skip to content

Commit f9ad69c

Browse files
feat(profiler): Add experimental profiler under experiments.enable_profiling
* Works with single threaded servers for now * No-ops for multi-threaded servers when `signal.signal` fails on a non-main thread see https://docs.python.org/3/library/signal.html#signal.signal
1 parent 9857bc9 commit f9ad69c

File tree

7 files changed

+291
-1
lines changed

7 files changed

+291
-1
lines changed

sentry_sdk/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,10 @@ def capture_event(
401401
envelope = Envelope(headers=headers)
402402

403403
if is_transaction:
404+
if "profile" in event_opt:
405+
event_opt["profile"]["transaction_id"] = event_opt["event_id"]
406+
event_opt["profile"]["version_name"] = event_opt["release"]
407+
envelope.add_profile(event_opt.pop("profile"))
404408
envelope.add_transaction(event_opt)
405409
else:
406410
envelope.add_event(event_opt)

sentry_sdk/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"smart_transaction_trimming": Optional[bool],
3535
"propagate_tracestate": Optional[bool],
3636
"custom_measurements": Optional[bool],
37+
"enable_profiling": Optional[bool],
3738
},
3839
total=False,
3940
)

sentry_sdk/envelope.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ def add_transaction(
6262
# type: (...) -> None
6363
self.add_item(Item(payload=PayloadRef(json=transaction), type="transaction"))
6464

65+
def add_profile(
66+
self, profile # type: Any
67+
):
68+
# type: (...) -> None
69+
self.add_item(Item(payload=PayloadRef(json=profile), type="profile"))
70+
6571
def add_session(
6672
self, session # type: Union[Session, Any]
6773
):

sentry_sdk/integrations/wsgi.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from sentry_sdk.tracing import Transaction
1212
from sentry_sdk.sessions import auto_session_tracking
1313
from sentry_sdk.integrations._wsgi_common import _filter_headers
14+
from sentry_sdk.profiler import profiling
1415

1516
from sentry_sdk._types import MYPY
1617

@@ -127,7 +128,7 @@ def __call__(self, environ, start_response):
127128

128129
with hub.start_transaction(
129130
transaction, custom_sampling_context={"wsgi_environ": environ}
130-
):
131+
), profiling(transaction, hub):
131132
try:
132133
rv = self.app(
133134
environ,

sentry_sdk/profiler.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""
2+
This file is originally based on code from https://github.com/nylas/nylas-perftools, which is published under the following license:
3+
4+
The MIT License (MIT)
5+
6+
Copyright (c) 2014 Nylas
7+
8+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
11+
12+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
13+
"""
14+
15+
import atexit
16+
import signal
17+
import time
18+
from contextlib import contextmanager
19+
20+
import sentry_sdk
21+
from sentry_sdk._compat import PY2
22+
from sentry_sdk.utils import logger
23+
24+
if PY2:
25+
import thread # noqa
26+
else:
27+
import threading
28+
29+
from sentry_sdk._types import MYPY
30+
31+
if MYPY:
32+
import typing
33+
from typing import Generator
34+
from typing import Optional
35+
import sentry_sdk.tracing
36+
37+
38+
if PY2:
39+
40+
def thread_id():
41+
# type: () -> int
42+
return thread.get_ident()
43+
44+
def nanosecond_time():
45+
# type: () -> int
46+
return int(time.clock() * 1e9)
47+
48+
else:
49+
50+
def thread_id():
51+
# type: () -> int
52+
return threading.get_ident()
53+
54+
def nanosecond_time():
55+
# type: () -> int
56+
return int(time.perf_counter() * 1e9)
57+
58+
59+
class FrameData:
60+
def __init__(self, frame):
61+
# type: (typing.Any) -> None
62+
self.function_name = frame.f_code.co_name
63+
self.module = frame.f_globals["__name__"]
64+
65+
# Depending on Python version, frame.f_code.co_filename either stores just the file name or the entire absolute path.
66+
self.file_name = frame.f_code.co_filename
67+
self.line_number = frame.f_code.co_firstlineno
68+
69+
@property
70+
def _attribute_tuple(self):
71+
# type: () -> typing.Tuple[str, str, str, int]
72+
"""Returns a tuple of the attributes used in comparison"""
73+
return (self.function_name, self.module, self.file_name, self.line_number)
74+
75+
def __eq__(self, other):
76+
# type: (typing.Any) -> bool
77+
if isinstance(other, FrameData):
78+
return self._attribute_tuple == other._attribute_tuple
79+
return False
80+
81+
def __hash__(self):
82+
# type: () -> int
83+
return hash(self._attribute_tuple)
84+
85+
86+
class StackSample:
87+
def __init__(self, top_frame, profiler_start_time, frame_indices):
88+
# type: (typing.Any, int, typing.Dict[FrameData, int]) -> None
89+
self.sample_time = nanosecond_time() - profiler_start_time
90+
self.stack = [] # type: typing.List[int]
91+
self._add_all_frames(top_frame, frame_indices)
92+
93+
def _add_all_frames(self, top_frame, frame_indices):
94+
# type: (typing.Any, typing.Dict[FrameData, int]) -> None
95+
frame = top_frame
96+
while frame is not None:
97+
frame_data = FrameData(frame)
98+
if frame_data not in frame_indices:
99+
frame_indices[frame_data] = len(frame_indices)
100+
self.stack.append(frame_indices[frame_data])
101+
frame = frame.f_back
102+
self.stack = list(reversed(self.stack))
103+
104+
105+
class Sampler(object):
106+
"""
107+
A simple stack sampler for low-overhead CPU profiling: samples the call
108+
stack every `interval` seconds and keeps track of counts by frame. Because
109+
this uses signals, it only works on the main thread.
110+
"""
111+
112+
def __init__(self, transaction, interval=0.01):
113+
# type: (sentry_sdk.tracing.Transaction, float) -> None
114+
self.interval = interval
115+
self.stack_samples = [] # type: typing.List[StackSample]
116+
self._frame_indices = dict() # type: typing.Dict[FrameData, int]
117+
self._transaction = transaction
118+
self.duration = 0 # This value will only be correct after the profiler has been started and stopped
119+
transaction._profile = self
120+
121+
def __enter__(self):
122+
# type: () -> None
123+
self.start()
124+
125+
def __exit__(self, *_):
126+
# type: (*typing.List[typing.Any]) -> None
127+
self.stop()
128+
129+
def start(self):
130+
# type: () -> None
131+
self._start_time = nanosecond_time()
132+
self.stack_samples = []
133+
self._frame_indices = dict()
134+
try:
135+
signal.signal(signal.SIGVTALRM, self._sample)
136+
except ValueError:
137+
logger.error(
138+
"Profiler failed to run because it was started from a non-main thread"
139+
)
140+
return
141+
142+
signal.setitimer(signal.ITIMER_VIRTUAL, self.interval)
143+
atexit.register(self.stop)
144+
145+
def _sample(self, _, frame):
146+
# type: (typing.Any, typing.Any) -> None
147+
self.stack_samples.append(
148+
StackSample(frame, self._start_time, self._frame_indices)
149+
)
150+
signal.setitimer(signal.ITIMER_VIRTUAL, self.interval)
151+
152+
def to_json(self):
153+
# type: () -> typing.Any
154+
"""
155+
Exports this object to a JSON format compatible with Sentry's profiling visualizer.
156+
Returns dictionary which can be serialized to JSON.
157+
"""
158+
return {
159+
"samples": [
160+
{
161+
"frames": sample.stack,
162+
"relative_timestamp_ns": sample.sample_time,
163+
"thread_id": thread_id(),
164+
}
165+
for sample in self.stack_samples
166+
],
167+
"frames": [
168+
{
169+
"name": frame.function_name,
170+
"file": frame.file_name,
171+
"line": frame.line_number,
172+
}
173+
for frame in self.frame_list()
174+
],
175+
}
176+
177+
def frame_list(self):
178+
# type: () -> typing.List[FrameData]
179+
# Build frame array from the frame indices
180+
frames = [None] * len(self._frame_indices) # type: typing.List[typing.Any]
181+
for frame, index in self._frame_indices.items():
182+
frames[index] = frame
183+
return frames
184+
185+
def stop(self):
186+
# type: () -> None
187+
self.duration = nanosecond_time() - self._start_time
188+
signal.setitimer(signal.ITIMER_VIRTUAL, 0)
189+
190+
@property
191+
def transaction_name(self):
192+
# type: () -> str
193+
return self._transaction.name
194+
195+
196+
def has_profiling_enabled(hub=None):
197+
# type: (Optional[sentry_sdk.Hub]) -> bool
198+
if hub is None:
199+
hub = sentry_sdk.Hub.current
200+
201+
options = hub.client and hub.client.options
202+
return bool(options and options["_experiments"].get("enable_profiling"))
203+
204+
205+
@contextmanager
206+
def profiling(transaction, hub=None):
207+
# type: (sentry_sdk.tracing.Transaction, Optional[sentry_sdk.Hub]) -> Generator[None, None, None]
208+
if has_profiling_enabled(hub):
209+
with Sampler(transaction):
210+
yield
211+
else:
212+
yield

sentry_sdk/tracing.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import uuid
22
import random
33
import time
4+
import platform
45

56
from datetime import datetime, timedelta
67

78
import sentry_sdk
89

10+
from sentry_sdk.profiler import has_profiling_enabled
911
from sentry_sdk.utils import logger
1012
from sentry_sdk._types import MYPY
1113

@@ -19,6 +21,7 @@
1921
from typing import List
2022
from typing import Tuple
2123
from typing import Iterator
24+
from sentry_sdk.profiler import Sampler
2225

2326
from sentry_sdk._types import SamplingContext, MeasurementUnit
2427

@@ -533,6 +536,7 @@ class Transaction(Span):
533536
# tracestate data from other vendors, of the form `dogs=yes,cats=maybe`
534537
"_third_party_tracestate",
535538
"_measurements",
539+
"_profile",
536540
"_baggage",
537541
)
538542

@@ -566,6 +570,7 @@ def __init__(
566570
self._sentry_tracestate = sentry_tracestate
567571
self._third_party_tracestate = third_party_tracestate
568572
self._measurements = {} # type: Dict[str, Any]
573+
self._profile = None # type: Optional[Sampler]
569574
self._baggage = baggage
570575

571576
def __repr__(self):
@@ -658,6 +663,27 @@ def finish(self, hub=None):
658663
"spans": finished_spans,
659664
}
660665

666+
if (
667+
has_profiling_enabled(hub)
668+
and hub.client is not None
669+
and self._profile is not None
670+
):
671+
event["profile"] = {
672+
"device_os_name": platform.system(),
673+
"device_os_version": platform.release(),
674+
"duration_ns": self._profile.duration,
675+
"environment": hub.client.options["environment"],
676+
"platform": "python",
677+
"platform_version": platform.python_version(),
678+
"profile_id": uuid.uuid4().hex,
679+
"profile": self._profile.to_json(),
680+
"trace_id": self.trace_id,
681+
"transaction_id": None, # Gets added in client.py
682+
"transaction_name": self.name,
683+
"version_code": "", # TODO: Determine appropriate value. Currently set to empty string so profile will not get rejected.
684+
"version_name": None, # Gets added in client.py
685+
}
686+
661687
if has_custom_measurements_enabled():
662688
event["measurements"] = self._measurements
663689

tests/integrations/wsgi/test_wsgi.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,43 @@ def sample_app(environ, start_response):
279279
assert session_aggregates[0]["exited"] == 2
280280
assert session_aggregates[0]["crashed"] == 1
281281
assert len(session_aggregates) == 1
282+
283+
284+
def test_profile_sent_when_profiling_enabled(capture_envelopes, sentry_init):
285+
def test_app(environ, start_response):
286+
start_response("200 OK", [])
287+
return ["Go get the ball! Good dog!"]
288+
289+
sentry_init(traces_sample_rate=1.0, _experiments={"enable_profiling": True})
290+
app = SentryWsgiMiddleware(test_app)
291+
envelopes = capture_envelopes()
292+
293+
client = Client(app)
294+
client.get("/")
295+
296+
profile_sent = False
297+
for item in envelopes[0].items:
298+
if item.headers["type"] == "profile":
299+
profile_sent = True
300+
break
301+
assert profile_sent
302+
303+
304+
def test_profile_not_sent_when_profiling_disabled(capture_envelopes, sentry_init):
305+
def test_app(environ, start_response):
306+
start_response("200 OK", [])
307+
return ["Go get the ball! Good dog!"]
308+
309+
sentry_init(traces_sample_rate=1.0)
310+
app = SentryWsgiMiddleware(test_app)
311+
envelopes = capture_envelopes()
312+
313+
client = Client(app)
314+
client.get("/")
315+
316+
profile_sent = False
317+
for item in envelopes[0].items:
318+
if item.headers["type"] == "profile":
319+
profile_sent = True
320+
break
321+
assert not profile_sent

0 commit comments

Comments
 (0)