From 07d2e15a8181c5f353131880757519e290196a2c Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Tue, 16 Jun 2026 15:45:15 +0200 Subject: [PATCH 1/4] feat(dynamic-sampling): Add per-project sample rate override for custom DS Allow pinning specific projects to a fixed dynamic sampling rate out-of-band via the dynamic-sampling.sample-rate-override-per-project option (a map of project id to rate). Previously the custom (AM3) dynamic sampling path had no override hook -- the existing getsentry override only wraps the blended/automatic rate, which the custom path never calls. The override hard-replaces the resolved rate in both custom-DS paths: - Legacy path: get_guarded_project_sample_rate, winning over org/project targets and the recently-added/rebalanced rate. - New per-org path: applied as an explicit apply_project_sample_rate_overrides step in the scheduler after balancing, so it is surfaced in the pipeline and feeds the cached rates and transaction balancing. Out-of-range or invalid values are ignored. Also adds tests covering both paths and the AM3 organization-mode sample rate behavior. Refs TET-2490 Co-Authored-By: Claude Opus 4.8 --- .../dynamic_sampling/per_org/calculations.py | 21 ++++++++++++++ .../dynamic_sampling/per_org/scheduler.py | 2 ++ src/sentry/dynamic_sampling/rules/base.py | 7 +++++ .../dynamic_sampling/sample_rate_override.py | 28 +++++++++++++++++++ src/sentry/options/defaults.py | 10 +++++++ .../per_org/test_calculations.py | 28 +++++++++++++++++++ 6 files changed, 96 insertions(+) create mode 100644 src/sentry/dynamic_sampling/sample_rate_override.py diff --git a/src/sentry/dynamic_sampling/per_org/calculations.py b/src/sentry/dynamic_sampling/per_org/calculations.py index 4584bf76d397a2..0e880aa1918e0e 100644 --- a/src/sentry/dynamic_sampling/per_org/calculations.py +++ b/src/sentry/dynamic_sampling/per_org/calculations.py @@ -2,6 +2,7 @@ import logging from collections.abc import Iterable +from dataclasses import replace from typing import TYPE_CHECKING, cast import orjson @@ -23,6 +24,7 @@ from sentry.dynamic_sampling.per_org.gate import project_balancing_debug_project_ids from sentry.dynamic_sampling.per_org.queries import ProjectTransactionCounts, ProjectVolume from sentry.dynamic_sampling.rules.utils import get_redis_client_for_ds +from sentry.dynamic_sampling.sample_rate_override import get_sample_rate_override_for_project from sentry.dynamic_sampling.tasks.common import sample_rate_to_float from sentry.dynamic_sampling.tasks.helpers.boost_low_volume_projects import ( generate_boost_low_volume_projects_cache_key, @@ -63,6 +65,25 @@ def run_project_balancing( ) +def apply_project_sample_rate_overrides( + rebalanced_projects: list[RebalancedItem], +) -> list[RebalancedItem]: + """ + Hard-replace the balanced sample rate of any project that has a per-project override + configured via the ``dynamic-sampling.sample-rate-override-per-project`` option. + + Applied as an explicit step in the scheduler (rather than inside the balancing model) + so the override is surfaced in the pipeline. The result feeds the cached project + sample rates and the downstream transaction balancing. + """ + return [ + replace(item, new_sample_rate=override) + if (override := get_sample_rate_override_for_project(int(item.id))) is not None + else item + for item in rebalanced_projects + ] + + def get_cached_rebalanced_project_sample_rates(org_id: int) -> dict[int, float | None]: redis_client = get_redis_client_for_ds() cache_key = generate_boost_low_volume_projects_cache_key(org_id=org_id) diff --git a/src/sentry/dynamic_sampling/per_org/scheduler.py b/src/sentry/dynamic_sampling/per_org/scheduler.py index 3a6774a9a6e771..05dd06446e0791 100644 --- a/src/sentry/dynamic_sampling/per_org/scheduler.py +++ b/src/sentry/dynamic_sampling/per_org/scheduler.py @@ -9,6 +9,7 @@ from taskbroker_client.retry import Retry from sentry.dynamic_sampling.per_org.calculations import ( + apply_project_sample_rate_overrides, compare_rebalanced_projects_with_cache, compare_rebalanced_transactions_with_cache, get_cached_rebalanced_project_sample_rates, @@ -126,6 +127,7 @@ def run_calculations_per_org_task(org_id: OrganizationId) -> DynamicSamplingStat if config.should_balance_projects: rebalanced_projects = run_project_balancing(config, project_volumes) + rebalanced_projects = apply_project_sample_rate_overrides(rebalanced_projects) config.set_rebalanced_project_sample_rates(rebalanced_projects) cached_sample_rates = get_cached_rebalanced_project_sample_rates(config.organization.id) compare_rebalanced_projects_with_cache( diff --git a/src/sentry/dynamic_sampling/rules/base.py b/src/sentry/dynamic_sampling/rules/base.py index bf76a98d4dfe47..078c8610259b52 100644 --- a/src/sentry/dynamic_sampling/rules/base.py +++ b/src/sentry/dynamic_sampling/rules/base.py @@ -9,6 +9,7 @@ from sentry.dynamic_sampling.rules.biases.base import Bias from sentry.dynamic_sampling.rules.combine import get_relay_biases from sentry.dynamic_sampling.rules.utils import PolymorphicRule, RuleType, get_enabled_user_biases +from sentry.dynamic_sampling.sample_rate_override import get_sample_rate_override_for_project from sentry.dynamic_sampling.tasks.helpers.boost_low_volume_projects import ( get_boost_low_volume_projects_sample_rate, ) @@ -58,6 +59,12 @@ def get_guarded_project_sample_rate(organization: Organization, project: Project return float(project.get_option("sentry:target_sample_rate", TARGET_SAMPLE_RATE_DEFAULT)) if has_custom_dynamic_sampling(organization): + # A per-project override (configured via options) hard-replaces the rate the custom + # dynamic sampling path would otherwise compute, winning over project/org targets and + # the boosted/rebalanced rate. + override = get_sample_rate_override_for_project(project.id) + if override is not None: + return override sample_rate = organization.get_option("sentry:target_sample_rate") else: sample_rate = quotas.backend.get_blended_sample_rate( diff --git a/src/sentry/dynamic_sampling/sample_rate_override.py b/src/sentry/dynamic_sampling/sample_rate_override.py new file mode 100644 index 00000000000000..3ac41b5abb7dd6 --- /dev/null +++ b/src/sentry/dynamic_sampling/sample_rate_override.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from sentry import options + + +def get_sample_rate_override_for_project(project_id: int) -> float | None: + """ + Return a per-project sample rate override for custom dynamic sampling, if one is + configured via the ``dynamic-sampling.sample-rate-override-per-project`` option. + + The option maps a stringified project id to a fixed sample rate that hard-replaces + whatever rate the custom dynamic sampling path would otherwise compute. Returns + ``None`` when no override applies (no entry, or an out-of-range/invalid value, which + we ignore rather than emit an invalid rule). + """ + overrides = options.get("dynamic-sampling.sample-rate-override-per-project") + raw = overrides.get(str(project_id)) + if raw is None: + return None + + try: + rate = float(raw) + except (TypeError, ValueError): + return None + + if 0.0 <= rate <= 1.0: + return rate + return None diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 0202306afe1a78..2a36ee87619ec9 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2148,6 +2148,16 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) +# Per-project sample rate overrides for custom dynamic sampling. Maps a stringified +# project id to a fixed sample rate (0.0-1.0) that hard-replaces the rate the custom +# dynamic sampling path would otherwise compute for that project. Example: +# {"12345": 0.5}. An empty mapping disables the override. +register( + "dynamic-sampling.sample-rate-override-per-project", + default={}, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) + # Controls the intensity of dynamic sampling transaction rebalancing. 0.0 = explict rebalancing # not performed, 1.0= full rebalancing (tries to bring everything to mean). Note that even at 0.0 # there will still be some rebalancing between the explicit and implicit transactions ( so setting rebalancing diff --git a/tests/sentry/dynamic_sampling/per_org/test_calculations.py b/tests/sentry/dynamic_sampling/per_org/test_calculations.py index 8db67c1dfb2116..c2043718371a29 100644 --- a/tests/sentry/dynamic_sampling/per_org/test_calculations.py +++ b/tests/sentry/dynamic_sampling/per_org/test_calculations.py @@ -8,6 +8,7 @@ from sentry.dynamic_sampling.models.common import RebalancedItem from sentry.dynamic_sampling.models.projects_rebalancing import ProjectsRebalancingInput from sentry.dynamic_sampling.per_org.calculations import ( + apply_project_sample_rate_overrides, compare_rebalanced_projects_with_cache, compare_rebalanced_transactions_with_cache, get_cached_rebalanced_project_sample_rates, @@ -25,6 +26,7 @@ generate_boost_low_volume_transactions_cache_key, ) from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.options import override_options def _project_volume(project_id: int, total: int = 100, keep: int = 25) -> ProjectVolume: @@ -71,6 +73,32 @@ def test_run_project_balancing_returns_rebalanced_projects(self) -> None: ] assert result == rebalanced_projects + def test_apply_project_sample_rate_overrides(self) -> None: + overridden_id = 1001 + normal_id = 1002 + rebalanced_projects = [ + RebalancedItem(id=overridden_id, count=100, new_sample_rate=0.25), + RebalancedItem(id=normal_id, count=100, new_sample_rate=0.25), + ] + + with override_options( + {"dynamic-sampling.sample-rate-override-per-project": {str(overridden_id): 0.9}} + ): + result = apply_project_sample_rate_overrides(rebalanced_projects) + + result_by_id = {item.id: item.new_sample_rate for item in result} + # Overridden project gets the option value; the other keeps its balanced rate. + assert result_by_id[overridden_id] == 0.9 + assert result_by_id[normal_id] == 0.25 + + def test_apply_project_sample_rate_overrides_noop_without_option(self) -> None: + rebalanced_projects = [ + RebalancedItem(id=2001, count=100, new_sample_rate=0.25), + ] + # No overrides configured -> the balanced rates are returned untouched. + result = apply_project_sample_rate_overrides(rebalanced_projects) + assert result == rebalanced_projects + def test_compare_rebalanced_projects_with_cache_logs_per_project(self) -> None: org = self.create_organization() project_with_volume = self.create_project(organization=org) From f7d2eb468c6311eeafe420df87a868b2bfe29389 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Tue, 16 Jun 2026 15:45:45 +0200 Subject: [PATCH 2/4] test(dynamic-sampling): Cover old-path sample rate override and AM3 org mode Add tests for the per-project override applied in get_guarded_project_sample_rate (override wins over the recently-added boost; out-of-range values ignored) and for the AM3 organization-mode sample rate being the configured target regardless of ingested volume. Refs TET-2490 Co-Authored-By: Claude Opus 4.8 --- .../tasks/test_boost_low_volume_projects.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/tests/sentry/dynamic_sampling/tasks/test_boost_low_volume_projects.py b/tests/sentry/dynamic_sampling/tasks/test_boost_low_volume_projects.py index cd383b31a2682f..20e4e7196c32af 100644 --- a/tests/sentry/dynamic_sampling/tasks/test_boost_low_volume_projects.py +++ b/tests/sentry/dynamic_sampling/tasks/test_boost_low_volume_projects.py @@ -27,6 +27,7 @@ from sentry.testutils.cases import BaseMetricsLayerTestCase, SnubaTestCase, TestCase from sentry.testutils.helpers.datetime import freeze_time from sentry.testutils.helpers.features import with_feature +from sentry.testutils.helpers.options import override_options MOCK_DATETIME = (timezone.now() - timedelta(days=1)).replace( hour=0, minute=0, second=0, microsecond=0 @@ -167,6 +168,108 @@ def test_simple_one_org_one_project_task_target_sample_rate(self) -> None: ) assert (sample_rate, got_value) == (0.5, True) + @with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"]) + def test_am3_org_mode_high_volume_uses_target_sample_rate(self) -> None: + # AM3-style org in organization sampling mode with custom dynamic sampling. + # Even with high traffic (~12-16M root transactions and ~40-50M spans per + # day before DS), the resulting org-wide sample rate is the configured + # target sample rate -- it is NOT derived from the ingested volume. + org1 = self.create_organization("am3-org-mode") + org1.update_option("sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION) + org1.update_option("sentry:target_sample_rate", 0.25) + p1 = self.create_project(organization=org1) + + # ~14M root transactions/day (midpoint of 12-16M), split keep/drop. Org + # mode measures root segments (~= transactions); the ~45M total spans are + # not what the org-mode rate is based on. + self.store_performance_metric( + name=SpanMRI.COUNT_PER_ROOT_PROJECT.value, + tags={"transaction": "foo_transaction", "decision": "keep", "is_segment": "true"}, + minutes_before_now=30, + value=4_000_000, + project_id=p1.id, + org_id=org1.id, + ) + self.store_performance_metric( + name=SpanMRI.COUNT_PER_ROOT_PROJECT.value, + tags={"transaction": "foo_transaction", "decision": "drop", "is_segment": "true"}, + minutes_before_now=30, + value=10_000_000, + project_id=p1.id, + org_id=org1.id, + ) + + with self.tasks(): + boost_low_volume_projects_of_org_with_query.delay(org1.id) + + sample_rate, got_value = get_boost_low_volume_projects_sample_rate( + org1.id, p1.id, error_sample_rate_fallback=None + ) + assert (sample_rate, got_value) == (0.25, True) + + @with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"]) + def test_am3_org_mode_high_volume_defaults_to_full_sample_rate(self) -> None: + # Same high-volume AM3 org-mode org, but with no target sample rate + # configured: it falls back to the default target of 1.0 (100%), + # regardless of how much traffic it sends. + org1 = self.create_organization("am3-org-mode-default") + org1.update_option("sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION) + p1 = self.create_project(organization=org1) + + self.store_performance_metric( + name=SpanMRI.COUNT_PER_ROOT_PROJECT.value, + tags={"transaction": "foo_transaction", "decision": "keep", "is_segment": "true"}, + minutes_before_now=30, + value=14_000_000, + project_id=p1.id, + org_id=org1.id, + ) + + with self.tasks(): + boost_low_volume_projects_of_org_with_query.delay(org1.id) + + sample_rate, got_value = get_boost_low_volume_projects_sample_rate( + org1.id, p1.id, error_sample_rate_fallback=None + ) + assert (sample_rate, got_value) == (1.0, True) + + @with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"]) + def test_per_project_sample_rate_override(self) -> None: + # A per-project override configured via options hard-replaces the rate the + # custom dynamic sampling path would otherwise resolve for that project -- + # winning even over the recently-added 100% boost -- and leaves other projects + # untouched. + org1 = self.create_organization("am3-override-org") + org1.update_option("sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION) + org1.update_option("sentry:target_sample_rate", 0.5) + overridden = self.create_project(organization=org1) + normal = self.create_project(organization=org1) + + # Baseline: freshly-created projects are boosted to 1.0 by the recently-added + # rule, so neither resolves to the org target yet. + assert get_guarded_project_sample_rate(org1, overridden) == 1.0 + + with override_options( + {"dynamic-sampling.sample-rate-override-per-project": {str(overridden.id): 0.9}} + ): + assert get_guarded_project_sample_rate(org1, overridden) == 0.9 + # Not in the override map -> unaffected by the override. + assert get_guarded_project_sample_rate(org1, normal) == 1.0 + + @with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"]) + def test_per_project_sample_rate_override_ignores_out_of_range(self) -> None: + org1 = self.create_organization("am3-override-org-bad") + org1.update_option("sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION) + org1.update_option("sentry:target_sample_rate", 0.5) + project = self.create_project(organization=org1) + + baseline = get_guarded_project_sample_rate(org1, project) + with override_options( + {"dynamic-sampling.sample-rate-override-per-project": {str(project.id): 2.0}} + ): + # Out-of-range override is ignored; the resolved rate is unchanged. + assert get_guarded_project_sample_rate(org1, project) == baseline + @with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"]) def test_project_mode_sampling_with_query(self) -> None: org1 = self.create_organization("test-org") From fd01794aa40d24cc23b37be8121ce505b1671294 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Tue, 16 Jun 2026 15:49:29 +0200 Subject: [PATCH 3/4] test(dynamic-sampling): Drop unrelated AM3 org-mode sample rate tests Remove the AM3 organization-mode high-volume tests that are unrelated to the per-project sample rate override; keep only the override tests. Refs TET-2490 Co-Authored-By: Claude Opus 4.8 --- .../tasks/test_boost_low_volume_projects.py | 65 ------------------- 1 file changed, 65 deletions(-) diff --git a/tests/sentry/dynamic_sampling/tasks/test_boost_low_volume_projects.py b/tests/sentry/dynamic_sampling/tasks/test_boost_low_volume_projects.py index 20e4e7196c32af..a66f03086adb35 100644 --- a/tests/sentry/dynamic_sampling/tasks/test_boost_low_volume_projects.py +++ b/tests/sentry/dynamic_sampling/tasks/test_boost_low_volume_projects.py @@ -168,71 +168,6 @@ def test_simple_one_org_one_project_task_target_sample_rate(self) -> None: ) assert (sample_rate, got_value) == (0.5, True) - @with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"]) - def test_am3_org_mode_high_volume_uses_target_sample_rate(self) -> None: - # AM3-style org in organization sampling mode with custom dynamic sampling. - # Even with high traffic (~12-16M root transactions and ~40-50M spans per - # day before DS), the resulting org-wide sample rate is the configured - # target sample rate -- it is NOT derived from the ingested volume. - org1 = self.create_organization("am3-org-mode") - org1.update_option("sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION) - org1.update_option("sentry:target_sample_rate", 0.25) - p1 = self.create_project(organization=org1) - - # ~14M root transactions/day (midpoint of 12-16M), split keep/drop. Org - # mode measures root segments (~= transactions); the ~45M total spans are - # not what the org-mode rate is based on. - self.store_performance_metric( - name=SpanMRI.COUNT_PER_ROOT_PROJECT.value, - tags={"transaction": "foo_transaction", "decision": "keep", "is_segment": "true"}, - minutes_before_now=30, - value=4_000_000, - project_id=p1.id, - org_id=org1.id, - ) - self.store_performance_metric( - name=SpanMRI.COUNT_PER_ROOT_PROJECT.value, - tags={"transaction": "foo_transaction", "decision": "drop", "is_segment": "true"}, - minutes_before_now=30, - value=10_000_000, - project_id=p1.id, - org_id=org1.id, - ) - - with self.tasks(): - boost_low_volume_projects_of_org_with_query.delay(org1.id) - - sample_rate, got_value = get_boost_low_volume_projects_sample_rate( - org1.id, p1.id, error_sample_rate_fallback=None - ) - assert (sample_rate, got_value) == (0.25, True) - - @with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"]) - def test_am3_org_mode_high_volume_defaults_to_full_sample_rate(self) -> None: - # Same high-volume AM3 org-mode org, but with no target sample rate - # configured: it falls back to the default target of 1.0 (100%), - # regardless of how much traffic it sends. - org1 = self.create_organization("am3-org-mode-default") - org1.update_option("sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION) - p1 = self.create_project(organization=org1) - - self.store_performance_metric( - name=SpanMRI.COUNT_PER_ROOT_PROJECT.value, - tags={"transaction": "foo_transaction", "decision": "keep", "is_segment": "true"}, - minutes_before_now=30, - value=14_000_000, - project_id=p1.id, - org_id=org1.id, - ) - - with self.tasks(): - boost_low_volume_projects_of_org_with_query.delay(org1.id) - - sample_rate, got_value = get_boost_low_volume_projects_sample_rate( - org1.id, p1.id, error_sample_rate_fallback=None - ) - assert (sample_rate, got_value) == (1.0, True) - @with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"]) def test_per_project_sample_rate_override(self) -> None: # A per-project override configured via options hard-replaces the rate the From 7db4245a71c82044ce857188a52b83f2a6aa6959 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Tue, 16 Jun 2026 16:51:53 +0200 Subject: [PATCH 4/4] perf(dynamic-sampling): Read sample rate overrides option once per batch apply_project_sample_rate_overrides called get_sample_rate_override_for_project per project, which read the option on every call. Add get_sample_rate_overrides that reads and validates the option once into a {project_id: rate} map; the batch path builds it once and intersects against it, and the single-project helper now delegates to it. Refs TET-2490 Co-Authored-By: Claude Opus 4.8 --- .../dynamic_sampling/per_org/calculations.py | 10 +++-- .../dynamic_sampling/sample_rate_override.py | 39 +++++++++++-------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/sentry/dynamic_sampling/per_org/calculations.py b/src/sentry/dynamic_sampling/per_org/calculations.py index 0e880aa1918e0e..7bb8e63b87fd44 100644 --- a/src/sentry/dynamic_sampling/per_org/calculations.py +++ b/src/sentry/dynamic_sampling/per_org/calculations.py @@ -24,7 +24,7 @@ from sentry.dynamic_sampling.per_org.gate import project_balancing_debug_project_ids from sentry.dynamic_sampling.per_org.queries import ProjectTransactionCounts, ProjectVolume from sentry.dynamic_sampling.rules.utils import get_redis_client_for_ds -from sentry.dynamic_sampling.sample_rate_override import get_sample_rate_override_for_project +from sentry.dynamic_sampling.sample_rate_override import get_sample_rate_overrides from sentry.dynamic_sampling.tasks.common import sample_rate_to_float from sentry.dynamic_sampling.tasks.helpers.boost_low_volume_projects import ( generate_boost_low_volume_projects_cache_key, @@ -76,9 +76,13 @@ def apply_project_sample_rate_overrides( so the override is surfaced in the pipeline. The result feeds the cached project sample rates and the downstream transaction balancing. """ + overrides = get_sample_rate_overrides() + if not overrides: + return rebalanced_projects + return [ - replace(item, new_sample_rate=override) - if (override := get_sample_rate_override_for_project(int(item.id))) is not None + replace(item, new_sample_rate=overrides[int(item.id)]) + if int(item.id) in overrides else item for item in rebalanced_projects ] diff --git a/src/sentry/dynamic_sampling/sample_rate_override.py b/src/sentry/dynamic_sampling/sample_rate_override.py index 3ac41b5abb7dd6..b1dfe6d0c6fc67 100644 --- a/src/sentry/dynamic_sampling/sample_rate_override.py +++ b/src/sentry/dynamic_sampling/sample_rate_override.py @@ -3,26 +3,31 @@ from sentry import options -def get_sample_rate_override_for_project(project_id: int) -> float | None: +def get_sample_rate_overrides() -> dict[int, float]: """ - Return a per-project sample rate override for custom dynamic sampling, if one is - configured via the ``dynamic-sampling.sample-rate-override-per-project`` option. + Return the validated per-project sample rate overrides for custom dynamic sampling, + as configured via the ``dynamic-sampling.sample-rate-override-per-project`` option. The option maps a stringified project id to a fixed sample rate that hard-replaces - whatever rate the custom dynamic sampling path would otherwise compute. Returns - ``None`` when no override applies (no entry, or an out-of-range/invalid value, which - we ignore rather than emit an invalid rule). + whatever rate the custom dynamic sampling path would otherwise compute. Entries with + an invalid id or an out-of-range/invalid rate are skipped (rather than emitting an + invalid rule). Reads the option once so callers iterating over many projects don't + re-read it per project. """ - overrides = options.get("dynamic-sampling.sample-rate-override-per-project") - raw = overrides.get(str(project_id)) - if raw is None: - return None + overrides: dict[int, float] = {} + for raw_id, raw_rate in options.get( + "dynamic-sampling.sample-rate-override-per-project" + ).items(): + try: + project_id = int(raw_id) + rate = float(raw_rate) + except (TypeError, ValueError): + continue + if 0.0 <= rate <= 1.0: + overrides[project_id] = rate + return overrides - try: - rate = float(raw) - except (TypeError, ValueError): - return None - if 0.0 <= rate <= 1.0: - return rate - return None +def get_sample_rate_override_for_project(project_id: int) -> float | None: + """Return the sample rate override for a single project, or ``None`` if none applies.""" + return get_sample_rate_overrides().get(project_id)