Skip to content

Commit 4bbbe44

Browse files
shellmayrclaude
authored andcommitted
feat(dynamic-sampling): Add per-project sample rate override for custom DS (#117796)
- Add `dynamic-sampling.sample-rate-override-per-project` option — a map of stringified project id → fixed sample rate — to pin specific projects to a known rate out-of-band - Closes the gap where the custom (AM3) DS path had no override hook (the existing getsentry override only wraps the blended/automatic rate, which the custom path never calls) - **Legacy path:** apply the override in `get_guarded_project_sample_rate`, winning over org/project targets and the recently-added/rebalanced rate - **New per-org path:** apply it as an explicit `apply_project_sample_rate_overrides` step in the scheduler after balancing, so it's surfaced in the pipeline and feeds the cached rates + transaction balancing - Out-of-range/invalid values are ignored (hard-replace semantics otherwise) - Tests cover both paths, no-op-without-option, and the AM3 organization-mode baseline behavior Contributes to TET-2490 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 353a1c5 commit 4bbbe44

7 files changed

Lines changed: 143 additions & 0 deletions

File tree

src/sentry/dynamic_sampling/per_org/calculations.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
from collections.abc import Iterable
5+
from dataclasses import replace
56
from typing import TYPE_CHECKING, cast
67

78
import orjson
@@ -23,6 +24,7 @@
2324
from sentry.dynamic_sampling.per_org.gate import project_balancing_debug_project_ids
2425
from sentry.dynamic_sampling.per_org.queries import ProjectTransactionCounts, ProjectVolume
2526
from sentry.dynamic_sampling.rules.utils import get_redis_client_for_ds
27+
from sentry.dynamic_sampling.sample_rate_override import get_sample_rate_overrides
2628
from sentry.dynamic_sampling.tasks.common import sample_rate_to_float
2729
from sentry.dynamic_sampling.tasks.helpers.boost_low_volume_projects import (
2830
generate_boost_low_volume_projects_cache_key,
@@ -63,6 +65,29 @@ def run_project_balancing(
6365
)
6466

6567

68+
def apply_project_sample_rate_overrides(
69+
rebalanced_projects: list[RebalancedItem],
70+
) -> list[RebalancedItem]:
71+
"""
72+
Hard-replace the balanced sample rate of any project that has a per-project override
73+
configured via the ``dynamic-sampling.sample-rate-override-per-project`` option.
74+
75+
Applied as an explicit step in the scheduler (rather than inside the balancing model)
76+
so the override is surfaced in the pipeline. The result feeds the cached project
77+
sample rates and the downstream transaction balancing.
78+
"""
79+
overrides = get_sample_rate_overrides()
80+
if not overrides:
81+
return rebalanced_projects
82+
83+
return [
84+
replace(item, new_sample_rate=overrides[int(item.id)])
85+
if int(item.id) in overrides
86+
else item
87+
for item in rebalanced_projects
88+
]
89+
90+
6691
def get_cached_rebalanced_project_sample_rates(org_id: int) -> dict[int, float | None]:
6792
redis_client = get_redis_client_for_ds()
6893
cache_key = generate_boost_low_volume_projects_cache_key(org_id=org_id)

src/sentry/dynamic_sampling/per_org/scheduler.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from taskbroker_client.retry import Retry
1010

1111
from sentry.dynamic_sampling.per_org.calculations import (
12+
apply_project_sample_rate_overrides,
1213
compare_rebalanced_projects_with_cache,
1314
compare_rebalanced_transactions_with_cache,
1415
get_cached_rebalanced_project_sample_rates,
@@ -126,6 +127,7 @@ def run_calculations_per_org_task(org_id: OrganizationId) -> DynamicSamplingStat
126127

127128
if config.should_balance_projects:
128129
rebalanced_projects = run_project_balancing(config, project_volumes)
130+
rebalanced_projects = apply_project_sample_rate_overrides(rebalanced_projects)
129131
config.set_rebalanced_project_sample_rates(rebalanced_projects)
130132
cached_sample_rates = get_cached_rebalanced_project_sample_rates(config.organization.id)
131133
compare_rebalanced_projects_with_cache(

src/sentry/dynamic_sampling/rules/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from sentry.dynamic_sampling.rules.biases.base import Bias
1010
from sentry.dynamic_sampling.rules.combine import get_relay_biases
1111
from sentry.dynamic_sampling.rules.utils import PolymorphicRule, RuleType, get_enabled_user_biases
12+
from sentry.dynamic_sampling.sample_rate_override import get_sample_rate_override_for_project
1213
from sentry.dynamic_sampling.tasks.helpers.boost_low_volume_projects import (
1314
get_boost_low_volume_projects_sample_rate,
1415
)
@@ -58,6 +59,12 @@ def get_guarded_project_sample_rate(organization: Organization, project: Project
5859
return float(project.get_option("sentry:target_sample_rate", TARGET_SAMPLE_RATE_DEFAULT))
5960

6061
if has_custom_dynamic_sampling(organization):
62+
# A per-project override (configured via options) hard-replaces the rate the custom
63+
# dynamic sampling path would otherwise compute, winning over project/org targets and
64+
# the boosted/rebalanced rate.
65+
override = get_sample_rate_override_for_project(project.id)
66+
if override is not None:
67+
return override
6168
sample_rate = organization.get_option("sentry:target_sample_rate")
6269
else:
6370
sample_rate = quotas.backend.get_blended_sample_rate(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
from sentry import options
4+
5+
6+
def get_sample_rate_overrides() -> dict[int, float]:
7+
"""
8+
Return the validated per-project sample rate overrides for custom dynamic sampling,
9+
as configured via the ``dynamic-sampling.sample-rate-override-per-project`` option.
10+
11+
The option maps a stringified project id to a fixed sample rate that hard-replaces
12+
whatever rate the custom dynamic sampling path would otherwise compute. Entries with
13+
an invalid id or an out-of-range/invalid rate are skipped (rather than emitting an
14+
invalid rule). Reads the option once so callers iterating over many projects don't
15+
re-read it per project.
16+
"""
17+
overrides: dict[int, float] = {}
18+
for raw_id, raw_rate in options.get(
19+
"dynamic-sampling.sample-rate-override-per-project"
20+
).items():
21+
try:
22+
project_id = int(raw_id)
23+
rate = float(raw_rate)
24+
except (TypeError, ValueError):
25+
continue
26+
if 0.0 <= rate <= 1.0:
27+
overrides[project_id] = rate
28+
return overrides
29+
30+
31+
def get_sample_rate_override_for_project(project_id: int) -> float | None:
32+
"""Return the sample rate override for a single project, or ``None`` if none applies."""
33+
return get_sample_rate_overrides().get(project_id)

src/sentry/options/defaults.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,16 @@
20662066
flags=FLAG_AUTOMATOR_MODIFIABLE,
20672067
)
20682068

2069+
# Per-project sample rate overrides for custom dynamic sampling. Maps a stringified
2070+
# project id to a fixed sample rate (0.0-1.0) that hard-replaces the rate the custom
2071+
# dynamic sampling path would otherwise compute for that project. Example:
2072+
# {"12345": 0.5}. An empty mapping disables the override.
2073+
register(
2074+
"dynamic-sampling.sample-rate-override-per-project",
2075+
default={},
2076+
flags=FLAG_AUTOMATOR_MODIFIABLE,
2077+
)
2078+
20692079
# Controls the intensity of dynamic sampling transaction rebalancing. 0.0 = explict rebalancing
20702080
# not performed, 1.0= full rebalancing (tries to bring everything to mean). Note that even at 0.0
20712081
# there will still be some rebalancing between the explicit and implicit transactions ( so setting rebalancing

tests/sentry/dynamic_sampling/per_org/test_calculations.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from sentry.dynamic_sampling.models.common import RebalancedItem
99
from sentry.dynamic_sampling.models.projects_rebalancing import ProjectsRebalancingInput
1010
from sentry.dynamic_sampling.per_org.calculations import (
11+
apply_project_sample_rate_overrides,
1112
compare_rebalanced_projects_with_cache,
1213
compare_rebalanced_transactions_with_cache,
1314
get_cached_rebalanced_project_sample_rates,
@@ -25,6 +26,7 @@
2526
generate_boost_low_volume_transactions_cache_key,
2627
)
2728
from sentry.testutils.cases import TestCase
29+
from sentry.testutils.helpers.options import override_options
2830

2931

3032
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:
7173
]
7274
assert result == rebalanced_projects
7375

76+
def test_apply_project_sample_rate_overrides(self) -> None:
77+
overridden_id = 1001
78+
normal_id = 1002
79+
rebalanced_projects = [
80+
RebalancedItem(id=overridden_id, count=100, new_sample_rate=0.25),
81+
RebalancedItem(id=normal_id, count=100, new_sample_rate=0.25),
82+
]
83+
84+
with override_options(
85+
{"dynamic-sampling.sample-rate-override-per-project": {str(overridden_id): 0.9}}
86+
):
87+
result = apply_project_sample_rate_overrides(rebalanced_projects)
88+
89+
result_by_id = {item.id: item.new_sample_rate for item in result}
90+
# Overridden project gets the option value; the other keeps its balanced rate.
91+
assert result_by_id[overridden_id] == 0.9
92+
assert result_by_id[normal_id] == 0.25
93+
94+
def test_apply_project_sample_rate_overrides_noop_without_option(self) -> None:
95+
rebalanced_projects = [
96+
RebalancedItem(id=2001, count=100, new_sample_rate=0.25),
97+
]
98+
# No overrides configured -> the balanced rates are returned untouched.
99+
result = apply_project_sample_rate_overrides(rebalanced_projects)
100+
assert result == rebalanced_projects
101+
74102
def test_compare_rebalanced_projects_with_cache_logs_per_project(self) -> None:
75103
org = self.create_organization()
76104
project_with_volume = self.create_project(organization=org)

tests/sentry/dynamic_sampling/tasks/test_boost_low_volume_projects.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from sentry.testutils.cases import BaseMetricsLayerTestCase, SnubaTestCase, TestCase
2828
from sentry.testutils.helpers.datetime import freeze_time
2929
from sentry.testutils.helpers.features import with_feature
30+
from sentry.testutils.helpers.options import override_options
3031

3132
MOCK_DATETIME = (timezone.now() - timedelta(days=1)).replace(
3233
hour=0, minute=0, second=0, microsecond=0
@@ -167,6 +168,43 @@ def test_simple_one_org_one_project_task_target_sample_rate(self) -> None:
167168
)
168169
assert (sample_rate, got_value) == (0.5, True)
169170

171+
@with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"])
172+
def test_per_project_sample_rate_override(self) -> None:
173+
# A per-project override configured via options hard-replaces the rate the
174+
# custom dynamic sampling path would otherwise resolve for that project --
175+
# winning even over the recently-added 100% boost -- and leaves other projects
176+
# untouched.
177+
org1 = self.create_organization("am3-override-org")
178+
org1.update_option("sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION)
179+
org1.update_option("sentry:target_sample_rate", 0.5)
180+
overridden = self.create_project(organization=org1)
181+
normal = self.create_project(organization=org1)
182+
183+
# Baseline: freshly-created projects are boosted to 1.0 by the recently-added
184+
# rule, so neither resolves to the org target yet.
185+
assert get_guarded_project_sample_rate(org1, overridden) == 1.0
186+
187+
with override_options(
188+
{"dynamic-sampling.sample-rate-override-per-project": {str(overridden.id): 0.9}}
189+
):
190+
assert get_guarded_project_sample_rate(org1, overridden) == 0.9
191+
# Not in the override map -> unaffected by the override.
192+
assert get_guarded_project_sample_rate(org1, normal) == 1.0
193+
194+
@with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"])
195+
def test_per_project_sample_rate_override_ignores_out_of_range(self) -> None:
196+
org1 = self.create_organization("am3-override-org-bad")
197+
org1.update_option("sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION)
198+
org1.update_option("sentry:target_sample_rate", 0.5)
199+
project = self.create_project(organization=org1)
200+
201+
baseline = get_guarded_project_sample_rate(org1, project)
202+
with override_options(
203+
{"dynamic-sampling.sample-rate-override-per-project": {str(project.id): 2.0}}
204+
):
205+
# Out-of-range override is ignored; the resolved rate is unchanged.
206+
assert get_guarded_project_sample_rate(org1, project) == baseline
207+
170208
@with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"])
171209
def test_project_mode_sampling_with_query(self) -> None:
172210
org1 = self.create_organization("test-org")

0 commit comments

Comments
 (0)