Browse Source

feat(mep): Metrics compatibility endpoint (#37443)

- This adds the Metrics compat endpoint
    - Checks which projects have DS rules
    - For each of those projects calls it compatible if it
        - has no unparameterized transactions
        - has at least one transaction that isn't null or unparameterized
    - Calculates for the projects with DS rules, the
        - total transactions in metrics
        - total null transactions in metrics
        - total unparameterized transactions in metrics
William Mak 2 years ago
parent
commit
6786f018cf

+ 1 - 0
src/sentry/api/bases/organization.py

@@ -355,6 +355,7 @@ class OrganizationEndpoint(Endpoint):
             "start": start,
             "start": start,
             "end": end,
             "end": end,
             "project_id": [p.id for p in projects],
             "project_id": [p.id for p in projects],
+            "project_objects": projects,
             "organization_id": organization.id,
             "organization_id": organization.id,
         }
         }
 
 

+ 94 - 1
src/sentry/api/endpoints/organization_events_meta.py

@@ -12,7 +12,8 @@ from sentry.api.event_search import parse_search_query
 from sentry.api.helpers.group_index import build_query_params_from_request
 from sentry.api.helpers.group_index import build_query_params_from_request
 from sentry.api.serializers import serialize
 from sentry.api.serializers import serialize
 from sentry.api.serializers.models.group import GroupSerializer
 from sentry.api.serializers.models.group import GroupSerializer
-from sentry.snuba import discover
+from sentry.search.events.fields import get_function_alias
+from sentry.snuba import discover, metrics_performance
 
 
 
 
 class OrganizationEventsMetaEndpoint(OrganizationEventsEndpointBase):
 class OrganizationEventsMetaEndpoint(OrganizationEventsEndpointBase):
@@ -33,6 +34,98 @@ class OrganizationEventsMetaEndpoint(OrganizationEventsEndpointBase):
         return Response({"count": result["data"][0]["count"]})
         return Response({"count": result["data"][0]["count"]})
 
 
 
 
+class OrganizationEventsMetricsCompatiblity(OrganizationEventsEndpointBase):
+    """Metrics data can contain less than great data like null or unparameterized transactions
+
+    This endpoint will return projects that have perfect data along with the overall counts of projects so the
+    frontend can make decisions about which projects to show and related info
+    """
+
+    private = True
+
+    def get(self, request: Request, organization) -> Response:
+        data = {
+            "compatible_projects": [],
+            "dynamic_sampling_projects": [],
+            "sum": {
+                "metrics": None,
+                "metrics_null": None,
+                "metrics_unparam": None,
+            },
+        }
+        try:
+            # This will be used on the perf homepage and contains preset queries, allow global views
+            params = self.get_snuba_params(request, organization, check_global_views=False)
+        except NoProjects:
+            return Response(data)
+        data["compatible_projects"] = params["project_id"]
+        for project in params["project_objects"]:
+            dynamic_sampling = project.get_option("sentry:dynamic_sampling")
+            if dynamic_sampling is not None:
+                data["dynamic_sampling_projects"].append(project.id)
+                if len(data["dynamic_sampling_projects"]) > 50:
+                    break
+
+        # None of the projects had DS rules, nothing is compat the sum & compat projects list is useless
+        if len(data["dynamic_sampling_projects"]) == 0:
+            return Response(data)
+        data["dynamic_sampling_projects"].sort()
+
+        # Save ourselves some work, only query the projects that have DS rules
+        params["project_id"] = data["dynamic_sampling_projects"]
+
+        with self.handle_query_errors():
+            count_unparam = "count_unparameterized_transactions()"
+            count_has_txn = "count_has_transaction_name()"
+            count_null = "count_null_transactions()"
+            compatible_results = metrics_performance.query(
+                selected_columns=[
+                    "project.id",
+                    count_unparam,
+                    count_has_txn,
+                ],
+                params=params,
+                query=f"{count_unparam}:0 AND {count_has_txn}:>0",
+                referrer="api.organization-events-metrics-compatibility.compatible",
+                functions_acl=["count_unparameterized_transactions", "count_has_transaction_name"],
+                use_aggregate_conditions=True,
+            )
+            data["compatible_projects"] = sorted(
+                row["project.id"] for row in compatible_results["data"]
+            )
+
+            sum_metrics = metrics_performance.query(
+                selected_columns=["count()"],
+                params=params,
+                referrer="api.organization-events-metrics-compatibility.sum_metrics",
+                query="",
+            )
+            data["sum"]["metrics"] = (
+                sum_metrics["data"][0].get("count") if len(sum_metrics["data"]) > 0 else 0
+            )
+
+            sum_unparameterized = metrics_performance.query(
+                selected_columns=[count_unparam, count_null],
+                params=params,
+                referrer="api.organization-events-metrics-compatibility.sum_unparameterized",
+                query='transaction:"<< unparameterized >>" OR !has:transaction',
+                functions_acl=["count_unparameterized_transactions", "count_null_transactions"],
+                use_aggregate_conditions=True,
+            )
+            data["sum"]["metrics_null"] = (
+                sum_unparameterized["data"][0].get(get_function_alias(count_null))
+                if len(sum_unparameterized["data"]) > 0
+                else 0
+            )
+            data["sum"]["metrics_unparam"] = (
+                sum_unparameterized["data"][0].get(get_function_alias(count_unparam))
+                if len(sum_unparameterized["data"]) > 0
+                else 0
+            )
+
+        return Response(data)
+
+
 UNESCAPED_QUOTE_RE = re.compile('(?<!\\\\)"')
 UNESCAPED_QUOTE_RE = re.compile('(?<!\\\\)"')
 
 
 
 

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

