Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(profiling): Add profiler options to init #1947

Merged
merged 5 commits into from Mar 13, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions sentry_sdk/_types.py
Expand Up @@ -85,3 +85,5 @@

FractionUnit = Literal["ratio", "percent"]
MeasurementUnit = Union[DurationUnit, InformationUnit, FractionUnit, str]

ProfilerMode = Literal["sleep", "thread", "gevent", "unknown"]
5 changes: 2 additions & 3 deletions sentry_sdk/client.py
Expand Up @@ -28,7 +28,7 @@
from sentry_sdk.utils import ContextVar
from sentry_sdk.sessions import SessionFlusher
from sentry_sdk.envelope import Envelope
from sentry_sdk.profiler import setup_profiler
from sentry_sdk.profiler import has_profiling_enabled, setup_profiler

from sentry_sdk._types import TYPE_CHECKING

Expand Down Expand Up @@ -174,8 +174,7 @@ def _capture_envelope(envelope):
finally:
_client_init_debug.set(old_debug)

profiles_sample_rate = self.options["_experiments"].get("profiles_sample_rate")
if profiles_sample_rate is not None and profiles_sample_rate > 0:
if has_profiling_enabled(self.options):
try:
setup_profiler(self.options)
except ValueError as e:
Expand Down
7 changes: 6 additions & 1 deletion sentry_sdk/consts.py
Expand Up @@ -19,6 +19,7 @@
BreadcrumbProcessor,
Event,
EventProcessor,
ProfilerMode,
TracesSampler,
TransactionProcessor,
)
Expand All @@ -33,8 +34,9 @@
"max_spans": Optional[int],
"record_sql_params": Optional[bool],
"smart_transaction_trimming": Optional[bool],
# TODO: Remvoe these 2 profiling related experiments
"profiles_sample_rate": Optional[float],
"profiler_mode": Optional[str],
"profiler_mode": Optional[ProfilerMode],
},
total=False,
)
Expand Down Expand Up @@ -115,6 +117,9 @@ def __init__(
propagate_traces=True, # type: bool
traces_sample_rate=None, # type: Optional[float]
traces_sampler=None, # type: Optional[TracesSampler]
profiles_sample_rate=None, # type: Optional[float]
profiles_sampler=None, # type: Optional[TracesSampler]
profiler_mode=None, # type: Optional[ProfilerMode]
auto_enabling_integrations=True, # type: bool
auto_session_tracking=True, # type: bool
send_client_reports=True, # type: bool
Expand Down
41 changes: 35 additions & 6 deletions sentry_sdk/profiler.py
Expand Up @@ -46,7 +46,7 @@
from typing_extensions import TypedDict

import sentry_sdk.tracing
from sentry_sdk._types import SamplingContext
from sentry_sdk._types import SamplingContext, ProfilerMode

ThreadId = str

Expand Down Expand Up @@ -148,6 +148,23 @@ def is_gevent():
PROFILE_MINIMUM_SAMPLES = 2


def has_profiling_enabled(options):
# type: (Dict[str, Any]) -> bool
profiles_sampler = options["profiles_sampler"]
if profiles_sampler is not None:
return True

profiles_sample_rate = options["profiles_sample_rate"]
if profiles_sample_rate is not None and profiles_sample_rate > 0:
return True

profiles_sample_rate = options["_experiments"].get("profiles_sample_rate")
if profiles_sample_rate is not None and profiles_sample_rate > 0:
return True

return False


def setup_profiler(options):
# type: (Dict[str, Any]) -> bool
global _scheduler
Expand All @@ -171,7 +188,13 @@ def setup_profiler(options):
else:
default_profiler_mode = ThreadScheduler.mode

profiler_mode = options["_experiments"].get("profiler_mode", default_profiler_mode)
if options.get("profiler_mode") is not None:
profiler_mode = options["profiler_mode"]
else:
profiler_mode = (
options.get("_experiments", {}).get("profiler_mode")
or default_profiler_mode
)

if (
profiler_mode == ThreadScheduler.mode
Expand Down Expand Up @@ -491,7 +514,13 @@ def _set_initial_sampling_decision(self, sampling_context):
return

options = client.options
sample_rate = options["_experiments"].get("profiles_sample_rate")

if callable(options.get("profiles_sampler")):
sample_rate = options["profiles_sampler"](sampling_context)
elif options["profiles_sample_rate"] is not None:
sample_rate = options["profiles_sample_rate"]
else:
sample_rate = options["_experiments"].get("profiles_sample_rate")

# The profiles_sample_rate option was not set, so profiling
# was never enabled.
Expand Down Expand Up @@ -695,7 +724,7 @@ def valid(self):


class Scheduler(object):
mode = "unknown"
mode = "unknown" # type: ProfilerMode

def __init__(self, frequency):
# type: (int) -> None
Expand Down Expand Up @@ -824,7 +853,7 @@ class ThreadScheduler(Scheduler):
the sampler at a regular interval.
"""

mode = "thread"
mode = "thread" # type: ProfilerMode
name = "sentry.profiler.ThreadScheduler"

def __init__(self, frequency):
Expand Down Expand Up @@ -905,7 +934,7 @@ class GeventScheduler(Scheduler):
results in a sample containing only the sampler's code.
"""

mode = "gevent"
mode = "gevent" # type: ProfilerMode
name = "sentry.profiler.GeventScheduler"

def __init__(self, frequency):
Expand Down
119 changes: 107 additions & 12 deletions tests/test_profiler.py
Expand Up @@ -46,6 +46,16 @@ def process_test_sample(sample):
return [(tid, (stack, stack)) for tid, stack in sample]


def non_experimental_options(mode=None, sample_rate=None):
return {"profiler_mode": mode, "profiles_sample_rate": sample_rate}


def experimental_options(mode=None, sample_rate=None):
return {
"_experiments": {"profiler_mode": mode, "profiles_sample_rate": sample_rate}
}


@requires_python_version(3, 3)
@pytest.mark.parametrize(
"mode",
Expand All @@ -57,9 +67,16 @@ def process_test_sample(sample):
),
],
)
def test_profiler_invalid_mode(mode, teardown_profiling):
@pytest.mark.parametrize(
"make_options",
[
pytest.param(experimental_options, id="experiment"),
pytest.param(non_experimental_options, id="non experimental"),
],
)
def test_profiler_invalid_mode(mode, make_options, teardown_profiling):
with pytest.raises(ValueError):
setup_profiler({"_experiments": {"profiler_mode": mode}})
setup_profiler(make_options(mode))


