Repository: scoutapp/scout_apm_python
Pull request #807 (2025-01-07 21:29:50)

Implement sampling
## Changes
- Instantiates the new `Sampler` class as a singleton class variable of the `tracked_request` class.
- Uses the `Sampler.should _sample->bool` inside the `finish` method of the same class. 
- adds test to check singleton functionality.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2ff97a..81121e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@
 - Change to tz-aware dates internally (Issue #799)
 - psutil dependency un-pin (#790)
 
+## [3.3.0] 2025-01-07
+### Added
+- Added support for down-sampling via Scout configuration.
+  - Sample rates can be set globally or for specific jobs/endpoints
+  - Check out our [documentation](https://scoutapm.com/docs/python/configuration#sampling) for more information and example usage.
+
 ## [3.2.0] 2024-09-12
 ### Added
 - "Operation" attribute added to TrackedRequest class to better support development of [scout_apm_python_logging](https://github.com/scoutapp/scout_apm_python_logging)
diff --git a/setup.py b/setup.py
index d1a59c5..b649c6f 100644
--- a/setup.py
+++ b/setup.py
@@ -33,7 +33,7 @@ else:
 
 setup(
     name="scout_apm",
-    version="3.2.1",
+    version="3.3.0",
     description="Scout Application Performance Monitoring Agent",
     long_description=long_description,
     long_description_content_type="text/markdown",
diff --git a/src/scout_apm/core/tracked_request.py b/src/scout_apm/core/tracked_request.py
index 0df7336..4f531ae 100644
--- a/src/scout_apm/core/tracked_request.py
+++ b/src/scout_apm/core/tracked_request.py
@@ -10,6 +10,7 @@ from scout_apm.core.agent.commands import BatchCommand
 from scout_apm.core.agent.socket import CoreAgentSocketThread
 from scout_apm.core.config import scout_config
 from scout_apm.core.n_plus_one_tracker import NPlusOneTracker
+from scout_apm.core.sampler import Sampler
 from scout_apm.core.samplers.memory import get_rss_in_mb
 from scout_apm.core.samplers.thread import SamplersThread
 
@@ -23,7 +24,16 @@ class TrackedRequest(object):
     their keyname
     """
 
+    _sampler = None
+
+    @classmethod
+    def get_sampler(cls):
+        if cls._sampler is None:
+            cls._sampler = Sampler(scout_config)
+        return cls._sampler
+
     __slots__ = (
+        "sampler",
         "request_id",
         "start_time",
         "end_time",
@@ -150,8 +160,10 @@ class TrackedRequest(object):
             self.end_time = dt.datetime.now(dt.timezone.utc)
 
         if self.is_real_request:
-            self.tag("mem_delta", self._get_mem_delta())
-            if not self.is_ignored() and not self.sent:
+            if not self.sent and self.get_sampler().should_sample(
+                self.operation, self.is_ignored()
+            ):
+                self.tag("mem_delta", self._get_mem_delta())
                 self.sent = True
                 batch_command = BatchCommand.from_tracked_request(self)
                 if scout_config.value("log_payload_content"):


Test patch:
diff --git a/tests/unit/core/test_tracked_request.py b/tests/unit/core/test_tracked_request.py
index 7fb4304..ecf0749 100644
--- a/tests/unit/core/test_tracked_request.py
+++ b/tests/unit/core/test_tracked_request.py
@@ -16,6 +16,12 @@ from tests.tools import (
 )
 
 
+@pytest.fixture(autouse=True)
+def clear_sampler():
+    """Reset the sampler before each test"""
+    TrackedRequest._sampler = None
+
+
 @pytest.fixture
 def reset_config():
     """
@@ -26,6 +32,8 @@ def reset_config():
     finally:
         # Reset Scout configuration.
         scout_config.reset_all()
+        # Clear the sampler
+        TrackedRequest._sampler = None
 
 
 def test_tracked_request_repr(tracked_request):
@@ -358,3 +366,46 @@ def test_request_only_sent_once(tracked_request, caplog):
         len([log_tuple for log_tuple in caplog.record_tuples if log_tuple == info_log])
         == 2
     )
+
+
+def test_sampler_behavior(tracked_request):
+    """Test that sampler is only created when first needed and shared across requests"""
+    assert TrackedRequest._sampler is None
+    sampler1 = TrackedRequest.get_sampler()
+
+    assert TrackedRequest._sampler is not None
+
+    # Should get the same sampler instance
+    sampler2 = TrackedRequest.get_sampler()
+    assert sampler1 is sampler2
+
+    # Test that all TrackedRequests share the same sampler
+    request1 = TrackedRequest()
+    request2 = TrackedRequest()
+
+    sampler1 = request1.get_sampler()
+    sampler2 = request2.get_sampler()
+
+    assert sampler1 is sampler2
+
+
+@pytest.mark.parametrize(
+    "operation,is_real,expected_calls",
+    [
+        ("Controller/test", True, 1),  # Should check sampling
+        ("Controller/test", False, 0),  # Shouldn't check sampling if not real
+    ],
+)
+def test_finish_sampling_behavior(tracked_request, operation, is_real, expected_calls):
+    """Test that sampling only occurs under the right conditions"""
+    mock_sampler = mock.Mock()
+    mock_sampler.should_sample.return_value = True
+    TrackedRequest._sampler = mock_sampler
+
+    tracked_request.operation = operation
+    tracked_request.is_real_request = is_real
+    tracked_request.sent = False
+
+    tracked_request.finish()
+
+    assert mock_sampler.should_sample.call_count == expected_calls