Skip to content

Commit c174f36

Browse files
committed
feat(profiling): Continuous profiling sample rate
This introduces a new top level setting for the continuous profiling session sample rate. The sample rate is evaluated once at the beginning and is used to determine whether or not the profiler will be run for the remainder of the process.
1 parent 968b362 commit c174f36

File tree

4 files changed

+130
-40
lines changed

4 files changed

+130
-40
lines changed

sentry_sdk/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@ def __init__(
528528
profiles_sample_rate=None, # type: Optional[float]
529529
profiles_sampler=None, # type: Optional[TracesSampler]
530530
profiler_mode=None, # type: Optional[ProfilerMode]
531+
profile_session_sample_rate=None, # type: Optional[float]
531532
auto_enabling_integrations=True, # type: bool
532533
disabled_integrations=None, # type: Optional[Sequence[sentry_sdk.integrations.Integration]]
533534
auto_session_tracking=True, # type: bool

sentry_sdk/profiler/continuous_profiler.py

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import atexit
22
import os
3+
import random
34
import sys
45
import threading
56
import time
@@ -83,11 +84,15 @@ def setup_continuous_profiler(options, sdk_info, capture_func):
8384
else:
8485
default_profiler_mode = ThreadContinuousScheduler.mode
8586

86-
experiments = options.get("_experiments", {})
87+
if options.get("profiler_mode") is not None:
88+
profiler_mode = options["profiler_mode"]
89+
else:
90+
# TODO: deprecate this and just use the existing `profiler_mode`
91+
experiments = options.get("_experiments", {})
8792

88-
profiler_mode = (
89-
experiments.get("continuous_profiling_mode") or default_profiler_mode
90-
)
93+
profiler_mode = (
94+
experiments.get("continuous_profiling_mode") or default_profiler_mode
95+
)
9196

9297
frequency = DEFAULT_SAMPLING_FREQUENCY
9398

@@ -113,21 +118,15 @@ def setup_continuous_profiler(options, sdk_info, capture_func):
113118
return True
114119

115120

116-
def try_autostart_continuous_profiler():
121+
def try_continuous_profiling_auto_start():
117122
# type: () -> None
118123
if _scheduler is None:
119124
return
120125

121-
# Ensure that the scheduler only autostarts once per process.
122-
# This is necessary because many web servers use forks to spawn
123-
# additional processes. And the profiler is only spawned on the
124-
# master process, then it often only profiles the main process
125-
# and not the ones where the requests are being handled.
126-
#
127126
# Additionally, we only want this autostart behaviour once per
128127
# process. If the user explicitly calls `stop_profiler`, it should
129128
# be respected and not start the profiler again.
130-
if not _scheduler.should_autostart():
129+
if not _scheduler.is_auto_start_enabled():
131130
return
132131

133132
_scheduler.ensure_running()
@@ -164,6 +163,16 @@ def get_profiler_id():
164163
return _scheduler.profiler_id
165164

166165

166+
def determine_profile_session_sampling_decision(sample_rate):
167+
# type: (Union[float, None]) -> bool
168+
169+
# `None` is treated as `0.0`
170+
if not sample_rate:
171+
return False
172+
173+
return random.random() < float(sample_rate)
174+
175+
167176
class ContinuousScheduler:
168177
mode = "unknown" # type: ContinuousProfilerMode
169178

@@ -175,15 +184,29 @@ def __init__(self, frequency, options, sdk_info, capture_func):
175184
self.capture_func = capture_func
176185
self.sampler = self.make_sampler()
177186
self.buffer = None # type: Optional[ProfileBuffer]
187+
self.pid = None # type: Optional[int]
178188

179189
self.running = False
180190

181-
def should_autostart(self):
191+
profile_session_sample_rate = self.options.get("profile_session_sample_rate")
192+
self.sampled = determine_profile_session_sampling_decision(
193+
profile_session_sample_rate
194+
)
195+
196+
def is_auto_start_enabled(self):
182197
# type: () -> bool
183198
experiments = self.options.get("_experiments")
184199
if not experiments:
185200
return False
186-
return experiments.get("continuous_profiling_auto_start")
201+
if not experiments.get("continuous_profiling_auto_start"):
202+
return False
203+
204+
# Ensure that the scheduler only autostarts once per process.
205+
# This is necessary because many web servers use forks to spawn
206+
# additional processes. And the profiler is only spawned on the
207+
# master process, then it often only profiles the main process
208+
# and not the ones where the requests are being handled.
209+
return self.pid != os.getpid()
187210

188211
def ensure_running(self):
189212
# type: () -> None
@@ -277,15 +300,15 @@ def __init__(self, frequency, options, sdk_info, capture_func):
277300
super().__init__(frequency, options, sdk_info, capture_func)
278301

279302
self.thread = None # type: Optional[threading.Thread]
280-
self.pid = None # type: Optional[int]
281303
self.lock = threading.Lock()
282304

283-
def should_autostart(self):
284-
# type: () -> bool
285-
return super().should_autostart() and self.pid != os.getpid()
286-
287305
def ensure_running(self):
288306
# type: () -> None
307+
308+
# if the current profile session is not sampled, ensure_running is noop
309+
if not self.sampled:
310+
return
311+
289312
pid = os.getpid()
290313

291314
# is running on the right process
@@ -356,17 +379,16 @@ def __init__(self, frequency, options, sdk_info, capture_func):
356379
super().__init__(frequency, options, sdk_info, capture_func)
357380

358381
self.thread = None # type: Optional[_ThreadPool]
359-
self.pid = None # type: Optional[int]
360382
self.lock = threading.Lock()
361383

362-
def should_autostart(self):
363-
# type: () -> bool
364-
return super().should_autostart() and self.pid != os.getpid()
365-
366384
def ensure_running(self):
367385
# type: () -> None
368386
pid = os.getpid()
369387

388+
# if the current profile session is not sampled, ensure_running is noop
389+
if not self.sampled:
390+
return
391+
370392
# is running on the right process
371393
if self.running and self.pid == pid:
372394
return

sentry_sdk/scope.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from sentry_sdk.attachments import Attachment
1313
from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, FALSE_VALUES, INSTRUMENTER
1414
from sentry_sdk.feature_flags import FlagBuffer, DEFAULT_FLAG_CAPACITY
15-
from sentry_sdk.profiler.continuous_profiler import try_autostart_continuous_profiler
15+
from sentry_sdk.profiler.continuous_profiler import try_continuous_profiling_auto_start
1616
from sentry_sdk.profiler.transaction_profiler import Profile
1717
from sentry_sdk.session import Session
1818
from sentry_sdk.tracing_utils import (
@@ -1022,7 +1022,7 @@ def start_transaction(
10221022
if instrumenter != configuration_instrumenter:
10231023
return NoOpSpan()
10241024

1025-
try_autostart_continuous_profiler()
1025+
try_continuous_profiling_auto_start()
10261026

10271027
custom_sampling_context = custom_sampling_context or {}
10281028

tests/profiler/test_continuous_profiler.py

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,25 @@
2323
requires_gevent = pytest.mark.skipif(gevent is None, reason="gevent not enabled")
2424

2525

26-
def experimental_options(mode=None, auto_start=None):
27-
return {
28-
"_experiments": {
29-
"continuous_profiling_auto_start": auto_start,
30-
"continuous_profiling_mode": mode,
26+
def get_client_options(use_top_level_profiler_mode):
27+
def client_options(mode=None, auto_start=None, profile_session_sample_rate=1.0):
28+
if use_top_level_profiler_mode:
29+
return {
30+
"profiler_mode": mode,
31+
"profile_session_sample_rate": profile_session_sample_rate,
32+
"_experiments": {
33+
"continuous_profiling_auto_start": auto_start,
34+
},
35+
}
36+
return {
37+
"profile_session_sample_rate": profile_session_sample_rate,
38+
"_experiments": {
39+
"continuous_profiling_auto_start": auto_start,
40+
"continuous_profiling_mode": mode,
41+
},
3142
}
32-
}
43+
44+
return client_options
3345

3446

3547
mock_sdk_info = {
@@ -42,7 +54,10 @@ def experimental_options(mode=None, auto_start=None):
4254
@pytest.mark.parametrize("mode", [pytest.param("foo")])
4355
@pytest.mark.parametrize(
4456
"make_options",
45-
[pytest.param(experimental_options, id="experiment")],
57+
[
58+
pytest.param(get_client_options(True), id="non-experiment"),
59+
pytest.param(get_client_options(False), id="experiment"),
60+
],
4661
)
4762
def test_continuous_profiler_invalid_mode(mode, make_options, teardown_profiling):
4863
with pytest.raises(ValueError):
@@ -62,7 +77,10 @@ def test_continuous_profiler_invalid_mode(mode, make_options, teardown_profiling
6277
)
6378
@pytest.mark.parametrize(
6479
"make_options",
65-
[pytest.param(experimental_options, id="experiment")],
80+
[
81+
pytest.param(get_client_options(True), id="non-experiment"),
82+
pytest.param(get_client_options(False), id="experiment"),
83+
],
6684
)
6785
def test_continuous_profiler_valid_mode(mode, make_options, teardown_profiling):
6886
options = make_options(mode=mode)
@@ -82,7 +100,10 @@ def test_continuous_profiler_valid_mode(mode, make_options, teardown_profiling):
82100
)
83101
@pytest.mark.parametrize(
84102
"make_options",
85-
[pytest.param(experimental_options, id="experiment")],
103+
[
104+
pytest.param(get_client_options(True), id="non-experiment"),
105+
pytest.param(get_client_options(False), id="experiment"),
106+
],
86107
)
87108
def test_continuous_profiler_setup_twice(mode, make_options, teardown_profiling):
88109
options = make_options(mode=mode)
@@ -178,7 +199,10 @@ def assert_single_transaction_without_profile_chunks(envelopes):
178199
)
179200
@pytest.mark.parametrize(
180201
"make_options",
181-
[pytest.param(experimental_options, id="experiment")],
202+
[
203+
pytest.param(get_client_options(True), id="non-experiment"),
204+
pytest.param(get_client_options(False), id="experiment"),
205+
],
182206
)
183207
@mock.patch("sentry_sdk.profiler.continuous_profiler.PROFILE_BUFFER_SECONDS", 0.01)
184208
def test_continuous_profiler_auto_start_and_manual_stop(
@@ -191,7 +215,7 @@ def test_continuous_profiler_auto_start_and_manual_stop(
191215
options = make_options(mode=mode, auto_start=True)
192216
sentry_init(
193217
traces_sample_rate=1.0,
194-
_experiments=options.get("_experiments", {}),
218+
**options,
195219
)
196220

197221
envelopes = capture_envelopes()
@@ -235,10 +259,13 @@ def test_continuous_profiler_auto_start_and_manual_stop(
235259
)
236260
@pytest.mark.parametrize(
237261
"make_options",
238-
[pytest.param(experimental_options, id="experiment")],
262+
[
263+
pytest.param(get_client_options(True), id="non-experiment"),
264+
pytest.param(get_client_options(False), id="experiment"),
265+
],
239266
)
240267
@mock.patch("sentry_sdk.profiler.continuous_profiler.PROFILE_BUFFER_SECONDS", 0.01)
241-
def test_continuous_profiler_manual_start_and_stop(
268+
def test_continuous_profiler_manual_start_and_stop_sampled(
242269
sentry_init,
243270
capture_envelopes,
244271
mode,
@@ -248,7 +275,7 @@ def test_continuous_profiler_manual_start_and_stop(
248275
options = make_options(mode=mode)
249276
sentry_init(
250277
traces_sample_rate=1.0,
251-
_experiments=options.get("_experiments", {}),
278+
**options,
252279
)
253280

254281
envelopes = capture_envelopes()
@@ -275,3 +302,43 @@ def test_continuous_profiler_manual_start_and_stop(
275302
time.sleep(0.05)
276303

277304
assert_single_transaction_without_profile_chunks(envelopes)
305+
306+
307+
@pytest.mark.parametrize(
308+
"mode",
309+
[
310+
pytest.param("thread"),
311+
pytest.param("gevent", marks=requires_gevent),
312+
],
313+
)
314+
@pytest.mark.parametrize(
315+
"make_options",
316+
[
317+
pytest.param(get_client_options(True), id="non-experiment"),
318+
pytest.param(get_client_options(False), id="experiment"),
319+
],
320+
)
321+
def test_continuous_profiler_manual_start_and_stop_unsampled(
322+
sentry_init,
323+
capture_envelopes,
324+
mode,
325+
make_options,
326+
teardown_profiling,
327+
):
328+
options = make_options(mode=mode, profile_session_sample_rate=0.0)
329+
sentry_init(
330+
traces_sample_rate=1.0,
331+
**options,
332+
)
333+
334+
envelopes = capture_envelopes()
335+
336+
start_profiler()
337+
338+
with sentry_sdk.start_transaction(name="profiling"):
339+
with sentry_sdk.start_span(op="op"):
340+
time.sleep(0.05)
341+
342+
assert_single_transaction_without_profile_chunks(envelopes)
343+
344+
stop_profiler()

0 commit comments

Comments
 (0)