From 81dfafd058421602405e5c0575c69ba6ce0f89a5 Mon Sep 17 00:00:00 2001 From: Alexander Tarasov Date: Fri, 4 Sep 2026 16:11:46 +0200 Subject: [PATCH 1/3] fix(issues): Cap page size on full=true event and hash listings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `full=true` serializes a complete event body per row, so the cost of a page scales with the frames it contains, not the row count. `per_page` counts rows and defaults to 100, which is a poor fit for native events whose bodies are large. #122718 clamped this on GroupEventsEndpoint. The same pattern lives on ProjectEventsEndpoint (full opt-in, project-scoped) and GroupHashesEndpoint (full defaults to true), which were missed. Move the shared cap into the event serializer module — where EventSerializer, the thing that makes full expensive, lives — and apply the same get_per_page override on all three. Cursors are unaffected, so callers still page through the whole set. Note that because full defaults to true on GroupHashesEndpoint, this lowers its default page size for every caller, not only opt-in ones. Co-Authored-By: Claude --- src/sentry/api/serializers/models/event.py | 3 ++ src/sentry/issues/endpoints/group_events.py | 8 +-- src/sentry/issues/endpoints/group_hashes.py | 9 ++++ src/sentry/issues/endpoints/project_events.py | 13 ++++- .../issues/endpoints/test_group_events.py | 2 +- .../issues/endpoints/test_group_hashes.py | 18 +++++++ .../api/endpoints/test_project_events.py | 52 +++++++++++++++++++ 7 files changed, 99 insertions(+), 6 deletions(-) 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..70afbe0cd521 100644 --- a/tests/sentry/issues/endpoints/test_group_hashes.py +++ b/tests/sentry/issues/endpoints/test_group_hashes.py @@ -1,7 +1,11 @@ from unittest.mock import patch from urllib.parse import urlencode +from django.test import RequestFactory + +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 +179,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() + + # full defaults to true on this endpoint, so an unqualified request clamps + assert endpoint.get_per_page(RequestFactory().get("/")) == FULL_PAYLOAD_MAX_PER_PAGE + assert ( + endpoint.get_per_page(RequestFactory().get("/?per_page=100")) + == FULL_PAYLOAD_MAX_PER_PAGE + ) + # a smaller explicit page is honored + assert endpoint.get_per_page(RequestFactory().get("/?per_page=5")) == 5 + # opting out of full removes the clamp + assert endpoint.get_per_page(RequestFactory().get("/?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) From 1ca6cb8a3b73ab026fabd7cb101c15a5f200f5ec Mon Sep 17 00:00:00 2001 From: Alexander Tarasov Date: Mon, 7 Sep 2026 12:41:08 +0200 Subject: [PATCH 2/3] fix(issues): Use a DRF Request in group hashes per_page test get_per_page is typed for rest_framework.request.Request; the test passed a WSGIRequest from RequestFactory, failing mypy. Wrap it in a DRF Request. Co-Authored-By: Claude --- .../sentry/issues/endpoints/test_group_hashes.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/sentry/issues/endpoints/test_group_hashes.py b/tests/sentry/issues/endpoints/test_group_hashes.py index 70afbe0cd521..b81bcc450b0a 100644 --- a/tests/sentry/issues/endpoints/test_group_hashes.py +++ b/tests/sentry/issues/endpoints/test_group_hashes.py @@ -2,6 +2,7 @@ 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 @@ -182,16 +183,16 @@ def test_full_param(self) -> None: 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(RequestFactory().get("/")) == FULL_PAYLOAD_MAX_PER_PAGE - assert ( - endpoint.get_per_page(RequestFactory().get("/?per_page=100")) - == FULL_PAYLOAD_MAX_PER_PAGE - ) + 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(RequestFactory().get("/?per_page=5")) == 5 + assert endpoint.get_per_page(req("/?per_page=5")) == 5 # opting out of full removes the clamp - assert endpoint.get_per_page(RequestFactory().get("/?full=false&per_page=100")) == 100 + assert endpoint.get_per_page(req("/?full=false&per_page=100")) == 100 def test_unmerge(self) -> None: self.login_as(user=self.user) From a9165202f0d8c31f3141ddf85e8ced08aa6e8649 Mon Sep 17 00:00:00 2001 From: Alexander Tarasov Date: Mon, 7 Sep 2026 14:16:53 +0200 Subject: [PATCH 3/3] docs(issues): Note the full=true page-size cap in the OpenAPI descriptions Document on all three event/hash listing endpoints that requesting the full event body caps the page size at 10. group-events and project-events share EventParams.FULL_PAYLOAD; group-hashes has its own default-true full param. Co-Authored-By: Claude --- src/sentry/apidocs/parameters.py | 2 +- src/sentry/issues/endpoints/group_hashes.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index 26716efd6fb9..ae1eda65d459 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -962,7 +962,7 @@ class EventParams: name="full", type=OpenApiTypes.BOOL, location=OpenApiParameter.QUERY, - description="Specify true to include the full event body, including the stacktrace, in the event payload.", + description="Specify true to include the full event body, including the stacktrace, in the event payload. When true, the page size is capped at 10.", required=False, default=False, ) diff --git a/src/sentry/issues/endpoints/group_hashes.py b/src/sentry/issues/endpoints/group_hashes.py index 5c0606a38e88..6491739e3a4a 100644 --- a/src/sentry/issues/endpoints/group_hashes.py +++ b/src/sentry/issues/endpoints/group_hashes.py @@ -78,7 +78,7 @@ def get_per_page( location=OpenApiParameter.QUERY, required=False, default=True, - description="Specify true to include the full event body, including the stacktrace, in the event payload.", + description="Specify true to include the full event body, including the stacktrace, in the event payload. When true, the page size is capped at 10.", ), CursorQueryParam, ],