Skip to content

Commit b813f6f

Browse files
committed
support float sampling values
switch to used random.random in sampler and tests for float support add coerce rate to float helper method
1 parent b33d880 commit b813f6f

4 files changed

Lines changed: 172 additions & 70 deletions

File tree

src/scout_apm/core/config.py

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def __init__(self):
250250
"monitor": False,
251251
"name": "Python App",
252252
"revision_sha": self._git_revision_sha(),
253-
"sample_rate": 100,
253+
"sample_rate": 1,
254254
"sample_endpoints": [],
255255
"endpoint_sample_rate": None,
256256
"sample_jobs": [],
@@ -307,28 +307,54 @@ def convert_to_float(value: Any) -> float:
307307
return 0.0
308308

309309

310-
def convert_sample_rate(value: Any) -> Optional[int]:
310+
def _coerce_rate_to_float(value: Any, context: str = "") -> float:
311311
"""
312-
Converts sample rate to integer, ensuring it's between 0 and 100.
312+
Helper to convert a rate value to float between 0 and 1.
313+
For backwards compatibility, values > 1 are treated as percentages
314+
and converted to decimals (e.g., 80 -> 0.80).
315+
316+
Args:
317+
value: The value to convert
318+
context: Optional context string for better error messages
319+
(e.g., "endpoint /users")
320+
321+
Returns:
322+
Float between 0.0 and 1.0
323+
324+
Raises:
325+
ValueError: If value cannot be converted to float
326+
"""
327+
rate = float(value)
328+
# Anything above 1 is assumed a percentage for backwards compat
329+
if rate > 1:
330+
rate = rate / 100
331+
# Clamp between 0 and 1
332+
if rate < 0 or rate > 1:
333+
context_str = f"For {context}, you" if context else "You"
334+
logger.warning(
335+
f"Sample rates must be between 0 and 1. {context_str} passed in {value}, "
336+
f"which we interpreted as {rate}. Clamping."
337+
)
338+
rate = max(0.0, min(1.0, rate))
339+
return rate
340+
341+
342+
def convert_sample_rate(value: Any) -> Optional[float]:
343+
"""
344+
Converts sample rate to float, ensuring it's between 0 and 1.
345+
For backwards compatibility, values > 1 are treated as percentages
346+
and converted to decimals (e.g., 80 -> 0.80).
313347
Allows None as a valid value.
314348
"""
315349
if value is None:
316350
return None
317351
try:
318-
rate = int(value)
319-
if not (0 <= rate <= 100):
320-
logger.warning(
321-
f"Invalid sample rate {rate}. Must be between 0 and 100. "
322-
"Defaulting to 100."
323-
)
324-
return 100
325-
return rate
352+
return _coerce_rate_to_float(value)
326353
except (TypeError, ValueError):
327354
logger.warning(
328-
f"Invalid sample rate {value}. Must be a number between 0 and 100. "
329-
"Defaulting to 100."
355+
f"Invalid sample rate {value}. Must be a number. Defaulting to 1.0."
330356
)
331-
return 100
357+
return 1.0
332358

333359

334360
def convert_to_list(value: Any) -> List[Any]:
@@ -351,30 +377,36 @@ def convert_ignore_paths(value: Any) -> List[str]:
351377
return [_strip_leading_slash(path) for path in raw_paths]
352378

353379