@@ -255,6 +255,7 @@ from .endpoints.organization_events_has_measurements import (
 from .endpoints.organization_events_histogram import OrganizationEventsHistogramEndpoint
 from .endpoints.organization_events_histogram import OrganizationEventsHistogramEndpoint
 from .endpoints.organization_events_meta import (
 from .endpoints.organization_events_meta import (
     OrganizationEventsMetaEndpoint,
     OrganizationEventsMetaEndpoint,
+    OrganizationEventsMetricsCompatiblity,
     OrganizationEventsRelatedIssuesEndpoint,
     OrganizationEventsRelatedIssuesEndpoint,
 )
 )
 from .endpoints.organization_events_span_ops import OrganizationEventsSpanOpsEndpoint
 from .endpoints.organization_events_span_ops import OrganizationEventsSpanOpsEndpoint
@@ -1172,6 +1173,11 @@ urlpatterns = [
                     OrganizationEventsMetaEndpoint.as_view(),
                     OrganizationEventsMetaEndpoint.as_view(),
                     name="sentry-api-0-organization-events-meta",
                     name="sentry-api-0-organization-events-meta",
                 ),
                 ),
+                url(
+                    r"^(?P<organization_slug>[^\/]+)/events-metrics-compatibility/$",
+                    OrganizationEventsMetricsCompatiblity.as_view(),
+                    name="sentry-api-0-organization-events-metrics-compatibility",
+                ),
                 url(
                 url(
                     r"^(?P<organization_slug>[^\/]+)/events-histogram/$",
                     r"^(?P<organization_slug>[^\/]+)/events-histogram/$",
                     OrganizationEventsHistogramEndpoint.as_view(),
                     OrganizationEventsHistogramEndpoint.as_view(),

+ 98 - 0
src/sentry/search/events/datasets/metrics.py

@@ -174,6 +174,104 @@ class MetricsDatasetConfig(DatasetConfig):
                     snql_set=self._resolve_count_miserable_function,
                     snql_set=self._resolve_count_miserable_function,
                     default_result_type="integer",
                     default_result_type="integer",
                 ),
                 ),
