From 085595b5f02931a3268c2de2a58b6986f3766d75 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Fri, 20 Oct 2023 15:36:01 +0200 Subject: [PATCH] feat(api): Added `error_sampler` option (#2456) * Created issues_sampler * Verify the event gets passed * Restructured tests, adding different sample rates based on exception * Update tests/test_client.py Co-authored-by: Ivana Kellyerova * Pass hint also to the sampler * Renamed issues_sampler to events_sampler * Handle invalid events_sampler return value * Added value to warning * Rename to `error_sampler` --------- Co-authored-by: Ivana Kellyerova --- sentry_sdk/client.py | 32 ++++++++++-- sentry_sdk/consts.py | 2 + tests/test_client.py | 117 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index b65c3f0c76..749ab23cfe 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -454,12 +454,34 @@ def _should_capture( def _should_sample_error( self, event, # type: Event + hint, # type: Hint ): # type: (...) -> bool - not_in_sample_rate = ( - self.options["sample_rate"] < 1.0 - and random.random() >= self.options["sample_rate"] - ) + sampler = self.options.get("error_sampler", None) + + if callable(sampler): + with capture_internal_exceptions(): + sample_rate = sampler(event, hint) + else: + sample_rate = self.options["sample_rate"] + + try: + not_in_sample_rate = sample_rate < 1.0 and random.random() >= sample_rate + except TypeError: + parameter, verb = ( + ("error_sampler", "returned") + if callable(sampler) + else ("sample_rate", "contains") + ) + logger.warning( + "The provided %s %s an invalid value of %s. The value should be a float or a bool. Defaulting to sampling the event." + % (parameter, verb, repr(sample_rate)) + ) + + # If the sample_rate has an invalid value, we should sample the event, since the default behavior + # (when no sample_rate or error_sampler is provided) is to sample all events. + not_in_sample_rate = False + if not_in_sample_rate: # because we will not sample this event, record a "lost event". if self.transport: @@ -556,7 +578,7 @@ def capture_event( if ( not is_transaction and not is_checkin - and not self._should_sample_error(event) + and not self._should_sample_error(event, hint) ): return None diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 5bc3e2aa85..60cb65bc15 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -22,6 +22,7 @@ BreadcrumbProcessor, Event, EventProcessor, + Hint, ProfilerMode, TracesSampler, TransactionProcessor, @@ -261,6 +262,7 @@ def __init__( event_scrubber=None, # type: Optional[sentry_sdk.scrubber.EventScrubber] max_value_length=DEFAULT_MAX_VALUE_LENGTH, # type: int enable_backpressure_handling=True, # type: bool + error_sampler=None, # type: Optional[Callable[[Event, Hint], Union[float, bool]]] ): # type: (...) -> None pass diff --git a/tests/test_client.py b/tests/test_client.py index bf3e4e79be..5a7a5cff16 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -25,6 +25,12 @@ from sentry_sdk.utils import logger from sentry_sdk.serializer import MAX_DATABAG_BREADTH from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, DEFAULT_MAX_VALUE_LENGTH +from sentry_sdk._types import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any, Optional, Union + from sentry_sdk._types import Event try: from unittest import mock # python 3.3 and above @@ -1196,3 +1202,114 @@ def test_debug_option( assert "something is wrong" in caplog.text else: assert "something is wrong" not in caplog.text + + +class IssuesSamplerTestConfig: + def __init__( + self, + expected_events, + sampler_function=None, + sample_rate=None, + exception_to_raise=Exception, + ): + # type: (int, Optional[Callable[[Event], Union[float, bool]]], Optional[float], type[Exception]) -> None + self.sampler_function_mock = ( + None + if sampler_function is None + else mock.MagicMock(side_effect=sampler_function) + ) + self.expected_events = expected_events + self.sample_rate = sample_rate + self.exception_to_raise = exception_to_raise + + def init_sdk(self, sentry_init): + # type: (Callable[[*Any], None]) -> None + sentry_init( + error_sampler=self.sampler_function_mock, sample_rate=self.sample_rate + ) + + def raise_exception(self): + # type: () -> None + raise self.exception_to_raise() + + +@mock.patch("sentry_sdk.client.random.random", return_value=0.618) +@pytest.mark.parametrize( + "test_config", + ( + # Baseline test with error_sampler only, both floats and bools + IssuesSamplerTestConfig(sampler_function=lambda *_: 1.0, expected_events=1), + IssuesSamplerTestConfig(sampler_function=lambda *_: 0.7, expected_events=1), + IssuesSamplerTestConfig(sampler_function=lambda *_: 0.6, expected_events=0), + IssuesSamplerTestConfig(sampler_function=lambda *_: 0.0, expected_events=0), + IssuesSamplerTestConfig(sampler_function=lambda *_: True, expected_events=1), + IssuesSamplerTestConfig(sampler_function=lambda *_: False, expected_events=0), + # Baseline test with sample_rate only + IssuesSamplerTestConfig(sample_rate=1.0, expected_events=1), + IssuesSamplerTestConfig(sample_rate=0.7, expected_events=1), + IssuesSamplerTestConfig(sample_rate=0.6, expected_events=0), + IssuesSamplerTestConfig(sample_rate=0.0, expected_events=0), + # error_sampler takes precedence over sample_rate + IssuesSamplerTestConfig( + sampler_function=lambda *_: 1.0, sample_rate=0.0, expected_events=1 + ), + IssuesSamplerTestConfig( + sampler_function=lambda *_: 0.0, sample_rate=1.0, expected_events=0 + ), + # Different sample rates based on exception, retrieved both from event and hint + IssuesSamplerTestConfig( + sampler_function=lambda event, _: { + "ZeroDivisionError": 1.0, + "AttributeError": 0.0, + }[event["exception"]["values"][0]["type"]], + exception_to_raise=ZeroDivisionError, + expected_events=1, + ), + IssuesSamplerTestConfig( + sampler_function=lambda event, _: { + "ZeroDivisionError": 1.0, + "AttributeError": 0.0, + }[event["exception"]["values"][0]["type"]], + exception_to_raise=AttributeError, + expected_events=0, + ), + IssuesSamplerTestConfig( + sampler_function=lambda _, hint: { + ZeroDivisionError: 1.0, + AttributeError: 0.0, + }[hint["exc_info"][0]], + exception_to_raise=ZeroDivisionError, + expected_events=1, + ), + IssuesSamplerTestConfig( + sampler_function=lambda _, hint: { + ZeroDivisionError: 1.0, + AttributeError: 0.0, + }[hint["exc_info"][0]], + exception_to_raise=AttributeError, + expected_events=0, + ), + # If sampler returns invalid value, we should still send the event + IssuesSamplerTestConfig( + sampler_function=lambda *_: "This is an invalid return value for the sampler", + expected_events=1, + ), + ), +) +def test_error_sampler(_, sentry_init, capture_events, test_config): + test_config.init_sdk(sentry_init) + + events = capture_events() + + try: + test_config.raise_exception() + except Exception: + capture_exception() + + assert len(events) == test_config.expected_events + + if test_config.sampler_function_mock is not None: + assert test_config.sampler_function_mock.call_count == 1 + + # Ensure two arguments (the event and hint) were passed to the sampler function + assert len(test_config.sampler_function_mock.call_args[0]) == 2