Browse Source

ref(metrics): move metrics samples endpoint to own file (#71319)

Simon Hellmayr 9 months ago
parent
commit
6425790a65

+ 1 - 0
pyproject.toml

@@ -165,6 +165,7 @@ module = [
     "sentry.api.endpoints.organization_member_unreleased_commits",
     "sentry.api.endpoints.organization_metrics",
     "sentry.api.endpoints.organization_metrics_meta",
+    "sentry.api.endpoints.organization_metrics_samples",
     "sentry.api.endpoints.organization_onboarding_continuation_email",
     "sentry.api.endpoints.organization_projects",
     "sentry.api.endpoints.organization_projects_experiment",

+ 3 - 93
src/sentry/api/endpoints/organization_metrics.py

@@ -1,7 +1,6 @@
 from collections.abc import Sequence
 from datetime import datetime, timedelta, timezone
 
-from rest_framework import serializers
 from rest_framework.exceptions import NotFound, ParseError
 from rest_framework.request import Request
 from rest_framework.response import Response
@@ -10,9 +9,7 @@ from sentry import options
 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 import OrganizationEventsV2EndpointBase
 from sentry.api.bases.organization import (
-    NoProjects,
     OrganizationAndStaffPermission,
     OrganizationEndpoint,
     OrganizationMetricsPermission,
@@ -22,10 +19,9 @@ from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.paginator import GenericOffsetPaginator
 from sentry.api.serializers import serialize
 from sentry.api.serializers.models.metrics_code_locations import MetricCodeLocationsSerializer
-from sentry.api.utils import get_date_range_from_params, handle_query_errors
+from sentry.api.utils import get_date_range_from_params
 from sentry.auth.elevated_mode import has_elevated_mode
-from sentry.exceptions import InvalidParams, InvalidSearchQuery
-from sentry.models.organization import Organization
+from sentry.exceptions import InvalidParams
 from sentry.sentry_metrics.querying.data import (
     MetricsAPIQueryResultsTransformer,
     MQLQuery,
@@ -43,7 +39,6 @@ from sentry.sentry_metrics.querying.metadata import (
     get_metrics_meta,
     get_tag_values,
 )
-from sentry.sentry_metrics.querying.samples_list import get_sample_list_executor_cls
 from sentry.sentry_metrics.querying.types import QueryOrder, QueryType
 from sentry.sentry_metrics.use_case_id_registry import (
     UseCaseID,
@@ -52,14 +47,13 @@ from sentry.sentry_metrics.use_case_id_registry import (
 )
 from sentry.sentry_metrics.utils import string_to_use_case_id
 from sentry.snuba.metrics import QueryDefinition, get_all_tags, get_series, get_single_metric_info
-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
 from sentry.types.ratelimit import RateLimit, RateLimitCategory
 from sentry.utils import metrics
 from sentry.utils.cursors import Cursor, CursorResult
-from sentry.utils.dates import get_rollup_from_request, parse_stats_period
+from sentry.utils.dates import parse_stats_period
 
 
 def can_access_use_case_id(request: Request, use_case_id: UseCaseID) -> bool:
@@ -523,90 +517,6 @@ class OrganizationMetricsQueryEndpoint(OrganizationEndpoint):
         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())
-    max = serializers.FloatField(required=False)
-    min = serializers.FloatField(required=False)
-    operation = serializers.CharField(required=False)
-    query = serializers.CharField(required=False)
-    referrer = serializers.CharField(required=False)
-    sort = serializers.CharField(required=False)
-
-    def validate_mri(self, mri: str) -> 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:
-        try:
-            snuba_params, params = self.get_snuba_dataclass(request, organization)
-        except NoProjects:
-            return Response(status=404)
-
-        try:
-            rollup = get_rollup_from_request(
-                request,
-                params,
-                default_interval=None,
-                error=InvalidSearchQuery(),
-            )
-        except InvalidSearchQuery:
-            rollup = 3600  # use a default of 1 hour
-
-        serializer = MetricsSamplesSerializer(data=request.GET)
-        if not serializer.is_valid():
-            return Response(serializer.errors, status=400)
-
-        serialized = serializer.validated_data
-
-        executor_cls = get_sample_list_executor_cls(serialized["mri"])
-        if not executor_cls:
-            raise ParseError(f"Unsupported MRI: {serialized['mri']}")
-
-        sort = serialized.get("sort")
-        if sort is not None:
-            column = sort[1:] if sort.startswith("-") else sort
-            if not executor_cls.supports_sort(column):
-                raise ParseError(f"Unsupported sort: {sort} for MRI")
-
-        executor = executor_cls(
-            mri=serialized["mri"],
-            params=params,
-            snuba_params=snuba_params,
-            fields=serialized["field"],
-            operation=serialized.get("operation"),
-            query=serialized.get("query", ""),
-            min=serialized.get("min"),
-            max=serialized.get("max"),
-            sort=serialized.get("sort"),
-            rollup=rollup,
-            referrer=Referrer.API_ORGANIZATION_METRICS_SAMPLES,
-        )
-
-        with handle_query_errors():
-            return self.paginate(
-                request=request,
-                paginator=GenericOffsetPaginator(data_fn=executor.get_matching_spans),
-                on_results=lambda results: self.handle_results_with_meta(
-                    request,
-                    organization,
-                    params["project_id"],
-                    results,
-                    standard_meta=True,
-                ),
-            )
-
-
 @region_silo_endpoint
 class OrganizationMetricsCodeLocationsEndpoint(OrganizationEndpoint):
     publish_status = {

+ 101 - 0
src/sentry/api/endpoints/organization_metrics_samples.py

@@ -0,0 +1,101 @@
+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.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 import NoProjects, OrganizationEventsV2EndpointBase
+from sentry.api.paginator import GenericOffsetPaginator
+from sentry.api.utils import handle_query_errors
+from sentry.exceptions import InvalidSearchQuery
+from sentry.models.organization import Organization
+from sentry.sentry_metrics.querying.samples_list import get_sample_list_executor_cls
+from sentry.snuba.metrics.naming_layer.mri import is_mri
+from sentry.snuba.referrer import Referrer
+from sentry.utils.dates import get_rollup_from_request
+
+
+class MetricsSamplesSerializer(serializers.Serializer):
+    mri = serializers.CharField(required=True)
+    field = serializers.ListField(required=True, allow_empty=False, child=serializers.CharField())
+    max = serializers.FloatField(required=False)
+    min = serializers.FloatField(required=False)
+    operation = serializers.CharField(required=False)
+    query = serializers.CharField(required=False)
+    referrer = serializers.CharField(required=False)
+    sort = serializers.CharField(required=False)
+
+    def validate_mri(self, mri: str) -> 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:
+        try:
+            snuba_params, params = self.get_snuba_dataclass(request, organization)
+        except NoProjects:
+            return Response(status=404)
+
+        try:
+            rollup = get_rollup_from_request(
+                request,
+                params,
+                default_interval=None,
+                error=InvalidSearchQuery(),
+            )
+        except InvalidSearchQuery:
+            rollup = 3600  # use a default of 1 hour
+
+        serializer = MetricsSamplesSerializer(data=request.GET)
+        if not serializer.is_valid():
+            return Response(serializer.errors, status=400)
+
+        serialized = serializer.validated_data
+
+        executor_cls = get_sample_list_executor_cls(serialized["mri"])
+        if not executor_cls:
+            raise ParseError(f"Unsupported MRI: {serialized['mri']}")
+
+        sort = serialized.get("sort")
+        if sort is not None:
+            column = sort[1:] if sort.startswith("-") else sort
+            if not executor_cls.supports_sort(column):
+                raise ParseError(f"Unsupported sort: {sort} for MRI")
+
+        executor = executor_cls(
+            mri=serialized["mri"],
+            params=params,
+            snuba_params=snuba_params,
+            fields=serialized["field"],
+            operation=serialized.get("operation"),
+            query=serialized.get("query", ""),
+            min=serialized.get("min"),
+            max=serialized.get("max"),
+            sort=serialized.get("sort"),
+            rollup=rollup,
+            referrer=Referrer.API_ORGANIZATION_METRICS_SAMPLES,
+        )
+
+        with handle_query_errors():
+            return self.paginate(
+                request=request,
+                paginator=GenericOffsetPaginator(data_fn=executor.get_matching_spans),
+                on_results=lambda results: self.handle_results_with_meta(
+                    request,
+                    organization,
+                    params["project_id"],
+                    results,
+                    standard_meta=True,
+                ),
+            )

+ 1 - 1
src/sentry/api/urls.py

@@ -426,7 +426,6 @@ from .endpoints.organization_metrics import (
     OrganizationMetricsDataEndpoint,
     OrganizationMetricsDetailsEndpoint,
     OrganizationMetricsQueryEndpoint,
-    OrganizationMetricsSamplesEndpoint,
     OrganizationMetricsTagDetailsEndpoint,
     OrganizationMetricsTagsEndpoint,
 )
@@ -437,6 +436,7 @@ from .endpoints.organization_metrics_meta import (
     OrganizationMetricsCompatibility,
     OrganizationMetricsCompatibilitySums,
 )
+from .endpoints.organization_metrics_samples import OrganizationMetricsSamplesEndpoint
 from .endpoints.organization_onboarding_continuation_email import (
     OrganizationOnboardingContinuationEmail,
 )

+ 1 - 585
tests/sentry/api/endpoints/test_organization_metrics.py

@@ -1,11 +1,8 @@
 import copy
-from datetime import timedelta
 from functools import partial
-from uuid import uuid4
 
 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
@@ -18,11 +15,9 @@ from sentry.snuba.metrics import (
     complement,
     division_float,
 )
-from sentry.testutils.cases import APITestCase, BaseSpansTestCase
-from sentry.testutils.helpers.datetime import before_now
+from sentry.testutils.cases import APITestCase
 from sentry.testutils.silo import assume_test_silo_mode
 from sentry.testutils.skips import requires_snuba
-from sentry.utils.samples import load_data
 
 pytestmark = [pytest.mark.sentry_metrics, requires_snuba]
 
@@ -117,582 +112,3 @@ class OrganizationMetricsPermissionTest(APITestCase):
         for method, endpoint, *rest in self.endpoints:
             response = self.send_request(self.organization, token, method, endpoint, *rest)
             assert response.status_code in (200, 400, 404)
-
-
-class OrganizationMetricsSamplesEndpointTest(BaseSpansTestCase, APITestCase):
-    view = "sentry-api-0-organization-metrics-samples"
-
-    def setUp(self):
-        super().setUp()
-        self.login_as(user=self.user)
-
-    def do_request(self, query, **kwargs):
-        return self.client.get(
-            reverse(self.view, kwargs={"organization_id_or_slug": self.organization.slug}),
-            query,
-            format="json",
-            **kwargs,
-        )
-
-    def test_no_project(self):
-        query = {
-            "mri": "d:spans/exclusive_time@millisecond",
-            "field": ["id"],
-            "project": [],
-        }
-
-        response = self.do_request(query)
-        assert response.status_code == 404, response.data
-
-    def test_bad_params(self):
-        query = {
-            "mri": "foo",
-            "field": [],
-            "project": [self.project.id],
-        }
-
-        response = self.do_request(query)
-        assert response.status_code == 400, response.data
-        assert response.data == {
-            "mri": [ErrorDetail(string="Invalid MRI: foo", code="invalid")],
-            "field": [ErrorDetail(string="This field is required.", code="required")],
-        }
-
-    def test_unsupported_mri(self):
-        query = {
-            "mri": "d:spans/made_up@none",
-            "field": ["id"],
-            "project": [self.project.id],
-        }
-
-        response = self.do_request(query)
-        assert response.status_code == 400, response.data
-        assert response.data == {
-            "detail": ErrorDetail(
-                string="Unsupported MRI: d:spans/made_up@none", code="parse_error"
-            )
-        }
-
-    def test_unsupported_sort(self):
-        query = {
-            "mri": "d:spans/exclusive_time@millisecond",
-            "field": ["id"],
-            "project": [self.project.id],
-            "sort": "id",
-        }
-
-        response = self.do_request(query)
-        assert response.status_code == 400, response.data
-        assert response.data == {
-            "detail": ErrorDetail(string="Unsupported sort: id for MRI", code="parse_error")
-        }
-
-    def test_span_exclusive_time_samples_zero_group(self):
-        durations = [100, 200, 300]
-        span_ids = [uuid4().hex[:16] for _ in durations]
-        good_span_id = span_ids[1]
-
-        for i, (span_id, duration) in enumerate(zip(span_ids, durations)):
-            ts = before_now(days=i, minutes=10).replace(microsecond=0)
-
-            self.store_indexed_span(
-                self.project.id,
-                uuid4().hex,
-                uuid4().hex,
-                span_id=span_id,
-                duration=duration,
-                exclusive_time=duration,
-                timestamp=ts,
-            )
-
-        query = {
-            "mri": "d:spans/exclusive_time@millisecond",
-            "field": ["id"],
-            "project": [self.project.id],
-            "statsPeriod": "14d",
-            "min": 150,
-            "max": 250,
-        }
-        response = self.do_request(query)
-        assert response.status_code == 200, response.data
-        expected = {int(good_span_id, 16)}
-        actual = {int(row["id"], 16) for row in response.data["data"]}
-        assert actual == expected
-
-        for row in response.data["data"]:
-            assert row["summary"] == {
-                "min": 200.0,
-                "max": 200.0,
-                "sum": 200.0,
-                "count": 1,
-            }
-
-        query = {
-            "mri": "d:spans/duration@millisecond",
-            "field": ["id", "span.self_time"],
-            "project": [self.project.id],
-            "statsPeriod": "14d",
-            "sort": "-summary",
-        }
-        response = self.do_request(query)
-        assert response.status_code == 200, response.data
-        expected = {int(span_id, 16) for span_id in span_ids}
-        actual = {int(row["id"], 16) for row in response.data["data"]}
-        assert actual == expected
-
-        for duration, row in zip(reversed(durations), response.data["data"]):
-            assert row["summary"] == {
-                "min": duration,
-                "max": duration,
-                "sum": duration,
-                "count": 1,
-            }
-
-    def test_span_measurement_samples(self):
-        durations = [100, 200, 300]
-        span_ids = [uuid4().hex[:16] for _ in durations]
-        good_span_id = span_ids[1]
-
-        for i, (span_id, duration) in enumerate(zip(span_ids, durations)):
-            ts = before_now(days=i, minutes=10).replace(microsecond=0)
-
-            self.store_indexed_span(
-                self.project.id,
-                uuid4().hex,
-                uuid4().hex,
-                span_id=span_id,
-                duration=duration,
-                timestamp=ts,
-                measurements={
-                    measurement: duration + j + 1
-                    for j, measurement in enumerate(
-                        [
-                            "frames.slow",
-                            "score.total",
-                            "score.inp",
-                            "score.weight.inp",
-                            "http.response_content_length",
-                            "http.decoded_response_content_length",
-                            "http.response_transfer_size",
-                        ]
-                    )
-                },
-            )
-
-            self.store_indexed_span(
-                self.project.id,
-                uuid4().hex,
-                uuid4().hex,
-                span_id=uuid4().hex[:16],
-                timestamp=ts,
-            )
-
-        for i, mri in enumerate(
-            [
-                "g:spans/mobile.slow_frames@none",
-                "d:spans/webvital.score.total@ratio",
-                "d:spans/webvital.score.inp@ratio",
-                "d:spans/webvital.score.weight.inp@ratio",
-                "d:spans/http.response_content_length@byte",
-                "d:spans/http.decoded_response_content_length@byte",
-                "d:spans/http.response_transfer_size@byte",
-            ]
-        ):
-            query = {
-                "mri": mri,
-                "field": ["id"],
-                "project": [self.project.id],
-                "statsPeriod": "14d",
-                "min": 150.0,
-                "max": 250.0,
-            }
-            response = self.do_request(query)
-            assert response.status_code == 200, response.data
-            expected = {int(good_span_id, 16)}
-            actual = {int(row["id"], 16) for row in response.data["data"]}
-            assert actual == expected, mri
-
-            for row in response.data["data"]:
-                assert row["summary"] == {
-                    "min": 201 + i,
-                    "max": 201 + i,
-                    "sum": 201 + i,
-                    "count": 1,
-                }, mri
-
-            query = {
-                "mri": mri,
-                "field": ["id", "span.duration"],
-                "project": [self.project.id],
-                "statsPeriod": "14d",
-                "sort": "-summary",
-            }
-            response = self.do_request(query)
-            assert response.status_code == 200, response.data
-            expected = {int(span_id, 16) for span_id in span_ids}
-            actual = {int(row["id"], 16) for row in response.data["data"]}
-            assert actual == expected, mri
-
-            for duration, row in zip(reversed(durations), response.data["data"]):
-                assert row["summary"] == {
-                    "min": duration + i + 1,
-                    "max": duration + i + 1,
-                    "sum": duration + i + 1,
-                    "count": 1,
-                }, mri
-
-    def test_transaction_duration_samples(self):
-        durations = [100, 200, 300]
-        span_ids = [uuid4().hex[:16] for _ in durations]
-        good_span_id = span_ids[1]
-
-        for i, (span_id, duration) in enumerate(zip(span_ids, durations)):
-            ts = before_now(days=i, minutes=10).replace(microsecond=0)
-            start_ts = ts - timedelta(microseconds=duration * 1000)
-
-            # first write to the transactions dataset
-            data = load_data("transaction", start_timestamp=start_ts, timestamp=ts)
-            data["contexts"]["trace"]["span_id"] = span_id
-            self.store_event(
-                data=data,
-                project_id=self.project.id,
-            )
-
-            # next write to the spans dataset
-            self.store_segment(
-                self.project.id,
-                uuid4().hex,
-                uuid4().hex,
-                duration=duration,
-                span_id=span_id,
-                timestamp=ts,
-            )
-
-        query = {
-            "mri": "d:transactions/duration@millisecond",
-            "field": ["id"],
-            "project": [self.project.id],
-            "statsPeriod": "14d",
-            "min": 150,
-            "max": 250,
-        }
-        response = self.do_request(query)
-        assert response.status_code == 200, response.data
-        expected = {int(good_span_id, 16)}
-        actual = {int(row["id"], 16) for row in response.data["data"]}
-        assert actual == expected
-
-        for row in response.data["data"]:
-            assert row["summary"] == {
-                "min": 200,
-                "max": 200,
-                "sum": 200,
-                "count": 1,
-            }
-
-        query = {
-            "mri": "d:transactions/duration@millisecond",
-            "field": ["id", "span.duration"],
-            "project": [self.project.id],
-            "statsPeriod": "14d",
-            "sort": "-summary",
-        }
-        response = self.do_request(query)
-        assert response.status_code == 200, response.data
-        expected = {int(span_id, 16) for span_id in span_ids}
-        actual = {int(row["id"], 16) for row in response.data["data"]}
-        assert actual == expected
-
-        for duration, row in zip(reversed(durations), response.data["data"]):
-            assert row["summary"] == {
-                "min": duration,
-                "max": duration,
-                "sum": duration,
-                "count": 1,
-            }
-
-    def test_transaction_measurement_samples(self):
-        durations = [100, 200, 300]
-        span_ids = [uuid4().hex[:16] for _ in durations]
-        good_span_id = span_ids[1]
-
-        for i, (span_id, duration) in enumerate(zip(span_ids, durations)):
-            ts = before_now(days=i, minutes=10).replace(microsecond=0)
-            start_ts = ts - timedelta(microseconds=duration * 1000)
-
-            # first write to the transactions dataset
-            data = load_data("transaction", start_timestamp=start_ts, timestamp=ts)
-            # good span ids will have the measurement
-            data["measurements"] = {"lcp": {"value": duration}}
-            data["contexts"]["trace"]["span_id"] = span_id
-            self.store_event(
-                data=data,
-                project_id=self.project.id,
-            )
-
-            # next write to the spans dataset
-            self.store_segment(
-                self.project.id,
-                uuid4().hex,
-                uuid4().hex,
-                duration=duration,
-                span_id=span_id,
-                timestamp=ts,
-            )
-
-        span_id = uuid4().hex[:16]
-        ts = before_now(days=10, minutes=10).replace(microsecond=0)
-
-        # first write to the transactions dataset
-        data = load_data("transaction", timestamp=ts)
-        # bad span ids will not have the measurement
-        data["measurements"] = {}
-        data["contexts"]["trace"]["span_id"] = span_id
-        self.store_event(
-            data=data,
-            project_id=self.project.id,
-        )
-
-        # next write to the spans dataset
-        self.store_segment(
-            self.project.id,
-            uuid4().hex,
-            uuid4().hex,
-            span_id=span_id,
-            timestamp=ts,
-        )
-
-        query = {
-            "mri": "d:transactions/measurements.lcp@millisecond",
-            "field": ["id"],
-            "project": [self.project.id],
-            "statsPeriod": "14d",
-            "min": 150.0,
-            "max": 250.0,
-        }
-        response = self.do_request(query)
-        assert response.status_code == 200, response.data
-        expected = {int(good_span_id, 16)}
-        actual = {int(row["id"], 16) for row in response.data["data"]}
-        assert actual == expected
-
-        for row in response.data["data"]:
-            assert row["summary"] == {
-                "min": 200.0,
-                "max": 200.0,
-                "sum": 200.0,
-                "count": 1,
-            }
-
-        query = {
-            "mri": "d:transactions/measurements.lcp@millisecond",
-            "field": ["id", "span.duration"],
-            "project": [self.project.id],
-            "statsPeriod": "14d",
-            "sort": "-summary",
-        }
-        response = self.do_request(query)
-        assert response.status_code == 200, response.data
-        expected = {int(span_id, 16) for span_id in span_ids}
-        actual = {int(row["id"], 16) for row in response.data["data"]}
-        assert actual == expected
-
-        for duration, row in zip(reversed(durations), response.data["data"]):
-            assert row["summary"] == {
-                "min": duration,
-                "max": duration,
-                "sum": duration,
-                "count": 1,
-            }
-
-    def test_custom_samples(self):
-        mri = "d:custom/value@millisecond"
-        values = [100, 200, 300]
-        span_ids = [uuid4().hex[:16] for _ in values]
-        good_span_id = span_ids[1]
-
-        # 10 is below the min
-        # 20 is within bounds
-        # 30 is above the max
-        for i, (span_id, val) in enumerate(zip(span_ids, values)):
-            ts = before_now(days=i, minutes=10).replace(microsecond=0)
-            self.store_indexed_span(
-                self.project.id,
-                uuid4().hex,
-                uuid4().hex,
-                span_id=span_id,
-                duration=val,
-                timestamp=ts,
-                store_metrics_summary={
-                    mri: [
-                        {
-                            "min": val - 1,
-                            "max": val + 1,
-                            "sum": val * (i + 1) * 2,
-                            "count": (i + 1) * 2,
-                            "tags": {},
-                        }
-                    ]
-                },
-            )
-
-        self.store_indexed_span(
-            self.project.id,
-            uuid4().hex,
-            uuid4().hex,
-            span_id=uuid4().hex[:16],
-            timestamp=before_now(days=10, minutes=10).replace(microsecond=0),
-            store_metrics_summary={
-                "d:custom/other@millisecond": [
-                    {
-                        "min": 210.0,
-                        "max": 210.0,
-                        "sum": 210.0,
-                        "count": 1,
-                        "tags": {},
-                    }
-                ]
-            },
-        )
-
-        for operation, min_bound, max_bound in [
-            ("avg", 150.0, 250.0),
-            ("min", 150.0, 250.0),
-            ("max", 150.0, 250.0),
-            ("count", 3, 5),
-        ]:
-            query = {
-                "mri": mri,
-                "field": ["id"],
-                "project": [self.project.id],
-                "statsPeriod": "14d",
-                "min": min_bound,
-                "max": max_bound,
-                "operation": operation,
-            }
-            response = self.do_request(query)
-            assert response.status_code == 200, (operation, response.data)
-            expected = {int(good_span_id, 16)}
-            actual = {int(row["id"], 16) for row in response.data["data"]}
-            assert actual == expected, operation
-
-            for row in response.data["data"]:
-                assert row["summary"] == {
-                    "min": 199.0,
-                    "max": 201.0,
-                    "sum": 800.0,
-                    "count": 4,
-                }, operation
-
-        for operation in ["avg", "min", "max", "count"]:
-            query = {
-                "mri": mri,
-                "field": ["id", "span.duration"],
-                "project": [self.project.id],
-                "statsPeriod": "14d",
-                "sort": "-summary",
-                "operation": operation,
-            }
-            response = self.do_request(query)
-            assert response.status_code == 200, response.data
-            expected = {int(span_id, 16) for span_id in span_ids}
-            actual = {int(row["id"], 16) for row in response.data["data"]}
-            assert actual == expected
-
-            for i, (val, row) in enumerate(zip(reversed(values), response.data["data"])):
-                assert row["summary"] == {
-                    "min": val - 1,
-                    "max": val + 1,
-                    "sum": val * (len(values) - i) * 2,
-                    "count": (len(values) - i) * 2,
-                }
-
-    def test_multiple_span_sample_per_time_bucket(self):
-        custom_mri = "d:custom/value@millisecond"
-        values = [100, 200, 300, 400, 500]
-        span_ids = [uuid4().hex[:16] for _ in values]
-        ts = before_now(days=0, minutes=10).replace(microsecond=0)
-
-        for span_id, value in zip(span_ids, values):
-            self.store_indexed_span(
-                self.project.id,
-                uuid4().hex,
-                uuid4().hex,
-                span_id=span_id,
-                duration=value,
-                exclusive_time=value,
-                timestamp=ts,
-                measurements={"score.total": value},
-                store_metrics_summary={
-                    custom_mri: [
-                        {
-                            "min": value - 1,
-                            "max": value + 1,
-                            "sum": value * 2,
-                            "count": 2,
-                            "tags": {},
-                        }
-                    ]
-                },
-            )
-
-        for mri in [
-            "d:spans/exclusive_time@millisecond",
-            "d:spans/webvital.score.total@ratio",
-            custom_mri,
-        ]:
-            query = {
-                "mri": mri,
-                "field": ["id"],
-                "project": [self.project.id],
-                "statsPeriod": "24h",
-            }
-            response = self.do_request(query)
-            assert response.status_code == 200, response.data
-            expected = {int(span_ids[i], 16) for i in [2, 3, 4]}
-            actual = {int(row["id"], 16) for row in response.data["data"]}
-            assert actual == expected
-
-    def test_multiple_transaction_sample_per_time_bucket(self):
-        values = [100, 200, 300, 400, 500]
-        span_ids = [uuid4().hex[:16] for _ in values]
-        ts = before_now(days=0, minutes=10).replace(microsecond=0)
-
-        for span_id, value in zip(span_ids, values):
-            start_ts = ts - timedelta(microseconds=value * 1000)
-
-            # first write to the transactions dataset
-            data = load_data("transaction", start_timestamp=start_ts, timestamp=ts)
-            # good span ids will have the measurement
-            data["measurements"] = {"lcp": {"value": value}}
-            data["contexts"]["trace"]["span_id"] = span_id
-            self.store_event(
-                data=data,
-                project_id=self.project.id,
-            )
-
-            # next write to the spans dataset
-            self.store_segment(
-                self.project.id,
-                uuid4().hex,
-                uuid4().hex,
-                duration=value,
-                span_id=span_id,
-                timestamp=ts,
-            )
-
-        for mri in [
-            "d:transactions/duration@millisecond",
-            "d:transactions/measurements.lcp@millisecond",
-        ]:
-            query = {
-                "mri": mri,
-                "field": ["id"],
-                "project": [self.project.id],
-                "statsPeriod": "24h",
-            }
-            response = self.do_request(query)
-            assert response.status_code == 200, response.data
-            expected = {int(span_ids[i], 16) for i in [2, 3, 4]}
-            actual = {int(row["id"], 16) for row in response.data["data"]}
-            assert actual == expected

+ 588 - 0
tests/sentry/api/endpoints/test_organization_metrics_samples.py

@@ -0,0 +1,588 @@
+from datetime import timedelta
+from uuid import uuid4
+
+from django.urls import reverse
+from rest_framework.exceptions import ErrorDetail
+
+from sentry.testutils.cases import APITestCase, BaseSpansTestCase
+from sentry.testutils.helpers.datetime import before_now
+from sentry.utils.samples import load_data
+
+
+class OrganizationMetricsSamplesEndpointTest(BaseSpansTestCase, APITestCase):
+    view = "sentry-api-0-organization-metrics-samples"
+
+    def setUp(self):
+        super().setUp()
+        self.login_as(user=self.user)
+
+    def do_request(self, query, **kwargs):
+        return self.client.get(
+            reverse(self.view, kwargs={"organization_id_or_slug": self.organization.slug}),
+            query,
+            format="json",
+            **kwargs,
+        )
+
+    def test_no_project(self):
+        query = {
+            "mri": "d:spans/exclusive_time@millisecond",
+            "field": ["id"],
+            "project": [],
+        }
+
+        response = self.do_request(query)
+        assert response.status_code == 404, response.data
+
+    def test_bad_params(self):
+        query = {
+            "mri": "foo",
+            "field": [],
+            "project": [self.project.id],
+        }
+
+        response = self.do_request(query)
+        assert response.status_code == 400, response.data
+        assert response.data == {
+            "mri": [ErrorDetail(string="Invalid MRI: foo", code="invalid")],
+            "field": [ErrorDetail(string="This field is required.", code="required")],
+        }
+
+    def test_unsupported_mri(self):
+        query = {
+            "mri": "d:spans/made_up@none",
+            "field": ["id"],
+            "project": [self.project.id],
+        }
+
+        response = self.do_request(query)
+        assert response.status_code == 400, response.data
+        assert response.data == {
+            "detail": ErrorDetail(
+                string="Unsupported MRI: d:spans/made_up@none", code="parse_error"
+            )
+        }
+
+    def test_unsupported_sort(self):
+        query = {
+            "mri": "d:spans/exclusive_time@millisecond",
+            "field": ["id"],
+            "project": [self.project.id],
+            "sort": "id",
+        }
+
+        response = self.do_request(query)
+        assert response.status_code == 400, response.data
+        assert response.data == {
+            "detail": ErrorDetail(string="Unsupported sort: id for MRI", code="parse_error")
+        }
+
+    def test_span_exclusive_time_samples_zero_group(self):
+        durations = [100, 200, 300]
+        span_ids = [uuid4().hex[:16] for _ in durations]
+        good_span_id = span_ids[1]
+
+        for i, (span_id, duration) in enumerate(zip(span_ids, durations)):
+            ts = before_now(days=i, minutes=10).replace(microsecond=0)
+
+            self.store_indexed_span(
+                self.project.id,
+                uuid4().hex,
+                uuid4().hex,
+                span_id=span_id,
+                duration=duration,
+                exclusive_time=duration,
+                timestamp=ts,
+            )
+
+        query = {
+            "mri": "d:spans/exclusive_time@millisecond",
+            "field": ["id"],
+            "project": [self.project.id],
+            "statsPeriod": "14d",
+            "min": 150,
+            "max": 250,
+        }
+        response = self.do_request(query)
+        assert response.status_code == 200, response.data
+        expected = {int(good_span_id, 16)}
+        actual = {int(row["id"], 16) for row in response.data["data"]}
+        assert actual == expected
+
+        for row in response.data["data"]:
+            assert row["summary"] == {
+                "min": 200.0,
+                "max": 200.0,
+                "sum": 200.0,
+                "count": 1,
+            }
+
+        query = {
+            "mri": "d:spans/duration@millisecond",
+            "field": ["id", "span.self_time"],
+            "project": [self.project.id],
+            "statsPeriod": "14d",
+            "sort": "-summary",
+        }
+        response = self.do_request(query)
+        assert response.status_code == 200, response.data
+        expected = {int(span_id, 16) for span_id in span_ids}
+        actual = {int(row["id"], 16) for row in response.data["data"]}
+        assert actual == expected
+
+        for duration, row in zip(reversed(durations), response.data["data"]):
+            assert row["summary"] == {
+                "min": duration,
+                "max": duration,
+                "sum": duration,
+                "count": 1,
+            }
+
+    def test_span_measurement_samples(self):
+        durations = [100, 200, 300]
+        span_ids = [uuid4().hex[:16] for _ in durations]
+        good_span_id = span_ids[1]
+
+        for i, (span_id, duration) in enumerate(zip(span_ids, durations)):
+            ts = before_now(days=i, minutes=10).replace(microsecond=0)
+
+            self.store_indexed_span(
+                self.project.id,
+                uuid4().hex,
+                uuid4().hex,
+                span_id=span_id,
+                duration=duration,
+                timestamp=ts,
+                measurements={
+                    measurement: duration + j + 1
+                    for j, measurement in enumerate(
+                        [
+                            "frames.slow",
+                            "score.total",
+                            "score.inp",
+                            "score.weight.inp",
+                            "http.response_content_length",
+                            "http.decoded_response_content_length",
+                            "http.response_transfer_size",
+                        ]
+                    )
+                },
+            )
+
+            self.store_indexed_span(
+                self.project.id,
+                uuid4().hex,
+                uuid4().hex,
+                span_id=uuid4().hex[:16],
+                timestamp=ts,
+            )
+
+        for i, mri in enumerate(
+            [
+                "g:spans/mobile.slow_frames@none",
+                "d:spans/webvital.score.total@ratio",
+                "d:spans/webvital.score.inp@ratio",
+                "d:spans/webvital.score.weight.inp@ratio",
+                "d:spans/http.response_content_length@byte",
+                "d:spans/http.decoded_response_content_length@byte",
+                "d:spans/http.response_transfer_size@byte",
+            ]
+        ):
+            query = {
+                "mri": mri,
+                "field": ["id"],
+                "project": [self.project.id],
+                "statsPeriod": "14d",
+                "min": 150.0,
+                "max": 250.0,
+            }
+            response = self.do_request(query)
+            assert response.status_code == 200, response.data
+            expected = {int(good_span_id, 16)}
+            actual = {int(row["id"], 16) for row in response.data["data"]}
+            assert actual == expected, mri
+
+            for row in response.data["data"]:
+                assert row["summary"] == {
+                    "min": 201 + i,
+                    "max": 201 + i,
+                    "sum": 201 + i,
+                    "count": 1,
+                }, mri
+
+            query = {
+                "mri": mri,
+                "field": ["id", "span.duration"],
+                "project": [self.project.id],
+                "statsPeriod": "14d",
+                "sort": "-summary",
+            }
+            response = self.do_request(query)
+            assert response.status_code == 200, response.data
+            expected = {int(span_id, 16) for span_id in span_ids}
+            actual = {int(row["id"], 16) for row in response.data["data"]}
+            assert actual == expected, mri
+
+            for duration, row in zip(reversed(durations), response.data["data"]):
+                assert row["summary"] == {
+                    "min": duration + i + 1,
+                    "max": duration + i + 1,
+                    "sum": duration + i + 1,
+                    "count": 1,
+                }, mri
+
+    def test_transaction_duration_samples(self):
+        durations = [100, 200, 300]
+        span_ids = [uuid4().hex[:16] for _ in durations]
+        good_span_id = span_ids[1]
+
+        for i, (span_id, duration) in enumerate(zip(span_ids, durations)):
+            ts = before_now(days=i, minutes=10).replace(microsecond=0)
+            start_ts = ts - timedelta(microseconds=duration * 1000)
+
+            # first write to the transactions dataset
+            data = load_data("transaction", start_timestamp=start_ts, timestamp=ts)
+            data["contexts"]["trace"]["span_id"] = span_id
+            self.store_event(
+                data=data,
+                project_id=self.project.id,
+            )
+
+            # next write to the spans dataset
+            self.store_segment(
+                self.project.id,
+                uuid4().hex,
+                uuid4().hex,
+                duration=duration,
+                span_id=span_id,
+                timestamp=ts,
+            )
+
+        query = {
+            "mri": "d:transactions/duration@millisecond",
+            "field": ["id"],
+            "project": [self.project.id],
+            "statsPeriod": "14d",
+            "min": 150,
+            "max": 250,
+        }
+        response = self.do_request(query)
+        assert response.status_code == 200, response.data
+        expected = {int(good_span_id, 16)}
+        actual = {int(row["id"], 16) for row in response.data["data"]}
+        assert actual == expected
+
+        for row in response.data["data"]:
+            assert row["summary"] == {
+                "min": 200,
+                "max": 200,
+                "sum": 200,
+                "count": 1,
+            }
+
+        query = {
+            "mri": "d:transactions/duration@millisecond",
+            "field": ["id", "span.duration"],
+            "project": [self.project.id],
+            "statsPeriod": "14d",
+            "sort": "-summary",
+        }
+        response = self.do_request(query)
+        assert response.status_code == 200, response.data
+        expected = {int(span_id, 16) for span_id in span_ids}
+        actual = {int(row["id"], 16) for row in response.data["data"]}
+        assert actual == expected
+
+        for duration, row in zip(reversed(durations), response.data["data"]):
+            assert row["summary"] == {
+                "min": duration,
+                "max": duration,
+                "sum": duration,
+                "count": 1,
+            }
+
+    def test_transaction_measurement_samples(self):
+        durations = [100, 200, 300]
+        span_ids = [uuid4().hex[:16] for _ in durations]
+        good_span_id = span_ids[1]
+
+        for i, (span_id, duration) in enumerate(zip(span_ids, durations)):
+            ts = before_now(days=i, minutes=10).replace(microsecond=0)
+            start_ts = ts - timedelta(microseconds=duration * 1000)
+
+            # first write to the transactions dataset
+            data = load_data("transaction", start_timestamp=start_ts, timestamp=ts)
+            # good span ids will have the measurement
+            data["measurements"] = {"lcp": {"value": duration}}
+            data["contexts"]["trace"]["span_id"] = span_id
+            self.store_event(
+                data=data,
+                project_id=self.project.id,
+            )
+
+            # next write to the spans dataset
+            self.store_segment(
+                self.project.id,
+                uuid4().hex,
+                uuid4().hex,
+                duration=duration,
+                span_id=span_id,
+                timestamp=ts,
+            )
+
+        span_id = uuid4().hex[:16]
+        ts = before_now(days=10, minutes=10).replace(microsecond=0)
+
+        # first write to the transactions dataset
+        data = load_data("transaction", timestamp=ts)
+        # bad span ids will not have the measurement
+        data["measurements"] = {}
+        data["contexts"]["trace"]["span_id"] = span_id
+        self.store_event(
+            data=data,
+            project_id=self.project.id,
+        )
+
+        # next write to the spans dataset
+        self.store_segment(
+            self.project.id,
+            uuid4().hex,
+            uuid4().hex,
+            span_id=span_id,
+            timestamp=ts,
+        )
+
+        query = {
+            "mri": "d:transactions/measurements.lcp@millisecond",
+            "field": ["id"],
+            "project": [self.project.id],
+            "statsPeriod": "14d",
+            "min": 150.0,
+            "max": 250.0,
+        }
+        response = self.do_request(query)
+        assert response.status_code == 200, response.data
+        expected = {int(good_span_id, 16)}
+        actual = {int(row["id"], 16) for row in response.data["data"]}
+        assert actual == expected
+
+        for row in response.data["data"]:
+            assert row["summary"] == {
+                "min": 200.0,
+                "max": 200.0,
+                "sum": 200.0,
+                "count": 1,
+            }
+
+        query = {
+            "mri": "d:transactions/measurements.lcp@millisecond",
+            "field": ["id", "span.duration"],
+            "project": [self.project.id],
+            "statsPeriod": "14d",
+            "sort": "-summary",
+        }
+        response = self.do_request(query)
+        assert response.status_code == 200, response.data
+        expected = {int(span_id, 16) for span_id in span_ids}
+        actual = {int(row["id"], 16) for row in response.data["data"]}
+        assert actual == expected
+
+        for duration, row in zip(reversed(durations), response.data["data"]):
+            assert row["summary"] == {
+                "min": duration,
+                "max": duration,
+                "sum": duration,
+                "count": 1,
+            }
+
+    def test_custom_samples(self):
+        mri = "d:custom/value@millisecond"
+        values = [100, 200, 300]
+        span_ids = [uuid4().hex[:16] for _ in values]
+        good_span_id = span_ids[1]
+
+        # 10 is below the min
+        # 20 is within bounds
+        # 30 is above the max
+        for i, (span_id, val) in enumerate(zip(span_ids, values)):
+            ts = before_now(days=i, minutes=10).replace(microsecond=0)
+            self.store_indexed_span(
+                self.project.id,
+                uuid4().hex,
+                uuid4().hex,
+                span_id=span_id,
+                duration=val,
+                timestamp=ts,
+                store_metrics_summary={
+                    mri: [
+                        {
+                            "min": val - 1,
+                            "max": val + 1,
+                            "sum": val * (i + 1) * 2,
+                            "count": (i + 1) * 2,
+                            "tags": {},
+                        }
+                    ]
+                },
+            )
+
+        self.store_indexed_span(
+            self.project.id,
+            uuid4().hex,
+            uuid4().hex,
+            span_id=uuid4().hex[:16],
+            timestamp=before_now(days=10, minutes=10).replace(microsecond=0),
+            store_metrics_summary={
+                "d:custom/other@millisecond": [
+                    {
+                        "min": 210.0,
+                        "max": 210.0,
+                        "sum": 210.0,
+                        "count": 1,
+                        "tags": {},
+                    }
+                ]
+            },
+        )
+
+        for operation, min_bound, max_bound in [
+            ("avg", 150.0, 250.0),
+            ("min", 150.0, 250.0),
+            ("max", 150.0, 250.0),
+            ("count", 3, 5),
+        ]:
+            query = {
+                "mri": mri,
+                "field": ["id"],
+                "project": [self.project.id],
+                "statsPeriod": "14d",
+                "min": min_bound,
+                "max": max_bound,
+                "operation": operation,
+            }
+            response = self.do_request(query)
+            assert response.status_code == 200, (operation, response.data)
+            expected = {int(good_span_id, 16)}
+            actual = {int(row["id"], 16) for row in response.data["data"]}
+            assert actual == expected, operation
+
+            for row in response.data["data"]:
+                assert row["summary"] == {
+                    "min": 199.0,
+                    "max": 201.0,
+                    "sum": 800.0,
+                    "count": 4,
+                }, operation
+
+        for operation in ["avg", "min", "max", "count"]:
+            query = {
+                "mri": mri,
+                "field": ["id", "span.duration"],
+                "project": [self.project.id],
+                "statsPeriod": "14d",
+                "sort": "-summary",
+                "operation": operation,
+            }
+            response = self.do_request(query)
+            assert response.status_code == 200, response.data
+            expected = {int(span_id, 16) for span_id in span_ids}
+            actual = {int(row["id"], 16) for row in response.data["data"]}
+            assert actual == expected
+
+            for i, (val, row) in enumerate(zip(reversed(values), response.data["data"])):
+                assert row["summary"] == {
+                    "min": val - 1,
+                    "max": val + 1,
+                    "sum": val * (len(values) - i) * 2,
+                    "count": (len(values) - i) * 2,
+                }
+
+    def test_multiple_span_sample_per_time_bucket(self):
+        custom_mri = "d:custom/value@millisecond"
+        values = [100, 200, 300, 400, 500]
+        span_ids = [uuid4().hex[:16] for _ in values]
+        ts = before_now(days=0, minutes=10).replace(microsecond=0)
+
+        for span_id, value in zip(span_ids, values):
+            self.store_indexed_span(
+                self.project.id,
+                uuid4().hex,
+                uuid4().hex,
+                span_id=span_id,
+                duration=value,
+                exclusive_time=value,
+                timestamp=ts,
+                measurements={"score.total": value},
+                store_metrics_summary={
+                    custom_mri: [
+                        {
+                            "min": value - 1,
+                            "max": value + 1,
+                            "sum": value * 2,
+                            "count": 2,
+                            "tags": {},
+                        }
+                    ]
+                },
+            )
+
+        for mri in [
+            "d:spans/exclusive_time@millisecond",
+            "d:spans/webvital.score.total@ratio",
+            custom_mri,
+        ]:
+            query = {
+                "mri": mri,
+                "field": ["id"],
+                "project": [self.project.id],
+                "statsPeriod": "24h",
+            }
+            response = self.do_request(query)
+            assert response.status_code == 200, response.data
+            expected = {int(span_ids[i], 16) for i in [2, 3, 4]}
+            actual = {int(row["id"], 16) for row in response.data["data"]}
+            assert actual == expected
+
+    def test_multiple_transaction_sample_per_time_bucket(self):
+        values = [100, 200, 300, 400, 500]
+        span_ids = [uuid4().hex[:16] for _ in values]
+        ts = before_now(days=0, minutes=10).replace(microsecond=0)
+
+        for span_id, value in zip(span_ids, values):
+            start_ts = ts - timedelta(microseconds=value * 1000)
+
+            # first write to the transactions dataset
+            data = load_data("transaction", start_timestamp=start_ts, timestamp=ts)
+            # good span ids will have the measurement
+            data["measurements"] = {"lcp": {"value": value}}
+            data["contexts"]["trace"]["span_id"] = span_id
+            self.store_event(
+                data=data,
+                project_id=self.project.id,
+            )
+
+            # next write to the spans dataset
+            self.store_segment(
+                self.project.id,
+                uuid4().hex,
+                uuid4().hex,
+                duration=value,
+                span_id=span_id,
+                timestamp=ts,
+            )
+
+        for mri in [
+            "d:transactions/duration@millisecond",
+            "d:transactions/measurements.lcp@millisecond",
+        ]:
+            query = {
+                "mri": mri,
+                "field": ["id"],
+                "project": [self.project.id],
+                "statsPeriod": "24h",
+            }
+            response = self.do_request(query)
+            assert response.status_code == 200, response.data
+            expected = {int(span_ids[i], 16) for i in [2, 3, 4]}
+            actual = {int(row["id"], 16) for row in response.data["data"]}
+            assert actual == expected