diff --git a/sentry-python/8031/README.md b/sentry-python/8031/README.md new file mode 100644 index 0000000..66b68a3 --- /dev/null +++ b/sentry-python/8031/README.md @@ -0,0 +1,51 @@ +# Reproduction for sentry-python#8031 + +**Issue:** https://github.com/getsentry/sentry-python/issues/8031 + +## Description + +`tests/test_monitor.py::test_segment_uses_downsampled_rate` sometimes fails with `assert 0.25 == 0.5`. + +The test sets `monitor.interval = 0.1` and calls `monitor.run()` once by hand, then expects `downsample_factor == 1`. But the first `monitor.is_healthy()` call also starts the monitor's background thread, and that thread calls `run()` every `interval` seconds as well. On a slow runner, more than 0.1 s passes before the sampling decision, so the factor becomes 2 and the sample rate 0.25. + +`repro.py` runs the same sequence many times. Between the manual `run()` and the sampling decision it waits a random 0–`--delay-max` seconds, to stand in for a slow CI runner. + +## Steps to Reproduce + +No DSN is needed; the transport is a stub. To use a real project anyway, run `export SENTRY_DSN=`. + +1. Run the reproduction (uv installs `sentry-sdk` from the inline script metadata): + ```bash + uv run repro.py --iterations 100 + ``` +2. Short gaps (below the 0.1 s interval) don't trigger it: + ```bash + uv run repro.py --iterations 100 --delay-max 0.05 + ``` +3. Stopping the background thread first, so the health check runs only by hand as the test intends, makes it pass: + ```bash + uv run repro.py --iterations 100 --stop-thread + ``` + +The script exits with code 1 if any iteration gets an unexpected rate. + +## Expected Behavior + +After one manual `run()`, the sample rate is `0.5` in every iteration. + +## Actual Behavior + +With gaps of up to 0.2 s, the rate is `0.25` in about half the iterations: + +``` +sentry-sdk 2.72.0 +unexpected sample rate (expected 0.5) in 55/100 iterations +``` + +With `--delay-max 0.05` the result was 0/100, and with `--stop-thread` it was 0/100. + +## Environment + +- Python: 3.12 (any version works) +- sentry-sdk: 2.72.0 +- OS: Linux diff --git a/sentry-python/8031/repro.py b/sentry-python/8031/repro.py new file mode 100644 index 0000000..1bf7996 --- /dev/null +++ b/sentry-python/8031/repro.py @@ -0,0 +1,90 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = ["sentry-sdk"] +# /// +"""Reproduction for getsentry/sentry-python#8031. + +Mirrors tests/test_monitor.py::test_segment_uses_downsampled_rate: +an unhealthy transport, monitor.interval = 0.1, one manual monitor.run(), +then a sampling decision. The test expects downsample_factor == 1 and a +sample rate of 0.5. + +The monitor's own background thread (started by the first is_healthy() call) +also calls run() every `interval` seconds. If the gap between the first +is_healthy() and the sampling decision is longer than `interval` (a slow or +busy CI runner), the factor is 2 and the sample rate 0.25. + +`--delay-max` sets the largest random gap (seconds) inserted between the +manual run() and the sampling decision, to stand in for a slow runner. +""" + +import argparse +import os +import random +import time +from unittest import mock + +import sentry_sdk +from sentry_sdk.transport import Transport + + +class UnhealthyTransport(Transport): + def capture_envelope(self, envelope): + pass + + def is_healthy(self): + return False + + +def one_iteration(delay_max, stop_thread): + sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN") or "https://public@example.com/1", + traces_sample_rate=1.0, + transport=UnhealthyTransport(), + ) + client = sentry_sdk.get_client() + monitor = client.monitor + if stop_thread: + # Drive health checks only by hand, as the test intends. + monitor.kill() + monitor.interval = 0.1 + + assert monitor.is_healthy() is True # starts the background thread + monitor.run() + factor_after_run = monitor.downsample_factor + + time.sleep(random.uniform(0, delay_max)) # slow runner + + with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=750000): + with sentry_sdk.start_transaction(name="foobar") as transaction: + rate = transaction.sample_rate + + client.close() + return factor_after_run, rate + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--iterations", type=int, default=100) + parser.add_argument("--delay-max", type=float, default=0.2) + parser.add_argument( + "--stop-thread", + action="store_true", + help="kill the monitor's background thread first (the proposed test fix)", + ) + args = parser.parse_args() + + print("sentry-sdk", sentry_sdk.VERSION) + bad = 0 + for _ in range(args.iterations): + factor, rate = one_iteration(args.delay_max, args.stop_thread) + if factor != 1 or rate != 0.5: + bad += 1 + print( + "unexpected sample rate (expected 0.5) in %d/%d iterations" % (bad, args.iterations) + ) + raise SystemExit(1 if bad else 0) + + +if __name__ == "__main__": + main()