Repository: getsentry/sentry

Issue:
Add new endpoint for support the new metrics/traces samples experience
This endpoint will be the starting point to unify the sample experience in Performance and Metrics.


Discussion:

Resolving patch:
diff --git a/src/sentry/api/endpoints/organization_metrics.py b/src/sentry/api/endpoints/organization_metrics.py
--- a/src/sentry/api/endpoints/organization_metrics.py
+++ b/src/sentry/api/endpoints/organization_metrics.py
@@ -1,15 +1,23 @@
+from rest_framework import serializers
 from rest_framework.exceptions import ParseError
 from rest_framework.request import Request
 from rest_framework.response import Response
 
+from sentry import features
 from sentry.api.api_owners import ApiOwner
 from sentry.api.api_publish_status import ApiPublishStatus
 from sentry.api.base import region_silo_endpoint
-from sentry.api.bases.organization import OrganizationEndpoint, OrganizationMetricsPermission
+from sentry.api.bases import OrganizationEventsV2EndpointBase
+from sentry.api.bases.organization import (
+    NoProjects,
+    OrganizationEndpoint,
+    OrganizationMetricsPermission,
+)
 from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.paginator import GenericOffsetPaginator
 from sentry.api.utils import get_date_range_from_params
 from sentry.exceptions import InvalidParams
+from sentry.models.organization import Organization
 from sentry.sentry_metrics.querying.data import run_metrics_query
 from sentry.sentry_metrics.querying.data_v2 import run_metrics_queries_plan
 from sentry.sentry_metrics.querying.data_v2.plan import MetricsQueriesPlan, QueryOrder
@@ -28,6 +36,7 @@
     get_single_metric_info,
     get_tag_values,
 )
+from sentry.snuba.metrics.naming_layer.mri import is_mri
 from sentry.snuba.metrics.utils import DerivedMetricException, DerivedMetricParseException
 from sentry.snuba.referrer import Referrer
 from sentry.snuba.sessions_v2 import InvalidField
@@ -410,3 +419,42 @@ def post(self, request: Request, organization) -> Response:
             return Response(status=500, data={"detail": str(e)})
 
         return Response(status=200, data=results)
+
+
+class MetricsSamplesSerializer(serializers.Serializer):
+    mri = serializers.CharField(required=True)
+    field = serializers.ListField(required=True, allow_empty=False, child=serializers.CharField())
+
+    def validate_mri(self, mri: str):
+        if not is_mri(mri):
+            raise serializers.ValidationError(f"Invalid MRI: {mri}")
+
+        return mri
+
+
+@region_silo_endpoint
+class OrganizationMetricsSamplesEndpoint(OrganizationEventsV2EndpointBase):
+    publish_status = {
+        "GET": ApiPublishStatus.EXPERIMENTAL,
+    }
+    owner = ApiOwner.TELEMETRY_EXPERIENCE
+
+    def get(self, request: Request, organization: Organization) -> Response:
+        if not features.has("organizations:metrics-samples-list", organization, actor=request.user):
+            return Response(status=404)
+
+        try:
+            params = self.get_snuba_params(request, organization)
+        except NoProjects:
+            return Response(status=404)
+
+        serializer = MetricsSamplesSerializer(data=request.GET)
+        if not serializer.is_valid():
+            return Response(serializer.errors, status=400)
+
+        serialized = serializer.validated_data
+
+        assert params
+        assert serialized
+
+        return Response(status=200)
diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py
--- a/src/sentry/api/urls.py
+++ b/src/sentry/api/urls.py
@@ -386,6 +386,7 @@
     OrganizationMetricsDataEndpoint,
     OrganizationMetricsDetailsEndpoint,
     OrganizationMetricsQueryEndpoint,
+    OrganizationMetricsSamplesEndpoint,
     OrganizationMetricsTagDetailsEndpoint,
     OrganizationMetricsTagsEndpoint,
 )
@@ -1986,6 +1987,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
         OrganizationMetricsQueryEndpoint.as_view(),
         name="sentry-api-0-organization-metrics-query",
     ),
+    re_path(
+        r"^(?P<organization_slug>[^/]+)/metrics/samples/$",
+        OrganizationMetricsSamplesEndpoint.as_view(),
+        name="sentry-api-0-organization-metrics-samples",
+    ),
     re_path(
         r"^(?P<organization_slug>[^/]+)/metrics/tags/$",
         OrganizationMetricsTagsEndpoint.as_view(),


Test patch:
diff --git a/tests/sentry/api/endpoints/test_organization_metrics.py b/tests/sentry/api/endpoints/test_organization_metrics.py
--- a/tests/sentry/api/endpoints/test_organization_metrics.py
+++ b/tests/sentry/api/endpoints/test_organization_metrics.py
@@ -3,6 +3,7 @@
 
 import pytest
 from django.urls import reverse
+from rest_framework.exceptions import ErrorDetail
 
 from sentry.models.apitoken import ApiToken
 from sentry.sentry_metrics import indexer
@@ -97,3 +98,59 @@ def test_permissions(self):
         for method, endpoint, *rest in self.endpoints:
             response = self.send_request(token, method, endpoint, *rest)
             assert response.status_code in (200, 400, 404)
+
+
+@region_silo_test
+class OrganizationMetricsSamplesEndpointTest(APITestCase):
+    view = "sentry-api-0-organization-metrics-samples"
+
+    def setUp(self):
+        self.login_as(user=self.user)
+
+    def do_request(self, query, features=None, **kwargs):
+        if features is None:
+            features = []
+        with self.feature(features):
+            return self.client.get(
+                reverse(self.view, kwargs={"organization_slug": self.organization.slug}),
+                query,
+                format="json",
+                **kwargs,
+            )
+
+    def test_feature_flag(self):
+        query = {
+            "mri": "d:transactions/duration@millisecond",
+            "field": ["id"],
+            "project": [self.project.id],
+        }
+
+        response = self.do_request(query)
+        assert response.status_code == 404
+
+        response = self.do_request(query, features=["organizations:metrics-samples-list"])
+        assert response.status_code == 200
+
+    def test_no_project(self):
+        query = {
+            "mri": "d:transactions/duration@millisecond",
+            "field": ["id"],
+            "project": [],
+        }
+
+        response = self.do_request(query, features=["organizations:metrics-samples-list"])
+        assert response.status_code == 404
+
+    def test_bad_params(self):
+        query = {
+            "mri": "foo",
+            "field": [],
+            "project": [self.project.id],
+        }
+
+        response = self.do_request(query, features=["organizations:metrics-samples-list"])
+        assert response.status_code == 400
+        assert response.data == {
+            "mri": [ErrorDetail(string="Invalid MRI: foo", code="invalid")],
+            "field": [ErrorDetail(string="This field is required.", code="required")],
+        }