Browse Source

feat(dynamic-sampling): Adds endpoint that returns onboarding flow trace info [TET-176] (#36113)

This PR adds an endpoint for the dynamic sampling onboarding flow that:

- Does a query to the transactions table to fetch a random sampleSize over the last passed statsPeriod date range.
- If distrubutedTracing mode is enabled, then it runs a subsequent query to fetch the project breakdown in the traces from the first query
- Calculates distribution function values like p50, p90, p95, p99, avg, max, min on the client side sample rates returned from the first query
- Returns the percentage of transactions that did not have a sample rate
Ahmed Etefy 2 years ago
parent
commit
923658b395

+ 290 - 0
src/sentry/api/endpoints/project_dynamic_sampling.py

@@ -0,0 +1,290 @@
+from datetime import timedelta
+
+from django.utils import timezone
+from rest_framework import status
+from rest_framework.request import Request
+from rest_framework.response import Response
+from snuba_sdk import Column
+from snuba_sdk.conditions import Condition, Op
+from snuba_sdk.request import Request as SnubaRequest
+
+from sentry import features
+from sentry.api.bases.project import ProjectEndpoint, ProjectPermission
+from sentry.models import Project
+from sentry.search.events.builder import QueryBuilder
+from sentry.snuba import discover
+from sentry.utils.dates import parse_stats_period
+from sentry.utils.snuba import Dataset, raw_snql_query
+
+
+class EmptyTransactionDatasetException(Exception):
+    ...
+
+
+def percentile_fn(data, percentile):
+    """
+    Returns the nth percentile from a sorted list
+
+    :param percentile: A value between 0 and 1
+    :param data: Sorted list of values
+    """
+    return data[int((len(data) - 1) * percentile)] if len(data) > 0 else None
+
+
+class DynamicSamplingPermission(ProjectPermission):
+    # ToDo(ahmed): Revisit the permission level for Dynamic Sampling once the requirements are
+    #  better defined
+    scope_map = {"GET": ["project:write", "project:admin"]}
+
+
+class ProjectDynamicSamplingDistributionEndpoint(ProjectEndpoint):
+    private = True
+    permission_classes = (DynamicSamplingPermission,)
+
+    @staticmethod
+    def _get_sample_rates_data(data):
+        return {
+            "min": min(data, default=None),
+            "max": max(data, default=None),
+            "avg": sum(data) / len(data) if len(data) > 0 else None,
+            "p50": percentile_fn(data, 0.5),
+            "p90": percentile_fn(data, 0.9),
+            "p95": percentile_fn(data, 0.95),
+            "p99": percentile_fn(data, 0.99),
+        }
+
+    @staticmethod
+    def __get_root_transactions_count(project, query, sample_size, start_time, end_time):
+        # Run query that gets total count of transactions with these conditions for the specified
+        # time period
+        return discover.query(
+            selected_columns=[
+                "count()",
+            ],
+            query=f"{query} event.type:transaction !has:trace.parent_span_id",
+            params={
+                "start": start_time,
+                "end": end_time,
+                "project_id": [project.id],
+                "organization_id": project.organization,
+            },
+            orderby=[],
+            offset=0,
+            limit=sample_size,
+            equations=[],
+            auto_fields=True,
+            auto_aggregations=True,
+            allow_metric_aggregates=True,
+            use_aggregate_conditions=True,
+            transform_alias_to_input_format=True,
+            functions_acl=None,
+            referrer="dynamic-sampling.distribution.fetch-parent-transactions-count",
+        )["data"][0]["count()"]
+
+    def __generate_root_transactions_sampling_factor(
+        self, project, query, sample_size, start_time, end_time
+    ):
+        """
+        Generate a sampling factor representative of the total transactions count, that is
+        divisible by 10 (for simplicity). Essentially the logic here does the following:
+        If we have total count of n = 50000 then, we want the `normalized_transactions_count` to be
+        10000 as long as the n value doesn't exceed the 75% threshold between 10000 and 100000 else,
+        we default to 100000. The reason we do this is because falling back to 10000 will
+        yield more results as more numbers will be divisible by 10000 than 100000 for example.
+        We add a 75% threshold that is an arbitrary threshold just to limit the number of returned
+        results when dealing with values of n like 90,000 (In this case, we probably want the
+        `normalized_transactions_coun`t to be 100,000 rather than 10,000)
+        """
+        root_transactions_count = self.__get_root_transactions_count(
+            project=project,
+            query=query,
+            sample_size=sample_size,
+            start_time=start_time,
+            end_time=end_time,
+        )
+        if root_transactions_count == 0:
+            raise EmptyTransactionDatasetException()
+
+        if sample_size % 10 == 0:
+            normalized_sample_count = sample_size
+        else:
+            sample_digit_count = len(str(sample_size))
+            normalized_sample_count = 10**sample_digit_count
+
+        digit_count = len(str(root_transactions_count))
+        normalized_transactions_count = (
+            10 ** (digit_count - 1)
+            if (root_transactions_count <= 0.75 * 10**digit_count)
+            else 10**digit_count
+        )
+        return max(normalized_transactions_count / (10 * normalized_sample_count), 1)
+
+    def __fetch_randomly_sampled_root_transactions(
+        self, project, query, sample_size, start_time, end_time
+    ):
+        """
+        Fetches a random sample of root transactions of size `sample_size` in the last period
+        defined by `stats_period`. The random sample is fetched by generating a random number by
+        for every row, and then doing a modulo operation on it, and if that number is divisible
+        by the sampling factor then its kept, otherwise is discarded. This is an alternative to
+        sampling the query before applying the conditions. The goal here is to fetch the
+        transaction ids, their sample rates and their trace ids.
+        """
+        sampling_factor = self.__generate_root_transactions_sampling_factor(
+            project=project,
+            query=query,
+            sample_size=sample_size,
+            start_time=start_time,
+            end_time=end_time,
+        )
+        builder = QueryBuilder(
+            Dataset.Discover,
+            params={
+                "start": start_time,
+                "end": end_time,
+                "project_id": [project.id],
+                "organization_id": project.organization,
+            },
+            query=f"{query} event.type:transaction !has:trace.parent_span_id",
+            selected_columns=[
+                "id",
+                "trace",
+                "trace.client_sample_rate",
+                "random_number() as rand_num",
+                f"modulo(rand_num, {sampling_factor}) as modulo_num",
+            ],
+            equations=[],
+            orderby=None,
+            auto_fields=True,
+            auto_aggregations=True,
+            use_aggregate_conditions=True,
+            functions_acl=["random_number", "modulo"],
+            limit=sample_size,
+            offset=0,
+            equation_config={"auto_add": False},
+        )
+        builder.add_conditions([Condition(lhs=Column("modulo_num"), op=Op.EQ, rhs=0)])
+        snuba_query = builder.get_snql_query().query
+        groupby = snuba_query.groupby + [Column("modulo_num")]
+        snuba_query = snuba_query.set_groupby(groupby)
+
+        return raw_snql_query(
+            SnubaRequest(dataset=Dataset.Discover.value, app_id="default", query=snuba_query),
+            referrer="dynamic-sampling.distribution.fetch-parent-transactions",
+        )["data"]
+
+    def get(self, request: Request, project) -> Response:
+        """
+        Generates distribution function values for client sample rates from a random sample of
+        root transactions, and provides the projects breakdown for these transaction when
+        creating a dynamic sampling rule for distributed traces.
+        ``````````````````````````````````````````````````
+
+        :pparam string organization_slug: the slug of the organization the
+                                          release belongs to.
+        :qparam string query: If set, this parameter is used to filter root transactions.
+        :qparam string sampleSize: If set, specifies the sample size of random root transactions.
+        :qparam string distributedTrace: Set to distinguish the dynamic sampling creation rule
+                                    whether it is for distributed trace or single transactions.
+        :qparam string statsPeriod: an optional stat period (can be one of
+                                    ``"24h"``, ``"14d"``, and ``""``).
+        :auth: required
+        """
+        if not features.has(
+            "organizations:server-side-sampling", project.organization, actor=request.user
+        ):
+            return Response(
+                {
+                    "detail": [
+                        "Dynamic sampling feature flag needs to be enabled before you can perform "
+                        "this action."
+                    ]
+                },
+                status=404,
+            )
+
+        query = request.GET.get("query", "")
+        requested_sample_size = min(int(request.GET.get("sampleSize", 100)), 1000)
+        distributed_trace = request.GET.get("distributedTrace", "1") == "1"
+        stats_period = min(
+            parse_stats_period(request.GET.get("statsPeriod", "1h")), timedelta(hours=24)
+        )
+
+        end_time = timezone.now()
+        start_time = end_time - stats_period
+
+        try:
+            root_transactions = self.__fetch_randomly_sampled_root_transactions(
+                project=project,
+                query=query,
+                sample_size=requested_sample_size,
+                start_time=start_time,
+                end_time=end_time,
+            )
+        except EmptyTransactionDatasetException:
+            return Response(
+                {
+                    "project_breakdown": None,
+                    "sample_size": 0,
+                    "null_sample_rate_percentage": None,
+                    "sample_rate_distributions": None,
+                }
+            )
+        sample_size = len(root_transactions)
+        sample_rates = sorted(
+            transaction.get("trace.client_sample_rate") for transaction in root_transactions
+        )
+        non_null_sample_rates = sorted(
+            float(sample_rate) for sample_rate in sample_rates if sample_rate not in {"", None}
+        )
+
+        project_breakdown = None
+        if distributed_trace:
+            # If the distributedTrace flag was enabled, then we are also interested in fetching
+            # the project breakdown of the projects in the trace of the root transaction
+            trace_id_list = [transaction.get("trace") for transaction in root_transactions]
+            projects_in_org = Project.objects.filter(organization=project.organization).values_list(
+                "id", flat=True
+            )
+
+            project_breakdown = discover.query(
+                selected_columns=["project_id", "project", "count()"],
+                query=f"event.type:transaction trace:[{','.join(trace_id_list)}]",
+                params={
+                    "start": start_time,
+                    "end": end_time,
+                    "project_id": list(projects_in_org),
+                    "organization_id": project.organization,
+                },
+                equations=[],
+                orderby=[],
+                offset=0,
+                limit=20,
+                auto_fields=True,
+                auto_aggregations=True,
+                allow_metric_aggregates=True,
+                use_aggregate_conditions=True,
+                transform_alias_to_input_format=True,
+                referrer="dynamic-sampling.distribution.fetch-project-breakdown",
+            )["data"]
+
+            # If the number of the projects in the breakdown is greater than 10 projects,
+            # then a question needs to be raised on the eligibility of the org for dynamic sampling
+            if len(project_breakdown) > 10:
+                return Response(
+                    status=status.HTTP_400_BAD_REQUEST,
+                    data={
+                        "details": "Way too many projects in the distributed trace's project breakdown"
+                    },
+                )
+
+        return Response(
+            {
+                "project_breakdown": project_breakdown,
+                "sample_size": sample_size,
+                "null_sample_rate_percentage": (
+                    (sample_size - len(non_null_sample_rates)) / sample_size * 100
+                ),
+                "sample_rate_distributions": self._get_sample_rates_data(non_null_sample_rates),
+            }
+        )

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

@@ -360,6 +360,7 @@ from .endpoints.project_create_sample import ProjectCreateSampleEndpoint
 from .endpoints.project_create_sample_transaction import ProjectCreateSampleTransactionEndpoint
 from .endpoints.project_details import ProjectDetailsEndpoint
 from .endpoints.project_docs_platform import ProjectDocsPlatformEndpoint
+from .endpoints.project_dynamic_sampling import ProjectDynamicSamplingDistributionEndpoint
 from .endpoints.project_environment_details import ProjectEnvironmentDetailsEndpoint
 from .endpoints.project_environments import ProjectEnvironmentsEndpoint
 from .endpoints.project_event_details import EventJsonEndpoint, ProjectEventDetailsEndpoint
@@ -2223,6 +2224,11 @@ urlpatterns = [
                     ProjectProfilingTransactionIDProfileIDEndpoint.as_view(),
                     name="sentry-api-0-project-profiling-transactions",
                 ),
+                url(
+                    r"^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/dynamic-sampling/distribution/$",
+                    ProjectDynamicSamplingDistributionEndpoint.as_view(),
+                    name="sentry-api-0-project-dynamic-sampling-distribution",
+                ),
             ]
         ),
     ),

