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)