354-
def convert_endpoint_sampling(value: Union[str, Dict[str, Any]]) -> Dict[str, int]:
380+
def convert_endpoint_sampling(value: Union[str, Dict[str, Any]]) -> Dict[str, float]:
355381
"""
356382
Converts endpoint sampling configuration from string or dict format
357383
to a normalized dict.
358-
Example: '/endpoint:40,/test:0' -> {'/endpoint': 40, '/test': 0}
384+
For backwards compatibility, values > 1 are treated as percentages
385+
and converted to decimals (e.g., 80 -> 0.80).
386+
Example: '/endpoint:40,/test:0' -> {'/endpoint': 0.40, '/test': 0.0}
359387
"""
360388
if isinstance(value, dict):
361-
return {_strip_leading_slash(k): int(v) for k, v in value.items()}
389+
result = {}
390+
for k, v in value.items():
391+
try:
392+
result[_strip_leading_slash(k)] = _coerce_rate_to_float(
393+
v, context=f"endpoint {k}"
394+
)
395+
except (TypeError, ValueError):
396+
logger.warning(f"Invalid sampling rate for endpoint {k}: {v}")
397+
continue
398+
return result
362399
if isinstance(value, str):
363400
if not value.strip():
364401
return {}
365402
result = {}
366403
pairs = [pair.strip() for pair in value.split(",")]
367404
for pair in pairs:
368405
try:
369-
endpoint, rate = pair.split(":")
370-
rate_int = int(rate)
371-
if not (0 <= rate_int <= 100):
372-
logger.warning(
373-
f"Invalid sampling rate {rate} for endpoint {endpoint}. "
374-
"Must be between 0 and 100."
375-
)
376-
continue
377-
result[_strip_leading_slash(endpoint)] = rate_int
406+
endpoint, rate_str = pair.split(":")
407+
result[_strip_leading_slash(endpoint)] = _coerce_rate_to_float(
408+
rate_str, context=f"endpoint {endpoint}"
409+
)
378410
except ValueError:
379411
logger.warning(f"Invalid sampling configuration: {pair}")
380412
continue

src/scout_apm/core/sampler.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def _any_sampling(self):
4444
Boolean indicating if any sampling is enabled
4545
"""
4646
return (
47-
self.sample_rate < 100
47+
self.sample_rate < 1
4848
or self.sample_endpoints
4949
or self.sample_jobs
5050
or self.ignore_endpoints
@@ -92,7 +92,7 @@ def _get_operation_type_and_name(
9292
else:
9393
return None, None
9494

95-
def get_effective_sample_rate(self, operation: str, is_ignored: bool) -> int:
95+
def get_effective_sample_rate(self, operation: str, is_ignored: bool) -> float:
9696
"""
9797
Determines the effective sample rate for a given operation.
9898
@@ -107,7 +107,7 @@ def get_effective_sample_rate(self, operation: str, is_ignored: bool) -> int:
107107
is_ignored: boolean for if the specific transaction is ignored
108108
109109
Returns:
110-
Integer between 0 and 100 representing sample rate
110+
Float between 0 and 1 representing sample rate
111111
"""
112112
op_type, name = self._get_operation_type_and_name(operation)
113113
patterns = self.sample_endpoints if op_type == "endpoint" else self.sample_jobs
@@ -144,6 +144,4 @@ def should_sample(self, operation: str, is_ignored: bool) -> bool:
144144
"""
145145
if not self._any_sampling():
146146
return True
147-
return random.randint(1, 100) <= self.get_effective_sample_rate(
148-
operation, is_ignored
149-
)
147+
return random.random() <= self.get_effective_sample_rate(operation, is_ignored)

tests/unit/core/test_config.py

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -246,13 +246,30 @@ def test_sample_rate_conversion_from_env():
246246
config = ScoutConfig()
247247
with mock.patch.dict(os.environ, {"SCOUT_SAMPLE_RATE": "50"}):
248248
value = config.value("sample_rate")
249-
assert isinstance(value, int)
250-
assert value == 50
249+
assert isinstance(value, float)
250+
assert value == 0.50 # 50 is converted to 0.50 for backwards compatibility
251251

252252

253253
@pytest.mark.parametrize(
254254
"original, converted",
255-
[("0", 0), ("50", 50), ("100", 100), ("x", 100)],
255+
[
256+
# Float values (0-1 range)
257+
("0", 0.0),
258+
("0.5", 0.5),
259+
("1", 1.0),
260+
("0.001", 0.001),
261+
# Integer percentages (> 1, backwards compatibility)
262+
("50", 0.50),
263+
("100", 1.0),
264+
("1", 1.0),
265+
("1.5", 0.015), # 1.5% -> 0.015
266+
# Edge cases
267+
("x", 1.0), # Invalid defaults to 1.0
268+
(None, None), # None is preserved
269+
# Clamping
270+
("-2.5", 0.0), # Negative values clamped to 0
271+
("150", 1.0), # > 100% clamped to 1.0
272+
],
256273
)
257274
def test_sample_rate_conversion_from_python(original, converted):
258275
ScoutConfig.set(sample_rate=original)
@@ -270,14 +287,21 @@ def test_endpoint_sampling_conversion_from_env():
270287
):
271288
value = config.value("sample_endpoints")
272289
assert isinstance(value, dict)
273-
assert value == {"endpoint": 40, "test": 0}
290+
assert value == {"endpoint": 0.40, "test": 0.0} # Converted to floats
274291

