Skip to content

Commit e121145

Browse files
authored
Add sampling logic (#805)
* add new sampling configuration keys and converters * add types to all config methods * use 0-100 ints instead of floats * add sampler module * add sampler tests * short circuit sampling logic if no sampling configured * simplify pattern match add support for sample_endpoint/job_rate * rename config option to X_sample_rate * check endpoint/job sample rate in any_sampling * flatten control flow * use prefix to check ignores
1 parent c9af237 commit e121145

3 files changed

Lines changed: 330 additions & 2 deletions

File tree

src/scout_apm/core/config.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,10 @@ def log(self) -> None:
8787
"name",
8888
"revision_sha",
8989
"sample_rate",
90+
"endpoint_sample_rate",
9091
"sample_endpoints",
9192
"sample_jobs",
93+
"job_sample_rate",
9294
"scm_subdirectory",
9395
"shutdown_message_enabled",
9496
"shutdown_timeout_seconds",
@@ -250,7 +252,9 @@ def __init__(self):
250252
"revision_sha": self._git_revision_sha(),
251253
"sample_rate": 100,
252254
"sample_endpoints": [],
255+
"endpoint_sample_rate": None,
253256
"sample_jobs": [],
257+
"job_sample_rate": None,
254258
"scm_subdirectory": "",
255259
"shutdown_message_enabled": True,
256260
"shutdown_timeout_seconds": 2.0,
@@ -299,10 +303,13 @@ def convert_to_float(value: Any) -> float:
299303
return 0.0
300304

301305

302-
def convert_sample_rate(value: Any) -> int:
306+
def convert_sample_rate(value: Any) -> Optional[int]:
303307
"""
304-
Converts sample rate to integer, ensuring it's between 0 and 100
308+
Converts sample rate to integer, ensuring it's between 0 and 100.
309+
Allows None as a valid value.
305310
"""
311+
if value is None:
312+
return None
306313
try:
307314
rate = int(value)
308315
if not (0 <= rate <= 100):
@@ -374,7 +381,9 @@ def convert_endpoint_sampling(value: Union[str, Dict[str, Any]]) -> Dict[str, in
374381
"monitor": convert_to_bool,
375382
"sample_rate": convert_sample_rate,
376383
"sample_endpoints": convert_endpoint_sampling,
384+
"endpoint_sample_rate": convert_sample_rate,
377385
"sample_jobs": convert_endpoint_sampling,
386+
"job_sample_rate": convert_sample_rate,
378387
"shutdown_message_enabled": convert_to_bool,
379388
"shutdown_timeout_seconds": convert_to_float,
380389
}

src/scout_apm/core/sampler.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# coding=utf-8
2+
3+
import random
4+
from typing import Dict, Optional, Tuple
5+
6+
7+
class Sampler:
8+
"""
9+
Handles sampling decision logic for Scout APM.
10+
11+
This class encapsulates all sampling-related functionality including:
12+
- Loading and managing sampling configuration
13+
- Pattern matching for operations (endpoints and jobs)
14+
- Making sampling decisions based on operation type and patterns
15+
"""
16+
17+
# Constants for operation type detection
18+
CONTROLLER_PREFIX = "Controller/"
19+
JOB_PREFIX = "Job/"
20+
21+
def __init__(self, config):
22+
"""
23+
Initialize sampler with Scout configuration.
24+
25+
Args:
26+
config: ScoutConfig instance containing sampling configuration
27+
"""
28+
self.config = config
29+
self.sample_rate = config.value("sample_rate")
30+
self.sample_endpoints = config.value("sample_endpoints")
31+
self.sample_jobs = config.value("sample_jobs")
32+
self.ignore_endpoints = set(
33+
config.value("ignore_endpoints") + config.value("ignore")
34+
)
35+
self.ignore_jobs = set(config.value("ignore_jobs"))
36+
self.endpoint_sample_rate = config.value("endpoint_sample_rate")
37+
self.job_sample_rate = config.value("job_sample_rate")
38+
39+
def _any_sampling(self):
40+
"""
41+
Check if any sampling is enabled.
42+
43+
Returns:
44+
Boolean indicating if any sampling is enabled
45+
"""
46+
return (
47+
self.sample_rate < 100
48+
or self.sample_endpoints
49+
or self.sample_jobs
50+
or self.ignore_endpoints
51+
or self.ignore_jobs
52+
or self.endpoint_sample_rate is not None
53+
or self.job_sample_rate is not None
54+
)
55+
56+
def _find_matching_rate(
57+
self, name: str, patterns: Dict[str, float]
58+
) -> Optional[str]:
59+
"""
60+
Finds the matching sample rate for a given operation name.
61+
62+
Args:
63+
name: The operation name to match
64+
patterns: Dictionary of pattern to sample rate mappings
65+
66+
Returns:
67+
The sample rate for the matching pattern or None if no match found
68+
"""
69+
70+
for pattern, rate in patterns.items():
71+
if name.startswith(pattern):
72+
return rate
73+
return None
74+
75+
def _get_operation_type_and_name(
76+
self, operation: str
77+
) -> Tuple[Optional[str], Optional[str]]:
78+
"""
79+
Determines if an operation is an endpoint or job and extracts its name.
80+
81+
Args:
82+
operation: The full operation string (e.g. "Controller/users/show")
83+
84+
Returns:
85+
Tuple of (type, name) where type is either 'endpoint' or 'job',
86+
and name is the operation name without the prefix
87+
"""
88+
if operation.startswith(self.CONTROLLER_PREFIX):
89+
return "endpoint", operation[len(self.CONTROLLER_PREFIX) :]
90+
elif operation.startswith(self.JOB_PREFIX):
91+
return "job", operation[len(self.JOB_PREFIX) :]
92+
else:
93+
return None, None
94+
95+
def get_effective_sample_rate(self, operation: str, is_ignored: bool) -> int:
96+
"""
97+
Determines the effective sample rate for a given operation.
98+
99+
Prioritization:
100+
1. Sampling rate for specific endpoint or job
101+
2. Specified ignore pattern or flag for operation
102+
3. Global endpoint or job sample rate
103+
4. Global sample rate
104+
105+
Args:
106+
operation: The operation string (e.g. "Controller/users/show")
107+
is_ignored: boolean for if the specific transaction is ignored
108+
109+
Returns:
110+
Integer between 0 and 100 representing sample rate
111+
"""
112+
op_type, name = self._get_operation_type_and_name(operation)
113+
patterns = self.sample_endpoints if op_type == "endpoint" else self.sample_jobs
114+
ignores = self.ignore_endpoints if op_type == "endpoint" else self.ignore_jobs
115+
default_operation_rate = (
116+
self.endpoint_sample_rate if op_type == "endpoint" else self.job_sample_rate
117+
)
118+
119+
if not op_type or not name:
120+
return self.sample_rate
121+
matching_rate = self._find_matching_rate(name, patterns)
122+
if matching_rate is not None:
123+
return matching_rate
124+
for prefix in ignores:
125+
if name.startswith(prefix) or is_ignored:
126+
return 0
127+
if default_operation_rate is not None:
128+
return default_operation_rate
129+
130+
# Fall back to global sample rate
131+
return self.sample_rate
132+
133+
def should_sample(self, operation: str, is_ignored: bool) -> bool:
134+
"""
135+
Determines if an operation should be sampled.
136+
If no sampling is enabled, always return True.
137+
138+
Args:
139+
operation: The operation string (e.g. "Controller/users/show"
140+
or "Job/mailer")
141+
142+
Returns:
143+
Boolean indicating whether to sample this operation
144+
"""
145+
if not self._any_sampling():
146+
return True
147+
return random.randint(1, 100) <= self.get_effective_sample_rate(
148+
operation, is_ignored
149+
)

tests/unit/core/test_sampler.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# coding=utf-8
2+
3+
from unittest import mock
4+
5+
import pytest
6+
7+
from scout_apm.core.config import ScoutConfig
8+
from scout_apm.core.sampler import Sampler
9+
10+
11+
@pytest.fixture
12+
def config():
13+
config = ScoutConfig()
14+
ScoutConfig.set(
15+
sample_rate=50, # 50% global sampling
16+
sample_endpoints={
17+
"users/test": 0, # Never sample specific endpoint
18+
"users": 100, # Always sample
19+
"test": 20, # 20% sampling for test endpoints
20+
"health": 0, # Never sample health checks
21+
},
22+
sample_jobs={
23+
"critical-job": 100, # Always sample
24+
"batch": 30, # 30% sampling for batch jobs
25+
},
26+
ignore_endpoints=["metrics", "ping"],
27+
ignore_jobs=["test-job"],
28+
endpoint_sample_rate=70, # 70% sampling for unspecified endpoints
29+
job_sample_rate=40, # 40% sampling for unspecified jobs
30+
)
31+
yield config
32+
ScoutConfig.reset_all()
33+
34+
35+
@pytest.fixture
36+
def sampler(config):
37+
return Sampler(config)
38+
39+
40+
def test_should_sample_endpoint_always(sampler):
41+
assert sampler.should_sample("Controller/users", False) is True
42+
43+
44+
def test_should_sample_endpoint_never(sampler):
45+
assert sampler.should_sample("Controller/health/check", False) is False
46+
assert sampler.should_sample("Controller/users/test", False) is False
47+
48+
49+
def test_should_sample_endpoint_ignored(sampler):
50+
assert sampler.should_sample("Controller/metrics/some/more", False) is False
51+
52+
53+
def test_should_sample_endpoint_partial(sampler):
54+
with mock.patch("random.randint", return_value=10):
55+
assert sampler.should_sample("Controller/test/endpoint", False) is True
56+
with mock.patch("random.randint", return_value=30):
57+
assert sampler.should_sample("Controller/test/endpoint", False) is False
58+
59+
60+
def test_should_sample_job_always(sampler):
61+
assert sampler.should_sample("Job/critical-job", False) is True
62+
63+
64+
def test_should_sample_job_never(sampler):
65+
assert sampler.should_sample("Job/test-job", False) is False
66+
67+
68+
def test_should_sample_job_partial(sampler):
69+
with mock.patch("random.randint", return_value=10):
70+
assert sampler.should_sample("Job/batch-process", False) is True
71+
with mock.patch("random.randint", return_value=40):
72+
assert sampler.should_sample("Job/batch-process", False) is False
73+
74+
75+
def test_should_sample_unknown_operation(sampler):
76+
with mock.patch("random.randint", return_value=10):
77+
assert sampler.should_sample("Unknown/operation", False) is True
78+
with mock.patch("random.randint", return_value=60):
79+
assert sampler.should_sample("Unknown/operation", False) is False
80+
81+
82+
def test_should_sample_no_sampling_enabled(config):
83+
config.set(
84+
sample_rate=100, # Return config to defaults
85+
sample_endpoints={},
86+
sample_jobs={},
87+
ignore_endpoints=[],
88+
ignore_jobs=[],
89+
endpoint_sample_rate=None,
90+
job_sample_rate=None,
91+
)
92+
sampler = Sampler(config)
93+
assert sampler.should_sample("Controller/any_endpoint", False) is True
94+
assert sampler.should_sample("Job/any_job", False) is True
95+
96+
97+
def test_should_sample_endpoint_default_rate(sampler):
98+
with mock.patch("random.randint", return_value=60):
99+
assert sampler.should_sample("Controller/unspecified", False) is True
100+
with mock.patch("random.randint", return_value=80):
101+
assert sampler.should_sample("Controller/unspecified", False) is False
102+
103+
104+
def test_should_sample_job_default_rate(sampler):
105+
with mock.patch("random.randint", return_value=30):
106+
assert sampler.should_sample("Job/unspecified-job", False) is True
107+
with mock.patch("random.randint", return_value=50):
108+
assert sampler.should_sample("Job/unspecified-job", False) is False
109+
110+
111+
def test_should_sample_endpoint_fallback_to_global_rate(config):
112+
config.set(endpoint_sample_rate=None)
113+
sampler = Sampler(config)
114+
with mock.patch("random.randint", return_value=40):
115+
assert sampler.should_sample("Controller/unspecified", False) is True
116+
with mock.patch("random.randint", return_value=60):
117+
assert sampler.should_sample("Controller/unspecified", False) is False
118+
119+
120+
def test_should_sample_job_fallback_to_global_rate(config):
121+
config.set(job_sample_rate=None)
122+
sampler = Sampler(config)
123+
with mock.patch("random.randint", return_value=40):
124+
assert sampler.should_sample("Job/unspecified-job", False) is True
125+
with mock.patch("random.randint", return_value=60):
126+
assert sampler.should_sample("Job/unspecified-job", False) is False
127+
128+
129+
def test_should_handle_legacy_ignore_with_specific_sampling(config):
130+
"""Test that specific sampling rates override legacy ignore patterns."""
131+
config.set(
132+
sample_endpoints={
133+
"foo/bar": 50, # Should override the ignore pattern for specific endpoint
134+
"foo": 0, # Ignore all other foo endpoints
135+
},
136+
)
137+
sampler = Sampler(config)
138+
139+
# foo/bar should be sampled at 50%
140+
with mock.patch("random.randint", return_value=40):
141+
assert sampler.should_sample("Controller/foo/bar", False) is True
142+
with mock.patch("random.randint", return_value=60):
143+
assert sampler.should_sample("Controller/foo/bar", False) is False
144+
145+
# foo/other should be ignored (0% sampling)
146+
assert sampler.should_sample("Controller/foo/other", False) is False
147+
148+
149+
def test_prefix_matching_precedence(config):
150+
"""Test that longer prefix matches take precedence."""
151+
config.set(
152+
sample_endpoints={
153+
"api/users/vip": 100, # Sample all VIP user endpoints
154+
"api/users": 50, # Sample 50% of user endpoints
155+
"api": 0, # Ignore all API endpoints by default
156+
}
157+
)
158+
sampler = Sampler(config)
159+
160+
# Regular API endpoint should be ignored
161+
assert sampler.should_sample("Controller/api/status", False) is False
162+
163+
# Users API should be sampled at 50%
164+
with mock.patch("random.randint", return_value=40):
165+
assert sampler.should_sample("Controller/api/users/list", False) is True
166+
with mock.patch("random.randint", return_value=60):
167+
assert sampler.should_sample("Controller/api/users/list", False) is False
168+
169+
# VIP users API should always be sampled
170+
assert sampler.should_sample("Controller/api/users/vip/list", False) is True

0 commit comments

Comments
 (0)