Skip to content

Commit 1c651c6

Browse files
authored
tests(profiling): Add tests for thread schedulers (#1683)
* tests(profiling): Add tests for thread schedulers
1 parent d2547ea commit 1c651c6

File tree

2 files changed

+126
-47
lines changed

2 files changed

+126
-47
lines changed

sentry_sdk/profiler.py

Lines changed: 50 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -111,17 +111,16 @@ def setup_profiler(options):
111111
# To buffer samples for `buffer_secs` at `frequency` Hz, we need
112112
# a capcity of `buffer_secs * frequency`.
113113
_sample_buffer = SampleBuffer(capacity=buffer_secs * frequency)
114-
_sampler = _init_sample_stack_fn(_sample_buffer)
115114

116115
profiler_mode = options["_experiments"].get("profiler_mode", SigprofScheduler.mode)
117116
if profiler_mode == SigprofScheduler.mode:
118-
_scheduler = SigprofScheduler(sampler=_sampler, frequency=frequency)
117+
_scheduler = SigprofScheduler(sample_buffer=_sample_buffer, frequency=frequency)
119118
elif profiler_mode == SigalrmScheduler.mode:
120-
_scheduler = SigalrmScheduler(sampler=_sampler, frequency=frequency)
119+
_scheduler = SigalrmScheduler(sample_buffer=_sample_buffer, frequency=frequency)
121120
elif profiler_mode == SleepScheduler.mode:
122-
_scheduler = SleepScheduler(sampler=_sampler, frequency=frequency)
121+
_scheduler = SleepScheduler(sample_buffer=_sample_buffer, frequency=frequency)
123122
elif profiler_mode == EventScheduler.mode:
124-
_scheduler = EventScheduler(sampler=_sampler, frequency=frequency)
123+
_scheduler = EventScheduler(sample_buffer=_sample_buffer, frequency=frequency)
125124
else:
126125
raise ValueError("Unknown profiler mode: {}".format(profiler_mode))
127126
_scheduler.setup()
@@ -142,29 +141,6 @@ def teardown_profiler():
142141
_scheduler = None
143142

144143

145-
def _init_sample_stack_fn(buffer):
146-
# type: (SampleBuffer) -> Callable[..., None]
147-
148-
def _sample_stack(*args, **kwargs):
149-
# type: (*Any, **Any) -> None
150-
"""
151-
Take a sample of the stack on all the threads in the process.
152-
This should be called at a regular interval to collect samples.
153-
"""
154-
155-
buffer.write(
156-
(
157-
nanosecond_time(),
158-
[
159-
(tid, extract_stack(frame))
160-
for tid, frame in sys._current_frames().items()
161-
],
162-
)
163-
)
164-
165-
return _sample_stack
166-
167-
168144
# We want to impose a stack depth limit so that samples aren't too large.
169145
MAX_STACK_DEPTH = 128
170146

@@ -242,8 +218,14 @@ def get_frame_name(frame):
242218

243219

244220
class Profile(object):
245-
def __init__(self, transaction, hub=None):
246-
# type: (sentry_sdk.tracing.Transaction, Optional[sentry_sdk.Hub]) -> None
221+
def __init__(
222+
self,
223+
scheduler, # type: Scheduler
224+
transaction, # type: sentry_sdk.tracing.Transaction
225+
hub=None, # type: Optional[sentry_sdk.Hub]
226+
):
227+
# type: (...) -> None
228+
self.scheduler = scheduler
247229
self.transaction = transaction
248230
self.hub = hub
249231
self._start_ns = None # type: Optional[int]
@@ -253,27 +235,26 @@ def __init__(self, transaction, hub=None):
253235

254236
def __enter__(self):
255237
# type: () -> None
256-
assert _scheduler is not None
257238
self._start_ns = nanosecond_time()
258-
_scheduler.start_profiling()
239+
self.scheduler.start_profiling()
259240

260241
def __exit__(self, ty, value, tb):
261242
# type: (Optional[Any], Optional[Any], Optional[Any]) -> None
262-
assert _scheduler is not None
263-
_scheduler.stop_profiling()
243+
self.scheduler.stop_profiling()
264244
self._stop_ns = nanosecond_time()
265245

266246
def to_json(self, event_opt):
267247
# type: (Any) -> Dict[str, Any]
268-
assert _sample_buffer is not None
269248
assert self._start_ns is not None
270249
assert self._stop_ns is not None
271250

272251
return {
273252
"environment": event_opt.get("environment"),
274253
"event_id": uuid.uuid4().hex,
275254
"platform": "python",
276-
"profile": _sample_buffer.slice_profile(self._start_ns, self._stop_ns),
255+
"profile": self.scheduler.sample_buffer.slice_profile(
256+
self._start_ns, self._stop_ns
257+
),
277258
"release": event_opt.get("release", ""),
278259
"timestamp": event_opt["timestamp"],
279260
"version": "1",
@@ -406,13 +387,36 @@ def slice_profile(self, start_ns, stop_ns):
406387
"thread_metadata": thread_metadata,
407388
}
408389

390+
def make_sampler(self):
391+
# type: () -> Callable[..., None]
392+
393+
def _sample_stack(*args, **kwargs):
394+
# type: (*Any, **Any) -> None
395+
"""
396+
Take a sample of the stack on all the threads in the process.
397+
This should be called at a regular interval to collect samples.
398+
"""
399+
400+
self.write(
401+
(
402+
nanosecond_time(),
403+
[
404+
(tid, extract_stack(frame))
405+
for tid, frame in sys._current_frames().items()
406+
],
407+
)
408+
)
409+
410+
return _sample_stack
411+
409412

410413
class Scheduler(object):
411414
mode = "unknown"
412415

413-
def __init__(self, sampler, frequency):
414-
# type: (Callable[..., None], int) -> None
415-
self.sampler = sampler
416+
def __init__(self, sample_buffer, frequency):
417+
# type: (SampleBuffer, int) -> None
418+
self.sample_buffer = sample_buffer
419+
self.sampler = sample_buffer.make_sampler()
416420
self._lock = threading.Lock()
417421
self._count = 0
418422
self._interval = 1.0 / frequency
@@ -447,9 +451,11 @@ class ThreadScheduler(Scheduler):
447451
mode = "thread"
448452
name = None # type: Optional[str]
449453

450-
def __init__(self, sampler, frequency):
451-
# type: (Callable[..., None], int) -> None
452-
super(ThreadScheduler, self).__init__(sampler=sampler, frequency=frequency)
454+
def __init__(self, sample_buffer, frequency):
455+
# type: (SampleBuffer, int) -> None
456+
super(ThreadScheduler, self).__init__(
457+
sample_buffer=sample_buffer, frequency=frequency
458+
)
453459
self.stop_events = Queue()
454460

455461
def setup(self):
@@ -716,7 +722,8 @@ def start_profiling(transaction, hub=None):
716722

717723
# if profiling was not enabled, this should be a noop
718724
if _should_profile(transaction, hub):
719-
with Profile(transaction, hub=hub):
725+
assert _scheduler is not None
726+
with Profile(_scheduler, transaction, hub=hub):
720727
yield
721728
else:
722729
yield

tests/test_profiler.py

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

99
from sentry_sdk.profiler import (
10+
EventScheduler,
1011
RawFrameData,
1112
SampleBuffer,
1213
SleepScheduler,
@@ -187,12 +188,83 @@ def get_scheduler_threads(scheduler):
187188
return [thread for thread in threading.enumerate() if thread.name == scheduler.name]
188189

189190

191+
class DummySampleBuffer(SampleBuffer):
192+
def __init__(self, capacity, sample_data=None):
193+
super(DummySampleBuffer, self).__init__(capacity)
194+
self.sample_data = [] if sample_data is None else sample_data
195+
196+
def make_sampler(self):
197+
def _sample_stack(*args, **kwargs):
198+
print("writing", self.sample_data[0])
199+
self.write(self.sample_data.pop(0))
200+
201+
return _sample_stack
202+
203+
204+
@minimum_python_33
205+
@pytest.mark.parametrize(
206+
("scheduler_class",),
207+
[
208+
pytest.param(SleepScheduler, id="sleep scheduler"),
209+
pytest.param(EventScheduler, id="event scheduler"),
210+
],
211+
)
212+
def test_thread_scheduler_takes_first_samples(scheduler_class):
213+
sample_buffer = DummySampleBuffer(
214+
capacity=1, sample_data=[(0, [(0, [RawFrameData("name", "file", 1)])])]
215+
)
216+
scheduler = scheduler_class(sample_buffer=sample_buffer, frequency=1000)
217+
assert scheduler.start_profiling()
218+
# immediately stopping means by the time the sampling thread will exit
219+
# before it samples at the end of the first iteration
220+
assert scheduler.stop_profiling()
221+
time.sleep(0.002)
222+
assert len(get_scheduler_threads(scheduler)) == 0
223+
224+
# there should be exactly 1 sample because we always sample once immediately
225+
profile = sample_buffer.slice_profile(0, 1)
226+
assert len(profile["samples"]) == 1
227+
228+
190229
@minimum_python_33
191-
def test_sleep_scheduler_single_background_thread():
192-
def sampler():
193-
pass
230+
@pytest.mark.parametrize(
231+
("scheduler_class",),
232+
[
233+
pytest.param(SleepScheduler, id="sleep scheduler"),
234+
pytest.param(EventScheduler, id="event scheduler"),
235+
],
236+
)
237+
def test_thread_scheduler_takes_more_samples(scheduler_class):
238+
sample_buffer = DummySampleBuffer(
239+
capacity=10,
240+
sample_data=[(i, [(0, [RawFrameData("name", "file", 1)])]) for i in range(3)],
241+
)
242+
scheduler = scheduler_class(sample_buffer=sample_buffer, frequency=1000)
243+
assert scheduler.start_profiling()
244+
# waiting a little before stopping the scheduler means the profiling
245+
# thread will get a chance to take a few samples before exiting
246+
time.sleep(0.002)
247+
assert scheduler.stop_profiling()
248+
time.sleep(0.002)
249+
assert len(get_scheduler_threads(scheduler)) == 0
250+
251+
# there should be more than 1 sample because we always sample once immediately
252+
# plus any samples take afterwards
253+
profile = sample_buffer.slice_profile(0, 3)
254+
assert len(profile["samples"]) > 1
194255

195-
scheduler = SleepScheduler(sampler=sampler, frequency=1000)
256+
257+
@minimum_python_33
258+
@pytest.mark.parametrize(
259+
("scheduler_class",),
260+
[
261+
pytest.param(SleepScheduler, id="sleep scheduler"),
262+
pytest.param(EventScheduler, id="event scheduler"),
263+
],
264+
)
265+
def test_thread_scheduler_single_background_thread(scheduler_class):
266+
sample_buffer = SampleBuffer(1)
267+
scheduler = scheduler_class(sample_buffer=sample_buffer, frequency=1000)
196268

197269
assert scheduler.start_profiling()
198270

0 commit comments

Comments
 (0)