Skip to content

fix(releases): Support environment filter in the query param#112805

Merged
skaasten merged 7 commits intomasterfrom
shaunkaasten/dain-1281-releases-endpoint-environment-query
Apr 14, 2026
Merged

fix(releases): Support environment filter in the query param#112805
skaasten merged 7 commits intomasterfrom
shaunkaasten/dain-1281-releases-endpoint-environment-query

Conversation

@skaasten
Copy link
Copy Markdown
Contributor

The /releases endpoint returns 400 when the query param contains an environment: token (e.g. ?query=environment:production). Other endpoints accept environment either as a dedicated ?environment= param or inline in the query string; this brings releases in line with that behaviour.

The frontend already sends environment: in the query string for some widgets (e.g. crashes by release), so the 400 is a real user-facing breakage.

What changed

src/sentry/api/release_search.py — added ENVIRONMENT_KEY = "environment" alongside the other local key constants, and included it in the release_search_config allowed-key set so the parser accepts it instead of raising InvalidSearchQuery.

src/sentry/api/endpoints/organization_releases.py — added a handler for ENVIRONMENT_KEY in _filter_releases_by_query. It calls classify_and_format_wildcard() (the same helper used by RELEASE_ALIAS) to map wildcard patterns to the right ORM lookups:

  • environment:*foo*__icontains
  • environment:foo*__istartswith
  • environment:*foo__iendswith
  • environment:prod → exact __in
  • !environment:*foo*exclude(...) variant for each

This handles the exact frontend format (!environment:\uF00DContains\uF00Dfoo) that was previously breaking.

Notes

When environment is supplied both as ?environment=prod and as environment:staging in the query string, the two filters are AND-ed — both .filter() calls are applied independently. This is consistent with how other chained filters work and is covered by a test.

release.stage uses filter_params.get("environment") for its environment scoping, which is only populated from the dedicated ?environment= param. This pre-existing inconsistency is out of scope here.

Refs DAIN-1281

skaasten and others added 3 commits April 13, 2026 11:25
The /releases endpoint returned 400 when the query param contained
an environment: token (e.g. query=environment:production), because
the release search config only allowed a fixed set of keys.

Other endpoints accept environment either as a dedicated ?environment=
param or inline in the query string. This change brings releases in
line with that behaviour.

- Add "environment" to the release search config allowed keys
- Handle the environment search filter in _filter_releases_by_query,
  including wildcard patterns (contains / starts_with / ends_with)
  via classify_and_format_wildcard(), matching how other filters
  like RELEASE_ALIAS already work

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When environment is supplied as both a dedicated ?environment= param and
as environment: in the query string, the two filters are AND-ed. Add a
test that locks this in so any future change to the semantics is
deliberate rather than accidental.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…TestCase helpers

Replace manual reverse() + self.client.get() calls with
get_success_response / get_error_response, matching the convention
used by other test classes in this file. No behaviour change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@linear-code
Copy link
Copy Markdown

linear-code bot commented Apr 13, 2026

@github-actions github-actions bot added the Scope: Backend Automatically applied to PRs that change backend components label Apr 13, 2026
@skaasten skaasten marked this pull request as ready for review April 13, 2026 15:57
@skaasten skaasten requested review from a team as code owners April 13, 2026 15:57
Comment thread src/sentry/api/endpoints/organization_releases.py Outdated
url = reverse(
"sentry-api-0-organization-releases", kwargs={"organization_id_or_slug": self.org.slug}
)
response = self.client.get(url + "?environment=" + self.env1.name, format="json")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migrated these tests to the less verbose style used for other test cases.

Comment on lines +184 to +186
if search_filter.key.name == ENVIRONMENT_KEY:
negated = search_filter.operator in ("!=", "NOT IN")
kind, value_o = search_filter.value.classify_and_format_wildcard()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed some of the other filter keys do queryset.filter_by_<key> for example queryset.filter_by_semver, should we have filter_by_semver.filter_by_enviornment instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion - I've updated it to now use that pattern. Let me know if that's looking better 🙏

…ases

The environment filter in _filter_releases_by_query uses a JOIN on
releaseprojectenvironment. When a release is associated with the same
environment across multiple projects, this produces duplicate rows.

__get_old already handles this with .distinct() but __get_new did not.
Add it there to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Unconditional distinct() regresses performance-optimized path
    • I replaced the query-time environment filter with an Exists subquery scoped by project and removed the unconditional distinct() so the optimized path no longer pays global DISTINCT overhead.

