Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 26 additions & 62 deletions src/sentry/core/endpoints/organization_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"],
)
Comment thread
shellmayr marked this conversation as resolved.
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:
Expand All @@ -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)
Comment thread
shellmayr marked this conversation as resolved.

if is_org_mode and "defaultAutofixAutomationTuning" in changed_data:
organization.update_option(
Expand Down Expand Up @@ -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.
Expand Down
130 changes: 57 additions & 73 deletions tests/sentry/core/endpoints/test_organization_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading