From 079c6ffe00b1f04a165f39a960623bfe4d8ebd6d Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Thu, 10 Sep 2026 11:08:35 +0200 Subject: [PATCH] ref(dynamic-sampling): Close Manual Mode and rebalance orgs through the per-org pipeline The organization details endpoint was the last caller of the legacy project balancing: it seeded project rates from 30 days of generic metrics on a switch to Manual Mode, and ran the legacy per-org rebalancing task on a target rate change. Close Manual Mode instead. A request to enter it is rejected, so every AM3 organization that is not already in it samples at the organization level, which is the default. An organization already in Manual Mode keeps it until it switches back. A target rate change now schedules the per-org calculation for that organization, so the endpoint no longer reads generic metrics. Co-Authored-By: Claude Fable 5.1 --- .../core/endpoints/organization_details.py | 88 ++++-------- .../endpoints/test_organization_details.py | 130 ++++++++---------- 2 files changed, 83 insertions(+), 135 deletions(-) diff --git a/src/sentry/core/endpoints/organization_details.py b/src/sentry/core/endpoints/organization_details.py index ec45db4b76fc..d540986eac1c 100644 --- a/src/sentry/core/endpoints/organization_details.py +++ b/src/sentry/core/endpoints/organization_details.py @@ -73,16 +73,11 @@ SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, SEER_DEFAULT_CODING_AGENT_DEFAULT, TARGET_SAMPLE_RATE_DEFAULT, - ObjectStatus, ) from sentry.core.endpoints.project_details import MAX_SENSITIVE_FIELD_CHARS from sentry.deletions.models.scheduleddeletion import CellScheduledDeletion -from sentry.dynamic_sampling.tasks.boost_low_volume_projects import ( - boost_low_volume_projects_of_org_with_query, - calculate_sample_rates_of_projects, - query_project_counts_by_org, -) -from sentry.dynamic_sampling.types import DynamicSamplingMode, SamplingMeasure +from sentry.dynamic_sampling.per_org.scheduler import run_calculations_per_org_task_entry +from sentry.dynamic_sampling.types import DynamicSamplingMode from sentry.dynamic_sampling.utils import ( has_custom_dynamic_sampling, is_organization_mode_sampling, @@ -101,7 +96,6 @@ from sentry.models.options.project_option import ProjectOption from sentry.models.organization import Organization, OrganizationStatus from sentry.models.organizationmember import OrganizationMember -from sentry.models.project import Project from sentry.organizations.services.organization import organization_service from sentry.organizations.services.organization.model import ( RpcOrganization, @@ -534,7 +528,15 @@ def validate_samplingMode(self, value): "Organization does not have the custom dynamic sample rate feature enabled." ) - # as this is handled by a choice field, we don't need to check the values of the field + # Manual Mode is closed to new organizations. An organization already in it keeps it + # until it switches back, and may keep sending its current mode. + if value == DynamicSamplingMode.PROJECT.value and not is_project_mode_sampling( + organization + ): + raise serializers.ValidationError( + "Manual Mode is no longer available. Sample rates are configured for the " + "whole organization." + ) return value @@ -1214,26 +1216,21 @@ def put( if request.access.has_scope("org:write") and has_custom_dynamic_sampling(organization): is_org_mode = is_organization_mode_sampling(organization) - # If the sampling mode was changed, adapt the project and org options accordingly - if "samplingMode" in changed_data: + # Manual Mode cannot be entered any more, so a changed sampling mode is always + # a switch back to Automatic Mode: the project rates give way to the org rate. + if "samplingMode" in changed_data and is_org_mode: with transaction.atomic(router.db_for_write(ProjectOption)): - if is_project_mode_sampling(organization): - self._compute_project_target_sample_rates(request, organization) - organization.delete_option("sentry:target_sample_rate") - changed_data["samplingMode"] = "to Advanced Mode" - - elif is_org_mode: - if "targetSampleRate" in changed_data: - organization.update_option( - "sentry:target_sample_rate", - serializer.validated_data["targetSampleRate"], - ) - changed_data["samplingMode"] = "to Default Mode" - - ProjectOption.objects.filter( - project__organization_id=organization.id, - key="sentry:target_sample_rate", - ).delete() + if "targetSampleRate" in changed_data: + organization.update_option( + "sentry:target_sample_rate", + serializer.validated_data["targetSampleRate"], + ) + changed_data["samplingMode"] = "to Default Mode" + + ProjectOption.objects.filter( + project__organization_id=organization.id, + key="sentry:target_sample_rate", + ).delete() # If the target sample rate for the org was changed, update the org option if is_org_mode and "targetSampleRate" in changed_data: @@ -1246,9 +1243,7 @@ def put( if is_org_mode and ( "samplingMode" in changed_data or "targetSampleRate" in changed_data ): - boost_low_volume_projects_of_org_with_query.delay( - organization.id, - ) + run_calculations_per_org_task_entry.delay(organization.id) if is_org_mode and "defaultAutofixAutomationTuning" in changed_data: organization.update_option( @@ -1314,37 +1309,6 @@ def put( return self.respond(context) return self.respond(as_validation_errors(serializer), status=status.HTTP_400_BAD_REQUEST) - def _compute_project_target_sample_rates(self, request: Request, organization: Organization): - # TODO: this will take a long time for organizations with a lot of projects - # so we need to refactor this into an async task we can run and observe - org_id = organization.id - measure = SamplingMeasure.SEGMENTS - projects_with_tx_count_and_rates = [] - for chunk in query_project_counts_by_org( - [org_id], measure, query_interval=timedelta(days=30) - ): - for row in chunk: - projects_with_tx_count_and_rates.append(row[1:]) - - rebalanced_projects = calculate_sample_rates_of_projects( - org_id, projects_with_tx_count_and_rates - ) - - project_ids = set( - Project.objects.filter(organization_id=org_id, status=ObjectStatus.ACTIVE).values_list( - "id", flat=True - ) - ) - - if rebalanced_projects is not None: - for rebalanced_item in rebalanced_projects: - if int(rebalanced_item.id) in project_ids: - ProjectOption.objects.update_or_create( - project_id=rebalanced_item.id, - key="sentry:target_sample_rate", - defaults={"value": round(rebalanced_item.new_sample_rate, 4)}, - ) - def handle_delete(self, request: Request, organization: Organization): """ This method exists as a way for getsentry to override this endpoint with less duplication. diff --git a/tests/sentry/core/endpoints/test_organization_details.py b/tests/sentry/core/endpoints/test_organization_details.py index 19a0b6fb5613..273133dea1e5 100644 --- a/tests/sentry/core/endpoints/test_organization_details.py +++ b/tests/sentry/core/endpoints/test_organization_details.py @@ -43,8 +43,7 @@ from sentry.replays.models import OrganizationMemberReplayAccess from sentry.signals import project_created from sentry.silo.safety import unguarded_write -from sentry.snuba.metrics import SpanMRI -from sentry.testutils.cases import APITestCase, BaseMetricsLayerTestCase, TwoFactorAPITestCase +from sentry.testutils.cases import APITestCase, TwoFactorAPITestCase from sentry.testutils.helpers.features import with_feature from sentry.testutils.outbox import outbox_runner from sentry.testutils.pytest.fixtures import django_db_all @@ -95,7 +94,7 @@ def has_scope(self, scope): @cell_silo_test(cells=cells, include_monolith_run=True) -class OrganizationDetailsTest(OrganizationDetailsTestBase, BaseMetricsLayerTestCase): +class OrganizationDetailsTest(OrganizationDetailsTestBase): @property def now(self): return datetime.now().replace(microsecond=0) @@ -484,23 +483,41 @@ def test_sampling_mode_default_when_not_set(self) -> None: assert self.organization.get_option("sentry:target_sample_rate") == 0.5 @django_db_all - def test_sampling_mode_org_to_project(self) -> None: - """ - Test changing sampling mode from organization-level to project-level: - - Should preserve existing project rates - - Should remove org-level target sample rate - """ + def test_sampling_mode_org_to_project_is_rejected(self) -> None: self.organization.update_option( "sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION.value ) self.organization.update_option("sentry:target_sample_rate", 0.4) + project = self.create_project(organization=self.organization) - project1 = self.create_project(organization=self.organization) - project2 = self.create_project(organization=self.organization) + with self.feature("organizations:dynamic-sampling-custom"): + response = self.get_response( + self.organization.slug, + method="put", + samplingMode=DynamicSamplingMode.PROJECT.value, + ) - # Set some existing sampling rates - project1.update_option("sentry:target_sample_rate", 0.3) - project2.update_option("sentry:target_sample_rate", 0.5) + assert response.status_code == 400 + assert response.data == { + "samplingMode": [ + "Manual Mode is no longer available. Sample rates are configured for the " + "whole organization." + ] + } + assert ( + self.organization.get_option("sentry:sampling_mode") + == DynamicSamplingMode.ORGANIZATION.value + ) + assert self.organization.get_option("sentry:target_sample_rate") == 0.4 + assert not ProjectOption.objects.filter( + project_id=project.id, key="sentry:target_sample_rate" + ).exists() + + @django_db_all + def test_sampling_mode_project_stays_allowed_for_project_mode_org(self) -> None: + self.organization.update_option("sentry:sampling_mode", DynamicSamplingMode.PROJECT.value) + project = self.create_project(organization=self.organization) + project.update_option("sentry:target_sample_rate", 0.3) with self.feature("organizations:dynamic-sampling-custom"): response = self.get_response( @@ -510,13 +527,28 @@ def test_sampling_mode_org_to_project(self) -> None: ) assert response.status_code == 200 + assert ( + self.organization.get_option("sentry:sampling_mode") + == DynamicSamplingMode.PROJECT.value + ) + assert project.get_option("sentry:target_sample_rate") == 0.3 - # Verify project rates were preserved - assert project1.get_option("sentry:target_sample_rate") == 0.3 - assert project2.get_option("sentry:target_sample_rate") == 0.5 + @django_db_all + def test_change_org_target_sample_rate_schedules_per_org_calculation(self) -> None: + self.organization.update_option( + "sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION.value + ) - # Verify org target rate was removed - assert not self.organization.get_option("sentry:target_sample_rate") + with ( + self.feature("organizations:dynamic-sampling-custom"), + patch( + "sentry.core.endpoints.organization_details.run_calculations_per_org_task_entry" + ) as task, + ): + response = self.get_response(self.organization.slug, method="put", targetSampleRate=0.1) + + assert response.status_code == 200 + task.delay.assert_called_once_with(self.organization.id) @django_db_all def test_change_just_org_target_sample_rate(self) -> None: @@ -568,54 +600,6 @@ def test_sampling_mode_change_requires_write_scope(self) -> None: assert response.status_code == 403 - @django_db_all - @with_feature(["organizations:dynamic-sampling", "organizations:dynamic-sampling-custom"]) - def test_sampling_mode_change_with_deleted_projects_that_had_metrics(self) -> None: - project_1 = self.create_project(organization=self.organization) - project_2 = self.create_project(organization=self.organization) - - # Create a team member for project_1 only - team_1 = self.create_team(organization=self.organization) - project_1.add_team(team_1) - member_user = self.create_user() - self.create_member( - user=member_user, organization=self.organization, role="owner", teams=[team_1] - ) - self.login_as(user=member_user) - - self.store_performance_metric( - name=SpanMRI.COUNT_PER_ROOT_PROJECT.value, - tags={"is_segment": "true", "decision": "keep"}, - minutes_before_now=60 * 24 * 12, - value=1, - project_id=project_1.id, - org_id=self.organization.id, - ) - self.store_performance_metric( - name=SpanMRI.COUNT_PER_ROOT_PROJECT.value, - tags={"is_segment": "true", "decision": "keep"}, - minutes_before_now=60 * 24 * 12, - value=1, - project_id=project_2.id, - org_id=self.organization.id, - ) - - project_2.delete() - - with self.feature("organizations:dynamic-sampling-custom"): - self.get_response( - self.organization.slug, - method="put", - samplingMode=DynamicSamplingMode.PROJECT.value, - ) - - assert ProjectOption.objects.filter( - project_id=project_1.id, key="sentry:target_sample_rate" - ) - assert not ProjectOption.objects.filter( - project_id=project_2.id, key="sentry:target_sample_rate" - ) - def test_sensitive_fields_too_long(self) -> None: value = 1000 * ["0123456789"] + ["1"] resp = self.get_response(self.organization.slug, method="put", sensitiveFields=value) @@ -1337,23 +1321,23 @@ def test_target_sample_rate_range(self) -> None: def test_sampling_mode_feature(self) -> None: with self.feature("organizations:dynamic-sampling-custom"): - data = {"samplingMode": "project"} + data = {"samplingMode": "organization"} self.get_success_response(self.organization.slug, **data) with self.feature({"organizations:dynamic-sampling-custom": False}): - data = {"samplingMode": "project"} + data = {"samplingMode": "organization"} self.get_error_response(self.organization.slug, status_code=400, **data) @with_feature("organizations:dynamic-sampling-custom") def test_sampling_mode_values(self) -> None: - # project - data = {"samplingMode": "project"} - self.get_success_response(self.organization.slug, **data) - # organization data = {"samplingMode": "organization"} self.get_success_response(self.organization.slug, **data) + # project can no longer be entered + data = {"samplingMode": "project"} + self.get_error_response(self.organization.slug, status_code=400, **data) + # invalid data = {"samplingMode": "invalid"} self.get_error_response(self.organization.slug, status_code=400, **data)