Create PR

Or push these changes by commenting:

@cursor push e85c43a17d
Preview (e85c43a17d)
diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py
--- a/src/sentry/api/endpoints/organization_releases.py
+++ b/src/sentry/api/endpoints/organization_releases.py
@@ -5,7 +5,7 @@
 
 import sentry_sdk
 from django.db import IntegrityError
-from django.db.models import F, Q
+from django.db.models import Exists, F, OuterRef, Q
 from drf_spectacular.utils import extend_schema
 from rest_framework.exceptions import ParseError
 from rest_framework.request import Request
@@ -182,32 +182,28 @@
             )
 
         if search_filter.key.name == ENVIRONMENT_KEY:
+            from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment
+
             negated = search_filter.operator in ("!=", "NOT IN")
             kind, value_o = search_filter.value.classify_and_format_wildcard()
             project_ids = filter_params["project_id"]
+            env_filter: dict[str, object] = {
+                "release": OuterRef("pk"),
+                "project_id__in": project_ids,
+            }
             if kind == "infix":
-                env_q = Q(
-                    releaseprojectenvironment__environment__name__icontains=value_o,
-                    releaseprojectenvironment__project_id__in=project_ids,
-                )
+                env_filter["environment__name__icontains"] = value_o
             elif kind == "prefix":
-                env_q = Q(
-                    releaseprojectenvironment__environment__name__istartswith=value_o,
-                    releaseprojectenvironment__project_id__in=project_ids,
-                )
+                env_filter["environment__name__istartswith"] = value_o
             elif kind == "suffix":
-                env_q = Q(
-                    releaseprojectenvironment__environment__name__iendswith=value_o,
-                    releaseprojectenvironment__project_id__in=project_ids,
-                )
+                env_filter["environment__name__iendswith"] = value_o
             else:
                 env_names = value_o if isinstance(value_o, list) else [value_o]
-                env_q = Q(
-                    releaseprojectenvironment__environment__name__in=env_names,
-                    releaseprojectenvironment__project_id__in=project_ids,
-                )
-            queryset = queryset.exclude(env_q) if negated else queryset.filter(env_q)
+                env_filter["environment__name__in"] = env_names
 
+            env_query = Exists(ReleaseProjectEnvironment.objects.filter(**env_filter))
+            queryset = queryset.exclude(env_query) if negated else queryset.filter(env_query)
+
     return queryset
 
 
@@ -418,7 +414,6 @@
                     status=400,
                 )
 
-        queryset = queryset.distinct()
         queryset = filter_releases_by_projects(queryset, filter_params["project_id"])
 
         if sort == "date":

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Comment thread src/sentry/api/endpoints/organization_releases.py Outdated
…nvironment

Replace the inline JOIN-based environment filter in _filter_releases_by_query
with a new ReleaseQuerySet.filter_by_environment method that uses an EXISTS
subquery. This avoids duplicate rows without requiring DISTINCT, which is
especially important on the performance-optimized __get_new path.

Also adds a delegation method on ReleaseModelManager for consistency with
other queryset methods like filter_by_semver and filter_by_stage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread src/sentry/api/endpoints/organization_releases.py
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit aa0dc34. Configure here.

Comment thread src/sentry/models/releases/util.py Outdated
…Endpoint

The stats endpoint builds its queryset with .values() before passing it to
_filter_releases_by_query. This test confirms that filter_by_environment()'s
Exists subquery works correctly on that ValuesQuerySet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous isinstance(value, list) guard would mishandle any Sequence that
isn't a list (e.g. a tuple): it would wrap the tuple as a single element,
producing 'environment__name__in=[tuple]' instead of spreading the values.
Checking for str instead correctly handles all Sequence types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@DominikB2014 DominikB2014 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, one thing we might want to check before is if the query param behaviour matches the beheviour of other endpoints like /events. In particular what happens when we have both the environment query param and envionrment in the query query param

@skaasten
Copy link
Copy Markdown
Contributor Author

lgtm, one thing we might want to check before is if the query param behaviour matches the beheviour of other endpoints like /events. In particular what happens when we have both the environment query param and envionrment in the query query param

It appears to me to be the same behaviour (use both) based on here where it applies the query first, and then appends conditions from the separate query params (e.g. environment).

@skaasten skaasten merged commit 8613824 into master Apr 14, 2026
77 checks passed
@skaasten skaasten deleted the shaunkaasten/dain-1281-releases-endpoint-environment-query branch April 14, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants