Skip to content

Commit 085595b

Browse files
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 <[email protected]> * 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 <[email protected]>
1 parent bf218e9 commit 085595b

File tree

3 files changed

+146
-5
lines changed

3 files changed

+146
-5
lines changed

sentry_sdk/client.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -454,12 +454,34 @@ def _should_capture(
454454
def _should_sample_error(
455455
self,
456456
event, # type: Event
457+
hint, # type: Hint
457458
):
458459
# type: (...) -> bool
459-
not_in_sample_rate = (
460-
self.options["sample_rate"] < 1.0
461-
and random.random() >= self.options["sample_rate"]
462-
)
460+
sampler = self.options.get("error_sampler", None)
461+
462+
if callable(sampler):
463+
with capture_internal_exceptions():
464+
sample_rate = sampler(event, hint)
465+
else:
466+
sample_rate = self.options["sample_rate"]
467+
468+
try:
469+
not_in_sample_rate = sample_rate < 1.0 and random.random() >= sample_rate
470+
except TypeError:
471+
parameter, verb = (
472+
("error_sampler", "returned")
473+
if callable(sampler)
474+
else ("sample_rate", "contains")
475+
)
476+
logger.warning(
477+
"The provided %s %s an invalid value of %s. The value should be a float or a bool. Defaulting to sampling the event."
478+
% (parameter, verb, repr(sample_rate))
479+
)
480+
481+
# If the sample_rate has an invalid value, we should sample the event, since the default behavior
482+
# (when no sample_rate or error_sampler is provided) is to sample all events.
483+
not_in_sample_rate = False
484+
463485
if not_in_sample_rate:
464486
# because we will not sample this event, record a "lost event".
465487
if self.transport:
@@ -556,7 +578,7 @@ def capture_event(
556578
if (
557579
not is_transaction
558580
and not is_checkin
559-
and not self._should_sample_error(event)
581+
and not self._should_sample_error(event, hint)
560582
):
561583
return None
562584

sentry_sdk/consts.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
BreadcrumbProcessor,
2323
Event,
2424
EventProcessor,
25+
Hint,
2526
ProfilerMode,
2627
TracesSampler,
2728
TransactionProcessor,
@@ -261,6 +262,7 @@ def __init__(
261262
event_scrubber=None, # type: Optional[sentry_sdk.scrubber.EventScrubber]
262263
max_value_length=DEFAULT_MAX_VALUE_LENGTH, # type: int
263264
enable_backpressure_handling=True, # type: bool
265+
error_sampler=None, # type: Optional[Callable[[Event, Hint], Union[float, bool]]]
264266
):
265267
# type: (...) -> None
266268
pass

tests/test_client.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
from sentry_sdk.utils import logger
2626
from sentry_sdk.serializer import MAX_DATABAG_BREADTH
2727
from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, DEFAULT_MAX_VALUE_LENGTH
28+
from sentry_sdk._types import TYPE_CHECKING
29+
30+
if TYPE_CHECKING:
31+
from collections.abc import Callable
32+
from typing import Any, Optional, Union
33+
from sentry_sdk._types import Event
2834

2935
try:
3036
from unittest import mock # python 3.3 and above
@@ -1196,3 +1202,114 @@ def test_debug_option(
11961202
assert "something is wrong" in caplog.text
11971203
else:
11981204
assert "something is wrong" not in caplog.text
1205+
1206+
1207+
class IssuesSamplerTestConfig:
1208+
def __init__(
1209+
self,
1210+
expected_events,
1211+
sampler_function=None,
1212+
sample_rate=None,
1213+
exception_to_raise=Exception,
1214+
):
1215+
# type: (int, Optional[Callable[[Event], Union[float, bool]]], Optional[float], type[Exception]) -> None
1216+
self.sampler_function_mock = (
1217+
None
1218+
if sampler_function is None
1219+
else mock.MagicMock(side_effect=sampler_function)
1220+
)
1221+
self.expected_events = expected_events
1222+
self.sample_rate = sample_rate
1223+
self.exception_to_raise = exception_to_raise
1224+
1225+
def init_sdk(self, sentry_init):
1226+
# type: (Callable[[*Any], None]) -> None
1227+
sentry_init(
1228+
error_sampler=self.sampler_function_mock, sample_rate=self.sample_rate
1229+
)
1230+
1231+
def raise_exception(self):
1232+
# type: () -> None
1233+
raise self.exception_to_raise()
1234+
1235+
1236+
@mock.patch("sentry_sdk.client.random.random", return_value=0.618)
1237+
@pytest.mark.parametrize(
1238+
"test_config",
1239+
(
1240+
# Baseline test with error_sampler only, both floats and bools
1241+
IssuesSamplerTestConfig(sampler_function=lambda *_: 1.0, expected_events=1),
1242+
IssuesSamplerTestConfig(sampler_function=lambda *_: 0.7, expected_events=1),
1243+
IssuesSamplerTestConfig(sampler_function=lambda *_: 0.6, expected_events=0),
1244+
IssuesSamplerTestConfig(sampler_function=lambda *_: 0.0, expected_events=0),
1245+
IssuesSamplerTestConfig(sampler_function=lambda *_: True, expected_events=1),
1246+
IssuesSamplerTestConfig(sampler_function=lambda *_: False, expected_events=0),
1247+
# Baseline test with sample_rate only
1248+
IssuesSamplerTestConfig(sample_rate=1.0, expected_events=1),
1249+
IssuesSamplerTestConfig(sample_rate=0.7, expected_events=1),
1250+
IssuesSamplerTestConfig(sample_rate=0.6, expected_events=0),
1251+
IssuesSamplerTestConfig(sample_rate=0.0, expected_events=0),
1252+
# error_sampler takes precedence over sample_rate
1253+
IssuesSamplerTestConfig(
1254+
sampler_function=lambda *_: 1.0, sample_rate=0.0, expected_events=1
1255+
),
1256+
IssuesSamplerTestConfig(
1257+
sampler_function=lambda *_: 0.0, sample_rate=1.0, expected_events=0
1258+
),
1259+
# Different sample rates based on exception, retrieved both from event and hint
1260+
IssuesSamplerTestConfig(
1261+
sampler_function=lambda event, _: {
1262+
"ZeroDivisionError": 1.0,
1263+
"AttributeError": 0.0,
1264+
}[event["exception"]["values"][0]["type"]],
1265+
exception_to_raise=ZeroDivisionError,
1266+
expected_events=1,
1267+
),
1268+
IssuesSamplerTestConfig(
1269+
sampler_function=lambda event, _: {
1270+
"ZeroDivisionError": 1.0,
1271+
"AttributeError": 0.0,
1272+
}[event["exception"]["values"][0]["type"]],
1273+
exception_to_raise=AttributeError,
1274+
expected_events=0,
1275+
),
1276+
IssuesSamplerTestConfig(
1277+
sampler_function=lambda _, hint: {
1278+
ZeroDivisionError: 1.0,
1279+
AttributeError: 0.0,
1280+
}[hint["exc_info"][0]],
1281+
exception_to_raise=ZeroDivisionError,
1282+
expected_events=1,
1283+
),
1284+
IssuesSamplerTestConfig(
1285+
sampler_function=lambda _, hint: {
1286+
ZeroDivisionError: 1.0,
1287+
AttributeError: 0.0,
1288+
}[hint["exc_info"][0]],
1289+
exception_to_raise=AttributeError,
1290+
expected_events=0,
1291+
),
1292+
# If sampler returns invalid value, we should still send the event
1293+
IssuesSamplerTestConfig(
1294+
sampler_function=lambda *_: "This is an invalid return value for the sampler",
1295+
expected_events=1,
1296+
),
1297+
),
1298+
)
1299+
def test_error_sampler(_, sentry_init, capture_events, test_config):
1300+
test_config.init_sdk(sentry_init)
1301+
1302+
events = capture_events()
1303+
1304+
try:
1305+
test_config.raise_exception()
1306+
except Exception:
1307+
capture_exception()
1308+
1309+
assert len(events) == test_config.expected_events
1310+
1311+
if test_config.sampler_function_mock is not None:
1312+
assert test_config.sampler_function_mock.call_count == 1
1313+
1314+
# Ensure two arguments (the event and hint) were passed to the sampler function
1315+
assert len(test_config.sampler_function_mock.call_args[0]) == 2

0 commit comments

Comments
 (0)