Browse Source

feat(vitals): creates api for organization vitals (#36233)

We are working on a new feature to show web and mobile vitals in a notification at the top of the page for users to try to get them to actually use the feature more. This PR adds the new API to power the UI.
Stephen Cefali 2 years ago
parent
commit
d517e1cf80

+ 2 - 1
.github/CODEOWNERS

@@ -379,6 +379,7 @@ yarn.lock           @getsentry/owners-js-deps
 
 ### Growth ###
 
-/src/sentry/api/endpoints/client_state.py       @getsentry/growth
+/src/sentry/api/endpoints/client_state.py                           @getsentry/growth
+/src/sentry/api/endpoints/organization_web_vitals_overview.py       @getsentry/growth
 
 ### End of Growth ###

+ 67 - 0
src/sentry/api/endpoints/organization_vitals_overview.py

@@ -0,0 +1,67 @@
+from datetime import timedelta
+
+from django.http import Http404
+from django.utils import timezone
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from sentry import experiments
+from sentry.api.bases import OrganizationEventsEndpointBase
+from sentry.models import Organization, Project, ProjectStatus
+from sentry.snuba import discover
+
+# Snuba names to the API layer that matches the TS definition
+NAME_MAPPING = {
+    "p75_measurements_fcp": "FCP",
+    "p75_measurements_lcp": "LCP",
+    "measurements.app_start_warm": "appStartWarm",
+    "measurements.app_start_cold": "appStartCold",
+}
+
+
+class OrganizationVitalsOverviewEndpoint(OrganizationEventsEndpointBase):
+    private = True
+
+    def get(self, request: Request, organization: Organization) -> Response:
+        # only can access endpint with experiment
+        if not experiments.get("VitalsAlertExperiment", organization, request.user):
+            raise Http404
+
+        # TODO: add caching
+        # try to get all the projects for the org even though it's possible they don't have access
+        project_ids = Project.objects.filter(
+            organization=organization, status=ProjectStatus.VISIBLE
+        ).values_list("id", flat=True)[
+            0:1000
+        ]  # only get 1000 because let's be reasonable
+
+        # TODO: add logic to make sure we only show for orgs with 100+ relevant transactions
+        # for each category
+
+        # Web vitals: p75 for LCP and FCP
+        # Mobile vitals: Cold Start and Warm Start
+        with self.handle_query_errors():
+            result = discover.query(
+                query="transaction.duration:<15m transaction.op:pageload event.type:transaction",
+                selected_columns=[
+                    "p75(measurements.lcp)",
+                    "p75(measurements.fcp)",
+                    "measurements.app_start_cold",
+                    "measurements.app_start_warm",
+                ],
+                limit=1,
+                params={
+                    "start": timezone.now() - timedelta(days=7),
+                    "end": timezone.now(),
+                    "organization_id": organization.id,
+                    "project_id": list(project_ids),
+                },
+                referrer="api.organization-vitals",
+            )
+            # only a single result
+            data = result["data"][0]
+            # map the names
+            output = {}
+            for key, val in data.items():
+                output[NAME_MAPPING[key]] = val
+            return self.respond(output)

+ 6 - 0
src/sentry/api/urls.py

@@ -350,6 +350,7 @@ from .endpoints.organization_user_issues_search import OrganizationUserIssuesSea
 from .endpoints.organization_user_reports import OrganizationUserReportsEndpoint
 from .endpoints.organization_user_teams import OrganizationUserTeamsEndpoint
 from .endpoints.organization_users import OrganizationUsersEndpoint
+from .endpoints.organization_vitals_overview import OrganizationVitalsOverviewEndpoint
 from .endpoints.project_agnostic_rule_conditions import ProjectAgnosticRuleConditionsEndpoint
 from .endpoints.project_app_store_connect_credentials import (
     AppStoreConnectAppsEndpoint,
@@ -1639,6 +1640,11 @@ urlpatterns = [
                     ClientStateEndpoint.as_view(),
                     name="sentry-api-0-organization-client-state",
                 ),
+                url(
+                    r"^(?P<organization_slug>[^/]+)/vitals-overview/$",
+                    OrganizationVitalsOverviewEndpoint.as_view(),
+                    name="sentry-api-0-organization-vitals-overview",
+                ),
             ]
         ),
     ),

+ 1 - 0
src/sentry/utils/prompts.py

@@ -12,6 +12,7 @@ DEFAULT_PROMPTS = {
     "distributed_tracing": {"required_fields": ["organization_id", "project_id"]},
     "quick_trace_missing": {"required_fields": ["organization_id", "project_id"]},
     "code_owners": {"required_fields": ["organization_id", "project_id"]},
+    "vitals_alert": {"required_fields": ["organization_id"]},
 }
 
 

+ 6 - 0
static/app/data/experimentConfig.tsx

@@ -31,6 +31,12 @@ export const experimentList = [
     parameter: 'exposed',
     assignments: [0, 1],
   },
+  {
+    key: 'VitalsAlertExperiment',
+    type: ExperimentType.Organization,
+    parameter: 'exposed',
+    assignments: [0, 1],
+  },
 ] as const;
 
 export const experimentConfig = experimentList.reduce(

+ 60 - 0
tests/sentry/api/endpoints/test_organization_vitals_overview.py

@@ -0,0 +1,60 @@
+from unittest import mock
+
+from sentry.testutils import APITestCase
+
+
+class OrganizationVitalsOverviewTest(APITestCase):
+    endpoint = "sentry-api-0-organization-vitals-overview"
+
+    def setUp(self):
+        super().setUp()
+        self.organization = self.create_organization(owner=self.user)
+        self.project = self.create_project(organization=self.organization)
+        self.login_as(user=self.user)
+
+    @mock.patch("sentry.api.endpoints.organization_vitals_overview.experiments.get", return_value=1)
+    @mock.patch("sentry.api.endpoints.organization_vitals_overview.discover.query")
+    def test_simple(self, mock_query, mock_experiment_get):
+        mock_query.return_value = {
+            "data": [
+                {
+                    "p75_measurements_fcp": 1000,
+                    "p75_measurements_lcp": 2000,
+                    "measurements.app_start_warm": 3000,
+                    "measurements.app_start_cold": 5000,
+                }
+            ]
+        }
+        response = self.get_response(self.organization.slug)
+        assert response.status_code == 200
+        assert response.data == {
+            "FCP": 1000,
+            "LCP": 2000,
+            "appStartWarm": 3000,
+            "appStartCold": 5000,
+        }
+        assert mock_query.call_count == 1
+        assert mock_query.call_args.kwargs["params"]["project_id"] == [self.project.id]
+        mock_experiment_get.assert_called_once_with(
+            "VitalsAlertExperiment", self.organization, self.user
+        )
+
+    @mock.patch("sentry.api.endpoints.organization_vitals_overview.experiments.get", return_value=0)
+    @mock.patch("sentry.api.endpoints.organization_vitals_overview.discover.query")
+    def test_no_experiment(self, mock_query, mock_experiment_get):
+        mock_query.return_value = {
+            "data": [
+                {
+                    "p75_measurements_fcp": 1000,
+                    "p75_measurements_lcp": 2000,
+                    "measurements.app_start_warm": 3000,
+                    "measurements.app_start_cold": 5000,
+                }
+            ]
+        }
+        response = self.get_response(self.organization.slug)
+        assert response.status_code == 404
+        assert mock_query.call_count == 0
+        mock_experiment_get.assert_called_once_with(
+            "VitalsAlertExperiment", self.organization, self.user
+        )