diff --git a/src/sentry/api/serializers/models/event.py b/src/sentry/api/serializers/models/event.py index ebfbcbc7f4cd..01cddbff2ed2 100644 --- a/src/sentry/api/serializers/models/event.py +++ b/src/sentry/api/serializers/models/event.py @@ -45,6 +45,9 @@ MAX_SQL_FORMAT_OPS = 20 MAX_SQL_FORMAT_LENGTH = 1500 +# Page-size ceiling for endpoints that serialize a full event body per row (full=true). +FULL_PAYLOAD_MAX_PER_PAGE = 10 + class EventTagOptional(TypedDict, total=False): query: str diff --git a/src/sentry/issues/endpoints/group_events.py b/src/sentry/issues/endpoints/group_events.py index cf262f830ed8..e7668c8237a7 100644 --- a/src/sentry/issues/endpoints/group_events.py +++ b/src/sentry/issues/endpoints/group_events.py @@ -19,7 +19,10 @@ from sentry.api.helpers.events import get_direct_hit_response, run_group_events_query from sentry.api.paginator import GenericOffsetPaginator from sentry.api.serializers import EventSerializer, SimpleEventSerializer, serialize -from sentry.api.serializers.models.event import SimpleEventSerializerResponse +from sentry.api.serializers.models.event import ( + FULL_PAYLOAD_MAX_PER_PAGE, + SimpleEventSerializerResponse, +) from sentry.api.utils import get_date_range_from_params, handle_query_errors from sentry.apidocs.constants import ( RESPONSE_BAD_REQUEST, @@ -58,9 +61,6 @@ class GroupEventsError(Exception): pass -FULL_PAYLOAD_MAX_PER_PAGE = 10 - - @extend_schema(tags=["Events"]) @cell_silo_endpoint class GroupEventsEndpoint(GroupEndpoint): diff --git a/src/sentry/issues/endpoints/group_hashes.py b/src/sentry/issues/endpoints/group_hashes.py index 93a403fde392..5c0606a38e88 100644 --- a/src/sentry/issues/endpoints/group_hashes.py +++ b/src/sentry/issues/endpoints/group_hashes.py @@ -15,6 +15,7 @@ from sentry.api.paginator import GenericOffsetPaginator from sentry.api.serializers import EventSerializer, SimpleEventSerializer, serialize from sentry.api.serializers.models.event import ( + FULL_PAYLOAD_MAX_PER_PAGE, EventSerializerResponse, SimpleEventSerializerResponse, ) @@ -54,6 +55,14 @@ class GroupHashesEndpoint(GroupEndpoint): "GET": ApiPublishStatus.PUBLIC, } + def get_per_page( + self, request: Request, default_per_page: int | None = None, max_per_page: int | None = None + ) -> int: + per_page = super().get_per_page(request, default_per_page, max_per_page) + if request.GET.get("full") not in ("0", "false"): + return min(per_page, FULL_PAYLOAD_MAX_PER_PAGE) + return per_page + @extend_schema( operation_id="listOrganizationIssueHashes", summary="List an Issue's Hashes", diff --git a/src/sentry/issues/endpoints/project_events.py b/src/sentry/issues/endpoints/project_events.py index 9977bf119ffa..e0d799efabc8 100644 --- a/src/sentry/issues/endpoints/project_events.py +++ b/src/sentry/issues/endpoints/project_events.py @@ -12,7 +12,10 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint from sentry.api.serializers import EventSerializer, SimpleEventSerializer, serialize -from sentry.api.serializers.models.event import SimpleEventSerializerResponse +from sentry.api.serializers.models.event import ( + FULL_PAYLOAD_MAX_PER_PAGE, + SimpleEventSerializerResponse, +) from sentry.api.utils import get_date_range_from_params from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED from sentry.apidocs.examples.event_examples import EventExamples @@ -44,6 +47,14 @@ class ProjectEventsEndpoint(ProjectEndpoint): } ) + def get_per_page( + self, request: Request, default_per_page: int | None = None, max_per_page: int | None = None + ) -> int: + per_page = super().get_per_page(request, default_per_page, max_per_page) + if request.GET.get("full") in ("1", "true"): + return min(per_page, FULL_PAYLOAD_MAX_PER_PAGE) + return per_page + @extend_schema( operation_id="listProjectEvents", summary="List a Project's Error Events", diff --git a/tests/sentry/issues/endpoints/test_group_events.py b/tests/sentry/issues/endpoints/test_group_events.py index 74b9f2357160..ec37d509ae34 100644 --- a/tests/sentry/issues/endpoints/test_group_events.py +++ b/tests/sentry/issues/endpoints/test_group_events.py @@ -6,7 +6,7 @@ from urllib3.connectionpool import ConnectionPool from urllib3.exceptions import ReadTimeoutError -from sentry.issues.endpoints.group_events import FULL_PAYLOAD_MAX_PER_PAGE +from sentry.api.serializers.models.event import FULL_PAYLOAD_MAX_PER_PAGE from sentry.issues.grouptype import ProfileFileIOGroupType from sentry.models.group import Group from sentry.search.eap.occurrences.rollout_utils import EAP_OCCURRENCES_SHOULD_RUN_EXPERIMENT_OPTION diff --git a/tests/sentry/issues/endpoints/test_group_hashes.py b/tests/sentry/issues/endpoints/test_group_hashes.py index 72c37a5d9406..b81bcc450b0a 100644 --- a/tests/sentry/issues/endpoints/test_group_hashes.py +++ b/tests/sentry/issues/endpoints/test_group_hashes.py @@ -1,7 +1,12 @@ from unittest.mock import patch from urllib.parse import urlencode +from django.test import RequestFactory +from rest_framework.request import Request + +from sentry.api.serializers.models.event import FULL_PAYLOAD_MAX_PER_PAGE from sentry.eventstream.snuba import SnubaEventStream +from sentry.issues.endpoints.group_hashes import GroupHashesEndpoint from sentry.models.grouphash import GroupHash from sentry.testutils.cases import APITestCase, SnubaTestCase from sentry.testutils.helpers.datetime import before_now @@ -175,6 +180,20 @@ def test_full_param(self) -> None: assert response.status_code == 200, response.content assert "entries" not in response.data[0]["latestEvent"] + def test_per_page_clamped_when_full(self) -> None: + endpoint = GroupHashesEndpoint() + + def req(path: str) -> Request: + return Request(RequestFactory().get(path)) + + # full defaults to true on this endpoint, so an unqualified request clamps + assert endpoint.get_per_page(req("/")) == FULL_PAYLOAD_MAX_PER_PAGE + assert endpoint.get_per_page(req("/?per_page=100")) == FULL_PAYLOAD_MAX_PER_PAGE + # a smaller explicit page is honored + assert endpoint.get_per_page(req("/?per_page=5")) == 5 + # opting out of full removes the clamp + assert endpoint.get_per_page(req("/?full=false&per_page=100")) == 100 + def test_unmerge(self) -> None: self.login_as(user=self.user) diff --git a/tests/snuba/api/endpoints/test_project_events.py b/tests/snuba/api/endpoints/test_project_events.py index b9dcb988dbf4..d0ce10d523e6 100644 --- a/tests/snuba/api/endpoints/test_project_events.py +++ b/tests/snuba/api/endpoints/test_project_events.py @@ -1,5 +1,6 @@ from django.urls import reverse +from sentry.api.serializers.models.event import FULL_PAYLOAD_MAX_PER_PAGE from sentry.testutils.cases import APITestCase, SnubaTestCase from sentry.testutils.helpers.datetime import before_now @@ -259,6 +260,57 @@ def test_full_param(self) -> None: assert response.status_code == 200, response.content assert "entries" not in response.data[0] + def test_full_clamps_per_page(self) -> None: + self.login_as(user=self.user) + + project = self.create_project() + for i in range(FULL_PAYLOAD_MAX_PER_PAGE + 5): + self.store_event( + data={"event_id": f"{i:032x}", "timestamp": before_now(minutes=1).isoformat()}, + project_id=project.id, + ) + + url = reverse( + "sentry-api-0-project-events", + kwargs={ + "organization_id_or_slug": project.organization.slug, + "project_id_or_slug": project.slug, + }, + ) + + response = self.client.get(url, {"full": "true", "per_page": "100"}, format="json") + assert response.status_code == 200, response.content + assert len(response.data) == FULL_PAYLOAD_MAX_PER_PAGE + assert "entries" in response.data[0] + + # a large per_page is clamped, not rejected + assert ( + self.client.get(url, {"full": "1", "per_page": "100"}, format="json").status_code == 200 + ) + + def test_per_page_not_clamped_without_full(self) -> None: + self.login_as(user=self.user) + + project = self.create_project() + total = FULL_PAYLOAD_MAX_PER_PAGE + 5 + for i in range(total): + self.store_event( + data={"event_id": f"{i:032x}", "timestamp": before_now(minutes=1).isoformat()}, + project_id=project.id, + ) + + url = reverse( + "sentry-api-0-project-events", + kwargs={ + "organization_id_or_slug": project.organization.slug, + "project_id_or_slug": project.slug, + }, + ) + + response = self.client.get(url, {"per_page": "100"}, format="json") + assert response.status_code == 200, response.content + assert len(response.data) == total + def test_sample(self) -> None: self.login_as(user=self.user)