@pytest.mark.parametrize(
Expand All @@ -70,17 +87,31 @@ def test_profiler_invalid_mode(mode, teardown_profiling):
pytest.param("gevent", marks=requires_gevent),
],
)
def test_profiler_valid_mode(mode, teardown_profiling):
@pytest.mark.parametrize(
"make_options",
[
pytest.param(experimental_options, id="experiment"),
pytest.param(non_experimental_options, id="non experimental"),
],
)
def test_profiler_valid_mode(mode, make_options, teardown_profiling):
# should not raise any exceptions
setup_profiler({"_experiments": {"profiler_mode": mode}})
setup_profiler(make_options(mode))


@requires_python_version(3, 3)
def test_profiler_setup_twice(teardown_profiling):
@pytest.mark.parametrize(
"make_options",
[
pytest.param(experimental_options, id="experiment"),
pytest.param(non_experimental_options, id="non experimental"),
],
)
def test_profiler_setup_twice(make_options, teardown_profiling):
# setting up the first time should return True to indicate success
assert setup_profiler({"_experiments": {}})
assert setup_profiler(make_options())
# setting up the second time should return False to indicate no-op
assert not setup_profiler({"_experiments": {}})
assert not setup_profiler(make_options())


@pytest.mark.parametrize(
Expand All @@ -100,21 +131,85 @@ def test_profiler_setup_twice(teardown_profiling):
pytest.param(None, 0, id="profiler not enabled"),
],
)
@pytest.mark.parametrize(
"make_options",
[
pytest.param(experimental_options, id="experiment"),
pytest.param(non_experimental_options, id="non experimental"),
],
)
@mock.patch("sentry_sdk.profiler.PROFILE_MINIMUM_SAMPLES", 0)
def test_profiled_transaction(
def test_profiles_sample_rate(
sentry_init,
capture_envelopes,
teardown_profiling,
profiles_sample_rate,
profile_count,
make_options,
mode,
):
options = make_options(mode=mode, sample_rate=profiles_sample_rate)
sentry_init(
traces_sample_rate=1.0,
profiler_mode=options.get("profiler_mode"),
profiles_sample_rate=options.get("profiles_sample_rate"),
_experiments=options.get("_experiments", {}),
)

envelopes = capture_envelopes()

with mock.patch("sentry_sdk.profiler.random.random", return_value=0.5):
with start_transaction(name="profiling"):
pass

items = defaultdict(list)
for envelope in envelopes:
for item in envelope.items:
items[item.type].append(item)

assert len(items["transaction"]) == 1
assert len(items["profile"]) == profile_count


@pytest.mark.parametrize(
"mode",
[
pytest.param("thread"),
pytest.param("gevent", marks=requires_gevent),
],
)
@pytest.mark.parametrize(
("profiles_sampler", "profile_count"),
[
pytest.param(lambda _: 1.00, 1, id="profiler sampled at 1.00"),
pytest.param(lambda _: 0.75, 1, id="profiler sampled at 0.75"),
pytest.param(lambda _: 0.25, 0, id="profiler sampled at 0.25"),
pytest.param(lambda _: 0.00, 0, id="profiler sampled at 0.00"),
pytest.param(lambda _: None, 0, id="profiler not enabled"),
pytest.param(
lambda ctx: 1 if ctx["transaction_context"]["name"] == "profiling" else 0,
1,
id="profiler sampled for transaction name",
),
pytest.param(
lambda ctx: 0 if ctx["transaction_context"]["name"] == "profiling" else 1,
0,
id="profiler not sampled for transaction name",
),
],
)
@mock.patch("sentry_sdk.profiler.PROFILE_MINIMUM_SAMPLES", 0)
def test_profiles_sampler(
sentry_init,
capture_envelopes,
teardown_profiling,
profiles_sampler,
profile_count,
mode,
):
sentry_init(
traces_sample_rate=1.0,
_experiments={
"profiles_sample_rate": profiles_sample_rate,
"profiler_mode": mode,
},
profiles_sampler=profiles_sampler,
)

envelopes = capture_envelopes()
Expand Down