Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/sentry/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def apply_cors_headers(
"sentry-trace, baggage, X-CSRFToken"
)
response["Access-Control-Expose-Headers"] = (
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, Endpoint, Retry-After, Link"
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, Endpoint, Retry-After, Link"
)

if request.META.get("HTTP_ORIGIN") == "null":
Expand Down
20 changes: 18 additions & 2 deletions src/sentry/core/endpoints/organization_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import options
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.organization import OrganizationAndStaffPermission, OrganizationEndpoint
Expand Down Expand Up @@ -162,13 +163,28 @@ def get(self, request: Request, organization: Organization) -> Response:

if get_all_projects:
queryset = queryset.order_by("slug").select_related("organization")
return Response(

# Fetch MAX + 1 to detect if there are more without expensive count()
max_projects = options.get("api.organization-projects-max-results")
projects = list(queryset[: max_projects + 1])
has_more = len(projects) > max_projects

response = Response(
serialize(
list(queryset),
projects[:max_projects],
request.user,
ProjectSummarySerializer(collapse=collapse, dataset=dataset),
)
)

if has_more:
response["X-Sentry-Warning"] = (
f"This organization has more than {max_projects} projects. "
f"Only the first {max_projects} are shown. "
f"Please use pagination or filter your query."
)

return response
else:
expand = set()
if request.GET.get("transactionStats"):
Expand Down
7 changes: 7 additions & 0 deletions src/sentry/options/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -3470,6 +3470,13 @@
flags=FLAG_AUTOMATOR_MODIFIABLE,
)

# Maximum number of projects to return when all_projects parameter is used
register(
"api.organization-projects-max-results",
default=500,
flags=FLAG_AUTOMATOR_MODIFIABLE,
)

register(
"performance.event-tracker.sample-rate.transactions",
default=0.0,
Expand Down
10 changes: 5 additions & 5 deletions tests/sentry/api/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def test_basic_cors(self) -> None:
"sentry-trace, baggage, X-CSRFToken"
)
assert response["Access-Control-Expose-Headers"] == (
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, "
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, "
"Endpoint, Retry-After, Link"
)
assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS"
Expand Down Expand Up @@ -161,7 +161,7 @@ def test_allow_credentials_subdomain(self) -> None:
"sentry-trace, baggage, X-CSRFToken"
)
assert response["Access-Control-Expose-Headers"] == (
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, "
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, "
"Endpoint, Retry-After, Link"
)
assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS"
Expand Down Expand Up @@ -189,7 +189,7 @@ def test_allow_credentials_root_domain(self) -> None:
"sentry-trace, baggage, X-CSRFToken"
)
assert response["Access-Control-Expose-Headers"] == (
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, "
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, "
"Endpoint, Retry-After, Link"
)
assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS"
Expand Down Expand Up @@ -218,7 +218,7 @@ def test_allow_credentials_allowed_domain(self) -> None:
"sentry-trace, baggage, X-CSRFToken"
)
assert response["Access-Control-Expose-Headers"] == (
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, "
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, "
"Endpoint, Retry-After, Link"
)
assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS"
Expand Down Expand Up @@ -291,7 +291,7 @@ def test_cors_not_configured_is_valid(self) -> None:
"sentry-trace, baggage, X-CSRFToken"
)
assert response["Access-Control-Expose-Headers"] == (
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, "
"X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, "
"Endpoint, Retry-After, Link"
)
assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS"
Expand Down
34 changes: 34 additions & 0 deletions tests/sentry/core/endpoints/test_organization_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from sentry.models.apikey import ApiKey
from sentry.silo.base import SiloMode
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers.options import override_options
from sentry.testutils.silo import assume_test_silo_mode
from sentry.testutils.skips import requires_snuba

Expand Down Expand Up @@ -312,6 +313,39 @@ def test_expand_context_options(self) -> None:
}
assert not response.data[1].get("options")

@override_options({"api.organization-projects-max-results": 3})
def test_all_projects_limit_exceeded(self) -> None:
# Create 4 projects to exceed the limit of 3
for i in range(4):
self.create_project(teams=[self.team], name=f"project-{i}", slug=f"project-{i}")

response = self.get_success_response(
self.organization.slug, qs_params={"all_projects": "1"}
)
# Should return 3 projects (capped)
assert len(response.data) == 3
# Should include truncation warning header
assert "X-Sentry-Warning" in response
assert "more than 3 projects" in response["X-Sentry-Warning"].lower()
assert "first 3" in response["X-Sentry-Warning"].lower()

@override_options({"api.organization-projects-max-results": 3})
def test_all_projects_at_limit(self) -> None:
# Create exactly 3 projects - should succeed without warning
projects = []
for i in range(3):
projects.append(
self.create_project(teams=[self.team], name=f"project-{i}", slug=f"project-{i}")
)

response = self.get_success_response(
self.organization.slug, qs_params={"all_projects": "1"}
)
# Should return all 3 projects
assert len(response.data) == 3
# Should NOT include truncation warning header (exactly at limit)
assert "X-Sentry-Warning" not in response


class OrganizationProjectsCountTest(APITestCase):
endpoint = "sentry-api-0-organization-projects-count"
Expand Down
Loading