+ 21 - 0
src/sentry/search/events/datasets/discover.py

@@ -371,6 +371,27 @@ class DiscoverDatasetConfig(DatasetConfig):
                     ),
                     default_result_type="duration",
                 ),
+                SnQLFunction(
+                    "random_number",
+                    snql_aggregate=lambda args, alias: Function(
+                        "rand",
+                        [],
+                        alias,
+                    ),
+                    default_result_type="integer",
+                    private=True,
+                ),
+                SnQLFunction(
+                    "modulo",
+                    required_args=[SnQLStringArg("column"), NumberRange("factor", None, None)],
+                    snql_aggregate=lambda args, alias: Function(
+                        "modulo",
+                        [Column(args["column"]), args["factor"]],
+                        alias,
+                    ),
+                    default_result_type="integer",
+                    private=True,
+                ),
                 SnQLFunction(
                     "avg_range",
                     required_args=[

+ 7 - 0
src/sentry/snuba/events.py

@@ -542,3 +542,10 @@ class Columns(Enum):
         discover_name="contexts[reprocessing.original_issue_id]",
         alias="reprocessing.original_issue_id",
     )
+    TRACE_SAMPLE_RATE = Column(
+        group_name="events.contexts[trace.client_sample_rate]",
+        event_name="contexts[trace.client_sample_rate]",
+        transaction_name="contexts[trace.client_sample_rate]",
+        discover_name="contexts[trace.client_sample_rate]",
+        alias="trace.client_sample_rate",
+    )

+ 511 - 0
tests/sentry/api/endpoints/test_project_dynamic_sampling.py

@@ -0,0 +1,511 @@
+from datetime import timedelta
+from unittest import mock
+
+from django.urls import reverse
+from django.utils import timezone
+from freezegun import freeze_time
+from snuba_sdk import Column
+from snuba_sdk.conditions import Condition, Op
+
+from sentry.models import Project
+from sentry.search.events.builder import QueryBuilder
+from sentry.snuba.dataset import Dataset
+from sentry.testutils import APITestCase
+from sentry.testutils.helpers import Feature
+
+
+def mocked_query_builder_query(referrer):
+    if referrer == "dynamic-sampling.distribution.fetch-parent-transactions":
+        return {
+            "data": [
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "6ddc83ee612b4e89b95b5278c8fd188f",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 4255299100,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "0b127a578f8440c793f9ba1de595229f",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 3976019453,
+                },
+                {
+                    "trace": "9f1a4413544f4d2d9cef4fe109ec426c",
+                    "id": "4d3058e9b3094dcebfdf318d5c025931",
+                    "trace.client_sample_rate": "0.9609190650573167",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 3941410921,
+                },
+                {
+                    "trace": "06f91ed13ce042f58f848a11bd26ba3c",
+                    "id": "c3bc0378a08249158d46e36f3dd1cc49",
+                    "trace.client_sample_rate": "0.8610195401441058",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 3877259197,
+                },
+                {
+                    "trace": "1f9f55e795f843efbf53a3eb84602c56",
+                    "id": "9d45de8df2d74e5ea8237e694d39c742",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 3573364680,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "e861796cea8242338981b2b43aa1b88a",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 3096490437,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "aac21853bbe746a794303a8f26ec0ac3",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 2311382371,
+                },
+                {
+                    "trace": "05578079ff5848bdb27c50f70687ee0b",
+                    "id": "060faa37691a48fb95ec2e3e4c06142a",
+                    "trace.client_sample_rate": "0.9545587106701261",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 2211686055,
+                },
+                {
+                    "trace": "05578079ff5848bdb27c50f70687ee0b",
+                    "id": "ae563f50cfb34f5d8d0b3c32f744dace",
+                    "trace.client_sample_rate": "0.811665375972728",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 2192550125,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "5fdeddd215e6410f83ffad9087f966e8",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 2175797883,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "0ff7bdf09ddf427b89cc6892a0909ba0",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 2142152502,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "9514c862fddb4686baac0477f4bd81db",
+                    "trace.client_sample_rate": "0.9059899056468697",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 1863063737,
+                },
+                {
+                    "trace": "c6e5dd7caeef4d6d8320b2d431fcaf1c",
+                    "id": "02b3325de01f4e4ca85f4ca26904141d",
+                    "trace.client_sample_rate": "0.8096753824342516",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 1764088972,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "712221220ff04138986905bb42c04bdf",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 1637151306,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "0b9eb3872cab4dddaed850ab3d9c1882",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 1500459010,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "27d80cd631574d349345cbd21bf89bcd",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 732695464,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "95c1ca5655ca4ddbb8421282abbaf950",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 523157974,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "0bcf9d68b50544d0a4369586aad0721f",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 283786475,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "da9b8f0f8e3c48f8af452a4def0dc356",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 259256656,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "7aa51f558a8c411793fe28d6fbc6ba55",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 171492976,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "5c7c7eca495842e39744196851edd947",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 121455970,
+                },
+            ]
+        }
+    raise Exception("Something went wrong!")
+
+
+def mocked_discover_query(referrer):
+    if referrer == "dynamic-sampling.distribution.fetch-parent-transactions-count":
+        return {"data": [{"count()": 100}]}
+    elif referrer == "dynamic-sampling.distribution.fetch-project-breakdown":
+        return {
+            "data": [
+                {"project_id": 27, "project": "earth", "count()": 34},
+                {"project_id": 28, "project": "heart", "count()": 3},
+                {"project_id": 24, "project": "water", "count()": 3},
+                {"project_id": 23, "project": "wind", "count()": 3},
+                {"project_id": 25, "project": "fire", "count()": 21},
+            ]
+        }
+    raise Exception("Something went wrong!")
+
+
+class ProjectDynamicSamplingTest(APITestCase):
+    @property
+    def endpoint(self):
+        return reverse(
+            "sentry-api-0-project-dynamic-sampling-distribution",
+            kwargs={
+                "organization_slug": self.project.organization.slug,
+                "project_slug": self.project.slug,
+            },
+        )
+
+    def test_permission(self):
+        user = self.create_user("foo@example.com")
+        self.login_as(user)
+        with Feature({"organizations:server-side-sampling": True}):
+            response = self.client.get(self.endpoint)
+            assert response.status_code == 403
+
+    def test_feature_flag_disabled(self):
+        self.login_as(self.user)
+        response = self.client.get(self.endpoint)
+        assert response.status_code == 404
+
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.raw_snql_query")
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.discover.query")
+    def test_successful_response_with_distributed_traces(self, mock_query, mock_querybuilder):
+        self.login_as(self.user)
+        mock_query.side_effect = [
+            mocked_discover_query("dynamic-sampling.distribution.fetch-parent-transactions-count"),
+            mocked_discover_query("dynamic-sampling.distribution.fetch-project-breakdown"),
+        ]
+        mock_querybuilder.return_value = mocked_query_builder_query(
+            referrer="dynamic-sampling.distribution.fetch-parent-transactions"
+        )
+        with Feature({"organizations:server-side-sampling": True}):
+            response = self.client.get(self.endpoint)
+            assert response.json() == {
+                "project_breakdown": [
+                    {"project_id": 27, "project": "earth", "count()": 34},
+                    {"project_id": 28, "project": "heart", "count()": 3},
+                    {"project_id": 24, "project": "water", "count()": 3},
+                    {"project_id": 23, "project": "wind", "count()": 3},
+                    {"project_id": 25, "project": "fire", "count()": 21},
+                ],
+                "sample_size": 21,
+                "null_sample_rate_percentage": 71.42857142857143,
+                "sample_rate_distributions": {
+                    "min": 0.8096753824342516,
+                    "max": 0.9609190650573167,
+                    "avg": 0.8839713299875663,
+                    "p50": 0.8610195401441058,
+                    "p90": 0.9545587106701261,
+                    "p95": 0.9545587106701261,
+                    "p99": 0.9545587106701261,
+                },
+            }
+
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.raw_snql_query")
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.discover.query")
+    def test_successful_response_with_single_transactions(self, mock_query, mock_querybuilder):
+        self.login_as(self.user)
+        mock_query.side_effect = [
+            mocked_discover_query("dynamic-sampling.distribution.fetch-parent-transactions-count"),
+            mocked_discover_query("dynamic-sampling.distribution.fetch-project-breakdown"),
+        ]
+        mock_querybuilder.return_value = mocked_query_builder_query(
+            referrer="dynamic-sampling.distribution.fetch-parent-transactions"
+        )
+        with Feature({"organizations:server-side-sampling": True}):
+            response = self.client.get(f"{self.endpoint}?distributedTrace=0")
+            assert response.json() == {
+                "project_breakdown": None,
+                "sample_size": 21,
+                "null_sample_rate_percentage": 71.42857142857143,
+                "sample_rate_distributions": {
+                    "min": 0.8096753824342516,
+                    "max": 0.9609190650573167,
+                    "avg": 0.8839713299875663,
+                    "p50": 0.8610195401441058,
+                    "p90": 0.9545587106701261,
+                    "p95": 0.9545587106701261,
+                    "p99": 0.9545587106701261,
+                },
+            }
+
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.raw_snql_query")
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.discover.query")
+    def test_successful_response_with_small_sample_size(self, mock_query, mock_querybuilder):
+        self.login_as(self.user)
+        mock_query.side_effect = [
+            mocked_discover_query("dynamic-sampling.distribution.fetch-parent-transactions-count"),
+            {
+                "data": [
+                    {"project_id": 25, "project": "fire", "count()": 2},
+                ]
+            },
+        ]
+        mock_querybuilder.return_value = {
+            "data": [
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "6ddc83ee612b4e89b95b5278c8fd188f",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 4255299100,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "0b127a578f8440c793f9ba1de595229f",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 3976019453,
+                },
+            ]
+        }
+
+        with Feature({"organizations:server-side-sampling": True}):
+            response = self.client.get(f"{self.endpoint}?sampleSize=2")
+            assert response.json() == {
+                "project_breakdown": [
+                    {"project_id": 25, "project": "fire", "count()": 2},
+                ],
+                "sample_size": 2,
+                "null_sample_rate_percentage": 100.0,
+                "sample_rate_distributions": {
+                    "min": None,
+                    "max": None,
+                    "avg": None,
+                    "p50": None,
+                    "p90": None,
+                    "p95": None,
+                    "p99": None,
+                },
+            }
+
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.discover.query")
+    def test_response_when_no_transactions_are_available(self, mock_query):
+        self.login_as(self.user)
+        mock_query.return_value = {"data": [{"count()": 0}]}
+        with Feature({"organizations:server-side-sampling": True}):
+            response = self.client.get(f"{self.endpoint}?sampleSize=2")
+            assert response.json() == {
+                "project_breakdown": None,
+                "sample_size": 0,
+                "null_sample_rate_percentage": None,
+                "sample_rate_distributions": None,
+            }
+
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.raw_snql_query")
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.discover.query")
+    def test_response_too_many_projects_in_the_breakdown(self, mock_query, mock_querybuilder):
+        self.login_as(self.user)
+        mock_query.side_effect = [
+            mocked_discover_query("dynamic-sampling.distribution.fetch-parent-transactions-count"),
+            {
+                "data": [
+                    {"project_id": 27, "project": "earth", "count()": 34},
+                    {"project_id": 28, "project": "heart", "count()": 3},
+                    {"project_id": 24, "project": "water", "count()": 3},
+                    {"project_id": 23, "project": "wind", "count()": 3},
+                    {"project_id": 25, "project": "fire", "count()": 21},
+                    {"project_id": 21, "project": "air", "count()": 21},
+                    {"project_id": 20, "project": "fire-air", "count()": 21},
+                    {"project_id": 22, "project": "fire-water", "count()": 21},
+                    {"project_id": 30, "project": "fire-earth", "count()": 21},
+                    {"project_id": 31, "project": "fire-fire", "count()": 21},
+                    {"project_id": 32, "project": "fire-heart", "count()": 21},
+                ]
+            },
+        ]
+        mock_querybuilder.return_value = mocked_query_builder_query(
+            referrer="dynamic-sampling.distribution.fetch-parent-transactions"
+        )
+        with Feature({"organizations:server-side-sampling": True}):
+            response = self.client.get(f"{self.endpoint}?sampleSize=2")
+            assert response.json() == {
+                "details": "Way too many projects in the distributed trace's project breakdown"
+            }
+
+    @freeze_time()
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.raw_snql_query")
+    @mock.patch("sentry.api.endpoints.project_dynamic_sampling.discover.query")
+    def test_request_params_are_applied_to_discover_query(self, mock_query, mock_querybuilder):
+        self.login_as(self.user)
+        mock_query.side_effect = [
+            {"data": [{"count()": 1000}]},
+            {
+                "data": [
+                    {"project_id": 25, "project": "fire", "count()": 2},
+                ]
+            },
+        ]
+        mock_querybuilder.return_value = {
+            "data": [
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "6ddc83ee612b4e89b95b5278c8fd188f",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 4255299100,
+                },
+                {
+                    "trace": "6503ee33b7bc43aead1facaa625a5dba",
+                    "id": "0b127a578f8440c793f9ba1de595229f",
+                    "trace.client_sample_rate": "",
+                    "project.name": "fire",
+                    "random_number() AS random_number": 3976019453,
+                },
+            ]
+        }
+        end_time = timezone.now()
+        start_time = end_time - timedelta(hours=6)
+        query = "environment:dev"
+        requested_sample_size = 2
+        projects_in_org = Project.objects.filter(
+            organization=self.project.organization
+        ).values_list("id", flat=True)
+        trace_id_list = ["6503ee33b7bc43aead1facaa625a5dba", "6503ee33b7bc43aead1facaa625a5dba"]
+
+        calls = [
+            mock.call(
+                selected_columns=[
+                    "count()",
+                ],
+                query=f"{query} event.type:transaction !has:trace.parent_span_id",
+                params={
+                    "start": start_time,
+                    "end": end_time,
+                    "project_id": [self.project.id],
+                    "organization_id": self.project.organization,
+                },
+                orderby=[],
+                offset=0,
+                limit=requested_sample_size,
+                equations=[],
+                auto_fields=True,
+                auto_aggregations=True,
+                allow_metric_aggregates=True,
+                use_aggregate_conditions=True,
+                transform_alias_to_input_format=True,
+                functions_acl=None,
+                referrer="dynamic-sampling.distribution.fetch-parent-transactions-count",
+            ),
+            mock.call(
+                selected_columns=["project_id", "project", "count()"],
+                query=f"event.type:transaction trace:[{','.join(trace_id_list)}]",
+                params={
+                    "start": start_time,
+                    "end": end_time,
+                    "project_id": list(projects_in_org),
+                    "organization_id": self.project.organization,
+                },
+                equations=[],
+                orderby=[],
+                offset=0,
+                limit=20,
+                auto_fields=True,
+                auto_aggregations=True,
+                allow_metric_aggregates=True,
+                use_aggregate_conditions=True,
+                transform_alias_to_input_format=True,
+                referrer="dynamic-sampling.distribution.fetch-project-breakdown",
+            ),
+        ]
+        query_builder = QueryBuilder(
+            Dataset.Discover,
+            selected_columns=[
+                "id",
+                "trace",
+                "trace.client_sample_rate",
+                "random_number() AS rand_num",
+                "modulo(rand_num, 10) as modulo_num",
+            ],
+            query=f"{query} event.type:transaction !has:trace.parent_span_id",
+            params={
+                "start": start_time,
+                "end": end_time,
+                "project_id": [self.project.id],
+                "organization_id": self.project.organization,
+            },
+            offset=0,
+            orderby=None,
+            limit=requested_sample_size,
+            equations=[],
+            auto_fields=True,
+            auto_aggregations=True,
+            use_aggregate_conditions=True,
+            functions_acl=["random_number", "modulo"],
+        )
+        query_builder.add_conditions([Condition(lhs=Column("modulo_num"), op=Op.EQ, rhs=0)])
+        snuba_query = query_builder.get_snql_query().query
+        groupby = snuba_query.groupby + [Column("modulo_num")]
+        snuba_query = snuba_query.set_groupby(groupby)
+
+        with Feature({"organizations:server-side-sampling": True}):
+            response = self.client.get(
+                f"{self.endpoint}?sampleSize={requested_sample_size}&query={query}&statsPeriod=6h"
+            )
+            assert response.status_code == 200
+            assert mock_query.mock_calls == calls
+
+            mock_querybuilder_query = mock_querybuilder.call_args_list[0][0][0].query
+
+            def snuba_sort_key(elem):
+                if isinstance(elem, Condition):
+                    try:
+                        return elem.lhs.name
+                    except AttributeError:
+                        return elem.lhs.function
+                elif isinstance(elem, Column):
+                    return elem.name
+                else:
+                    return elem.alias
+
+            assert sorted(mock_querybuilder_query.select, key=snuba_sort_key) == sorted(
+                snuba_query.select, key=snuba_sort_key
+            )
+            assert sorted(mock_querybuilder_query.where, key=snuba_sort_key) == sorted(
+                snuba_query.where, key=snuba_sort_key
+            )
+            assert sorted(mock_querybuilder_query.groupby, key=snuba_sort_key) == sorted(
+                snuba_query.groupby, key=snuba_sort_key
+            )