275292

276293
@pytest.mark.parametrize(
277294
"original, converted",
278295
[
279-
("/endpoint:40,/test:0", {"endpoint": 40, "test": 0}),
280-
({"endpoint": 40, "test": 0}, {"endpoint": 40, "test": 0}),
296+
# String format with percentages (backwards compat)
297+
("/endpoint:40,/test:0", {"endpoint": 0.40, "test": 0.0}),
298+
# Dict format with percentages (backwards compat)
299+
({"endpoint": 40, "test": 0}, {"endpoint": 0.40, "test": 0.0}),
300+
# Dict format with floats
301+
({"endpoint": 0.40, "test": 0.0}, {"endpoint": 0.40, "test": 0.0}),
302+
# String format with floats
303+
("/endpoint:0.5,/test:0.001", {"endpoint": 0.5, "test": 0.001}),
304+
# Empty
281305
("", {}),
282306
(object(), {}),
283307
],
@@ -296,14 +320,21 @@ def test_job_sampling_conversion_from_env():
296320
with mock.patch.dict(os.environ, {"SCOUT_SAMPLE_JOBS": "job1:30,job2:70"}):
297321
value = config.value("sample_jobs")
298322
assert isinstance(value, dict)
299-
assert value == {"job1": 30, "job2": 70}
323+
assert value == {"job1": 0.30, "job2": 0.70} # Converted to floats
300324

301325

302326
@pytest.mark.parametrize(
303327
"original, converted",
304328
[
305-
("job1:30,job2:70", {"job1": 30, "job2": 70}),
306-
({"job1": 30, "job2": 70}, {"job1": 30, "job2": 70}),
329+
# String format with percentages (backwards compat)
330+
("job1:30,job2:70", {"job1": 0.30, "job2": 0.70}),
331+
# Dict format with percentages (backwards compat)
332+
({"job1": 30, "job2": 70}, {"job1": 0.30, "job2": 0.70}),
333+
# Dict format with floats
334+
({"job1": 0.30, "job2": 0.70}, {"job1": 0.30, "job2": 0.70}),
335+
# String format with floats
336+
("job1:0.5,job2:0.001", {"job1": 0.5, "job2": 0.001}),
337+
# Empty
307338
("", {}),
308339
(object(), {}),
309340
],
@@ -315,3 +346,44 @@ def test_job_sampling_conversion_from_python(original, converted):
315346
assert config.value("sample_jobs") == converted
316347
finally:
317348
ScoutConfig.reset_all()
349+
350+
351+
# Additional tests for nullable sample rates
352+
@pytest.mark.parametrize(
353+
"original, converted",
354+
[
355+
(None, None),
356+
("0", 0.0),
357+
("0.5", 0.5),
358+
("50", 0.50),
359+
("100", 1.0),
360+
],
361+
)
362+
def test_endpoint_sample_rate_nullable(original, converted):
363+
"""Test that endpoint_sample_rate allows None and converts other values."""
364+
ScoutConfig.set(endpoint_sample_rate=original)
365+
config = ScoutConfig()
366+
try:
367+
assert config.value("endpoint_sample_rate") == converted
368+
finally:
369+
ScoutConfig.reset_all()
370+
371+
372+
@pytest.mark.parametrize(
373+
"original, converted",
374+
[
375+
(None, None),
376+
("0", 0.0),
377+
("0.5", 0.5),
378+
("50", 0.50),
379+
("100", 1.0),
380+
],
381+
)
382+
def test_job_sample_rate_nullable(original, converted):
383+
"""Test that job_sample_rate allows None and converts other values."""
384+
ScoutConfig.set(job_sample_rate=original)
385+
config = ScoutConfig()
386+
try:
387+
assert config.value("job_sample_rate") == converted
388+
finally:
389+
ScoutConfig.reset_all()

0 commit comments

Comments
 (0)