Skip to content

Commit 226d564

Browse files
authored
Implement sampling (#807)
* add new sampling configuration keys and converters * add types to all config methods * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * use 0-100 ints instead of floats * add sampler module * add sampler tests * fix always sample endpoint test * 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 * add test assertion * refactor sampler to better handler legacy ignore and prioritization * de-couple from TrackedRequest * simplify sampler logic to rely on configuration * flatten control flow * use prefix to check ignores * add sampler to tracked_request class * add 3.3.0 changelog * use singleton pattern for sampler * add sampler tests to test_tracked_request * update changelog url * pass ignored boolean to should_sample
1 parent e121145 commit 226d564

4 files changed

Lines changed: 72 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
- Change to tz-aware dates internally (Issue #799)
66
- psutil dependency un-pin (#790)
77

8+
## [3.3.0] 2025-01-07
9+
### Added
10+
- Added support for down-sampling via Scout configuration.
11+
- Sample rates can be set globally or for specific jobs/endpoints
12+
- Check out our [documentation](https://scoutapm.com/docs/python/configuration#sampling) for more information and example usage.
13+
814
## [3.2.0] 2024-09-12
915
### Added
1016
- "Operation" attribute added to TrackedRequest class to better support development of [scout_apm_python_logging](https://github.com/scoutapp/scout_apm_python_logging)

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
setup(
3535
name="scout_apm",
36-
version="3.2.1",
36+
version="3.3.0",
3737
description="Scout Application Performance Monitoring Agent",
3838
long_description=long_description,
3939
long_description_content_type="text/markdown",

src/scout_apm/core/tracked_request.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from scout_apm.core.agent.socket import CoreAgentSocketThread
1111
from scout_apm.core.config import scout_config
1212
from scout_apm.core.n_plus_one_tracker import NPlusOneTracker
13+
from scout_apm.core.sampler import Sampler
1314
from scout_apm.core.samplers.memory import get_rss_in_mb
1415
from scout_apm.core.samplers.thread import SamplersThread
1516

@@ -23,7 +24,16 @@ class TrackedRequest(object):
2324
their keyname
2425
"""
2526

27+
_sampler = None
28+
29+
@classmethod
30+
def get_sampler(cls):
31+
if cls._sampler is None:
32+
cls._sampler = Sampler(scout_config)
33+
return cls._sampler
34+
2635
__slots__ = (
36+
"sampler",
2737
"request_id",
2838
"start_time",
2939
"end_time",
@@ -150,8 +160,10 @@ def finish(self):
150160
self.end_time = dt.datetime.now(dt.timezone.utc)
151161

152162
if self.is_real_request:
153-
self.tag("mem_delta", self._get_mem_delta())
154-
if not self.is_ignored() and not self.sent:
163+
if not self.sent and self.get_sampler().should_sample(
164+
self.operation, self.is_ignored()
165+
):
166+
self.tag("mem_delta", self._get_mem_delta())
155167
self.sent = True
156168
batch_command = BatchCommand.from_tracked_request(self)
157169
if scout_config.value("log_payload_content"):

tests/unit/core/test_tracked_request.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
)
1717

1818

19+
@pytest.fixture(autouse=True)
20+
def clear_sampler():
21+
"""Reset the sampler before each test"""
22+
TrackedRequest._sampler = None
23+
24+
1925
@pytest.fixture
2026
def reset_config():
2127
"""
@@ -26,6 +32,8 @@ def reset_config():
2632
finally:
2733
# Reset Scout configuration.
2834
scout_config.reset_all()
35+
# Clear the sampler
36+
TrackedRequest._sampler = None
2937

3038

3139
def test_tracked_request_repr(tracked_request):
@@ -358,3 +366,46 @@ def test_request_only_sent_once(tracked_request, caplog):
358366
len([log_tuple for log_tuple in caplog.record_tuples if log_tuple == info_log])
359367
== 2
360368
)
369+
370+
371+
def test_sampler_behavior(tracked_request):
372+
"""Test that sampler is only created when first needed and shared across requests"""
373+
assert TrackedRequest._sampler is None
374+
sampler1 = TrackedRequest.get_sampler()
375+
376+
assert TrackedRequest._sampler is not None
377+
378+
# Should get the same sampler instance
379+
sampler2 = TrackedRequest.get_sampler()
380+
assert sampler1 is sampler2
381+
382+
# Test that all TrackedRequests share the same sampler
383+
request1 = TrackedRequest()
384+
request2 = TrackedRequest()
385+
386+
sampler1 = request1.get_sampler()
387+
sampler2 = request2.get_sampler()
388+
389+
assert sampler1 is sampler2
390+
391+
392+
@pytest.mark.parametrize(
393+
"operation,is_real,expected_calls",
394+
[
395+
("Controller/test", True, 1), # Should check sampling
396+
("Controller/test", False, 0), # Shouldn't check sampling if not real
397+
],
398+
)
399+
def test_finish_sampling_behavior(tracked_request, operation, is_real, expected_calls):
400+
"""Test that sampling only occurs under the right conditions"""
401+
mock_sampler = mock.Mock()
402+
mock_sampler.should_sample.return_value = True
403+
TrackedRequest._sampler = mock_sampler
404+
405+
tracked_request.operation = operation
406+
tracked_request.is_real_request = is_real
407+
tracked_request.sent = False
408+
409+
tracked_request.finish()
410+
411+
assert mock_sampler.should_sample.call_count == expected_calls

0 commit comments

Comments
 (0)