From aada4b9967791c76af6a3f25957e67443096cafb Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 12 Mar 2025 18:24:21 -0400 Subject: [PATCH 1/5] get and post endpoints --- .../serializers/models/exploresavedquery.py | 101 ++++ src/sentry/api/urls.py | 7 + .../examples/explore_saved_query_examples.py | 161 +++++++ src/sentry/apidocs/parameters.py | 27 ++ src/sentry/explore/endpoints/__init__.py | 0 src/sentry/explore/endpoints/bases.py | 39 ++ .../endpoints/explore_saved_queries.py | 190 ++++++++ src/sentry/explore/endpoints/serializers.py | 140 ++++++ tests/sentry/explore/__init__.py | 0 tests/sentry/explore/endpoints/__init__.py | 0 .../endpoints/test_explore_saved_queries.py | 448 ++++++++++++++++++ 11 files changed, 1113 insertions(+) create mode 100644 src/sentry/api/serializers/models/exploresavedquery.py create mode 100644 src/sentry/apidocs/examples/explore_saved_query_examples.py create mode 100644 src/sentry/explore/endpoints/__init__.py create mode 100644 src/sentry/explore/endpoints/bases.py create mode 100644 src/sentry/explore/endpoints/explore_saved_queries.py create mode 100644 src/sentry/explore/endpoints/serializers.py create mode 100644 tests/sentry/explore/__init__.py create mode 100644 tests/sentry/explore/endpoints/__init__.py create mode 100644 tests/sentry/explore/endpoints/test_explore_saved_queries.py diff --git a/src/sentry/api/serializers/models/exploresavedquery.py b/src/sentry/api/serializers/models/exploresavedquery.py new file mode 100644 index 00000000000000..a8961e50a6e1fe --- /dev/null +++ b/src/sentry/api/serializers/models/exploresavedquery.py @@ -0,0 +1,101 @@ +from collections import defaultdict +from typing import DefaultDict, TypedDict + +from sentry.api.serializers import Serializer, register +from sentry.constants import ALL_ACCESS_PROJECTS +from sentry.explore.models import ExploreSavedQuery, ExploreSavedQueryDataset +from sentry.users.api.serializers.user import UserSerializerResponse +from sentry.users.services.user.service import user_service +from sentry.utils.dates import outside_retention_with_modified_start, parse_timestamp + + +class ExploreSavedQueryResponseOptional(TypedDict, total=False): + environment: list[str] + query: str + fields: list[str] + range: str + start: str + end: str + orderby: str + visualize: list[dict] + interval: str + mode: str + + +class ExploreSavedQueryResponse(ExploreSavedQueryResponseOptional): + id: str + name: str + projects: list[int] + dataset: str + expired: bool + dateAdded: str + dateUpdated: str + createdBy: UserSerializerResponse + + +@register(ExploreSavedQuery) +class ExploreSavedQueryModelSerializer(Serializer): + def get_attrs(self, item_list, user, **kwargs): + result: DefaultDict[str, dict] = defaultdict(lambda: {"created_by": {}}) + + service_serialized = user_service.serialize_many( + filter={ + "user_ids": [ + explore_saved_query.created_by_id + for explore_saved_query in item_list + if explore_saved_query.created_by_id + ] + }, + as_user=user if user.id else None, + ) + serialized_users = {user["id"]: user for user in service_serialized} + + for explore_saved_query in item_list: + result[explore_saved_query]["created_by"] = serialized_users.get( + str(explore_saved_query.created_by_id) + ) + + return result + + def serialize(self, obj, attrs, user, **kwargs) -> ExploreSavedQueryResponse: + query_keys = [ + "environment", + "query", + "fields", + "range", + "start", + "end", + "orderby", + "visualize", + "interval", + "mode", + ] + data: ExploreSavedQueryResponse = { + "id": str(obj.id), + "name": obj.name, + "projects": [project.id for project in obj.projects.all()], + "dataset": ExploreSavedQueryDataset.get_type_name(obj.dataset), + "expired": False, + "dateAdded": obj.date_added, + "dateUpdated": obj.date_updated, + "createdBy": attrs.get("created_by"), + } + + for key in query_keys: + if obj.query.get(key) is not None: + data[key] = obj.query[key] # type: ignore[literal-required] + + # expire queries that are beyond the retention period + if "start" in obj.query: + start, end = parse_timestamp(obj.query["start"]), parse_timestamp(obj.query["end"]) + if start and end: + expired, modified_start = outside_retention_with_modified_start( + start, end, obj.organization + ) + data["expired"] = expired + data["start"] = modified_start.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + if obj.query.get("all_projects"): + data["projects"] = list(ALL_ACCESS_PROJECTS) + + return data diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index cafdc8beedd432..96ec20e2c69f6d 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -68,6 +68,7 @@ DiscoverSavedQueryDetailEndpoint, DiscoverSavedQueryVisitEndpoint, ) +from sentry.explore.endpoints.explore_saved_queries import ExploreSavedQueriesEndpoint from sentry.flags.endpoints.hooks import OrganizationFlagsHooksEndpoint from sentry.flags.endpoints.logs import ( OrganizationFlagLogDetailsEndpoint, @@ -1281,6 +1282,12 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ProjectTransactionThresholdOverrideEndpoint.as_view(), name="sentry-api-0-organization-project-transaction-threshold-override", ), + # Explore + re_path( + r"^(?P[^\/]+)/explore/saved/$", + ExploreSavedQueriesEndpoint.as_view(), + name="sentry-api-0-explore-saved-queries", + ), # Dashboards re_path( r"^(?P[^\/]+)/dashboards/$", diff --git a/src/sentry/apidocs/examples/explore_saved_query_examples.py b/src/sentry/apidocs/examples/explore_saved_query_examples.py new file mode 100644 index 00000000000000..139005f1dafcdc --- /dev/null +++ b/src/sentry/apidocs/examples/explore_saved_query_examples.py @@ -0,0 +1,161 @@ +from drf_spectacular.utils import OpenApiExample + +EXPLORE_SAVED_QUERY_OBJ = { + "id": "1", + "name": "Pageloads", + "projects": [], + "dateCreated": "2024-07-25T19:35:38.422859Z", + "dateUpdated": "2024-07-25T19:35:38.422874Z", + "environment": [], + "query": "span.op:pageload", + "fields": [ + "span.op", + "project", + "count(span.duration)", + "avg(span.duration)", + "p75(span.duration)", + "p95(span.duration)", + ], + "widths": [], + "range": "24h", + "orderby": "-count(span.duration)", + "yAxis": ["count(span.duration)"], + "mode": "samples", + "createdBy": { + "id": "1", + "name": "Admin", + "username": "admin", + "email": "admin@sentry.io", + "avatarUrl": "www.example.com", + "isActive": True, + "hasPasswordAuth": True, + "isManaged": False, + "dateJoined": "2021-10-25T17:07:33.190596Z", + "lastLogin": "2024-07-16T15:28:39.261659Z", + "has2fa": True, + "lastActive": "2024-07-16T20:45:49.364197Z", + "isSuperuser": False, + "isStaff": False, + "experiments": {}, + "emails": [{"id": "1", "email": "admin@sentry.io", "is_verified": True}], + "avatar": { + "avatarType": "letter_avatar", + "avatarUuid": None, + "avatarUrl": "www.example.com", + }, + }, +} + +SAVED_QUERIES = [ + { + "id": "1", + "name": "Pageloads", + "projects": [], + "dateCreated": "2024-07-25T19:35:38.422859Z", + "dateUpdated": "2024-07-25T19:35:38.422874Z", + "environment": [], + "query": "span.op:pageload", + "fields": [ + "span.op", + "timestamp", + ], + "widths": [], + "range": "24h", + "orderby": "-timestamp", + "yAxis": ["count(span.duration)"], + "mode": "samples", + "createdBy": { + "id": "1", + "name": "Admin", + "username": "admin", + "email": "admin@sentry.io", + "avatarUrl": "www.example.com", + "isActive": True, + "hasPasswordAuth": True, + "isManaged": False, + "dateJoined": "2021-10-25T17:07:33.190596Z", + "lastLogin": "2024-07-16T15:28:39.261659Z", + "has2fa": True, + "lastActive": "2024-07-16T20:45:49.364197Z", + "isSuperuser": False, + "isStaff": False, + "experiments": {}, + "emails": [{"id": "1", "email": "admin@sentry.io", "is_verified": True}], + "avatar": { + "avatarType": "letter_avatar", + "avatarUuid": None, + "avatarUrl": "www.example.com", + }, + }, + }, + { + "id": "2", + "name": "Cache Gets", + "projects": [], + "dateCreated": "2024-07-25T19:35:38.422859Z", + "dateUpdated": "2024-07-25T19:35:38.422874Z", + "environment": [], + "query": "span.op:cache.get", + "fields": [ + "span.op", + "span.duration" "timestamp", + ], + "widths": [], + "range": "24h", + "orderby": "-timestamp", + "yAxis": ["count(span.duration)"], + "mode": "samples", + "createdBy": { + "id": "1", + "name": "Admin", + "username": "admin", + "email": "admin@sentry.io", + "avatarUrl": "www.example.com", + "isActive": True, + "hasPasswordAuth": True, + "isManaged": False, + "dateJoined": "2021-10-25T17:07:33.190596Z", + "lastLogin": "2024-07-16T15:28:39.261659Z", + "has2fa": True, + "lastActive": "2024-07-16T20:45:49.364197Z", + "isSuperuser": False, + "isStaff": False, + "experiments": {}, + "emails": [{"id": "1", "email": "admin@sentry.io", "is_verified": True}], + "avatar": { + "avatarType": "letter_avatar", + "avatarUuid": None, + "avatarUrl": "www.example.com", + }, + }, + }, +] + + +class ExploreExamples: + EXPLORE_SAVED_QUERY_GET_RESPONSE = [ + OpenApiExample( + "Explore Saved Query GET response", + value=EXPLORE_SAVED_QUERY_OBJ, + status_codes=["200"], + response_only=True, + ) + ] + + EXPLORE_SAVED_QUERY_POST_RESPONSE = [ + OpenApiExample( + "Create Explore Saved Query", + value=EXPLORE_SAVED_QUERY_OBJ, + status_codes=["201"], + response_only=True, + ) + ] + + EXPLORE_SAVED_QUERIES_QUERY_RESPONSE = [ + OpenApiExample( + "Get Explore Saved Queries", + value=SAVED_QUERIES, + status_codes=["200"], + response_only=True, + ) + ] diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index 8ff5159616c1ae..8db22e1baa18e6 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -817,3 +817,30 @@ class DiscoverSavedQueriesParams: - `myqueries` """, ) + + +class ExploreSavedQueriesParams: + QUERY = OpenApiParameter( + name="query", + location="query", + required=False, + type=str, + description="""The name of the Explore query you'd like to filter by.""", + ) + + SORT = OpenApiParameter( + name="sortBy", + location="query", + required=False, + type=str, + description="""The property to sort results by. If not specified, the results are sorted by query name. + +Available fields are: +- `name` +- `dateCreated` +- `dateUpdated` +- `mostPopular` +- `recentlyViewed` +- `myqueries` + """, + ) diff --git a/src/sentry/explore/endpoints/__init__.py b/src/sentry/explore/endpoints/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/explore/endpoints/bases.py b/src/sentry/explore/endpoints/bases.py new file mode 100644 index 00000000000000..8512c577eb49dd --- /dev/null +++ b/src/sentry/explore/endpoints/bases.py @@ -0,0 +1,39 @@ +from sentry.api.bases.organization import OrganizationPermission +from sentry.explore.models import ExploreSavedQuery +from sentry.models.organization import Organization + + +class ExploreSavedQueryPermission(OrganizationPermission): + # Relaxed permissions for saved queries in Explore + scope_map = { + "GET": ["org:read", "org:write", "org:admin"], + "POST": ["org:read", "org:write", "org:admin"], + "PUT": ["org:read", "org:write", "org:admin"], + "DELETE": ["org:read", "org:write", "org:admin"], + } + + def has_object_permission(self, request, view, obj): + if isinstance(obj, Organization): + return super().has_object_permission(request, view, obj) + + if isinstance(obj, ExploreSavedQuery): + # 1. Saved Query contains certain projects + if obj.projects.exists(): + return request.access.has_projects_access(obj.projects.all()) + + # 2. Saved Query covers all projects or all my projects + + # allow when Open Membership + if obj.organization.flags.allow_joinleave: + return True + + # allow for Managers and Owners + if request.access.has_scope("org:write"): + return True + + # allow for creator + if request.user.id == obj.created_by_id: + return True + + return False + return True diff --git a/src/sentry/explore/endpoints/explore_saved_queries.py b/src/sentry/explore/endpoints/explore_saved_queries.py new file mode 100644 index 00000000000000..3cd409f3bc07d9 --- /dev/null +++ b/src/sentry/explore/endpoints/explore_saved_queries.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from django.db.models import Case, IntegerField, When +from drf_spectacular.utils import extend_schema +from rest_framework.exceptions import ParseError +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import features +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases import NoProjects, OrganizationEndpoint +from sentry.api.paginator import GenericOffsetPaginator +from sentry.api.serializers import serialize +from sentry.api.serializers.models.exploresavedquery import ( + ExploreSavedQueryModelSerializer, + ExploreSavedQueryResponse, +) +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND +from sentry.apidocs.examples.explore_saved_query_examples import ExploreExamples +from sentry.apidocs.parameters import ( + CursorQueryParam, + ExploreSavedQueriesParams, + GlobalParams, + VisibilityParams, +) +from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.explore.endpoints.bases import ExploreSavedQueryPermission +from sentry.explore.endpoints.serializers import ExploreSavedQuerySerializer +from sentry.explore.models import ExploreSavedQuery +from sentry.search.utils import tokenize_query + + +@extend_schema(tags=["Explore"]) +@region_silo_endpoint +class ExploreSavedQueriesEndpoint(OrganizationEndpoint): + publish_status = { + "GET": ApiPublishStatus.PUBLIC, + "POST": ApiPublishStatus.PUBLIC, + } + owner = ApiOwner.PERFORMANCE + permission_classes = (ExploreSavedQueryPermission,) + + def has_feature(self, organization, request): + return features.has( + "organizations:performance-trace-explorer", organization, actor=request.user + ) + + @extend_schema( + operation_id="List an Organization's Explore Saved Queries", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + VisibilityParams.PER_PAGE, + CursorQueryParam, + ExploreSavedQueriesParams.QUERY, + ExploreSavedQueriesParams.SORT, + ], + request=None, + responses={ + 200: inline_sentry_response_serializer( + "ExploreSavedQueryListResponse", list[ExploreSavedQueryResponse] + ), + 400: RESPONSE_BAD_REQUEST, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=ExploreExamples.EXPLORE_SAVED_QUERIES_QUERY_RESPONSE, + ) + def get(self, request: Request, organization) -> Response: + """ + Retrieve a list of saved queries that are associated with the given organization. + """ + + if not self.has_feature(organization, request): + return self.respond(status=404) + + queryset = ( + ExploreSavedQuery.objects.filter(organization=organization) + .prefetch_related("projects") + .extra(select={"lower_name": "lower(name)"}) + ) + query = request.query_params.get("query") + if query: + tokens = tokenize_query(query) + for key, value in tokens.items(): + if key == "name" or key == "query": + queryset = queryset.filter(name__icontains=" ".join(value)) + else: + queryset = queryset.none() + + sort_by = request.query_params.get("sortBy") + if sort_by and sort_by.startswith("-"): + sort_by, desc = sort_by[1:], True + else: + desc = False + + if sort_by == "name": + order_by: list[Case | str] = [ + "-lower_name" if desc else "lower_name", + "-date_added", + ] + + elif sort_by == "dateAdded": + order_by = ["-date_added" if desc else "date_added"] + + elif sort_by == "dateUpdated": + order_by = ["-date_updated" if desc else "date_updated"] + + elif sort_by == "mostPopular": + order_by = [ + "visits" if desc else "-visits", + "-date_updated", + ] + + elif sort_by == "recentlyViewed": + order_by = ["last_visited" if desc else "-last_visited"] + + elif sort_by == "myqueries": + order_by = [ + Case( + When(created_by_id=request.user.id, then=-1), + default="created_by_id", + output_field=IntegerField(), + ), + "-date_added", + ] + + else: + order_by = ["lower_name"] + + queryset = queryset.order_by(*order_by) + + def data_fn(offset, limit): + return list(queryset[offset : offset + limit]) + + return self.paginate( + request=request, + paginator=GenericOffsetPaginator(data_fn=data_fn), + on_results=lambda x: serialize(x, request.user), + default_per_page=25, + ) + + @extend_schema( + operation_id="Create a New Saved Query", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=ExploreSavedQuerySerializer, + responses={ + 201: ExploreSavedQueryModelSerializer, + 400: RESPONSE_BAD_REQUEST, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=ExploreExamples.EXPLORE_SAVED_QUERY_POST_RESPONSE, + ) + def post(self, request: Request, organization) -> Response: + """ + Create a new saved query for the given organization. + """ + if not self.has_feature(organization, request): + return self.respond(status=404) + + try: + params = self.get_filter_params( + request, organization, project_ids=request.data.get("projects") + ) + except NoProjects: + raise ParseError(detail="No Projects found, join a Team") + + serializer = ExploreSavedQuerySerializer( + data=request.data, + context={"params": params, "organization": organization, "user": request.user}, + ) + + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + data = serializer.validated_data + + model = ExploreSavedQuery.objects.create( + organization=organization, + name=data["name"], + query=data["query"], + dataset=data["dataset"], + created_by_id=request.user.id if request.user.is_authenticated else None, + ) + + model.set_projects(data["project_ids"]) + + return Response(serialize(model), status=201) diff --git a/src/sentry/explore/endpoints/serializers.py b/src/sentry/explore/endpoints/serializers.py new file mode 100644 index 00000000000000..bd5ef3c41a5040 --- /dev/null +++ b/src/sentry/explore/endpoints/serializers.py @@ -0,0 +1,140 @@ +from drf_spectacular.utils import extend_schema_serializer +from rest_framework import serializers +from rest_framework.serializers import ListField + +from sentry.constants import ALL_ACCESS_PROJECTS +from sentry.explore.models import ExploreSavedQueryDataset +from sentry.utils.dates import parse_stats_period, validate_interval + + +class VisualizeSerializer(serializers.Serializer): + chartType = serializers.IntegerField() + yAxes = serializers.ListField(child=serializers.CharField()) + + +@extend_schema_serializer(exclude_fields=["groupby"]) +class ExploreSavedQuerySerializer(serializers.Serializer): + name = serializers.CharField( + required=True, max_length=255, help_text="The user-defined saved query name." + ) + projects = ListField( + child=serializers.IntegerField(), + required=False, + default=[], + help_text="The saved projects filter for this query.", + ) + dataset = serializers.ChoiceField( + choices=ExploreSavedQueryDataset.as_text_choices(), + default=ExploreSavedQueryDataset.get_type_name(ExploreSavedQueryDataset.SPANS), + help_text="The dataset you would like to query. `spans` is the only supported value for now.", + ) + start = serializers.DateTimeField( + required=False, allow_null=True, help_text="The saved start time for this saved query." + ) + end = serializers.DateTimeField( + required=False, allow_null=True, help_text="The saved end time for this saved query." + ) + range = serializers.CharField( + required=False, + allow_null=True, + help_text="The saved time range period for this saved query.", + ) + fields = ListField( + child=serializers.CharField(), + required=False, + allow_null=True, + help_text="""The fields, functions, or equations that can be requested for the query. At most 20 fields can be selected per request. Each field can be one of the following types: +- A built-in key field. See possible fields in the [properties table](/product/sentry-basics/search/searchable-properties/#properties-table), under any field that is an event property. + - example: `field=transaction` +- A tag. Tags should use the `tag[]` formatting to avoid ambiguity with any fields + - example: `field=tag[isEnterprise]` +- A function which will be in the format of `function_name(parameters,...)`. See possible functions in the [query builder documentation](/product/discover-queries/query-builder/#stacking-functions). + - when a function is included, Discover will group by any tags or fields + - example: `field=count_if(transaction.duration,greater,300)` +- An equation when prefixed with `equation|`. Read more about [equations here](/product/discover-queries/query-builder/query-equations/). + - example: `field=equation|count_if(transaction.duration,greater,300) / count() * 100` +""", + ) # type: ignore[assignment] # XXX: clobbers Serializer.fields + orderby = serializers.CharField( + required=False, + allow_null=True, + help_text="How to order the query results. Must be something in the `field` list, excluding equations.", + ) + + groupby = ListField(child=serializers.CharField(), required=False, allow_null=True) + environment = ListField( + child=serializers.CharField(), + required=False, + allow_null=True, + help_text="The name of environments to filter by.", + ) + query = serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + help_text="Filters results by using [query syntax](/product/sentry-basics/search/).", + ) + visualize = ListField( + child=VisualizeSerializer(), + required=False, + allow_null=True, + help_text="The visualizations to be plotted on the chart.", + ) + interval = serializers.CharField( + required=False, allow_null=True, help_text="Resolution of the time series." + ) + mode = serializers.ChoiceField( + choices=[ + "samples", + "aggregate", + ], + help_text="The mode of the query.", + ) + + def validate_projects(self, projects): + from sentry.api.validators import validate_project_ids + + return validate_project_ids(projects, self.context["params"]["project_id"]) + + def validate(self, data): + query = {} + query_keys = [ + "environment", + "query", + "fields", + "range", + "start", + "end", + "orderby", + "visualize", + "interval", + "mode", + ] + + for key in query_keys: + if data.get(key) is not None: + query[key] = data[key] + + if data["projects"] == ALL_ACCESS_PROJECTS: + data["projects"] = [] + query["all_projects"] = True + + if "query" in query: + if "interval" in query: + interval = parse_stats_period(query["interval"]) + if interval is None: + raise serializers.ValidationError("Interval could not be parsed") + date_range = self.context["params"]["end"] - self.context["params"]["start"] + validate_interval( + interval, + serializers.ValidationError("Interval would cause too many results"), + date_range, + 0, + ) + + return { + "name": data["name"], + "project_ids": data["projects"], + "query": query, + "dataset": ExploreSavedQueryDataset.get_id_for_type_name(data["dataset"]), + } diff --git a/tests/sentry/explore/__init__.py b/tests/sentry/explore/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/sentry/explore/endpoints/__init__.py b/tests/sentry/explore/endpoints/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/sentry/explore/endpoints/test_explore_saved_queries.py b/tests/sentry/explore/endpoints/test_explore_saved_queries.py new file mode 100644 index 00000000000000..036e8b285d2c60 --- /dev/null +++ b/tests/sentry/explore/endpoints/test_explore_saved_queries.py @@ -0,0 +1,448 @@ +from django.urls import reverse + +from sentry.explore.models import ExploreSavedQuery +from sentry.testutils.cases import APITestCase, SnubaTestCase +from sentry.testutils.helpers.datetime import before_now + + +class ExploreSavedQueriesTest(APITestCase, SnubaTestCase): + feature_name = "organizations:performance-trace-explorer" + + def setUp(self): + super().setUp() + self.login_as(user=self.user) + self.org = self.create_organization(owner=self.user) + self.projects = [ + self.create_project(organization=self.org), + self.create_project(organization=self.org), + ] + self.project_ids = [project.id for project in self.projects] + self.project_ids_without_access = [self.create_project().id] + query = {"fields": ["span.op"], "mode": "samples"} + + model = ExploreSavedQuery.objects.create( + organization=self.org, + created_by_id=self.user.id, + name="Test query", + query=query, + ) + + model.set_projects(self.project_ids) + + self.url = reverse("sentry-api-0-explore-saved-queries", args=[self.org.slug]) + + def test_get(self): + with self.feature(self.feature_name): + response = self.client.get(self.url) + + assert response.status_code == 200, response.content + assert len(response.data) == 1 + assert response.data[0]["name"] == "Test query" + assert response.data[0]["projects"] == self.project_ids + assert response.data[0]["fields"] == ["span.op"] + assert response.data[0]["mode"] == "samples" + assert "createdBy" in response.data[0] + assert response.data[0]["createdBy"]["username"] == self.user.username + assert not response.data[0]["expired"] + + def test_get_name_filter(self): + with self.feature(self.feature_name): + response = self.client.get(self.url, format="json", data={"query": "Test"}) + + assert response.status_code == 200, response.content + assert len(response.data) == 1 + assert response.data[0]["name"] == "Test query" + + with self.feature(self.feature_name): + # Also available as the name: filter. + response = self.client.get(self.url, format="json", data={"query": "name:Test"}) + + assert response.status_code == 200, response.content + assert len(response.data) == 1 + assert response.data[0]["name"] == "Test query" + + with self.feature(self.feature_name): + response = self.client.get(self.url, format="json", data={"query": "name:Nope"}) + + assert response.status_code == 200, response.content + assert len(response.data) == 0 + + def test_get_all_paginated(self): + for i in range(0, 10): + query = {"fields": ["span.op"], "mode": "samples"} + model = ExploreSavedQuery.objects.create( + organization=self.org, + created_by_id=self.user.id, + name=f"My query {i}", + query=query, + ) + model.set_projects(self.project_ids) + + with self.feature(self.feature_name): + response = self.client.get(self.url, data={"per_page": 1}) + assert response.status_code == 200, response.content + assert len(response.data) == 1 + + def test_get_sortby(self): + query = {"fields": ["span.op"], "mode": "samples"} + model = ExploreSavedQuery.objects.create( + organization=self.org, + created_by_id=self.user.id, + name="My query", + query=query, + date_added=before_now(minutes=10), + date_updated=before_now(minutes=10), + ) + model.set_projects(self.project_ids) + + sort_options = { + "dateAdded": True, + "-dateAdded": False, + "dateUpdated": True, + "-dateUpdated": False, + "name": True, + "-name": False, + } + for sorting, forward_sort in sort_options.items(): + with self.feature(self.feature_name): + response = self.client.get(self.url, data={"sortBy": sorting}) + assert response.status_code == 200 + + values = [row[sorting.strip("-")] for row in response.data] + if not forward_sort: + values = list(reversed(values)) + assert list(sorted(values)) == values + + def test_get_sortby_most_popular(self): + query = {"fields": ["span.op"], "mode": "samples"} + model = ExploreSavedQuery.objects.create( + organization=self.org, + created_by_id=self.user.id, + name="My query", + query=query, + visits=3, + date_added=before_now(minutes=10), + date_updated=before_now(minutes=10), + last_visited=before_now(minutes=5), + ) + + model.set_projects(self.project_ids) + for forward_sort in [True, False]: + sorting = "mostPopular" if forward_sort else "-mostPopular" + with self.feature(self.feature_name): + response = self.client.get(self.url, data={"sortBy": sorting}) + + assert response.status_code == 200 + values = [row["name"] for row in response.data] + expected = ["My query", "Test query"] + + if not forward_sort: + expected = list(reversed(expected)) + + assert values == expected + + def test_get_sortby_recently_viewed(self): + query = {"fields": ["span.op"], "mode": "samples"} + model = ExploreSavedQuery.objects.create( + organization=self.org, + created_by_id=self.user.id, + name="My query", + query=query, + visits=3, + date_added=before_now(minutes=10), + date_updated=before_now(minutes=10), + last_visited=before_now(minutes=5), + ) + + model.set_projects(self.project_ids) + for forward_sort in [True, False]: + sorting = "recentlyViewed" if forward_sort else "-recentlyViewed" + with self.feature(self.feature_name): + response = self.client.get(self.url, data={"sortBy": sorting}) + + assert response.status_code == 200 + values = [row["name"] for row in response.data] + expected = ["Test query", "My query"] + + if not forward_sort: + expected = list(reversed(expected)) + + assert values == expected + + def test_get_sortby_myqueries(self): + uhoh_user = self.create_user(username="uhoh") + self.create_member(organization=self.org, user=uhoh_user) + + whoops_user = self.create_user(username="whoops") + self.create_member(organization=self.org, user=whoops_user) + + query = {"fields": ["span.op"], "mode": "samples"} + model = ExploreSavedQuery.objects.create( + organization=self.org, + created_by_id=uhoh_user.id, + name="a query for uhoh", + query=query, + date_added=before_now(minutes=10), + date_updated=before_now(minutes=10), + ) + model.set_projects(self.project_ids) + + model = ExploreSavedQuery.objects.create( + organization=self.org, + created_by_id=whoops_user.id, + name="a query for whoops", + query=query, + date_added=before_now(minutes=10), + date_updated=before_now(minutes=10), + ) + model.set_projects(self.project_ids) + + with self.feature(self.feature_name): + response = self.client.get(self.url, data={"sortBy": "myqueries"}) + assert response.status_code == 200, response.content + values = [int(item["createdBy"]["id"]) for item in response.data] + assert values == [self.user.id, uhoh_user.id, whoops_user.id] + + def test_get_expired_query(self): + query = { + "start": before_now(days=90), + "end": before_now(days=61), + } + ExploreSavedQuery.objects.create( + organization=self.org, + created_by_id=self.user.id, + name="My expired query", + query=query, + date_added=before_now(days=90), + date_updated=before_now(minutes=10), + ) + with self.options({"system.event-retention-days": 60}), self.feature(self.feature_name): + response = self.client.get(self.url, {"query": "name:My expired query"}) + + assert response.status_code == 200, response.content + assert response.data[0]["expired"] + + def test_post_require_mode(self): + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "New query", + "projects": self.project_ids, + "fields": [], + "range": "24h", + }, + ) + assert response.status_code == 400, response.content + assert "This field is required." == response.data["mode"][0] + + def test_post_success(self): + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "new query", + "projects": self.project_ids, + "fields": ["span.op", "count(span.duration)"], + "environment": ["dev"], + "query": "span.op:pageload", + "range": "24h", + "mode": "samples", + }, + ) + assert response.status_code == 201, response.content + data = response.data + assert data["fields"] == ["span.op", "count(span.duration)"] + assert data["range"] == "24h" + assert data["environment"] == ["dev"] + assert data["mode"] == "samples" + assert data["query"] == "span.op:pageload" + assert data["projects"] == self.project_ids + assert data["dataset"] == "spans" + + def test_post_all_projects(self): + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "New query", + "projects": [-1], + "fields": ["span.op", "count(span.duration)"], + "range": "24h", + "mode": "samples", + }, + ) + assert response.status_code == 201, response.content + assert response.data["projects"] == [-1] + + def test_save_with_project(self): + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "project query", + "projects": self.project_ids, + "fields": ["span.op", "count(span.duration)"], + "range": "24h", + "query": f"project:{self.projects[0].slug}", + "mode": "samples", + }, + ) + assert response.status_code == 201, response.content + assert ExploreSavedQuery.objects.filter(name="project query").exists() + + def test_save_with_project_and_my_projects(self): + team = self.create_team(organization=self.org, members=[self.user]) + project = self.create_project(organization=self.org, teams=[team]) + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "project query", + "projects": [], + "fields": ["span.op", "count(span.duration)"], + "range": "24h", + "query": f"project:{project.slug}", + "mode": "samples", + }, + ) + assert response.status_code == 201, response.content + assert ExploreSavedQuery.objects.filter(name="project query").exists() + + def test_save_with_org_projects(self): + project = self.create_project(organization=self.org) + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "project query", + "projects": [project.id], + "fields": ["span.op", "count(span.duration)"], + "range": "24h", + "mode": "samples", + }, + ) + assert response.status_code == 201, response.content + assert ExploreSavedQuery.objects.filter(name="project query").exists() + + def test_save_with_team_project(self): + team = self.create_team(organization=self.org, members=[self.user]) + project = self.create_project(organization=self.org, teams=[team]) + self.create_project(organization=self.org, teams=[team]) + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "project query", + "projects": [project.id], + "fields": ["span.op", "count(span.duration)"], + "range": "24h", + "mode": "samples", + }, + ) + assert response.status_code == 201, response.content + assert ExploreSavedQuery.objects.filter(name="project query").exists() + + def test_save_without_team(self): + team = self.create_team(organization=self.org, members=[]) + self.create_project(organization=self.org, teams=[team]) + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "without team query", + "projects": [], + "fields": ["span.op", "count(span.duration)"], + "range": "24h", + "mode": "samples", + }, + ) + + assert response.status_code == 400 + assert "No Projects found, join a Team" == response.data["detail"] + + def test_save_with_team_and_without_project(self): + team = self.create_team(organization=self.org, members=[self.user]) + self.create_project(organization=self.org, teams=[team]) + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "with team query", + "projects": [], + "fields": ["span.op", "count(span.duration)"], + "range": "24h", + "mode": "samples", + }, + ) + + assert response.status_code == 201, response.content + assert ExploreSavedQuery.objects.filter(name="with team query").exists() + + def test_save_with_wrong_projects(self): + other_org = self.create_organization(owner=self.user) + project = self.create_project(organization=other_org) + project2 = self.create_project(organization=self.org) + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "project query", + "projects": [project.id], + "fields": ["span.op", "count(span.duration)"], + "range": "24h", + "query": f"project:{project.slug}", + "mode": "samples", + }, + ) + assert response.status_code == 403, response.content + assert not ExploreSavedQuery.objects.filter(name="project query").exists() + + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "project query", + "projects": [project.id, project2.id], + "fields": ["span.op", "count(span.duration)"], + "range": "24h", + "query": f"project:{project.slug} project:{project2.slug}", + "mode": "samples", + }, + ) + assert response.status_code == 403, response.content + assert not ExploreSavedQuery.objects.filter(name="project query").exists() + + def test_save_interval(self): + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "Interval query", + "projects": [-1], + "fields": ["span.op", "count(span.duration)"], + "statsPeriod": "24h", + "query": "spaceAfterColon:1", + "mode": "samples", + "interval": "1m", + }, + ) + assert response.status_code == 201, response.content + assert response.data["name"] == "Interval query" + assert response.data["interval"] == "1m" + + def test_save_invalid_interval(self): + with self.feature(self.feature_name): + response = self.client.post( + self.url, + { + "name": "Interval query", + "projects": [-1], + "fields": ["span.op", "count(span.duration)"], + "range": "24h", + "query": "spaceAfterColon:1", + "interval": "1s", + "mode": "samples", + }, + ) + assert response.status_code == 400, response.content From 62d66500fe4dc02c26e122bb9ce85094969ec80e Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 12 Mar 2025 18:40:16 -0400 Subject: [PATCH 2/5] fix endpoint tag --- src/sentry/explore/endpoints/explore_saved_queries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/explore/endpoints/explore_saved_queries.py b/src/sentry/explore/endpoints/explore_saved_queries.py index 3cd409f3bc07d9..0eec03d8363d6c 100644 --- a/src/sentry/explore/endpoints/explore_saved_queries.py +++ b/src/sentry/explore/endpoints/explore_saved_queries.py @@ -32,7 +32,7 @@ from sentry.search.utils import tokenize_query -@extend_schema(tags=["Explore"]) +@extend_schema(tags=["Discover"]) @region_silo_endpoint class ExploreSavedQueriesEndpoint(OrganizationEndpoint): publish_status = { From 74965a36d32c10de3cf41bee63b09a1cf39dc709 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 12 Mar 2025 18:45:48 -0400 Subject: [PATCH 3/5] doc --- src/sentry/discover/endpoints/discover_saved_queries.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/discover/endpoints/discover_saved_queries.py b/src/sentry/discover/endpoints/discover_saved_queries.py index 009b919ad43862..55a8d7f054a657 100644 --- a/src/sentry/discover/endpoints/discover_saved_queries.py +++ b/src/sentry/discover/endpoints/discover_saved_queries.py @@ -148,7 +148,7 @@ def data_fn(offset, limit): ) @extend_schema( - operation_id="Create a New Saved Query", + operation_id="Create a New Saved Trace Explorer Query", parameters=[GlobalParams.ORG_ID_OR_SLUG], request=DiscoverSavedQuerySerializer, responses={ @@ -161,7 +161,7 @@ def data_fn(offset, limit): ) def post(self, request: Request, organization) -> Response: """ - Create a new saved query for the given organization. + Create a new saved trace explorer query for the given organization. """ if not self.has_feature(organization, request): return self.respond(status=404) From 6d28c27efadb7172423461d2bb3e490af6f4e659 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 12 Mar 2025 18:54:21 -0400 Subject: [PATCH 4/5] fix example --- .../examples/explore_saved_query_examples.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/sentry/apidocs/examples/explore_saved_query_examples.py b/src/sentry/apidocs/examples/explore_saved_query_examples.py index 139005f1dafcdc..542d4e0665e79c 100644 --- a/src/sentry/apidocs/examples/explore_saved_query_examples.py +++ b/src/sentry/apidocs/examples/explore_saved_query_examples.py @@ -4,7 +4,7 @@ "id": "1", "name": "Pageloads", "projects": [], - "dateCreated": "2024-07-25T19:35:38.422859Z", + "dateAdded": "2024-07-25T19:35:38.422859Z", "dateUpdated": "2024-07-25T19:35:38.422874Z", "environment": [], "query": "span.op:pageload", @@ -16,11 +16,11 @@ "p75(span.duration)", "p95(span.duration)", ], - "widths": [], "range": "24h", "orderby": "-count(span.duration)", - "yAxis": ["count(span.duration)"], "mode": "samples", + "dataset": "spans", + "expired": False, "createdBy": { "id": "1", "name": "Admin", @@ -51,7 +51,7 @@ "id": "1", "name": "Pageloads", "projects": [], - "dateCreated": "2024-07-25T19:35:38.422859Z", + "dateAdded": "2024-07-25T19:35:38.422859Z", "dateUpdated": "2024-07-25T19:35:38.422874Z", "environment": [], "query": "span.op:pageload", @@ -59,11 +59,11 @@ "span.op", "timestamp", ], - "widths": [], "range": "24h", "orderby": "-timestamp", - "yAxis": ["count(span.duration)"], "mode": "samples", + "dataset": "spans", + "expired": False, "createdBy": { "id": "1", "name": "Admin", @@ -92,7 +92,7 @@ "id": "2", "name": "Cache Gets", "projects": [], - "dateCreated": "2024-07-25T19:35:38.422859Z", + "dateAdded": "2024-07-25T19:35:38.422859Z", "dateUpdated": "2024-07-25T19:35:38.422874Z", "environment": [], "query": "span.op:cache.get", @@ -100,11 +100,11 @@ "span.op", "span.duration" "timestamp", ], - "widths": [], "range": "24h", "orderby": "-timestamp", - "yAxis": ["count(span.duration)"], "mode": "samples", + "dataset": "spans", + "expired": False, "createdBy": { "id": "1", "name": "Admin", From 5957a527362a44ec1bfe2d68789abe7ec9fc5f6f Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Thu, 13 Mar 2025 15:29:37 -0400 Subject: [PATCH 5/5] wrong file --- src/sentry/discover/endpoints/discover_saved_queries.py | 4 ++-- src/sentry/explore/endpoints/explore_saved_queries.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sentry/discover/endpoints/discover_saved_queries.py b/src/sentry/discover/endpoints/discover_saved_queries.py index 55a8d7f054a657..009b919ad43862 100644 --- a/src/sentry/discover/endpoints/discover_saved_queries.py +++ b/src/sentry/discover/endpoints/discover_saved_queries.py @@ -148,7 +148,7 @@ def data_fn(offset, limit): ) @extend_schema( - operation_id="Create a New Saved Trace Explorer Query", + operation_id="Create a New Saved Query", parameters=[GlobalParams.ORG_ID_OR_SLUG], request=DiscoverSavedQuerySerializer, responses={ @@ -161,7 +161,7 @@ def data_fn(offset, limit): ) def post(self, request: Request, organization) -> Response: """ - Create a new saved trace explorer query for the given organization. + Create a new saved query for the given organization. """ if not self.has_feature(organization, request): return self.respond(status=404) diff --git a/src/sentry/explore/endpoints/explore_saved_queries.py b/src/sentry/explore/endpoints/explore_saved_queries.py index 0eec03d8363d6c..63bfddec9ae167 100644 --- a/src/sentry/explore/endpoints/explore_saved_queries.py +++ b/src/sentry/explore/endpoints/explore_saved_queries.py @@ -142,7 +142,7 @@ def data_fn(offset, limit): ) @extend_schema( - operation_id="Create a New Saved Query", + operation_id="Create a New Trace Explorer Saved Query", parameters=[GlobalParams.ORG_ID_OR_SLUG], request=ExploreSavedQuerySerializer, responses={ @@ -155,7 +155,7 @@ def data_fn(offset, limit): ) def post(self, request: Request, organization) -> Response: """ - Create a new saved query for the given organization. + Create a new trace explorersaved query for the given organization. """ if not self.has_feature(organization, request): return self.respond(status=404)