+                fields.MetricsFunction(
+                    "count_unparameterized_transactions",
+                    snql_distribution=lambda args, alias: Function(
+                        "countIf",
+                        [
+                            Function(
+                                "and",
+                                [
+                                    Function(
+                                        "equals",
+                                        [
+                                            Column("metric_id"),
+                                            self.resolve_metric("transaction.duration"),
+                                        ],
+                                    ),
+                                    Function(
+                                        "equals",
+                                        [
+                                            self.builder.column("transaction"),
+                                            self.resolve_tag_value("<< unparameterized >>"),
+                                        ],
+                                    ),
+                                ],
+                            )
+                        ],
+                        alias,
+                    ),
+                    # Not yet exposed, need to add far more validation around tag&value
+                    private=True,
+                    default_result_type="integer",
+                ),
+                fields.MetricsFunction(
+                    "count_null_transactions",
+                    snql_distribution=lambda args, alias: Function(
+                        "countIf",
+                        [
+                            Function(
+                                "and",
+                                [
+                                    Function(
+                                        "equals",
+                                        [
+                                            Column("metric_id"),
+                                            self.resolve_metric("transaction.duration"),
+                                        ],
+                                    ),
+                                    Function(
+                                        "equals",
+                                        [
+                                            self.builder.column("transaction"),
+                                            0,
+                                        ],
+                                    ),
+                                ],
+                            )
+                        ],
+                        alias,
+                    ),
+                    private=True,
+                ),
+                fields.MetricsFunction(
+                    "count_has_transaction_name",
+                    snql_distribution=lambda args, alias: Function(
+                        "countIf",
+                        [
+                            Function(
+                                "and",
+                                [
+                                    Function(
+                                        "equals",
+                                        [
+                                            Column("metric_id"),
+                                            self.resolve_metric("transaction.duration"),
+                                        ],
+                                    ),
+                                    Function(
+                                        "and",
+                                        [
+                                            Function(
+                                                "notEquals", [self.builder.column("transaction"), 0]
+                                            ),
+                                            Function(
+                                                "notEquals",
+                                                [
+                                                    self.builder.column("transaction"),
+                                                    self.resolve_tag_value("<< unparameterized >>"),
+                                                ],
+                                            ),
+                                        ],
+                                    ),
+                                ],
+                            )
+                        ],
+                        alias,
+                    ),
+                    private=True,
+                    default_result_type="integer",
+                ),
                 fields.MetricsFunction(
                 fields.MetricsFunction(
                     "user_misery",
                     "user_misery",
                     optional_args=[
                     optional_args=[

+ 3 - 0
tests/sentry/api/endpoints/test_organization_transaction_anomaly_detection.py

@@ -67,6 +67,7 @@ class OrganizationTransactionAnomalyDetectionEndpoint(APITestCase, SnubaTestCase
             "params": {
             "params": {
                 "start": datetime(2022, 1, 25, 12, 0, tzinfo=timezone.utc),
                 "start": datetime(2022, 1, 25, 12, 0, tzinfo=timezone.utc),
                 "end": datetime(2022, 2, 8, 12, 0, tzinfo=timezone.utc),
                 "end": datetime(2022, 2, 8, 12, 0, tzinfo=timezone.utc),
+                "project_objects": [self.project],
                 "project_id": [self.project.id],
                 "project_id": [self.project.id],
                 "organization_id": self.organization.id,
                 "organization_id": self.organization.id,
                 "user_id": self.user.id,
                 "user_id": self.user.id,
@@ -101,6 +102,7 @@ class OrganizationTransactionAnomalyDetectionEndpoint(APITestCase, SnubaTestCase
             "params": {
             "params": {
                 "start": datetime(2022, 1, 28, 3, 21, 34, tzinfo=timezone.utc),
                 "start": datetime(2022, 1, 28, 3, 21, 34, tzinfo=timezone.utc),
                 "end": datetime(2022, 2, 11, 3, 21, 34, tzinfo=timezone.utc),
                 "end": datetime(2022, 2, 11, 3, 21, 34, tzinfo=timezone.utc),
+                "project_objects": [self.project],
                 "project_id": [self.project.id],
                 "project_id": [self.project.id],
                 "organization_id": self.organization.id,
                 "organization_id": self.organization.id,
                 "user_id": self.user.id,
                 "user_id": self.user.id,
@@ -134,6 +136,7 @@ class OrganizationTransactionAnomalyDetectionEndpoint(APITestCase, SnubaTestCase
             "params": {
             "params": {
                 "start": datetime(2021, 12, 20, 0, 0, tzinfo=timezone.utc),
                 "start": datetime(2021, 12, 20, 0, 0, tzinfo=timezone.utc),
                 "end": datetime(2022, 1, 17, 0, 0, tzinfo=timezone.utc),
                 "end": datetime(2022, 1, 17, 0, 0, tzinfo=timezone.utc),
+                "project_objects": [self.project],
                 "project_id": [self.project.id],
                 "project_id": [self.project.id],
                 "organization_id": self.organization.id,
                 "organization_id": self.organization.id,
                 "user_id": self.user.id,
                 "user_id": self.user.id,

+ 131 - 1
tests/snuba/api/endpoints/test_organization_events_meta.py

@@ -1,12 +1,15 @@
 from unittest import mock
 from unittest import mock
 
 
+import pytest
 from django.urls import reverse
 from django.urls import reverse
 from pytz import utc
 from pytz import utc
 from rest_framework.exceptions import ParseError
 from rest_framework.exceptions import ParseError
 
 
-from sentry.testutils import APITestCase, SnubaTestCase
+from sentry.testutils import APITestCase, MetricsEnhancedPerformanceTestCase, SnubaTestCase
 from sentry.testutils.helpers.datetime import before_now, iso_format
 from sentry.testutils.helpers.datetime import before_now, iso_format
 
 
+pytestmark = pytest.mark.sentry_metrics
+
 
 
 class OrganizationEventsMetaEndpoint(APITestCase, SnubaTestCase):
 class OrganizationEventsMetaEndpoint(APITestCase, SnubaTestCase):
     def setUp(self):
     def setUp(self):
@@ -355,3 +358,130 @@ class OrganizationEventsRelatedIssuesEndpoint(APITestCase, SnubaTestCase):
         assert len(response.data) == 1
         assert len(response.data) == 1
         assert response.data[0]["shortId"] == event.group.qualified_short_id
         assert response.data[0]["shortId"] == event.group.qualified_short_id
         assert int(response.data[0]["id"]) == event.group_id
         assert int(response.data[0]["id"]) == event.group_id
+
+
+class OrganizationEventsMetricsCompatiblity(MetricsEnhancedPerformanceTestCase):
+    def setUp(self):
+        super().setUp()
+        self.min_ago = before_now(minutes=1)
+        self.two_min_ago = before_now(minutes=2)
+        self.features = {
+            "organizations:performance-use-metrics": True,
+        }
+        self.login_as(user=self.user)
+        self.project.update_option("sentry:dynamic_sampling", "something-it-doesn't-matter")
+        # Don't create any txn on this, don't set its DS rules, it shouldn't show up anywhere
+        self.create_project()
+
+    def test_unparameterized_transactions(self):
+        # Make current project incompatible
+        self.store_transaction_metric(
+            1, tags={"transaction": "<< unparameterized >>"}, timestamp=self.min_ago
+        )
+        url = reverse(
+            "sentry-api-0-organization-events-metrics-compatibility",
+            kwargs={"organization_slug": self.project.organization.slug},
+        )
+        response = self.client.get(url, format="json")
+
+        assert response.status_code == 200, response.content
+        assert response.data["compatible_projects"] == []
+        assert response.data["dynamic_sampling_projects"] == [self.project.id]
+        assert response.data["sum"]["metrics"] == 1
+        assert response.data["sum"]["metrics_unparam"] == 1
+        assert response.data["sum"]["metrics_null"] == 0
+
+    def test_null_transaction(self):
+        # Make current project incompatible
+        self.store_transaction_metric(1, tags={}, timestamp=self.min_ago)
+        url = reverse(
+            "sentry-api-0-organization-events-metrics-compatibility",
+            kwargs={"organization_slug": self.project.organization.slug},
+        )
+        response = self.client.get(url, format="json")
+
+        assert response.status_code == 200, response.content
+        assert response.data["compatible_projects"] == []
+        assert response.data["dynamic_sampling_projects"] == [self.project.id]
+        assert response.data["sum"]["metrics"] == 1
+        assert response.data["sum"]["metrics_unparam"] == 0
+        assert response.data["sum"]["metrics_null"] == 1
+
+    def test_no_transaction(self):
+        # Make current project incompatible by having nothing
+        url = reverse(
+            "sentry-api-0-organization-events-metrics-compatibility",
+            kwargs={"organization_slug": self.project.organization.slug},
+        )
+        response = self.client.get(url, format="json")
+
+        assert response.status_code == 200, response.content
+        assert response.data["compatible_projects"] == []
+        assert response.data["dynamic_sampling_projects"] == [self.project.id]
+        assert response.data["sum"]["metrics"] == 0
+        assert response.data["sum"]["metrics_unparam"] == 0
+        assert response.data["sum"]["metrics_null"] == 0
+
+    def test_has_transaction(self):
+        self.store_transaction_metric(
+            1, tags={"transaction": "foo_transaction"}, timestamp=self.min_ago
+        )
+        url = reverse(
+            "sentry-api-0-organization-events-metrics-compatibility",
+            kwargs={"organization_slug": self.project.organization.slug},
+        )
+        response = self.client.get(url, format="json")
+
+        assert response.status_code == 200, response.content
+        assert response.data["compatible_projects"] == [self.project.id]
+        assert response.data["dynamic_sampling_projects"] == [self.project.id]
+        assert response.data["sum"]["metrics"] == 1
+        assert response.data["sum"]["metrics_unparam"] == 0
+        assert response.data["sum"]["metrics_null"] == 0
+
+    def test_multiple_projects(self):
+        project2 = self.create_project()
+        project2.update_option("sentry:dynamic_sampling", "something-it-doesn't-matter")
+        project3 = self.create_project()
+        project3.update_option("sentry:dynamic_sampling", "something-it-doesn't-matter")
+        # Not setting DS, it shouldn't show up
+        project4 = self.create_project()
+        self.store_transaction_metric(
+            1, tags={"transaction": "foo_transaction"}, timestamp=self.min_ago
+        )
+        self.store_transaction_metric(
+            1, tags={"transaction": "foo_transaction"}, timestamp=self.min_ago, project=project4.id
+        )
+        self.store_transaction_metric(
+            1,
+            tags={"transaction": "<< unparameterized >>"},
+            timestamp=self.min_ago,
+            project=project2.id,
+        )
+        self.store_transaction_metric(
+            1,
+            tags={},
+            timestamp=self.min_ago,
+            project=project3.id,
+        )
+        self.store_event(
+            data={"timestamp": iso_format(self.min_ago), "transaction": "foo_transaction"},
+            project_id=self.project.id,
+        )
+        url = reverse(
+            "sentry-api-0-organization-events-metrics-compatibility",
+            kwargs={"organization_slug": self.project.organization.slug},
+        )
+        response = self.client.get(url, format="json")
+
+        assert response.status_code == 200, response.content
+        assert response.data["compatible_projects"] == [self.project.id]
+        assert response.data["dynamic_sampling_projects"] == [
+            self.project.id,
+            project2.id,
+            project3.id,
+        ]
+        # project 4 shouldn't show up in these sums
+        assert response.data["sum"]["metrics"] == 3
+        assert response.data["sum"]["metrics_unparam"] == 1
+        assert response.data["sum"]["metrics_null"] == 1