From 9d6ea974a80b8a4c5391dc31d0ec8520d15b911b Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Thu, 9 Jan 2025 16:47:22 -0500 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20Add=20an=20option=20to=20toggle?= =?UTF-8?q?=20the=20new=20UI=20without=20opt-in/out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/endpoints/organization_index.py | 121 +++++++++--------- .../api/serializers/models/organization.py | 2 + src/sentry/constants.py | 1 + src/sentry/issues/streamline.py | 14 ++ src/sentry/options/defaults.py | 8 ++ 5 files changed, 88 insertions(+), 58 deletions(-) create mode 100644 src/sentry/issues/streamline.py diff --git a/src/sentry/api/endpoints/organization_index.py b/src/sentry/api/endpoints/organization_index.py index 45dfb2131681c4..8d6d34abf45267 100644 --- a/src/sentry/api/endpoints/organization_index.py +++ b/src/sentry/api/endpoints/organization_index.py @@ -24,6 +24,7 @@ from sentry.auth.superuser import is_active_superuser from sentry.db.models.query import in_iexact from sentry.hybridcloud.rpc import IDEMPOTENCY_KEY_LENGTH +from sentry.issues.streamline import apply_streamline_rollout_group from sentry.models.organization import Organization, OrganizationStatus from sentry.models.organizationmember import OrganizationMember from sentry.models.projectplatform import ProjectPlatform @@ -238,70 +239,74 @@ def post(self, request: Request) -> Response: serializer = OrganizationPostSerializer(data=request.data) - if serializer.is_valid(): - result = serializer.validated_data - - try: - create_default_team = bool(result.get("defaultTeam")) - provision_args = OrganizationProvisioningOptions( - provision_options=OrganizationOptions( - name=result["name"], - slug=result.get("slug") or result["name"], - owning_user_id=request.user.id, - create_default_team=create_default_team, - ), - post_provision_options=PostProvisionOptions( - getsentry_options=None, sentry_options=None - ), - ) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + result = serializer.validated_data + + try: + create_default_team = bool(result.get("defaultTeam")) + provision_args = OrganizationProvisioningOptions( + provision_options=OrganizationOptions( + name=result["name"], + slug=result.get("slug") or result["name"], + owning_user_id=request.user.id, + create_default_team=create_default_team, + ), + post_provision_options=PostProvisionOptions( + getsentry_options=None, sentry_options=None + ), + ) - rpc_org = organization_provisioning_service.provision_organization_in_region( - region_name=settings.SENTRY_REGION or settings.SENTRY_MONOLITH_REGION, - provisioning_options=provision_args, - ) - org = Organization.objects.get(id=rpc_org.id) + rpc_org = organization_provisioning_service.provision_organization_in_region( + region_name=settings.SENTRY_REGION or settings.SENTRY_MONOLITH_REGION, + provisioning_options=provision_args, + ) + org = Organization.objects.get(id=rpc_org.id) - org_setup_complete.send_robust( - instance=org, user=request.user, sender=self.__class__, referrer="in-app" - ) + org_setup_complete.send_robust( + instance=org, user=request.user, sender=self.__class__, referrer="in-app" + ) - self.create_audit_entry( - request=request, - organization=org, - target_object=org.id, - event=audit_log.get_event_id("ORG_ADD"), - data=org.get_audit_log_data(), - ) + self.create_audit_entry( + request=request, + organization=org, + target_object=org.id, + event=audit_log.get_event_id("ORG_ADD"), + data=org.get_audit_log_data(), + ) - analytics.record( - "organization.created", - org, - actor_id=request.user.id if request.user.is_authenticated else None, - ) + analytics.record( + "organization.created", + org, + actor_id=request.user.id if request.user.is_authenticated else None, + ) - # TODO(hybrid-cloud): We'll need to catch a more generic error - # when the internal RPC is implemented. - except IntegrityError: - return Response( - {"detail": "An organization with this slug already exists."}, status=409 - ) + # TODO(hybrid-cloud): We'll need to catch a more generic error + # when the internal RPC is implemented. + except IntegrityError: + return Response( + {"detail": "An organization with this slug already exists."}, status=409 + ) - # failure on sending this signal is acceptable - if result.get("agreeTerms"): - terms_accepted.send_robust( - user=request.user, - organization_id=org.id, - ip_address=request.META["REMOTE_ADDR"], - sender=type(self), - ) + # failure on sending this signal is acceptable + if result.get("agreeTerms"): + terms_accepted.send_robust( + user=request.user, + organization_id=org.id, + ip_address=request.META["REMOTE_ADDR"], + sender=type(self), + ) - if result.get("aggregatedDataConsent"): - org.update_option("sentry:aggregated_data_consent", True) + if result.get("aggregatedDataConsent"): + org.update_option("sentry:aggregated_data_consent", True) - analytics.record( - "aggregated_data_consent.organization_created", - organization_id=org.id, - ) + analytics.record( + "aggregated_data_consent.organization_created", + organization_id=org.id, + ) - return Response(serialize(org, request.user), status=201) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + apply_streamline_rollout_group(organization=org) + # If unset, the organization's users can opt-in/out. + # If true, they only receive the Streamline UI. If false, they only receive the Legacy UI. + return Response(serialize(org, request.user), status=201) diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index 57fe4a7765e958..2be69f5909a450 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -52,6 +52,7 @@ SAMPLING_MODE_DEFAULT, SCRAPE_JAVASCRIPT_DEFAULT, SENSITIVE_FIELDS_DEFAULT, + STREAMLINE_UI_ONLY, TARGET_SAMPLE_RATE_DEFAULT, UPTIME_AUTODETECTION, ObjectStatus, @@ -635,6 +636,7 @@ def serialize( # type: ignore[explicit-override, override] "rollbackEnabled": bool( obj.get_option("sentry:rollback_enabled", ROLLBACK_ENABLED_DEFAULT) ), + "streamlineOnly": obj.get_option("sentry:streamline_ui_only", STREAMLINE_UI_ONLY), } ) diff --git a/src/sentry/constants.py b/src/sentry/constants.py index 66b2a2ad69331a..5564e17b694c9f 100644 --- a/src/sentry/constants.py +++ b/src/sentry/constants.py @@ -717,6 +717,7 @@ class InsightModules(Enum): TARGET_SAMPLE_RATE_DEFAULT = 1.0 SAMPLING_MODE_DEFAULT = "organization" ROLLBACK_ENABLED_DEFAULT = True +STREAMLINE_UI_ONLY = None # `sentry:events_member_admin` - controls whether the 'member' role gets the event:admin scope EVENTS_MEMBER_ADMIN_DEFAULT = True diff --git a/src/sentry/issues/streamline.py b/src/sentry/issues/streamline.py new file mode 100644 index 00000000000000..b82531e291b995 --- /dev/null +++ b/src/sentry/issues/streamline.py @@ -0,0 +1,14 @@ +from sentry.models.group import Organization +from sentry.utils.options import sample_modulo + + +def apply_streamline_rollout_group(organization: Organization): + # If unset, the organization's users can opt-in/out. + if organization.get_option("sentry:streamline_ui_only") is not None: + return + + result = sample_modulo("issues.details.streamline_rollout_rate", organization.id) + # If result is true, they only receive the Streamline UI. + # If result is false, they only receive the Legacy UI. + # This behaviour is controlled on the frontend. + organization.update_option("sentry:streamline_ui_only", result) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 2e6b533659cec2..c7faef4b590803 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -816,6 +816,14 @@ ) +register( + "issues.details.streamline_rollout_rate", + type=Float, + default=0.5, + flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, +) + + # Killswitch for issue priority register( "issues.priority.enabled", From 129fccc0683be519a33b8c6e92db741acc5b20d3 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Thu, 9 Jan 2025 17:22:48 -0500 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=85=20Add=20some=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/api/serializers/models/organization.py | 1 + .../api/endpoints/test_organization_index.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index 2be69f5909a450..21d829b0609b46 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -495,6 +495,7 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp metricsActivateLastForGauges: bool requiresSso: bool rollbackEnabled: bool + streamlineOnly: bool class DetailedOrganizationSerializer(OrganizationSerializer): diff --git a/tests/sentry/api/endpoints/test_organization_index.py b/tests/sentry/api/endpoints/test_organization_index.py index 4a5ad9439b2444..727c8e2e10eb65 100644 --- a/tests/sentry/api/endpoints/test_organization_index.py +++ b/tests/sentry/api/endpoints/test_organization_index.py @@ -18,6 +18,7 @@ from sentry.silo.base import SiloMode from sentry.slug.patterns import ORG_SLUG_PATTERN from sentry.testutils.cases import APITestCase, TwoFactorAPITestCase +from sentry.testutils.helpers import override_options from sentry.testutils.hybrid_cloud import HybridCloudTestMixin from sentry.testutils.silo import assume_test_silo_mode, create_test_regions, region_silo_test from sentry.users.models.authenticator import Authenticator @@ -316,6 +317,20 @@ def test_data_consent(self): assert org.name == data["name"] assert OrganizationOption.objects.get_value(org, "sentry:aggregated_data_consent") is True + @override_options({"issues.details.streamline_rollout_rate": 1.0}) + def test_streamline_only_is_true(self): + self.login_as(user=self.user) + response = self.get_success_response(name="acme") + organization = Organization.objects.get(id=response.data["id"]) + assert OrganizationOption.objects.get_value(organization, "sentry:streamline_ui_only") + + @override_options({"issues.details.streamline_rollout_rate": 0}) + def test_streamline_only_is_false(self): + self.login_as(user=self.user) + response = self.get_success_response(name="acme") + organization = Organization.objects.get(id=response.data["id"]) + assert not OrganizationOption.objects.get_value(organization, "sentry:streamline_ui_only") + @region_silo_test(regions=create_test_regions("de", "us")) class OrganizationsCreateInRegionTest(OrganizationIndexTest, HybridCloudTestMixin): From e9b57ad28c710e2c03fc00882634320a3ca67604 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 10 Jan 2025 12:05:17 -0500 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20Correct=20type=20fo?= =?UTF-8?q?r=20serializer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/api/serializers/models/organization.py | 3 ++- tests/sentry/api/endpoints/test_organization_details.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index 21d829b0609b46..db68459b4eb4b4 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -495,7 +495,7 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp metricsActivateLastForGauges: bool requiresSso: bool rollbackEnabled: bool - streamlineOnly: bool + streamlineOnly: bool | None class DetailedOrganizationSerializer(OrganizationSerializer): @@ -720,6 +720,7 @@ def serialize( # type: ignore[explicit-override, override] "metricsActivateLastForGauges", "quota", "rollbackEnabled", + "streamlineOnly", ] ) class DetailedOrganizationSerializerWithProjectsAndTeamsResponse( diff --git a/tests/sentry/api/endpoints/test_organization_details.py b/tests/sentry/api/endpoints/test_organization_details.py index f02afa413d9341..fc41dc341002ef 100644 --- a/tests/sentry/api/endpoints/test_organization_details.py +++ b/tests/sentry/api/endpoints/test_organization_details.py @@ -733,6 +733,7 @@ def test_various_options(self, mock_get_repositories): "targetSampleRate": 0.1, "samplingMode": "organization", "rollbackEnabled": True, + "streamlineOnly": None, } # needed to set require2FA From 8b78222606d261f34ebb8593f68272341bf750e8 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Mon, 13 Jan 2025 11:07:21 -0500 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20Typing=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/issues/streamline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/issues/streamline.py b/src/sentry/issues/streamline.py index b82531e291b995..2c136f45a7f896 100644 --- a/src/sentry/issues/streamline.py +++ b/src/sentry/issues/streamline.py @@ -1,4 +1,4 @@ -from sentry.models.group import Organization +from sentry.models.organization import Organization from sentry.utils.options import sample_modulo From 85a2d7f956653ec6b710417caade21a6e34a1ea1 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Tue, 14 Jan 2025 14:19:19 -0500 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=85=20Refactor=20to=20apply=20a=20rol?= =?UTF-8?q?lout=20rate=20and=20split=20rate=20seperately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/endpoints/organization_index.py | 3 +- src/sentry/issues/streamline.py | 18 ++++++--- src/sentry/options/defaults.py | 11 ++++- .../api/endpoints/test_organization_index.py | 40 ++++++++++++++++++- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/sentry/api/endpoints/organization_index.py b/src/sentry/api/endpoints/organization_index.py index 8d6d34abf45267..3b38a2b5267a24 100644 --- a/src/sentry/api/endpoints/organization_index.py +++ b/src/sentry/api/endpoints/organization_index.py @@ -307,6 +307,5 @@ def post(self, request: Request) -> Response: ) apply_streamline_rollout_group(organization=org) - # If unset, the organization's users can opt-in/out. - # If true, they only receive the Streamline UI. If false, they only receive the Legacy UI. + return Response(serialize(org, request.user), status=201) diff --git a/src/sentry/issues/streamline.py b/src/sentry/issues/streamline.py index 2c136f45a7f896..a3530bec4cfc64 100644 --- a/src/sentry/issues/streamline.py +++ b/src/sentry/issues/streamline.py @@ -7,8 +7,16 @@ def apply_streamline_rollout_group(organization: Organization): if organization.get_option("sentry:streamline_ui_only") is not None: return - result = sample_modulo("issues.details.streamline_rollout_rate", organization.id) - # If result is true, they only receive the Streamline UI. - # If result is false, they only receive the Legacy UI. - # This behaviour is controlled on the frontend. - organization.update_option("sentry:streamline_ui_only", result) + # If the experiment is not enabled, the organization's users can opt-in/out. + rollout_result = sample_modulo( + "issues.details.streamline-experiment-rollout-rate", organization.id + ) + + if rollout_result: + split_result = sample_modulo( + "issues.details.streamline-experiment-split-rate", organization.id + ) + # If split_result is true, they only receive the Streamline UI. + # If split_result is false, they only receive the Legacy UI. + # This behaviour is controlled on the frontend. + organization.update_option("sentry:streamline_ui_only", split_result) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index c7faef4b590803..30b9c29fcf4e8a 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -816,8 +816,17 @@ ) +# Percentage of orgs that will be put into a bucket using the split rate below. register( - "issues.details.streamline_rollout_rate", + "issues.details.streamline-experiment-rollout-rate", + type=Float, + default=0.0, + flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, +) + +# 50% of orgs will only see the Streamline UI, 50% will only see the Legacy UI. +register( + "issues.details.streamline-experiment-split-rate", type=Float, default=0.5, flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, diff --git a/tests/sentry/api/endpoints/test_organization_index.py b/tests/sentry/api/endpoints/test_organization_index.py index 727c8e2e10eb65..79efc06f5a058c 100644 --- a/tests/sentry/api/endpoints/test_organization_index.py +++ b/tests/sentry/api/endpoints/test_organization_index.py @@ -317,15 +317,51 @@ def test_data_consent(self): assert org.name == data["name"] assert OrganizationOption.objects.get_value(org, "sentry:aggregated_data_consent") is True - @override_options({"issues.details.streamline_rollout_rate": 1.0}) + @override_options({"issues.details.streamline-experiment-rollout-rate": 0}) + @override_options({"issues.details.streamline-experiment-split-rate": 1.0}) + def test_streamline_only_is_unset_with_full_split_rate(self): + """ + If the rollout rate is 0%, Ignore split rate, the organization should not be put into a bucket. + """ + self.login_as(user=self.user) + response = self.get_success_response(name="acme") + organization = Organization.objects.get(id=response.data["id"]) + assert ( + OrganizationOption.objects.get_value(organization, "sentry:streamline_ui_only") is None + ) + + @override_options({"issues.details.streamline-experiment-rollout-rate": 0}) + @override_options({"issues.details.streamline-experiment-split-rate": 0}) + def test_streamline_only_is_unset_with_empty_split_rate(self): + """ + If the rollout rate is 0%, Ignore split rate, the organization should not be put into a bucket. + """ + self.login_as(user=self.user) + response = self.get_success_response(name="acme") + organization = Organization.objects.get(id=response.data["id"]) + assert ( + OrganizationOption.objects.get_value(organization, "sentry:streamline_ui_only") is None + ) + + @override_options({"issues.details.streamline-experiment-rollout-rate": 1.0}) + @override_options({"issues.details.streamline-experiment-split-rate": 1.0}) def test_streamline_only_is_true(self): + """ + If the rollout rate is 100%, the split rate should be applied to all orgs. + In this case, with a split rate of 100%, all orgs should see the Streamline UI. + """ self.login_as(user=self.user) response = self.get_success_response(name="acme") organization = Organization.objects.get(id=response.data["id"]) assert OrganizationOption.objects.get_value(organization, "sentry:streamline_ui_only") - @override_options({"issues.details.streamline_rollout_rate": 0}) + @override_options({"issues.details.streamline-experiment-rollout-rate": 1.0}) + @override_options({"issues.details.streamline-experiment-split-rate": 0}) def test_streamline_only_is_false(self): + """ + If the rollout rate is 100%, the split rate should be applied to all orgs. + In this case, with a split rate of 0%, all orgs should see the Legacy UI. + """ self.login_as(user=self.user) response = self.get_success_response(name="acme") organization = Organization.objects.get(id=response.data["id"]) From a2afc1e40f14bcc27387f0bb2f34c2606b56c6b2 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Tue, 14 Jan 2025 14:22:00 -0500 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Update=20cursor=20comm?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/issues/streamline.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sentry/issues/streamline.py b/src/sentry/issues/streamline.py index a3530bec4cfc64..bd94ba4c260620 100644 --- a/src/sentry/issues/streamline.py +++ b/src/sentry/issues/streamline.py @@ -7,16 +7,16 @@ def apply_streamline_rollout_group(organization: Organization): if organization.get_option("sentry:streamline_ui_only") is not None: return - # If the experiment is not enabled, the organization's users can opt-in/out. + # If the rollout_result is false, the organization is not in the experiment. rollout_result = sample_modulo( "issues.details.streamline-experiment-rollout-rate", organization.id ) if rollout_result: - split_result = sample_modulo( - "issues.details.streamline-experiment-split-rate", organization.id - ) # If split_result is true, they only receive the Streamline UI. # If split_result is false, they only receive the Legacy UI. # This behaviour is controlled on the frontend. + split_result = sample_modulo( + "issues.details.streamline-experiment-split-rate", organization.id + ) organization.update_option("sentry:streamline_ui_only", split_result) From 98ae051c07253aa20d1d1ee0790ef988c74182a3 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Tue, 14 Jan 2025 15:28:05 -0500 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=94=A5=20rm=20unnecessary=20check=20f?= =?UTF-8?q?or=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/issues/streamline.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/sentry/issues/streamline.py b/src/sentry/issues/streamline.py index bd94ba4c260620..fab9870171bde4 100644 --- a/src/sentry/issues/streamline.py +++ b/src/sentry/issues/streamline.py @@ -3,10 +3,6 @@ def apply_streamline_rollout_group(organization: Organization): - # If unset, the organization's users can opt-in/out. - if organization.get_option("sentry:streamline_ui_only") is not None: - return - # If the rollout_result is false, the organization is not in the experiment. rollout_result = sample_modulo( "issues.details.streamline-experiment-rollout-rate", organization.id