From a471c89fa8f96dcc25d584526e3d687c1ce49032 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 5 Mar 2025 20:40:59 -0600 Subject: [PATCH 01/24] Temp --- .../api/endpoints/organization_issue_stats.py | 137 ++++++++++++ src/sentry/api/urls.py | 11 + .../test_organization_issue_breakdown.py | 209 ++++++++++++++++++ 3 files changed, 357 insertions(+) create mode 100644 src/sentry/api/endpoints/organization_issue_stats.py create mode 100644 tests/sentry/api/endpoints/test_organization_issue_breakdown.py diff --git a/src/sentry/api/endpoints/organization_issue_stats.py b/src/sentry/api/endpoints/organization_issue_stats.py new file mode 100644 index 00000000000000..02cb35b9b1a64a --- /dev/null +++ b/src/sentry/api/endpoints/organization_issue_stats.py @@ -0,0 +1,137 @@ +import copy +from datetime import timedelta +from itertools import chain +from typing import Literal + +from django.db.models import Count, IntegerField, Q, Value +from django.db.models.functions import TruncDay +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 EnvironmentMixin, region_silo_endpoint +from sentry.api.bases.organization import NoProjects, OrganizationEndpoint +from sentry.api.helpers.environments import get_environments +from sentry.api.utils import get_date_range_from_params +from sentry.models.group import Group +from sentry.models.grouphistory import ( + ACTIONED_STATUSES, + STATUS_TO_STRING_LOOKUP, + STRING_TO_STATUS_LOOKUP, + GroupHistory, + GroupHistoryStatus, +) +from sentry.models.organization import Organization +from sentry.models.project import Project + + +@region_silo_endpoint +class OrganizationIssueStatsEndpoint(OrganizationEndpoint, EnvironmentMixin): + owner = ApiOwner.REPLAY + publish_status = {"GET": ApiPublishStatus.PRIVATE} + + def get(self, request: Request, organization: Organization) -> Response: + """Stats bucketed by time.""" + bucket: Literal["release", "timestamp"] + + +# FE/BE +def query_issues_by_day(): + # SELECT count(*), day(first_seen) FROM issues GROUP BY day(first_seen) + ... + + +def query_resolved_issues_by_day(): + # SELECT count(*), day(resolved_at) FROM issues GROUP BY day(resolved_at) + ... + + +# MOBILE +def query_issues_by_release(): + # SELECT count(*), day(first_release) FROM issues GROUP BY day(first_release) + ... + + # def get(self, request: Request, organization: Organization) -> Response: + # """ + # Returns a dict of team projects, and a time-series dict of issue stat breakdowns for each. + + # If a list of statuses is passed then we return the count of each status and the totals. + # Otherwise we the count of reviewed issues and the total count of issues. + # """ + # start, end = get_date_range_from_params(request.GET) + # end = end.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + # start = start.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + # environments = [e.id for e in get_environments(request, organization)] + + # if "statuses" in request.GET: + # statuses = [ + # STRING_TO_STATUS_LOOKUP[status] for status in request.GET.getlist("statuses") + # ] + # new_format = True + # else: + # statuses = [GroupHistoryStatus.UNRESOLVED] + ACTIONED_STATUSES + # new_format = False + + # new_issues = [] + + # base_day_format = {"total": 0} + # if new_format: + # for status in statuses: + # base_day_format[STATUS_TO_STRING_LOOKUP[status]] = 0 + # else: + # base_day_format["reviewed"] = 0 + + # if GroupHistoryStatus.NEW in statuses: + # group_environment_filter = ( + # Q(groupenvironment__environment_id=environments[0]) if environments else Q() + # ) + # statuses.remove(GroupHistoryStatus.NEW) + # new_issues = list( + # Group.objects.filter( + # group_environment_filter, first_seen__gte=start, first_seen__lte=end + # ) + # .annotate(bucket=TruncDay("first_seen")) + # .order_by("bucket") + # .values("project", "bucket") + # .annotate( + # count=Count("id"), + # status=Value(GroupHistoryStatus.NEW, output_field=IntegerField()), + # ) + # ) + + # group_history_environment_filter = ( + # Q(group__groupenvironment__environment_id=environments[0]) if environments else Q() + # ) + # bucketed_issues = ( + # GroupHistory.objects.filter( + # group_history_environment_filter, + # status__in=statuses, + # date_added__gte=start, + # date_added__lte=end, + # ) + # .annotate(bucket=TruncDay("date_added")) + # .order_by("bucket") + # .values("project", "bucket", "status") + # .annotate(count=Count("id")) + # ) + + # current_day, date_series_dict = start, {} + # while current_day < end: + # date_series_dict[current_day.isoformat()] = copy.deepcopy(base_day_format) + # current_day += timedelta(days=1) + + # project_list = Project.objects.get_for_team_ids(team_ids=[team.id]) + # agg_project_counts = { + # project.id: copy.deepcopy(date_series_dict) for project in project_list + # } + # for r in chain(bucketed_issues, new_issues): + # bucket = agg_project_counts[r["project"]][r["bucket"].isoformat()] + # bucket["total"] += r["count"] + # if not new_format and r["status"] != GroupHistoryStatus.UNRESOLVED: + # bucket["reviewed"] += r["count"] + # if new_format: + # bucket[STATUS_TO_STRING_LOOKUP[r["status"]]] += r["count"] + + # return Response(agg_project_counts) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 59bfe67a1a3761..ace07979367e0c 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -502,6 +502,7 @@ from .endpoints.organization_events_trends_v2 import OrganizationEventsNewTrendsStatsEndpoint from .endpoints.organization_events_vitals import OrganizationEventsVitalsEndpoint from .endpoints.organization_index import OrganizationIndexEndpoint +from .endpoints.organization_issue_stats import OrganizationIssueStatsEndpoint from .endpoints.organization_issues_resolved_in_release import ( OrganizationIssuesResolvedInReleaseEndpoint, ) @@ -1608,6 +1609,16 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationGroupIndexStatsEndpoint.as_view(), name="sentry-api-0-organization-group-index-stats", ), + re_path( + r"^(?P[^\/]+)/issues-stats/history/$", + OrganizationIssueStatsEndpoint.as_view(), + name="sentry-api-0-organization-issue-stats", + ), + re_path( + r"^(?P[^\/]+)/issues-stats/release/$", + OrganizationIssueStatsEndpoint.as_view(), + name="sentry-api-0-organization-issue-stats", + ), re_path( r"^(?P[^\/]+)/integrations/$", OrganizationIntegrationsEndpoint.as_view(), diff --git a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py new file mode 100644 index 00000000000000..c5dacf450e1518 --- /dev/null +++ b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py @@ -0,0 +1,209 @@ +from django.utils import timezone + +from sentry.models.groupassignee import GroupAssignee +from sentry.models.groupenvironment import GroupEnvironment +from sentry.models.grouphistory import GroupHistoryStatus +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.datetime import before_now, freeze_time +from sentry.utils.dates import floor_to_utc_day + + +@freeze_time() +class OrganizationIssueBreakdownTest(APITestCase): + endpoint = "sentry-api-0-organization-issue-breakdown" + + def test_status_format(self): + project1 = self.create_project(teams=[self.team], slug="foo") + project2 = self.create_project(teams=[self.team], slug="bar") + group1 = self.create_group(project=project1) + group2 = self.create_group(project=project2) + GroupAssignee.objects.assign(group1, self.user) + GroupAssignee.objects.assign(group2, self.user) + + self.create_group_history( + group=group1, date_added=before_now(days=5), status=GroupHistoryStatus.UNRESOLVED + ) + self.create_group_history( + group=group1, date_added=before_now(days=2), status=GroupHistoryStatus.RESOLVED + ) + self.create_group_history( + group=group1, date_added=before_now(days=2), status=GroupHistoryStatus.REGRESSED + ) + self.create_group_history( + group=group2, date_added=before_now(days=10), status=GroupHistoryStatus.UNRESOLVED + ) + self.create_group_history( + group=group2, date_added=before_now(days=1), status=GroupHistoryStatus.UNRESOLVED + ) + self.create_group_history(group=group2, status=GroupHistoryStatus.RESOLVED) + self.create_group_history(group=group2, status=GroupHistoryStatus.RESOLVED) + self.create_group_history(group=group2, status=GroupHistoryStatus.IGNORED) + + today = floor_to_utc_day(timezone.now()).isoformat() + yesterday = floor_to_utc_day(before_now(days=1)).isoformat() + two_days_ago = floor_to_utc_day(before_now(days=2)).isoformat() + self.login_as(user=self.user) + statuses = ["resolved", "regressed", "unresolved", "ignored"] + response = self.get_success_response( + self.team.organization.slug, self.team.slug, statsPeriod="7d", statuses=statuses + ) + + def compare_response(statuses, data_for_day, **expected_status_counts): + result = {status: 0 for status in statuses} + result["total"] = 0 + result.update(expected_status_counts) + assert result == data_for_day + + compare_response(statuses, response.data[project1.id][today]) + compare_response(statuses, response.data[project1.id][yesterday]) + compare_response( + statuses, response.data[project1.id][two_days_ago], regressed=1, resolved=1, total=2 + ) + compare_response( + statuses, response.data[project2.id][today], ignored=1, resolved=2, total=3 + ) + compare_response(statuses, response.data[project2.id][yesterday], unresolved=1, total=1) + compare_response(statuses, response.data[project2.id][two_days_ago]) + + statuses = ["resolved"] + response = self.get_success_response( + self.team.organization.slug, self.team.slug, statsPeriod="7d", statuses=statuses + ) + compare_response(statuses, response.data[project1.id][today]) + compare_response(statuses, response.data[project1.id][yesterday]) + compare_response(statuses, response.data[project1.id][two_days_ago], resolved=1, total=1) + compare_response(statuses, response.data[project2.id][today], resolved=2, total=2) + compare_response(statuses, response.data[project2.id][yesterday]) + compare_response(statuses, response.data[project2.id][two_days_ago]) + + statuses = ["resolved", "new"] + response = self.get_success_response( + self.team.organization.slug, self.team.slug, statsPeriod="7d", statuses=statuses + ) + compare_response(statuses, response.data[project1.id][today], new=1, total=1) + compare_response(statuses, response.data[project1.id][yesterday]) + compare_response(statuses, response.data[project1.id][two_days_ago], resolved=1, total=1) + compare_response(statuses, response.data[project2.id][today], new=1, resolved=2, total=3) + compare_response(statuses, response.data[project2.id][yesterday]) + compare_response(statuses, response.data[project2.id][two_days_ago]) + + def test_filter_by_environment(self): + project1 = self.create_project(teams=[self.team], slug="foo") + group1 = self.create_group(project=project1) + env1 = self.create_environment(name="prod", project=project1) + self.create_environment(name="dev", project=project1) + GroupAssignee.objects.assign(group1, self.user) + GroupEnvironment.objects.create(group_id=group1.id, environment_id=env1.id) + + self.create_group_history( + group=group1, date_added=timezone.now(), status=GroupHistoryStatus.UNRESOLVED + ) + self.create_group_history( + group=group1, date_added=timezone.now(), status=GroupHistoryStatus.RESOLVED + ) + self.create_group_history( + group=group1, date_added=timezone.now(), status=GroupHistoryStatus.REGRESSED + ) + + today = floor_to_utc_day(timezone.now()).isoformat() + self.login_as(user=self.user) + statuses = ["regressed", "resolved"] + response = self.get_success_response( + self.team.organization.slug, + self.team.slug, + statsPeriod="7d", + statuses=statuses, + environment="prod", + ) + + def compare_response(statuses, data_for_day, **expected_status_counts): + result = {status: 0 for status in statuses} + result["total"] = 0 + result.update(expected_status_counts) + assert result == data_for_day + + compare_response( + statuses, response.data[project1.id][today], regressed=1, resolved=1, total=2 + ) + + response = self.get_success_response( + self.team.organization.slug, + self.team.slug, + statsPeriod="7d", + statuses=statuses, + environment="dev", + ) + compare_response(statuses, response.data[project1.id][today]) + + def test_old_format(self): + project1 = self.create_project(teams=[self.team], slug="foo") + project2 = self.create_project(teams=[self.team], slug="bar") + group1 = self.create_group(project=project1, times_seen=10) + group2 = self.create_group(project=project2, times_seen=5) + GroupAssignee.objects.assign(group1, self.user) + GroupAssignee.objects.assign(group2, self.user) + + self.create_group_history( + group=group1, date_added=before_now(days=5), status=GroupHistoryStatus.UNRESOLVED + ) + self.create_group_history( + group=group1, date_added=before_now(days=2), status=GroupHistoryStatus.RESOLVED + ) + self.create_group_history( + group=group1, date_added=before_now(days=2), status=GroupHistoryStatus.REGRESSED + ) + self.create_group_history( + group=group2, date_added=before_now(days=10), status=GroupHistoryStatus.UNRESOLVED + ) + self.create_group_history( + group=group2, date_added=before_now(days=1), status=GroupHistoryStatus.UNRESOLVED + ) + self.create_group_history(group=group2, status=GroupHistoryStatus.RESOLVED) + self.create_group_history(group=group2, status=GroupHistoryStatus.RESOLVED) + self.create_group_history(group=group2, status=GroupHistoryStatus.IGNORED) + + today = floor_to_utc_day(timezone.now()).isoformat() + yesterday = floor_to_utc_day(before_now(days=1)).isoformat() + two_days_ago = floor_to_utc_day(before_now(days=2)).isoformat() + self.login_as(user=self.user) + response = self.get_success_response( + self.team.organization.slug, self.team.slug, statsPeriod="7d" + ) + assert len(response.data) == 2 + assert response.data[project1.id][today]["reviewed"] == 0 + assert response.data[project1.id][today]["total"] == 0 + assert response.data[project1.id][yesterday]["reviewed"] == 0 + assert response.data[project1.id][yesterday]["total"] == 0 + assert response.data[project1.id][two_days_ago]["reviewed"] == 1 + assert response.data[project1.id][two_days_ago]["reviewed"] == 1 + + assert response.data[project2.id][today]["reviewed"] == 3 + assert response.data[project2.id][today]["total"] == 3 + assert response.data[project2.id][yesterday]["reviewed"] == 0 + assert response.data[project2.id][yesterday]["total"] == 1 + assert response.data[project2.id][two_days_ago]["reviewed"] == 0 + assert response.data[project2.id][two_days_ago]["total"] == 0 + + self.create_group_history( + group=group1, date_added=before_now(days=1), status=GroupHistoryStatus.UNRESOLVED + ) + self.create_group_history(group=group2, status=GroupHistoryStatus.RESOLVED) + # making sure it doesnt bork anything + self.create_group_history(group=group2, status=GroupHistoryStatus.ASSIGNED) + + response = self.get_success_response(self.team.organization.slug, self.team.slug) + assert len(response.data) == 2 + + assert response.data[project1.id][today]["reviewed"] == 0 + assert response.data[project1.id][today]["total"] == 0 + assert response.data[project1.id][yesterday]["reviewed"] == 0 + assert response.data[project1.id][yesterday]["total"] == 1 + assert response.data[project1.id][two_days_ago]["reviewed"] == 1 + assert response.data[project1.id][two_days_ago]["reviewed"] == 1 + + assert response.data[project2.id][today]["reviewed"] == 4 + assert response.data[project2.id][today]["total"] == 4 + assert response.data[project2.id][yesterday]["reviewed"] == 0 + assert response.data[project2.id][yesterday]["total"] == 1 + assert response.data[project2.id][two_days_ago]["reviewed"] == 0 + assert response.data[project2.id][two_days_ago]["total"] == 0 From 26828ea9cd236d67460a3e4d5a7120c1c36b3fdb Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 7 Mar 2025 14:39:19 -0600 Subject: [PATCH 02/24] Add organization issue breakdown endpoint --- .../endpoints/organization_issue_breakdown.py | 137 ++++++++ .../api/endpoints/organization_issue_stats.py | 137 -------- src/sentry/api/urls.py | 13 +- .../test_organization_issue_breakdown.py | 311 +++++++----------- 4 files changed, 256 insertions(+), 342 deletions(-) create mode 100644 src/sentry/api/endpoints/organization_issue_breakdown.py delete mode 100644 src/sentry/api/endpoints/organization_issue_stats.py diff --git a/src/sentry/api/endpoints/organization_issue_breakdown.py b/src/sentry/api/endpoints/organization_issue_breakdown.py new file mode 100644 index 00000000000000..c751590ae88133 --- /dev/null +++ b/src/sentry/api/endpoints/organization_issue_breakdown.py @@ -0,0 +1,137 @@ +from datetime import datetime, timedelta +from typing import TypedDict + +from django.db.models import Count, F, Q +from django.db.models.functions import TruncDay +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import EnvironmentMixin, region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint +from sentry.api.helpers.environments import get_environments +from sentry.api.utils import get_date_range_from_params +from sentry.models.group import Group, GroupCategory, GroupStatus +from sentry.models.organization import Organization +from sentry.models.project import Project + +CATEGORY_MAP = { + "error": GroupCategory.ERROR, + "feedback": GroupCategory.FEEDBACK, +} + + +@region_silo_endpoint +class OrganizationIssueBreakdownEndpoint(OrganizationEndpoint, EnvironmentMixin): + owner = ApiOwner.REPLAY + publish_status = {"GET": ApiPublishStatus.PRIVATE} + + def get(self, request: Request, organization: Organization) -> Response: + """Stats bucketed by time.""" + start, end = get_date_range_from_params(request.GET) + end = end.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + start = start.replace(hour=0, minute=0, second=0, microsecond=0) + environments = [e.id for e in get_environments(request, organization)] + projects = self.get_projects(request, organization) + issue_category = CATEGORY_MAP.get(request.GET.get("category", "error"), GroupCategory.ERROR) + group_by = request.GET.get("group_by", "new") + + if group_by == "new": + response = query_new_issues(projects, environments, issue_category, start, end) + return Response({"data": response}, status=200) + if group_by == "resolved": + response = query_resolved_issues(projects, environments, issue_category, start, end) + return Response({"data": response}, status=200) + if group_by == "release": + response = query_issues_by_release(projects, environments, issue_category, start, end) + return Response({"data": response}, status=200) + else: + return Response("", status=404) + + +class BreakdownQueryResult(TypedDict): + bucket: str + count: int + + +def query_new_issues( + projects: list[Project], + environments: list[int], + issue_category: int, + start: datetime, + end: datetime, +) -> list[BreakdownQueryResult]: + # SELECT count(*), day(first_seen) FROM issues GROUP BY day(first_seen) + group_environment_filter = ( + Q(groupenvironment__environment_id=environments[0]) if environments else Q() + ) + issues_query = ( + Group.objects.filter( + group_environment_filter, + first_seen__gte=start, + first_seen__lte=end, + project__in=projects, + type=issue_category, + ) + .annotate(bucket=TruncDay("first_seen")) + .order_by("bucket") + .values("bucket") + .annotate(count=Count("id")) + ) + return list(issues_query) + + +def query_resolved_issues( + projects: list[Project], + environments: list[int], + issue_category: int, + start: datetime, + end: datetime, +) -> list[BreakdownQueryResult]: + # SELECT count(*), day(resolved_at) FROM issues WHERE status = resolved GROUP BY day(resolved_at) + group_environment_filter = ( + Q(groupenvironment__environment_id=environments[0]) if environments else Q() + ) + resolved_issues_query = ( + Group.objects.filter( + group_environment_filter, + first_seen__gte=start, + first_seen__lte=end, + project__in=projects, + type=issue_category, + status=GroupStatus.RESOLVED, + ) + .annotate(bucket=TruncDay("resolved_at")) + .order_by("bucket") + .values("bucket") + .annotate(count=Count("id")) + ) + return list(resolved_issues_query) + + +def query_issues_by_release( + projects: list[Project], + environments: list[int], + issue_category: int, + start: datetime, + end: datetime, +) -> list[BreakdownQueryResult]: + # SELECT count(*), first_release.version FROM issues JOIN release GROUP BY first_release.version + group_environment_filter = ( + Q(groupenvironment__environment_id=environments[0]) if environments else Q() + ) + issues_by_release_query = ( + Group.objects.filter( + group_environment_filter, + first_seen__gte=start, + first_seen__lte=end, + project__in=projects, + type=issue_category, + ) + .annotate(bucket=F("first_release__version")) + .order_by("bucket") + .values("bucket") + .annotate(count=Count("id")) + ) + return list(issues_by_release_query) diff --git a/src/sentry/api/endpoints/organization_issue_stats.py b/src/sentry/api/endpoints/organization_issue_stats.py deleted file mode 100644 index 02cb35b9b1a64a..00000000000000 --- a/src/sentry/api/endpoints/organization_issue_stats.py +++ /dev/null @@ -1,137 +0,0 @@ -import copy -from datetime import timedelta -from itertools import chain -from typing import Literal - -from django.db.models import Count, IntegerField, Q, Value -from django.db.models.functions import TruncDay -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 EnvironmentMixin, region_silo_endpoint -from sentry.api.bases.organization import NoProjects, OrganizationEndpoint -from sentry.api.helpers.environments import get_environments -from sentry.api.utils import get_date_range_from_params -from sentry.models.group import Group -from sentry.models.grouphistory import ( - ACTIONED_STATUSES, - STATUS_TO_STRING_LOOKUP, - STRING_TO_STATUS_LOOKUP, - GroupHistory, - GroupHistoryStatus, -) -from sentry.models.organization import Organization -from sentry.models.project import Project - - -@region_silo_endpoint -class OrganizationIssueStatsEndpoint(OrganizationEndpoint, EnvironmentMixin): - owner = ApiOwner.REPLAY - publish_status = {"GET": ApiPublishStatus.PRIVATE} - - def get(self, request: Request, organization: Organization) -> Response: - """Stats bucketed by time.""" - bucket: Literal["release", "timestamp"] - - -# FE/BE -def query_issues_by_day(): - # SELECT count(*), day(first_seen) FROM issues GROUP BY day(first_seen) - ... - - -def query_resolved_issues_by_day(): - # SELECT count(*), day(resolved_at) FROM issues GROUP BY day(resolved_at) - ... - - -# MOBILE -def query_issues_by_release(): - # SELECT count(*), day(first_release) FROM issues GROUP BY day(first_release) - ... - - # def get(self, request: Request, organization: Organization) -> Response: - # """ - # Returns a dict of team projects, and a time-series dict of issue stat breakdowns for each. - - # If a list of statuses is passed then we return the count of each status and the totals. - # Otherwise we the count of reviewed issues and the total count of issues. - # """ - # start, end = get_date_range_from_params(request.GET) - # end = end.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) - # start = start.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) - # environments = [e.id for e in get_environments(request, organization)] - - # if "statuses" in request.GET: - # statuses = [ - # STRING_TO_STATUS_LOOKUP[status] for status in request.GET.getlist("statuses") - # ] - # new_format = True - # else: - # statuses = [GroupHistoryStatus.UNRESOLVED] + ACTIONED_STATUSES - # new_format = False - - # new_issues = [] - - # base_day_format = {"total": 0} - # if new_format: - # for status in statuses: - # base_day_format[STATUS_TO_STRING_LOOKUP[status]] = 0 - # else: - # base_day_format["reviewed"] = 0 - - # if GroupHistoryStatus.NEW in statuses: - # group_environment_filter = ( - # Q(groupenvironment__environment_id=environments[0]) if environments else Q() - # ) - # statuses.remove(GroupHistoryStatus.NEW) - # new_issues = list( - # Group.objects.filter( - # group_environment_filter, first_seen__gte=start, first_seen__lte=end - # ) - # .annotate(bucket=TruncDay("first_seen")) - # .order_by("bucket") - # .values("project", "bucket") - # .annotate( - # count=Count("id"), - # status=Value(GroupHistoryStatus.NEW, output_field=IntegerField()), - # ) - # ) - - # group_history_environment_filter = ( - # Q(group__groupenvironment__environment_id=environments[0]) if environments else Q() - # ) - # bucketed_issues = ( - # GroupHistory.objects.filter( - # group_history_environment_filter, - # status__in=statuses, - # date_added__gte=start, - # date_added__lte=end, - # ) - # .annotate(bucket=TruncDay("date_added")) - # .order_by("bucket") - # .values("project", "bucket", "status") - # .annotate(count=Count("id")) - # ) - - # current_day, date_series_dict = start, {} - # while current_day < end: - # date_series_dict[current_day.isoformat()] = copy.deepcopy(base_day_format) - # current_day += timedelta(days=1) - - # project_list = Project.objects.get_for_team_ids(team_ids=[team.id]) - # agg_project_counts = { - # project.id: copy.deepcopy(date_series_dict) for project in project_list - # } - # for r in chain(bucketed_issues, new_issues): - # bucket = agg_project_counts[r["project"]][r["bucket"].isoformat()] - # bucket["total"] += r["count"] - # if not new_format and r["status"] != GroupHistoryStatus.UNRESOLVED: - # bucket["reviewed"] += r["count"] - # if new_format: - # bucket[STATUS_TO_STRING_LOOKUP[r["status"]]] += r["count"] - - # return Response(agg_project_counts) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 06433ae68b33a8..eb3ecc0a0b648d 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -503,7 +503,7 @@ from .endpoints.organization_events_trends_v2 import OrganizationEventsNewTrendsStatsEndpoint from .endpoints.organization_events_vitals import OrganizationEventsVitalsEndpoint from .endpoints.organization_index import OrganizationIndexEndpoint -from .endpoints.organization_issue_stats import OrganizationIssueStatsEndpoint +from .endpoints.organization_issue_breakdown import OrganizationIssueBreakdownEndpoint from .endpoints.organization_issues_resolved_in_release import ( OrganizationIssuesResolvedInReleaseEndpoint, ) @@ -1611,14 +1611,9 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: name="sentry-api-0-organization-group-index-stats", ), re_path( - r"^(?P[^\/]+)/issues-stats/history/$", - OrganizationIssueStatsEndpoint.as_view(), - name="sentry-api-0-organization-issue-stats", - ), - re_path( - r"^(?P[^\/]+)/issues-stats/release/$", - OrganizationIssueStatsEndpoint.as_view(), - name="sentry-api-0-organization-issue-stats", + r"^(?P[^\/]+)/issues-breakdown/$", + OrganizationIssueBreakdownEndpoint.as_view(), + name="sentry-api-0-organization-issue-breakdown", ), re_path( r"^(?P[^\/]+)/integrations/$", diff --git a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py index c5dacf450e1518..ab9693d0aa4c50 100644 --- a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py +++ b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py @@ -1,209 +1,128 @@ -from django.utils import timezone +from datetime import datetime, timedelta, timezone + +from django.urls import reverse -from sentry.models.groupassignee import GroupAssignee -from sentry.models.groupenvironment import GroupEnvironment -from sentry.models.grouphistory import GroupHistoryStatus from sentry.testutils.cases import APITestCase -from sentry.testutils.helpers.datetime import before_now, freeze_time -from sentry.utils.dates import floor_to_utc_day +from sentry.testutils.helpers.datetime import freeze_time +from sentry.utils import json @freeze_time() class OrganizationIssueBreakdownTest(APITestCase): endpoint = "sentry-api-0-organization-issue-breakdown" - def test_status_format(self): + def setUp(self): + super().setUp() + self.login_as(user=self.user) + self.url = reverse(self.endpoint, args=(self.organization.slug,)) + + def test_new_issues(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") - group1 = self.create_group(project=project1) - group2 = self.create_group(project=project2) - GroupAssignee.objects.assign(group1, self.user) - GroupAssignee.objects.assign(group2, self.user) - - self.create_group_history( - group=group1, date_added=before_now(days=5), status=GroupHistoryStatus.UNRESOLVED - ) - self.create_group_history( - group=group1, date_added=before_now(days=2), status=GroupHistoryStatus.RESOLVED - ) - self.create_group_history( - group=group1, date_added=before_now(days=2), status=GroupHistoryStatus.REGRESSED - ) - self.create_group_history( - group=group2, date_added=before_now(days=10), status=GroupHistoryStatus.UNRESOLVED - ) - self.create_group_history( - group=group2, date_added=before_now(days=1), status=GroupHistoryStatus.UNRESOLVED - ) - self.create_group_history(group=group2, status=GroupHistoryStatus.RESOLVED) - self.create_group_history(group=group2, status=GroupHistoryStatus.RESOLVED) - self.create_group_history(group=group2, status=GroupHistoryStatus.IGNORED) - - today = floor_to_utc_day(timezone.now()).isoformat() - yesterday = floor_to_utc_day(before_now(days=1)).isoformat() - two_days_ago = floor_to_utc_day(before_now(days=2)).isoformat() - self.login_as(user=self.user) - statuses = ["resolved", "regressed", "unresolved", "ignored"] - response = self.get_success_response( - self.team.organization.slug, self.team.slug, statsPeriod="7d", statuses=statuses - ) - - def compare_response(statuses, data_for_day, **expected_status_counts): - result = {status: 0 for status in statuses} - result["total"] = 0 - result.update(expected_status_counts) - assert result == data_for_day - - compare_response(statuses, response.data[project1.id][today]) - compare_response(statuses, response.data[project1.id][yesterday]) - compare_response( - statuses, response.data[project1.id][two_days_ago], regressed=1, resolved=1, total=2 - ) - compare_response( - statuses, response.data[project2.id][today], ignored=1, resolved=2, total=3 - ) - compare_response(statuses, response.data[project2.id][yesterday], unresolved=1, total=1) - compare_response(statuses, response.data[project2.id][two_days_ago]) - - statuses = ["resolved"] - response = self.get_success_response( - self.team.organization.slug, self.team.slug, statsPeriod="7d", statuses=statuses - ) - compare_response(statuses, response.data[project1.id][today]) - compare_response(statuses, response.data[project1.id][yesterday]) - compare_response(statuses, response.data[project1.id][two_days_ago], resolved=1, total=1) - compare_response(statuses, response.data[project2.id][today], resolved=2, total=2) - compare_response(statuses, response.data[project2.id][yesterday]) - compare_response(statuses, response.data[project2.id][two_days_ago]) - - statuses = ["resolved", "new"] - response = self.get_success_response( - self.team.organization.slug, self.team.slug, statsPeriod="7d", statuses=statuses - ) - compare_response(statuses, response.data[project1.id][today], new=1, total=1) - compare_response(statuses, response.data[project1.id][yesterday]) - compare_response(statuses, response.data[project1.id][two_days_ago], resolved=1, total=1) - compare_response(statuses, response.data[project2.id][today], new=1, resolved=2, total=3) - compare_response(statuses, response.data[project2.id][yesterday]) - compare_response(statuses, response.data[project2.id][two_days_ago]) - - def test_filter_by_environment(self): + + today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + tomorrow = today + timedelta(days=1) + self.create_group(project=project1, status=0, first_seen=today, type=1) + self.create_group(project=project1, status=1, first_seen=today, type=1) + self.create_group(project=project2, status=1, first_seen=tomorrow, type=1) + self.create_group(project=project2, status=2, first_seen=tomorrow, type=1) + self.create_group(project=project2, status=2, first_seen=tomorrow, type=6) + + response = self.client.get(self.url + "?statsPeriod=7d&category=error&group_by=new") + assert json.loads(response.content) == { + "data": [ + {"bucket": today.isoformat().replace("+00:00", "Z"), "count": 2}, + {"bucket": tomorrow.isoformat().replace("+00:00", "Z"), "count": 2}, + ] + } + + def test_resolved_issues(self): project1 = self.create_project(teams=[self.team], slug="foo") - group1 = self.create_group(project=project1) - env1 = self.create_environment(name="prod", project=project1) - self.create_environment(name="dev", project=project1) - GroupAssignee.objects.assign(group1, self.user) - GroupEnvironment.objects.create(group_id=group1.id, environment_id=env1.id) - - self.create_group_history( - group=group1, date_added=timezone.now(), status=GroupHistoryStatus.UNRESOLVED - ) - self.create_group_history( - group=group1, date_added=timezone.now(), status=GroupHistoryStatus.RESOLVED - ) - self.create_group_history( - group=group1, date_added=timezone.now(), status=GroupHistoryStatus.REGRESSED - ) - - today = floor_to_utc_day(timezone.now()).isoformat() - self.login_as(user=self.user) - statuses = ["regressed", "resolved"] - response = self.get_success_response( - self.team.organization.slug, - self.team.slug, - statsPeriod="7d", - statuses=statuses, - environment="prod", - ) - - def compare_response(statuses, data_for_day, **expected_status_counts): - result = {status: 0 for status in statuses} - result["total"] = 0 - result.update(expected_status_counts) - assert result == data_for_day - - compare_response( - statuses, response.data[project1.id][today], regressed=1, resolved=1, total=2 - ) - - response = self.get_success_response( - self.team.organization.slug, - self.team.slug, - statsPeriod="7d", - statuses=statuses, - environment="dev", - ) - compare_response(statuses, response.data[project1.id][today]) - - def test_old_format(self): + project2 = self.create_project(teams=[self.team], slug="bar") + + today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + tomorrow = today + timedelta(days=1) + self.create_group(project=project1, status=0, resolved_at=today, type=1) + self.create_group(project=project1, status=1, resolved_at=today, type=1) + self.create_group(project=project2, status=1, resolved_at=tomorrow, type=1) + self.create_group(project=project2, status=1, resolved_at=tomorrow, type=6) + self.create_group(project=project2, status=2, resolved_at=tomorrow, type=1) + + response = self.client.get(self.url + "?statsPeriod=7d&category=error&group_by=resolved") + assert json.loads(response.content) == { + "data": [ + {"bucket": today.isoformat().replace("+00:00", "Z"), "count": 1}, + {"bucket": tomorrow.isoformat().replace("+00:00", "Z"), "count": 1}, + ] + } + + def test_issues_by_release(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") - group1 = self.create_group(project=project1, times_seen=10) - group2 = self.create_group(project=project2, times_seen=5) - GroupAssignee.objects.assign(group1, self.user) - GroupAssignee.objects.assign(group2, self.user) - - self.create_group_history( - group=group1, date_added=before_now(days=5), status=GroupHistoryStatus.UNRESOLVED - ) - self.create_group_history( - group=group1, date_added=before_now(days=2), status=GroupHistoryStatus.RESOLVED - ) - self.create_group_history( - group=group1, date_added=before_now(days=2), status=GroupHistoryStatus.REGRESSED - ) - self.create_group_history( - group=group2, date_added=before_now(days=10), status=GroupHistoryStatus.UNRESOLVED - ) - self.create_group_history( - group=group2, date_added=before_now(days=1), status=GroupHistoryStatus.UNRESOLVED - ) - self.create_group_history(group=group2, status=GroupHistoryStatus.RESOLVED) - self.create_group_history(group=group2, status=GroupHistoryStatus.RESOLVED) - self.create_group_history(group=group2, status=GroupHistoryStatus.IGNORED) - - today = floor_to_utc_day(timezone.now()).isoformat() - yesterday = floor_to_utc_day(before_now(days=1)).isoformat() - two_days_ago = floor_to_utc_day(before_now(days=2)).isoformat() - self.login_as(user=self.user) - response = self.get_success_response( - self.team.organization.slug, self.team.slug, statsPeriod="7d" - ) - assert len(response.data) == 2 - assert response.data[project1.id][today]["reviewed"] == 0 - assert response.data[project1.id][today]["total"] == 0 - assert response.data[project1.id][yesterday]["reviewed"] == 0 - assert response.data[project1.id][yesterday]["total"] == 0 - assert response.data[project1.id][two_days_ago]["reviewed"] == 1 - assert response.data[project1.id][two_days_ago]["reviewed"] == 1 - - assert response.data[project2.id][today]["reviewed"] == 3 - assert response.data[project2.id][today]["total"] == 3 - assert response.data[project2.id][yesterday]["reviewed"] == 0 - assert response.data[project2.id][yesterday]["total"] == 1 - assert response.data[project2.id][two_days_ago]["reviewed"] == 0 - assert response.data[project2.id][two_days_ago]["total"] == 0 - - self.create_group_history( - group=group1, date_added=before_now(days=1), status=GroupHistoryStatus.UNRESOLVED - ) - self.create_group_history(group=group2, status=GroupHistoryStatus.RESOLVED) - # making sure it doesnt bork anything - self.create_group_history(group=group2, status=GroupHistoryStatus.ASSIGNED) - - response = self.get_success_response(self.team.organization.slug, self.team.slug) - assert len(response.data) == 2 - - assert response.data[project1.id][today]["reviewed"] == 0 - assert response.data[project1.id][today]["total"] == 0 - assert response.data[project1.id][yesterday]["reviewed"] == 0 - assert response.data[project1.id][yesterday]["total"] == 1 - assert response.data[project1.id][two_days_ago]["reviewed"] == 1 - assert response.data[project1.id][two_days_ago]["reviewed"] == 1 - - assert response.data[project2.id][today]["reviewed"] == 4 - assert response.data[project2.id][today]["total"] == 4 - assert response.data[project2.id][yesterday]["reviewed"] == 0 - assert response.data[project2.id][yesterday]["total"] == 1 - assert response.data[project2.id][two_days_ago]["reviewed"] == 0 - assert response.data[project2.id][two_days_ago]["total"] == 0 + release_one = self.create_release(project1, version="1.0.0") + release_two = self.create_release(project2, version="1.2.0") + self.create_group(project=project1, status=0, first_release=release_one, type=1) + self.create_group(project=project1, status=1, first_release=release_one, type=1) + self.create_group(project=project2, status=1, first_release=release_two, type=1) + self.create_group(project=project2, status=2, first_release=release_two, type=1) + self.create_group(project=project2, status=2, first_release=release_two, type=6) + + response = self.client.get(self.url + "?statsPeriod=7d&category=error&group_by=release") + assert json.loads(response.content) == { + "data": [ + {"bucket": "1.0.0", "count": 2}, + {"bucket": "1.2.0", "count": 2}, + ] + } + + def test_issues_invalid_group_by(self): + response = self.client.get(self.url + "?statsPeriod=7d&category=error&group_by=test") + assert response.status_code == 404 + + def test_new_feedback(self): + project1 = self.create_project(teams=[self.team], slug="foo") + project2 = self.create_project(teams=[self.team], slug="bar") + + today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + tomorrow = today + timedelta(days=1) + self.create_group(project=project1, status=0, first_seen=today, type=1) + self.create_group(project=project1, status=1, first_seen=today, type=1) + self.create_group(project=project2, status=1, first_seen=tomorrow, type=1) + self.create_group(project=project2, status=2, first_seen=tomorrow, type=1) + self.create_group(project=project2, status=2, first_seen=tomorrow, type=6) + + response = self.client.get(self.url + "?statsPeriod=7d&category=feedback&group_by=new") + assert json.loads(response.content) == { + "data": [{"bucket": tomorrow.isoformat().replace("+00:00", "Z"), "count": 1}] + } + + def test_resolved_feedback(self): + project1 = self.create_project(teams=[self.team], slug="foo") + project2 = self.create_project(teams=[self.team], slug="bar") + + today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + tomorrow = today + timedelta(days=1) + self.create_group(project=project1, status=0, resolved_at=today, type=1) + self.create_group(project=project1, status=1, resolved_at=today, type=1) + self.create_group(project=project2, status=1, resolved_at=tomorrow, type=1) + self.create_group(project=project2, status=1, resolved_at=tomorrow, type=6) + self.create_group(project=project2, status=2, resolved_at=tomorrow, type=1) + + response = self.client.get(self.url + "?statsPeriod=7d&category=feedback&group_by=resolved") + assert json.loads(response.content) == { + "data": [{"bucket": tomorrow.isoformat().replace("+00:00", "Z"), "count": 1}] + } + + def test_feedback_by_release(self): + project1 = self.create_project(teams=[self.team], slug="foo") + project2 = self.create_project(teams=[self.team], slug="bar") + release_one = self.create_release(project1, version="1.0.0") + release_two = self.create_release(project2, version="1.2.0") + self.create_group(project=project1, status=0, first_release=release_one, type=1) + self.create_group(project=project1, status=1, first_release=release_one, type=1) + self.create_group(project=project2, status=1, first_release=release_two, type=1) + self.create_group(project=project2, status=2, first_release=release_two, type=1) + self.create_group(project=project2, status=2, first_release=release_two, type=6) + + response = self.client.get(self.url + "?statsPeriod=7d&category=feedback&group_by=release") + assert json.loads(response.content) == {"data": [{"bucket": "1.2.0", "count": 1}]} From b90a21e0d2990f78bf9f77ee1d9ce6f09196e200 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 11 Mar 2025 11:49:26 -0500 Subject: [PATCH 03/24] Port issues by time to event-stats format --- .../endpoints/organization_issue_breakdown.py | 144 ++++++++++++++---- .../test_organization_issue_breakdown.py | 67 +++----- 2 files changed, 140 insertions(+), 71 deletions(-) diff --git a/src/sentry/api/endpoints/organization_issue_breakdown.py b/src/sentry/api/endpoints/organization_issue_breakdown.py index c751590ae88133..fb5862fa0720a0 100644 --- a/src/sentry/api/endpoints/organization_issue_breakdown.py +++ b/src/sentry/api/endpoints/organization_issue_breakdown.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from datetime import datetime, timedelta from typing import TypedDict @@ -29,50 +30,87 @@ class OrganizationIssueBreakdownEndpoint(OrganizationEndpoint, EnvironmentMixin) def get(self, request: Request, organization: Organization) -> Response: """Stats bucketed by time.""" - start, end = get_date_range_from_params(request.GET) - end = end.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) - start = start.replace(hour=0, minute=0, second=0, microsecond=0) environments = [e.id for e in get_environments(request, organization)] projects = self.get_projects(request, organization) - issue_category = CATEGORY_MAP.get(request.GET.get("category", "error"), GroupCategory.ERROR) - group_by = request.GET.get("group_by", "new") - - if group_by == "new": - response = query_new_issues(projects, environments, issue_category, start, end) - return Response({"data": response}, status=200) - if group_by == "resolved": - response = query_resolved_issues(projects, environments, issue_category, start, end) - return Response({"data": response}, status=200) - if group_by == "release": - response = query_issues_by_release(projects, environments, issue_category, start, end) - return Response({"data": response}, status=200) + issue_category = request.GET.get("category", "error") + type_filter = ( + Q(type=GroupCategory.ERROR) + if issue_category == "error" + else Q(type=GroupCategory.FEEDBACK) + ) + group_by = request.GET.get("group_by", "time") + + # Start/end truncation and interval generation. + interval = timedelta(days=1) # interval = request.GET.get("interval", "1d") + start, end = get_date_range_from_params(request.GET) + end = end.replace(hour=0, minute=0, second=0, microsecond=0) + interval + start = start.replace(hour=0, minute=0, second=0, microsecond=0) + + if group_by == "time": + # Series queries. + new_series = query_new_issues(projects, environments, type_filter, start, end) + resolved_series = query_resolved_issues(projects, environments, type_filter, start, end) + + # Filling and formatting. + series_response = empty_response(start, end, interval) + append_series(series_response, new_series) + append_series(series_response, resolved_series) else: - return Response("", status=404) + # Series queries. + series = query_issues_by_release(projects, environments, type_filter, start, end) + + # Filling and formatting. + series_response = empty_response(start, end, interval) + append_series(series_response, series) + return Response( + { + "data": [[bucket, series] for bucket, series in series_response.items()], + "start": int(start.timestamp()), + "end": int(end.timestamp()), + # I have no idea what purpose this data serves on the front-end. + "isMetricsData": False, + "meta": { + "fields": {"time": "date", "issues_count": "count"}, + "units": {"time": None, "issues_count": "int"}, + "isMetricsData": False, + "isMetricsExtractedData": False, + "tips": {}, + "datasetReason": "unchanged", + "dataset": "groups", + }, + }, + status=200, + ) + + +# Series generation. -class BreakdownQueryResult(TypedDict): - bucket: str + +class Series(TypedDict): + bucket: datetime count: int def query_new_issues( projects: list[Project], environments: list[int], - issue_category: int, + type_filter: Q, start: datetime, end: datetime, -) -> list[BreakdownQueryResult]: +) -> list[Series]: # SELECT count(*), day(first_seen) FROM issues GROUP BY day(first_seen) group_environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() ) + issues_query = ( Group.objects.filter( group_environment_filter, + type_filter, first_seen__gte=start, first_seen__lte=end, project__in=projects, - type=issue_category, ) .annotate(bucket=TruncDay("first_seen")) .order_by("bucket") @@ -85,10 +123,10 @@ def query_new_issues( def query_resolved_issues( projects: list[Project], environments: list[int], - issue_category: int, + type_filter: Q, start: datetime, end: datetime, -) -> list[BreakdownQueryResult]: +) -> list[Series]: # SELECT count(*), day(resolved_at) FROM issues WHERE status = resolved GROUP BY day(resolved_at) group_environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() @@ -96,10 +134,10 @@ def query_resolved_issues( resolved_issues_query = ( Group.objects.filter( group_environment_filter, - first_seen__gte=start, - first_seen__lte=end, + type_filter, + resolved_at__gte=start, + resolved_at__lte=end, project__in=projects, - type=issue_category, status=GroupStatus.RESOLVED, ) .annotate(bucket=TruncDay("resolved_at")) @@ -113,10 +151,10 @@ def query_resolved_issues( def query_issues_by_release( projects: list[Project], environments: list[int], - issue_category: int, + type_filter: Q, start: datetime, end: datetime, -) -> list[BreakdownQueryResult]: +) -> list[Series]: # SELECT count(*), first_release.version FROM issues JOIN release GROUP BY first_release.version group_environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() @@ -124,10 +162,10 @@ def query_issues_by_release( issues_by_release_query = ( Group.objects.filter( group_environment_filter, + type_filter, first_seen__gte=start, first_seen__lte=end, project__in=projects, - type=issue_category, ) .annotate(bucket=F("first_release__version")) .order_by("bucket") @@ -135,3 +173,51 @@ def query_issues_by_release( .annotate(count=Count("id")) ) return list(issues_by_release_query) + + +# Response filling and formatting. + + +class BucketNotFound(LookupError): + pass + + +class SeriesResponseItem(TypedDict): + count: int + + +SeriesResponse = dict[int, list[SeriesResponseItem]] + + +def append_series(resp: SeriesResponse, series: list[Series]) -> None: + # We're going to increment this index as we consume the series. + idx = 0 + + for bucket in resp.keys(): + try: + next_bucket = int(series[idx]["bucket"].timestamp()) + except IndexError: + next_bucket = -1 + + # If the buckets match use the series count. + if next_bucket == bucket: + resp[bucket].append({"count": series[idx]["count"]}) + idx += 1 + # If the buckets do not match generate a value to fill its slot. + else: + resp[bucket].append({"count": 0}) + + # Programmer error. Requires code fix. Likely your query is not truncating timestamps the way + # you think it is. + if idx != len(series): + raise BucketNotFound("No buckets matched. Did your query truncate correctly?") + + +def empty_response(start: datetime, end: datetime, interval: timedelta) -> SeriesResponse: + return {bucket: [] for bucket in iter_interval(start, end, interval)} + + +def iter_interval(start: datetime, end: datetime, interval: timedelta) -> Iterator[int]: + while start <= end: + yield int(start.timestamp()) + start = start + interval diff --git a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py index ab9693d0aa4c50..dd63b034bee1af 100644 --- a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py +++ b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py @@ -16,45 +16,26 @@ def setUp(self): self.login_as(user=self.user) self.url = reverse(self.endpoint, args=(self.organization.slug,)) - def test_new_issues(self): + def test_issues_by_time(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - tomorrow = today + timedelta(days=1) + tmrw = today + timedelta(days=1) + yday = today - timedelta(days=1) self.create_group(project=project1, status=0, first_seen=today, type=1) - self.create_group(project=project1, status=1, first_seen=today, type=1) - self.create_group(project=project2, status=1, first_seen=tomorrow, type=1) - self.create_group(project=project2, status=2, first_seen=tomorrow, type=1) - self.create_group(project=project2, status=2, first_seen=tomorrow, type=6) - - response = self.client.get(self.url + "?statsPeriod=7d&category=error&group_by=new") - assert json.loads(response.content) == { - "data": [ - {"bucket": today.isoformat().replace("+00:00", "Z"), "count": 2}, - {"bucket": tomorrow.isoformat().replace("+00:00", "Z"), "count": 2}, - ] - } - - def test_resolved_issues(self): - project1 = self.create_project(teams=[self.team], slug="foo") - project2 = self.create_project(teams=[self.team], slug="bar") - - today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - tomorrow = today + timedelta(days=1) - self.create_group(project=project1, status=0, resolved_at=today, type=1) - self.create_group(project=project1, status=1, resolved_at=today, type=1) - self.create_group(project=project2, status=1, resolved_at=tomorrow, type=1) - self.create_group(project=project2, status=1, resolved_at=tomorrow, type=6) - self.create_group(project=project2, status=2, resolved_at=tomorrow, type=1) - - response = self.client.get(self.url + "?statsPeriod=7d&category=error&group_by=resolved") - assert json.loads(response.content) == { - "data": [ - {"bucket": today.isoformat().replace("+00:00", "Z"), "count": 1}, - {"bucket": tomorrow.isoformat().replace("+00:00", "Z"), "count": 1}, - ] - } + self.create_group(project=project1, status=1, first_seen=today, resolved_at=today, type=1) + self.create_group(project=project2, status=1, first_seen=tmrw, resolved_at=tmrw, type=1) + self.create_group(project=project2, status=2, first_seen=tmrw, type=1) + self.create_group(project=project2, status=2, first_seen=tmrw, type=6) + + response = self.client.get(self.url + "?statsPeriod=1d&category=error") + response_json = response.json() + assert response_json["data"] == [ + [int(yday.timestamp()), [{"count": 0}, {"count": 0}]], + [int(today.timestamp()), [{"count": 2}, {"count": 1}]], + [int(tmrw.timestamp()), [{"count": 2}, {"count": 1}]], + ] def test_issues_by_release(self): project1 = self.create_project(teams=[self.team], slug="foo") @@ -66,14 +47,16 @@ def test_issues_by_release(self): self.create_group(project=project2, status=1, first_release=release_two, type=1) self.create_group(project=project2, status=2, first_release=release_two, type=1) self.create_group(project=project2, status=2, first_release=release_two, type=6) - - response = self.client.get(self.url + "?statsPeriod=7d&category=error&group_by=release") - assert json.loads(response.content) == { - "data": [ - {"bucket": "1.0.0", "count": 2}, - {"bucket": "1.2.0", "count": 2}, - ] - } + # No release. + self.create_group(project=project1, status=0, type=1) + self.create_group(project=project2, status=2, type=6) + + response = self.client.get(self.url + "?statsPeriod=1d&category=error") + response_json = response.json() + assert response_json["data"] == [ + ["1.0.0", [{"count": 2}, {"count": 1}]], + ["1.2.0", [{"count": 2}, {"count": 1}]], + ] def test_issues_invalid_group_by(self): response = self.client.get(self.url + "?statsPeriod=7d&category=error&group_by=test") From a5251d7110ad4a570fd5e5b3f37657eb38c1edd0 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 11 Mar 2025 12:03:03 -0500 Subject: [PATCH 04/24] Port releases to new format --- .../endpoints/organization_issue_breakdown.py | 43 ++++++++++--------- .../test_organization_issue_breakdown.py | 12 +++--- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/sentry/api/endpoints/organization_issue_breakdown.py b/src/sentry/api/endpoints/organization_issue_breakdown.py index fb5862fa0720a0..48d7e4c5e7a05f 100644 --- a/src/sentry/api/endpoints/organization_issue_breakdown.py +++ b/src/sentry/api/endpoints/organization_issue_breakdown.py @@ -1,9 +1,10 @@ from collections.abc import Iterator from datetime import datetime, timedelta -from typing import TypedDict +from typing import Any, TypedDict from django.db.models import Count, F, Q from django.db.models.functions import TruncDay +from django.db.models.query import QuerySet from rest_framework.request import Request from rest_framework.response import Response @@ -56,12 +57,9 @@ def get(self, request: Request, organization: Organization) -> Response: append_series(series_response, new_series) append_series(series_response, resolved_series) else: - # Series queries. - series = query_issues_by_release(projects, environments, type_filter, start, end) - - # Filling and formatting. - series_response = empty_response(start, end, interval) - append_series(series_response, series) + series_response = query_issues_by_release( + projects, environments, type_filter, start, end + ) return Response( { @@ -92,6 +90,13 @@ class Series(TypedDict): count: int +class SeriesResponseItem(TypedDict): + count: int + + +SeriesResponse = dict[str, list[SeriesResponseItem]] + + def query_new_issues( projects: list[Project], environments: list[int], @@ -154,7 +159,7 @@ def query_issues_by_release( type_filter: Q, start: datetime, end: datetime, -) -> list[Series]: +) -> SeriesResponse: # SELECT count(*), first_release.version FROM issues JOIN release GROUP BY first_release.version group_environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() @@ -166,13 +171,14 @@ def query_issues_by_release( first_seen__gte=start, first_seen__lte=end, project__in=projects, + first_release__isnull=False, ) .annotate(bucket=F("first_release__version")) .order_by("bucket") .values("bucket") .annotate(count=Count("id")) ) - return list(issues_by_release_query) + return to_series(issues_by_release_query) # Response filling and formatting. @@ -182,22 +188,15 @@ class BucketNotFound(LookupError): pass -class SeriesResponseItem(TypedDict): - count: int - - -SeriesResponse = dict[int, list[SeriesResponseItem]] - - def append_series(resp: SeriesResponse, series: list[Series]) -> None: # We're going to increment this index as we consume the series. idx = 0 for bucket in resp.keys(): try: - next_bucket = int(series[idx]["bucket"].timestamp()) + next_bucket = str(int(series[idx]["bucket"].timestamp())) except IndexError: - next_bucket = -1 + next_bucket = "-1" # If the buckets match use the series count. if next_bucket == bucket: @@ -217,7 +216,11 @@ def empty_response(start: datetime, end: datetime, interval: timedelta) -> Serie return {bucket: [] for bucket in iter_interval(start, end, interval)} -def iter_interval(start: datetime, end: datetime, interval: timedelta) -> Iterator[int]: +def iter_interval(start: datetime, end: datetime, interval: timedelta) -> Iterator[str]: while start <= end: - yield int(start.timestamp()) + yield str(int(start.timestamp())) start = start + interval + + +def to_series(series: QuerySet[Any, dict[str, Any]]) -> SeriesResponse: + return {s["bucket"]: [{"count": s["count"]}] for s in series} diff --git a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py index dd63b034bee1af..c39642fbb1585a 100644 --- a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py +++ b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py @@ -32,9 +32,9 @@ def test_issues_by_time(self): response = self.client.get(self.url + "?statsPeriod=1d&category=error") response_json = response.json() assert response_json["data"] == [ - [int(yday.timestamp()), [{"count": 0}, {"count": 0}]], - [int(today.timestamp()), [{"count": 2}, {"count": 1}]], - [int(tmrw.timestamp()), [{"count": 2}, {"count": 1}]], + [str(int(yday.timestamp())), [{"count": 0}, {"count": 0}]], + [str(int(today.timestamp())), [{"count": 2}, {"count": 1}]], + [str(int(tmrw.timestamp())), [{"count": 2}, {"count": 1}]], ] def test_issues_by_release(self): @@ -51,11 +51,11 @@ def test_issues_by_release(self): self.create_group(project=project1, status=0, type=1) self.create_group(project=project2, status=2, type=6) - response = self.client.get(self.url + "?statsPeriod=1d&category=error") + response = self.client.get(self.url + "?statsPeriod=1d&category=error&group_by=release") response_json = response.json() assert response_json["data"] == [ - ["1.0.0", [{"count": 2}, {"count": 1}]], - ["1.2.0", [{"count": 2}, {"count": 1}]], + ["1.0.0", [{"count": 2}]], + ["1.2.0", [{"count": 2}]], ] def test_issues_invalid_group_by(self): From ee14cf89263bb1b249f999d0f1363669b6e4d6a1 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 11 Mar 2025 12:24:03 -0500 Subject: [PATCH 05/24] Fix typing --- src/sentry/api/endpoints/organization_issue_breakdown.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/sentry/api/endpoints/organization_issue_breakdown.py b/src/sentry/api/endpoints/organization_issue_breakdown.py index 48d7e4c5e7a05f..805a50e0251953 100644 --- a/src/sentry/api/endpoints/organization_issue_breakdown.py +++ b/src/sentry/api/endpoints/organization_issue_breakdown.py @@ -14,7 +14,8 @@ from sentry.api.bases.organization import OrganizationEndpoint from sentry.api.helpers.environments import get_environments from sentry.api.utils import get_date_range_from_params -from sentry.models.group import Group, GroupCategory, GroupStatus +from sentry.issues.grouptype import GroupCategory +from sentry.models.group import Group, GroupStatus from sentry.models.organization import Organization from sentry.models.project import Project @@ -103,7 +104,7 @@ def query_new_issues( type_filter: Q, start: datetime, end: datetime, -) -> list[Series]: +) -> list[dict[str, Any]]: # SELECT count(*), day(first_seen) FROM issues GROUP BY day(first_seen) group_environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() @@ -131,7 +132,7 @@ def query_resolved_issues( type_filter: Q, start: datetime, end: datetime, -) -> list[Series]: +) -> list[dict[str, Any]]: # SELECT count(*), day(resolved_at) FROM issues WHERE status = resolved GROUP BY day(resolved_at) group_environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() @@ -188,7 +189,7 @@ class BucketNotFound(LookupError): pass -def append_series(resp: SeriesResponse, series: list[Series]) -> None: +def append_series(resp: SeriesResponse, series: list[dict[str, Any]]) -> None: # We're going to increment this index as we consume the series. idx = 0 From 285d695e9e69396d1655440d9534be93c21a83a8 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 11 Mar 2025 12:35:21 -0500 Subject: [PATCH 06/24] Update feedback coverage --- .../endpoints/organization_issue_breakdown.py | 4 +- .../test_organization_issue_breakdown.py | 46 ++++++++----------- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/src/sentry/api/endpoints/organization_issue_breakdown.py b/src/sentry/api/endpoints/organization_issue_breakdown.py index 805a50e0251953..3942d61f82ecc6 100644 --- a/src/sentry/api/endpoints/organization_issue_breakdown.py +++ b/src/sentry/api/endpoints/organization_issue_breakdown.py @@ -57,10 +57,12 @@ def get(self, request: Request, organization: Organization) -> Response: series_response = empty_response(start, end, interval) append_series(series_response, new_series) append_series(series_response, resolved_series) - else: + elif group_by == "release": series_response = query_issues_by_release( projects, environments, type_filter, start, end ) + else: + return Response("", status=404) return Response( { diff --git a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py index c39642fbb1585a..d4d8f9ab83999e 100644 --- a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py +++ b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py @@ -4,7 +4,6 @@ from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.datetime import freeze_time -from sentry.utils import json @freeze_time() @@ -67,34 +66,28 @@ def test_new_feedback(self): project2 = self.create_project(teams=[self.team], slug="bar") today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - tomorrow = today + timedelta(days=1) + tmrw = today + timedelta(days=1) + yday = today - timedelta(days=1) + # New cohort self.create_group(project=project1, status=0, first_seen=today, type=1) self.create_group(project=project1, status=1, first_seen=today, type=1) - self.create_group(project=project2, status=1, first_seen=tomorrow, type=1) - self.create_group(project=project2, status=2, first_seen=tomorrow, type=1) - self.create_group(project=project2, status=2, first_seen=tomorrow, type=6) - - response = self.client.get(self.url + "?statsPeriod=7d&category=feedback&group_by=new") - assert json.loads(response.content) == { - "data": [{"bucket": tomorrow.isoformat().replace("+00:00", "Z"), "count": 1}] - } - - def test_resolved_feedback(self): - project1 = self.create_project(teams=[self.team], slug="foo") - project2 = self.create_project(teams=[self.team], slug="bar") - - today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - tomorrow = today + timedelta(days=1) + self.create_group(project=project2, status=1, first_seen=tmrw, type=1) + self.create_group(project=project2, status=2, first_seen=tmrw, type=1) + self.create_group(project=project2, status=2, first_seen=tmrw, type=6) + # Resolved cohort self.create_group(project=project1, status=0, resolved_at=today, type=1) self.create_group(project=project1, status=1, resolved_at=today, type=1) - self.create_group(project=project2, status=1, resolved_at=tomorrow, type=1) - self.create_group(project=project2, status=1, resolved_at=tomorrow, type=6) - self.create_group(project=project2, status=2, resolved_at=tomorrow, type=1) + self.create_group(project=project2, status=1, resolved_at=today, type=6) + self.create_group(project=project2, status=1, resolved_at=tmrw, type=1) + self.create_group(project=project2, status=2, resolved_at=tmrw, type=1) - response = self.client.get(self.url + "?statsPeriod=7d&category=feedback&group_by=resolved") - assert json.loads(response.content) == { - "data": [{"bucket": tomorrow.isoformat().replace("+00:00", "Z"), "count": 1}] - } + response = self.client.get(self.url + "?statsPeriod=1d&category=feedback&group_by=time") + response_json = response.json() + assert response_json["data"] == [ + [str(int(yday.timestamp())), [{"count": 0}, {"count": 0}]], + [str(int(today.timestamp())), [{"count": 1}, {"count": 1}]], + [str(int(tmrw.timestamp())), [{"count": 1}, {"count": 0}]], + ] def test_feedback_by_release(self): project1 = self.create_project(teams=[self.team], slug="foo") @@ -107,5 +100,6 @@ def test_feedback_by_release(self): self.create_group(project=project2, status=2, first_release=release_two, type=1) self.create_group(project=project2, status=2, first_release=release_two, type=6) - response = self.client.get(self.url + "?statsPeriod=7d&category=feedback&group_by=release") - assert json.loads(response.content) == {"data": [{"bucket": "1.2.0", "count": 1}]} + response = self.client.get(self.url + "?statsPeriod=1d&category=feedback&group_by=release") + response_json = response.json() + assert response_json["data"] == [["1.2.0", [{"count": 1}]]] From 66d3369fb6c3479c180fab710ff532ccb6a3cc9b Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 11 Mar 2025 12:37:03 -0500 Subject: [PATCH 07/24] Return everything but feedbacks --- .../endpoints/organization_issue_breakdown.py | 2 +- .../test_organization_issue_breakdown.py | 32 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/sentry/api/endpoints/organization_issue_breakdown.py b/src/sentry/api/endpoints/organization_issue_breakdown.py index 3942d61f82ecc6..1a6a72354a498f 100644 --- a/src/sentry/api/endpoints/organization_issue_breakdown.py +++ b/src/sentry/api/endpoints/organization_issue_breakdown.py @@ -36,7 +36,7 @@ def get(self, request: Request, organization: Organization) -> Response: projects = self.get_projects(request, organization) issue_category = request.GET.get("category", "error") type_filter = ( - Q(type=GroupCategory.ERROR) + ~Q(type=GroupCategory.FEEDBACK) if issue_category == "error" else Q(type=GroupCategory.FEEDBACK) ) diff --git a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py index d4d8f9ab83999e..a7c2756e046a65 100644 --- a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py +++ b/tests/sentry/api/endpoints/test_organization_issue_breakdown.py @@ -23,9 +23,9 @@ def test_issues_by_time(self): tmrw = today + timedelta(days=1) yday = today - timedelta(days=1) self.create_group(project=project1, status=0, first_seen=today, type=1) - self.create_group(project=project1, status=1, first_seen=today, resolved_at=today, type=1) - self.create_group(project=project2, status=1, first_seen=tmrw, resolved_at=tmrw, type=1) - self.create_group(project=project2, status=2, first_seen=tmrw, type=1) + self.create_group(project=project1, status=1, first_seen=today, resolved_at=today, type=2) + self.create_group(project=project2, status=1, first_seen=tmrw, resolved_at=tmrw, type=3) + self.create_group(project=project2, status=2, first_seen=tmrw, type=4) self.create_group(project=project2, status=2, first_seen=tmrw, type=6) response = self.client.get(self.url + "?statsPeriod=1d&category=error") @@ -42,9 +42,9 @@ def test_issues_by_release(self): release_one = self.create_release(project1, version="1.0.0") release_two = self.create_release(project2, version="1.2.0") self.create_group(project=project1, status=0, first_release=release_one, type=1) - self.create_group(project=project1, status=1, first_release=release_one, type=1) - self.create_group(project=project2, status=1, first_release=release_two, type=1) - self.create_group(project=project2, status=2, first_release=release_two, type=1) + self.create_group(project=project1, status=1, first_release=release_one, type=2) + self.create_group(project=project2, status=1, first_release=release_two, type=3) + self.create_group(project=project2, status=2, first_release=release_two, type=4) self.create_group(project=project2, status=2, first_release=release_two, type=6) # No release. self.create_group(project=project1, status=0, type=1) @@ -70,16 +70,16 @@ def test_new_feedback(self): yday = today - timedelta(days=1) # New cohort self.create_group(project=project1, status=0, first_seen=today, type=1) - self.create_group(project=project1, status=1, first_seen=today, type=1) - self.create_group(project=project2, status=1, first_seen=tmrw, type=1) - self.create_group(project=project2, status=2, first_seen=tmrw, type=1) + self.create_group(project=project1, status=1, first_seen=today, type=2) + self.create_group(project=project2, status=1, first_seen=tmrw, type=3) + self.create_group(project=project2, status=2, first_seen=tmrw, type=4) self.create_group(project=project2, status=2, first_seen=tmrw, type=6) # Resolved cohort - self.create_group(project=project1, status=0, resolved_at=today, type=1) - self.create_group(project=project1, status=1, resolved_at=today, type=1) + self.create_group(project=project1, status=0, resolved_at=today, type=2) + self.create_group(project=project1, status=1, resolved_at=today, type=3) self.create_group(project=project2, status=1, resolved_at=today, type=6) - self.create_group(project=project2, status=1, resolved_at=tmrw, type=1) - self.create_group(project=project2, status=2, resolved_at=tmrw, type=1) + self.create_group(project=project2, status=1, resolved_at=tmrw, type=4) + self.create_group(project=project2, status=2, resolved_at=tmrw, type=5) response = self.client.get(self.url + "?statsPeriod=1d&category=feedback&group_by=time") response_json = response.json() @@ -95,9 +95,9 @@ def test_feedback_by_release(self): release_one = self.create_release(project1, version="1.0.0") release_two = self.create_release(project2, version="1.2.0") self.create_group(project=project1, status=0, first_release=release_one, type=1) - self.create_group(project=project1, status=1, first_release=release_one, type=1) - self.create_group(project=project2, status=1, first_release=release_two, type=1) - self.create_group(project=project2, status=2, first_release=release_two, type=1) + self.create_group(project=project1, status=1, first_release=release_one, type=2) + self.create_group(project=project2, status=1, first_release=release_two, type=3) + self.create_group(project=project2, status=2, first_release=release_two, type=4) self.create_group(project=project2, status=2, first_release=release_two, type=6) response = self.client.get(self.url + "?statsPeriod=1d&category=feedback&group_by=release") From 84ad555519212fab3f6c2c1b41287ef62fa0d533 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 11 Mar 2025 12:39:34 -0500 Subject: [PATCH 08/24] Rename to issue-metrics --- ...n_issue_breakdown.py => organization_issue_metrics.py} | 2 +- src/sentry/api/urls.py | 8 ++++---- ...ue_breakdown.py => test_organization_issue_metrics.py} | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/sentry/api/endpoints/{organization_issue_breakdown.py => organization_issue_metrics.py} (98%) rename tests/sentry/api/endpoints/{test_organization_issue_breakdown.py => test_organization_issue_metrics.py} (98%) diff --git a/src/sentry/api/endpoints/organization_issue_breakdown.py b/src/sentry/api/endpoints/organization_issue_metrics.py similarity index 98% rename from src/sentry/api/endpoints/organization_issue_breakdown.py rename to src/sentry/api/endpoints/organization_issue_metrics.py index 1a6a72354a498f..1f0733f4a6c4e7 100644 --- a/src/sentry/api/endpoints/organization_issue_breakdown.py +++ b/src/sentry/api/endpoints/organization_issue_metrics.py @@ -26,7 +26,7 @@ @region_silo_endpoint -class OrganizationIssueBreakdownEndpoint(OrganizationEndpoint, EnvironmentMixin): +class OrganizationIssueMetricsEndpoint(OrganizationEndpoint, EnvironmentMixin): owner = ApiOwner.REPLAY publish_status = {"GET": ApiPublishStatus.PRIVATE} diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index eb3ecc0a0b648d..7fb3d98a82c2e9 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -503,7 +503,7 @@ from .endpoints.organization_events_trends_v2 import OrganizationEventsNewTrendsStatsEndpoint from .endpoints.organization_events_vitals import OrganizationEventsVitalsEndpoint from .endpoints.organization_index import OrganizationIndexEndpoint -from .endpoints.organization_issue_breakdown import OrganizationIssueBreakdownEndpoint +from .endpoints.organization_issue_metrics import OrganizationIssueMetricsEndpoint from .endpoints.organization_issues_resolved_in_release import ( OrganizationIssuesResolvedInReleaseEndpoint, ) @@ -1611,9 +1611,9 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: name="sentry-api-0-organization-group-index-stats", ), re_path( - r"^(?P[^\/]+)/issues-breakdown/$", - OrganizationIssueBreakdownEndpoint.as_view(), - name="sentry-api-0-organization-issue-breakdown", + r"^(?P[^\/]+)/issues-metrics/$", + OrganizationIssueMetricsEndpoint.as_view(), + name="sentry-api-0-organization-issue-metrics", ), re_path( r"^(?P[^\/]+)/integrations/$", diff --git a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py b/tests/sentry/api/endpoints/test_organization_issue_metrics.py similarity index 98% rename from tests/sentry/api/endpoints/test_organization_issue_breakdown.py rename to tests/sentry/api/endpoints/test_organization_issue_metrics.py index a7c2756e046a65..46cf9f6020958c 100644 --- a/tests/sentry/api/endpoints/test_organization_issue_breakdown.py +++ b/tests/sentry/api/endpoints/test_organization_issue_metrics.py @@ -8,7 +8,7 @@ @freeze_time() class OrganizationIssueBreakdownTest(APITestCase): - endpoint = "sentry-api-0-organization-issue-breakdown" + endpoint = "sentry-api-0-organization-issue-metrics" def setUp(self): super().setUp() From 2a6d1b46a7ba7a38b9ee6f33efd07057f661c8d8 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 11 Mar 2025 12:41:19 -0500 Subject: [PATCH 09/24] Update codeowners --- .github/CODEOWNERS | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 192d08ec511052..be865f3b97632b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -329,11 +329,13 @@ tests/sentry/api/endpoints/test_organization_dashboard_widget_details.py @ge ## Replays -/static/app/components/replays/ @getsentry/replay-frontend -/static/app/utils/replays/ @getsentry/replay-frontend -/static/app/views/replays/ @getsentry/replay-frontend -/src/sentry/replays/ @getsentry/replay-backend -/tests/sentry/replays/ @getsentry/replay-backend +/static/app/components/replays/ @getsentry/replay-frontend +/static/app/utils/replays/ @getsentry/replay-frontend +/static/app/views/replays/ @getsentry/replay-frontend +/src/sentry/replays/ @getsentry/replay-backend +/tests/sentry/replays/ @getsentry/replay-backend +/src/sentry/api/endpoints/organization_issue_metrics.py @getsentry/replay-backend +/tests/sentry/api/endpoints/test_organization_issue_metrics.py @getsentry/replay-backend ## End of Replays From be20dda69dc86fc9c4a4726a9fe0c8f71b0af141 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 11 Mar 2025 13:57:20 -0500 Subject: [PATCH 10/24] Fix code location --- src/sentry/api/urls.py | 2 +- .../{api => issues}/endpoints/organization_issue_metrics.py | 0 .../endpoints/test_organization_issue_metrics.py | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename src/sentry/{api => issues}/endpoints/organization_issue_metrics.py (100%) rename tests/sentry/{api => issues}/endpoints/test_organization_issue_metrics.py (100%) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 7fb3d98a82c2e9..146f856f3e5497 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -191,6 +191,7 @@ SourceMapDebugEndpoint, TeamGroupsOldEndpoint, ) +from sentry.issues.endpoints.organization_issue_metrics import OrganizationIssueMetricsEndpoint from sentry.monitors.endpoints.organization_monitor_checkin_index import ( OrganizationMonitorCheckInIndexEndpoint, ) @@ -503,7 +504,6 @@ from .endpoints.organization_events_trends_v2 import OrganizationEventsNewTrendsStatsEndpoint from .endpoints.organization_events_vitals import OrganizationEventsVitalsEndpoint from .endpoints.organization_index import OrganizationIndexEndpoint -from .endpoints.organization_issue_metrics import OrganizationIssueMetricsEndpoint from .endpoints.organization_issues_resolved_in_release import ( OrganizationIssuesResolvedInReleaseEndpoint, ) diff --git a/src/sentry/api/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py similarity index 100% rename from src/sentry/api/endpoints/organization_issue_metrics.py rename to src/sentry/issues/endpoints/organization_issue_metrics.py diff --git a/tests/sentry/api/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py similarity index 100% rename from tests/sentry/api/endpoints/test_organization_issue_metrics.py rename to tests/sentry/issues/endpoints/test_organization_issue_metrics.py From c8f27ac1fd410b4fc8819dc19a6abb0a5b3292d4 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 11 Mar 2025 13:57:35 -0500 Subject: [PATCH 11/24] Update codeowners --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index be865f3b97632b..314670d760dd7e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -334,8 +334,8 @@ tests/sentry/api/endpoints/test_organization_dashboard_widget_details.py @ge /static/app/views/replays/ @getsentry/replay-frontend /src/sentry/replays/ @getsentry/replay-backend /tests/sentry/replays/ @getsentry/replay-backend -/src/sentry/api/endpoints/organization_issue_metrics.py @getsentry/replay-backend -/tests/sentry/api/endpoints/test_organization_issue_metrics.py @getsentry/replay-backend +/src/sentry/issues/endpoints/organization_issue_metrics.py @getsentry/replay-backend +/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @getsentry/replay-backend ## End of Replays From 76d487ff2e7a825d23928f530c7f5d5098eff043 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 12 Mar 2025 08:20:06 -0500 Subject: [PATCH 12/24] Update response output --- src/sentry/issues/endpoints/organization_issue_metrics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index 1f0733f4a6c4e7..8828a93e74ec2e 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -62,7 +62,7 @@ def get(self, request: Request, organization: Organization) -> Response: projects, environments, type_filter, start, end ) else: - return Response("", status=404) + return Response("Valid options for group_by are 'time' and 'release'", status=404) return Response( { @@ -72,8 +72,8 @@ def get(self, request: Request, organization: Organization) -> Response: # I have no idea what purpose this data serves on the front-end. "isMetricsData": False, "meta": { - "fields": {"time": "date", "issues_count": "count"}, - "units": {"time": None, "issues_count": "int"}, + "fields": {"time": "date", "issues_count": "integer"}, + "units": {"time": None, "issues_count": None}, "isMetricsData": False, "isMetricsExtractedData": False, "tips": {}, From fafe3131cbba2ea832310577ec09e57b68e47331 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 12 Mar 2025 08:42:34 -0500 Subject: [PATCH 13/24] Add test coverage --- .../test_organization_issue_metrics.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py index 46cf9f6020958c..1f1a9ef528a2ac 100644 --- a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py +++ b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @@ -61,6 +61,27 @@ def test_issues_invalid_group_by(self): response = self.client.get(self.url + "?statsPeriod=7d&category=error&group_by=test") assert response.status_code == 404 + def test_issues_by_time_project_filter(self): + """Assert the project filter works.""" + project1 = self.create_project(teams=[self.team], slug="foo") + project2 = self.create_project(teams=[self.team], slug="bar") + + today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + tmrw = today + timedelta(days=1) + yday = today - timedelta(days=1) + self.create_group(project=project1, status=0, first_seen=today, type=1) + self.create_group(project=project2, status=0, first_seen=today, type=1) + + response = self.client.get( + self.url + f"?statsPeriod=1d&category=error&project={project1.id}" + ) + response_json = response.json() + assert response_json["data"] == [ + [str(int(yday.timestamp())), [{"count": 0}, {"count": 0}]], + [str(int(today.timestamp())), [{"count": 1}, {"count": 0}]], + [str(int(tmrw.timestamp())), [{"count": 0}, {"count": 0}]], + ] + def test_new_feedback(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") From d40e9a9c11d8eac6c2b4a6976e017522a63532f4 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 12 Mar 2025 09:30:32 -0500 Subject: [PATCH 14/24] Update truncation logic to be 1 hour --- .../endpoints/organization_issue_metrics.py | 12 +-- .../test_organization_issue_metrics.py | 82 +++++++++---------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index 8828a93e74ec2e..9a96ee82ed3b8d 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -3,7 +3,7 @@ from typing import Any, TypedDict from django.db.models import Count, F, Q -from django.db.models.functions import TruncDay +from django.db.models.functions import TruncHour from django.db.models.query import QuerySet from rest_framework.request import Request from rest_framework.response import Response @@ -43,10 +43,10 @@ def get(self, request: Request, organization: Organization) -> Response: group_by = request.GET.get("group_by", "time") # Start/end truncation and interval generation. - interval = timedelta(days=1) # interval = request.GET.get("interval", "1d") + interval = timedelta(hours=1) # interval = request.GET.get("interval", "1d") start, end = get_date_range_from_params(request.GET) - end = end.replace(hour=0, minute=0, second=0, microsecond=0) + interval - start = start.replace(hour=0, minute=0, second=0, microsecond=0) + end = end.replace(minute=0, second=0, microsecond=0) + interval + start = start.replace(minute=0, second=0, microsecond=0) if group_by == "time": # Series queries. @@ -120,7 +120,7 @@ def query_new_issues( first_seen__lte=end, project__in=projects, ) - .annotate(bucket=TruncDay("first_seen")) + .annotate(bucket=TruncHour("first_seen")) .order_by("bucket") .values("bucket") .annotate(count=Count("id")) @@ -148,7 +148,7 @@ def query_resolved_issues( project__in=projects, status=GroupStatus.RESOLVED, ) - .annotate(bucket=TruncDay("resolved_at")) + .annotate(bucket=TruncHour("resolved_at")) .order_by("bucket") .values("bucket") .annotate(count=Count("id")) diff --git a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py index 1f1a9ef528a2ac..9406dfcda458b7 100644 --- a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py +++ b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @@ -19,21 +19,21 @@ def test_issues_by_time(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") - today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - tmrw = today + timedelta(days=1) - yday = today - timedelta(days=1) - self.create_group(project=project1, status=0, first_seen=today, type=1) - self.create_group(project=project1, status=1, first_seen=today, resolved_at=today, type=2) - self.create_group(project=project2, status=1, first_seen=tmrw, resolved_at=tmrw, type=3) - self.create_group(project=project2, status=2, first_seen=tmrw, type=4) - self.create_group(project=project2, status=2, first_seen=tmrw, type=6) - - response = self.client.get(self.url + "?statsPeriod=1d&category=error") + now = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) + next = now + timedelta(hours=1) + prev = now - timedelta(hours=1) + self.create_group(project=project1, status=0, first_seen=now, type=1) + self.create_group(project=project1, status=1, first_seen=now, resolved_at=now, type=2) + self.create_group(project=project2, status=1, first_seen=next, resolved_at=next, type=3) + self.create_group(project=project2, status=2, first_seen=next, type=4) + self.create_group(project=project2, status=2, first_seen=next, type=6) + + response = self.client.get(self.url + "?statsPeriod=1h&category=error") response_json = response.json() assert response_json["data"] == [ - [str(int(yday.timestamp())), [{"count": 0}, {"count": 0}]], - [str(int(today.timestamp())), [{"count": 2}, {"count": 1}]], - [str(int(tmrw.timestamp())), [{"count": 2}, {"count": 1}]], + [str(int(prev.timestamp())), [{"count": 0}, {"count": 0}]], + [str(int(now.timestamp())), [{"count": 2}, {"count": 1}]], + [str(int(next.timestamp())), [{"count": 2}, {"count": 1}]], ] def test_issues_by_release(self): @@ -50,7 +50,7 @@ def test_issues_by_release(self): self.create_group(project=project1, status=0, type=1) self.create_group(project=project2, status=2, type=6) - response = self.client.get(self.url + "?statsPeriod=1d&category=error&group_by=release") + response = self.client.get(self.url + "?statsPeriod=1h&category=error&group_by=release") response_json = response.json() assert response_json["data"] == [ ["1.0.0", [{"count": 2}]], @@ -66,48 +66,48 @@ def test_issues_by_time_project_filter(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") - today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - tmrw = today + timedelta(days=1) - yday = today - timedelta(days=1) - self.create_group(project=project1, status=0, first_seen=today, type=1) - self.create_group(project=project2, status=0, first_seen=today, type=1) + now = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) + next = now + timedelta(hours=1) + prev = now - timedelta(hours=1) + self.create_group(project=project1, status=0, first_seen=now, type=1) + self.create_group(project=project2, status=0, first_seen=now, type=1) response = self.client.get( - self.url + f"?statsPeriod=1d&category=error&project={project1.id}" + self.url + f"?statsPeriod=1h&category=error&project={project1.id}" ) response_json = response.json() assert response_json["data"] == [ - [str(int(yday.timestamp())), [{"count": 0}, {"count": 0}]], - [str(int(today.timestamp())), [{"count": 1}, {"count": 0}]], - [str(int(tmrw.timestamp())), [{"count": 0}, {"count": 0}]], + [str(int(prev.timestamp())), [{"count": 0}, {"count": 0}]], + [str(int(now.timestamp())), [{"count": 1}, {"count": 0}]], + [str(int(next.timestamp())), [{"count": 0}, {"count": 0}]], ] def test_new_feedback(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") - today = datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - tmrw = today + timedelta(days=1) - yday = today - timedelta(days=1) + now = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) + next = now + timedelta(hours=1) + prev = now - timedelta(hours=1) # New cohort - self.create_group(project=project1, status=0, first_seen=today, type=1) - self.create_group(project=project1, status=1, first_seen=today, type=2) - self.create_group(project=project2, status=1, first_seen=tmrw, type=3) - self.create_group(project=project2, status=2, first_seen=tmrw, type=4) - self.create_group(project=project2, status=2, first_seen=tmrw, type=6) + self.create_group(project=project1, status=0, first_seen=now, type=1) + self.create_group(project=project1, status=1, first_seen=now, type=2) + self.create_group(project=project2, status=1, first_seen=next, type=3) + self.create_group(project=project2, status=2, first_seen=next, type=4) + self.create_group(project=project2, status=2, first_seen=next, type=6) # Resolved cohort - self.create_group(project=project1, status=0, resolved_at=today, type=2) - self.create_group(project=project1, status=1, resolved_at=today, type=3) - self.create_group(project=project2, status=1, resolved_at=today, type=6) - self.create_group(project=project2, status=1, resolved_at=tmrw, type=4) - self.create_group(project=project2, status=2, resolved_at=tmrw, type=5) + self.create_group(project=project1, status=0, resolved_at=now, type=2) + self.create_group(project=project1, status=1, resolved_at=now, type=3) + self.create_group(project=project2, status=1, resolved_at=now, type=6) + self.create_group(project=project2, status=1, resolved_at=next, type=4) + self.create_group(project=project2, status=2, resolved_at=next, type=5) - response = self.client.get(self.url + "?statsPeriod=1d&category=feedback&group_by=time") + response = self.client.get(self.url + "?statsPeriod=1h&category=feedback&group_by=time") response_json = response.json() assert response_json["data"] == [ - [str(int(yday.timestamp())), [{"count": 0}, {"count": 0}]], - [str(int(today.timestamp())), [{"count": 1}, {"count": 1}]], - [str(int(tmrw.timestamp())), [{"count": 1}, {"count": 0}]], + [str(int(prev.timestamp())), [{"count": 0}, {"count": 0}]], + [str(int(now.timestamp())), [{"count": 1}, {"count": 1}]], + [str(int(next.timestamp())), [{"count": 1}, {"count": 0}]], ] def test_feedback_by_release(self): @@ -121,6 +121,6 @@ def test_feedback_by_release(self): self.create_group(project=project2, status=2, first_release=release_two, type=4) self.create_group(project=project2, status=2, first_release=release_two, type=6) - response = self.client.get(self.url + "?statsPeriod=1d&category=feedback&group_by=release") + response = self.client.get(self.url + "?statsPeriod=1h&category=feedback&group_by=release") response_json = response.json() assert response_json["data"] == [["1.2.0", [{"count": 1}]]] From e95af27e0089aea6d0132fe75ca3c334528cc73b Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 12 Mar 2025 09:51:35 -0500 Subject: [PATCH 15/24] Naming --- .../test_organization_issue_metrics.py | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py index 9406dfcda458b7..0b49d822c9b100 100644 --- a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py +++ b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @@ -19,11 +19,11 @@ def test_issues_by_time(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") - now = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) - next = now + timedelta(hours=1) - prev = now - timedelta(hours=1) - self.create_group(project=project1, status=0, first_seen=now, type=1) - self.create_group(project=project1, status=1, first_seen=now, resolved_at=now, type=2) + today = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) + next = today + timedelta(hours=1) + prev = today - timedelta(hours=1) + self.create_group(project=project1, status=0, first_seen=today, type=1) + self.create_group(project=project1, status=1, first_seen=today, resolved_at=today, type=2) self.create_group(project=project2, status=1, first_seen=next, resolved_at=next, type=3) self.create_group(project=project2, status=2, first_seen=next, type=4) self.create_group(project=project2, status=2, first_seen=next, type=6) @@ -32,7 +32,7 @@ def test_issues_by_time(self): response_json = response.json() assert response_json["data"] == [ [str(int(prev.timestamp())), [{"count": 0}, {"count": 0}]], - [str(int(now.timestamp())), [{"count": 2}, {"count": 1}]], + [str(int(today.timestamp())), [{"count": 2}, {"count": 1}]], [str(int(next.timestamp())), [{"count": 2}, {"count": 1}]], ] @@ -66,11 +66,11 @@ def test_issues_by_time_project_filter(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") - now = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) - next = now + timedelta(hours=1) - prev = now - timedelta(hours=1) - self.create_group(project=project1, status=0, first_seen=now, type=1) - self.create_group(project=project2, status=0, first_seen=now, type=1) + today = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) + next = today + timedelta(hours=1) + prev = today - timedelta(hours=1) + self.create_group(project=project1, status=0, first_seen=today, type=1) + self.create_group(project=project2, status=0, first_seen=today, type=1) response = self.client.get( self.url + f"?statsPeriod=1h&category=error&project={project1.id}" @@ -78,7 +78,7 @@ def test_issues_by_time_project_filter(self): response_json = response.json() assert response_json["data"] == [ [str(int(prev.timestamp())), [{"count": 0}, {"count": 0}]], - [str(int(now.timestamp())), [{"count": 1}, {"count": 0}]], + [str(int(today.timestamp())), [{"count": 1}, {"count": 0}]], [str(int(next.timestamp())), [{"count": 0}, {"count": 0}]], ] @@ -86,19 +86,19 @@ def test_new_feedback(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") - now = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) - next = now + timedelta(hours=1) - prev = now - timedelta(hours=1) + today = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) + next = today + timedelta(hours=1) + prev = today - timedelta(hours=1) # New cohort - self.create_group(project=project1, status=0, first_seen=now, type=1) - self.create_group(project=project1, status=1, first_seen=now, type=2) + self.create_group(project=project1, status=0, first_seen=today, type=1) + self.create_group(project=project1, status=1, first_seen=today, type=2) self.create_group(project=project2, status=1, first_seen=next, type=3) self.create_group(project=project2, status=2, first_seen=next, type=4) self.create_group(project=project2, status=2, first_seen=next, type=6) # Resolved cohort - self.create_group(project=project1, status=0, resolved_at=now, type=2) - self.create_group(project=project1, status=1, resolved_at=now, type=3) - self.create_group(project=project2, status=1, resolved_at=now, type=6) + self.create_group(project=project1, status=0, resolved_at=today, type=2) + self.create_group(project=project1, status=1, resolved_at=today, type=3) + self.create_group(project=project2, status=1, resolved_at=today, type=6) self.create_group(project=project2, status=1, resolved_at=next, type=4) self.create_group(project=project2, status=2, resolved_at=next, type=5) @@ -106,7 +106,7 @@ def test_new_feedback(self): response_json = response.json() assert response_json["data"] == [ [str(int(prev.timestamp())), [{"count": 0}, {"count": 0}]], - [str(int(now.timestamp())), [{"count": 1}, {"count": 1}]], + [str(int(today.timestamp())), [{"count": 1}, {"count": 1}]], [str(int(next.timestamp())), [{"count": 1}, {"count": 0}]], ] From c9b87e316a4df1caeda88c46efe71cf89b4d60c4 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Thu, 13 Mar 2025 14:54:45 -0500 Subject: [PATCH 16/24] First pass on new design --- .../endpoints/organization_issue_metrics.py | 254 ++++++++++-------- .../test_organization_issue_metrics.py | 100 +++++-- 2 files changed, 228 insertions(+), 126 deletions(-) diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index 9a96ee82ed3b8d..acaf5fd8326539 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -1,9 +1,11 @@ +import collections from collections.abc import Iterator from datetime import datetime, timedelta +from itertools import chain from typing import Any, TypedDict -from django.db.models import Count, F, Q -from django.db.models.functions import TruncHour +from django.db.models import Count, DateTimeField, F, Func, Q +from django.db.models.functions import Extract from django.db.models.query import QuerySet from rest_framework.request import Request from rest_framework.response import Response @@ -40,148 +42,184 @@ def get(self, request: Request, organization: Organization) -> Response: if issue_category == "error" else Q(type=GroupCategory.FEEDBACK) ) - group_by = request.GET.get("group_by", "time") - # Start/end truncation and interval generation. - interval = timedelta(hours=1) # interval = request.GET.get("interval", "1d") + interval_s = int(request.GET.get("interval", 3_600_000)) // 1000 # TODO: Safe parse + interval = timedelta(seconds=interval_s) start, end = get_date_range_from_params(request.GET) - end = end.replace(minute=0, second=0, microsecond=0) + interval - start = start.replace(minute=0, second=0, microsecond=0) - - if group_by == "time": - # Series queries. - new_series = query_new_issues(projects, environments, type_filter, start, end) - resolved_series = query_resolved_issues(projects, environments, type_filter, start, end) - - # Filling and formatting. - series_response = empty_response(start, end, interval) - append_series(series_response, new_series) - append_series(series_response, resolved_series) - elif group_by == "release": - series_response = query_issues_by_release( - projects, environments, type_filter, start, end + + def gen_ts(qs: QuerySet[Group], group_by: list[str], source: str, axis: str): + qs = make_timeseries_query( + qs, + projects, + environments, + type_filter, + group_by, + interval, + source, + start, + end, ) - else: - return Response("Valid options for group_by are 'time' and 'release'", status=404) + + grouped_series = collections.defaultdict(list) + for row in qs: + grouping = [row[g] for g in group_by] + key = "||||".join(grouping) + grouped_series[key].append({"timestamp": row["timestamp"], "value": row["value"]}) + + return [ + make_timeseries_result( + axis=axis, + group=key.split("||||") if key else [], + interval=interval.seconds * 1000, + order=i, + values=series, + ) + for i, (key, series) in enumerate(grouped_series.items()) + ] return Response( { - "data": [[bucket, series] for bucket, series in series_response.items()], - "start": int(start.timestamp()), - "end": int(end.timestamp()), - # I have no idea what purpose this data serves on the front-end. - "isMetricsData": False, + "timeseries": chain( + gen_ts(query_new_issues(), [], "first_seen", "new_issues_count"), + gen_ts(query_resolved_issues(), [], "resolved_at", "resolved_issues_count"), + gen_ts( + query_issues_by_release(), + ["first_release__version"], + "first_seen", + "new_issues_count_by_release", + ), + ), "meta": { - "fields": {"time": "date", "issues_count": "integer"}, - "units": {"time": None, "issues_count": None}, - "isMetricsData": False, - "isMetricsExtractedData": False, - "tips": {}, - "datasetReason": "unchanged", - "dataset": "groups", + "dataset": "issues", + "end": end.timestamp(), + "start": start.timestamp(), }, }, status=200, ) -# Series generation. - - -class Series(TypedDict): - bucket: datetime - count: int +class TimeSeries(TypedDict): + timestamp: float + value: float -class SeriesResponseItem(TypedDict): - count: int +class TimeSeriesResultMeta(TypedDict): + groupby: list[str] + interval: float + isOther: bool + order: int + type: str + unit: str | None -SeriesResponse = dict[str, list[SeriesResponseItem]] +class TimeSeriesResult(TypedDict): + axis: str + meta: TimeSeriesResultMeta + values: list[TimeSeries] -def query_new_issues( +def make_timeseries_query( + qs: QuerySet[Group], projects: list[Project], environments: list[int], type_filter: Q, + group_by: list[str], + stride: timedelta, + source: str, start: datetime, end: datetime, -) -> list[dict[str, Any]]: - # SELECT count(*), day(first_seen) FROM issues GROUP BY day(first_seen) - group_environment_filter = ( +) -> QuerySet[Group, dict[str, Any]]: + environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() ) + range_filters = {f"{source}__gte": start, f"{source}__lte": end} + + annotations = {} + order_by = [] + values = [] + for group in group_by: + annotations[group] = F(group) + order_by.append(group) + values.append(group) + + order_by.append("timestamp") + values.append("timestamp") + + annotations["timestamp"] = Extract( + Func( + stride, + source, + start, + function="date_bin", + output_field=DateTimeField(), + ), + "epoch", + ) - issues_query = ( - Group.objects.filter( - group_environment_filter, + return ( + qs.filter( + environment_filter, type_filter, - first_seen__gte=start, - first_seen__lte=end, - project__in=projects, + project_id__in=[p.id for p in projects], + **range_filters, ) - .annotate(bucket=TruncHour("first_seen")) - .order_by("bucket") - .values("bucket") - .annotate(count=Count("id")) + .annotate(**annotations) + .order_by(*order_by) + .values(*values) + .annotate(value=Count("id")) ) - return list(issues_query) -def query_resolved_issues( - projects: list[Project], - environments: list[int], - type_filter: Q, - start: datetime, - end: datetime, -) -> list[dict[str, Any]]: +def make_timeseries_result( + axis: str, + group: list[str], + interval: int, + order: int, + values: list[TimeSeries], +) -> TimeSeriesResult: + return { + "axis": axis, + "meta": { + "groupBy": group, + "interval": interval, + "isOther": False, + "order": order, + "type": "integer", + "unit": None, + }, + "values": values, + } + + +# Series generation. + + +class Series(TypedDict): + bucket: datetime + count: int + + +class SeriesResponseItem(TypedDict): + count: int + + +SeriesResponse = dict[str, list[SeriesResponseItem]] + + +def query_new_issues() -> QuerySet[Group]: + # SELECT count(*), day(first_seen) FROM issues GROUP BY day(first_seen) + return Group.objects + + +def query_resolved_issues() -> QuerySet[Group]: # SELECT count(*), day(resolved_at) FROM issues WHERE status = resolved GROUP BY day(resolved_at) - group_environment_filter = ( - Q(groupenvironment__environment_id=environments[0]) if environments else Q() - ) - resolved_issues_query = ( - Group.objects.filter( - group_environment_filter, - type_filter, - resolved_at__gte=start, - resolved_at__lte=end, - project__in=projects, - status=GroupStatus.RESOLVED, - ) - .annotate(bucket=TruncHour("resolved_at")) - .order_by("bucket") - .values("bucket") - .annotate(count=Count("id")) - ) - return list(resolved_issues_query) + return Group.objects.filter(status=GroupStatus.RESOLVED) -def query_issues_by_release( - projects: list[Project], - environments: list[int], - type_filter: Q, - start: datetime, - end: datetime, -) -> SeriesResponse: +def query_issues_by_release() -> QuerySet[Group]: # SELECT count(*), first_release.version FROM issues JOIN release GROUP BY first_release.version - group_environment_filter = ( - Q(groupenvironment__environment_id=environments[0]) if environments else Q() - ) - issues_by_release_query = ( - Group.objects.filter( - group_environment_filter, - type_filter, - first_seen__gte=start, - first_seen__lte=end, - project__in=projects, - first_release__isnull=False, - ) - .annotate(bucket=F("first_release__version")) - .order_by("bucket") - .values("bucket") - .annotate(count=Count("id")) - ) - return to_series(issues_by_release_query) + return Group.objects.filter(first_release__isnull=False) # Response filling and formatting. diff --git a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py index 0b49d822c9b100..bcf822a0c62c9e 100644 --- a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py +++ b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @@ -3,11 +3,9 @@ from django.urls import reverse from sentry.testutils.cases import APITestCase -from sentry.testutils.helpers.datetime import freeze_time -@freeze_time() -class OrganizationIssueBreakdownTest(APITestCase): +class OrganizationIssueMetricsTestCase(APITestCase): endpoint = "sentry-api-0-organization-issue-metrics" def setUp(self): @@ -15,25 +13,91 @@ def setUp(self): self.login_as(user=self.user) self.url = reverse(self.endpoint, args=(self.organization.slug,)) - def test_issues_by_time(self): + def test_get(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") + one = self.create_release(project1, version="1.0.0") + two = self.create_release(project2, version="1.2.0") + + curr = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) + prev = curr - timedelta(hours=1) + + # Release issues. + self.create_group(project=project1, status=0, first_seen=curr, first_release=one, type=1) + self.create_group(project=project1, status=1, first_seen=prev, first_release=one, type=2) + self.create_group(project=project2, status=1, first_seen=curr, first_release=two, type=3) + self.create_group(project=project2, status=2, first_seen=curr, first_release=two, type=4) + self.create_group(project=project2, status=2, first_seen=curr, first_release=two, type=6) + + # Time based issues. + self.create_group(project=project1, status=0, first_seen=curr, type=1) + self.create_group(project=project1, status=1, first_seen=curr, resolved_at=curr, type=2) + self.create_group(project=project2, status=1, first_seen=prev, resolved_at=prev, type=3) + self.create_group(project=project2, status=2, first_seen=prev, type=4) + self.create_group(project=project2, status=2, first_seen=prev, type=6) - today = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) - next = today + timedelta(hours=1) - prev = today - timedelta(hours=1) - self.create_group(project=project1, status=0, first_seen=today, type=1) - self.create_group(project=project1, status=1, first_seen=today, resolved_at=today, type=2) - self.create_group(project=project2, status=1, first_seen=next, resolved_at=next, type=3) - self.create_group(project=project2, status=2, first_seen=next, type=4) - self.create_group(project=project2, status=2, first_seen=next, type=6) - - response = self.client.get(self.url + "?statsPeriod=1h&category=error") + response = self.client.get( + self.url + f"?start={prev.isoformat()[:-6]}&end={curr.isoformat()[:-6]}&category=error" + ) response_json = response.json() - assert response_json["data"] == [ - [str(int(prev.timestamp())), [{"count": 0}, {"count": 0}]], - [str(int(today.timestamp())), [{"count": 2}, {"count": 1}]], - [str(int(next.timestamp())), [{"count": 2}, {"count": 1}]], + assert response_json["timeseries"] == [ + { + "axis": "new_issues_count", + "meta": { + "groupBy": [], + "interval": 3600000, + "isOther": False, + "order": 0, + "type": "integer", + "unit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 3}, + {"timestamp": int(curr.timestamp()), "value": 5}, + ], + }, + { + "axis": "resolved_issues_count", + "meta": { + "groupBy": [], + "interval": 3600000, + "isOther": False, + "order": 0, + "type": "integer", + "unit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 1}, + {"timestamp": int(curr.timestamp()), "value": 1}, + ], + }, + { + "axis": "new_issues_count_by_release", + "meta": { + "groupBy": ["1.0.0"], + "interval": 3600000, + "isOther": False, + "order": 0, + "type": "integer", + "unit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 1}, + {"timestamp": int(curr.timestamp()), "value": 1}, + ], + }, + { + "axis": "new_issues_count_by_release", + "meta": { + "groupBy": ["1.2.0"], + "interval": 3600000, + "isOther": False, + "order": 1, + "type": "integer", + "unit": None, + }, + "values": [{"timestamp": int(curr.timestamp()), "value": 2}], + }, ] def test_issues_by_release(self): From 72bd80d52e6e690a59e4dcab942547bc7217d01a Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 14 Mar 2025 11:05:16 -0500 Subject: [PATCH 17/24] Allow arbitrary intervals and cleanup old code --- .../endpoints/organization_issue_metrics.py | 164 ++++++++---------- .../test_organization_issue_metrics.py | 144 +++++++-------- 2 files changed, 149 insertions(+), 159 deletions(-) diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index acaf5fd8326539..56d74b44644c1a 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -7,6 +7,7 @@ from django.db.models import Count, DateTimeField, F, Func, Q from django.db.models.functions import Extract from django.db.models.query import QuerySet +from rest_framework.exceptions import ParseError from rest_framework.request import Request from rest_framework.response import Response @@ -21,11 +22,6 @@ from sentry.models.organization import Organization from sentry.models.project import Project -CATEGORY_MAP = { - "error": GroupCategory.ERROR, - "feedback": GroupCategory.FEEDBACK, -} - @region_silo_endpoint class OrganizationIssueMetricsEndpoint(OrganizationEndpoint, EnvironmentMixin): @@ -36,6 +32,7 @@ def get(self, request: Request, organization: Organization) -> Response: """Stats bucketed by time.""" environments = [e.id for e in get_environments(request, organization)] projects = self.get_projects(request, organization) + start, end = get_date_range_from_params(request.GET) issue_category = request.GET.get("category", "error") type_filter = ( ~Q(type=GroupCategory.FEEDBACK) @@ -43,11 +40,32 @@ def get(self, request: Request, organization: Organization) -> Response: else Q(type=GroupCategory.FEEDBACK) ) - interval_s = int(request.GET.get("interval", 3_600_000)) // 1000 # TODO: Safe parse - interval = timedelta(seconds=interval_s) - start, end = get_date_range_from_params(request.GET) - - def gen_ts(qs: QuerySet[Group], group_by: list[str], source: str, axis: str): + try: + interval_s = int(request.GET["interval"]) // 1000 + interval = timedelta(seconds=interval_s) + except KeyError: + # Defaulting for now. Probably better to compute some known interval. I.e. if we're + # close to an hour round up to an hour to ensure the best visual experience. + # + # Or maybe we require this field and ignore all these problems. + interval_s = 3600 + interval = timedelta(seconds=interval_s) + except ValueError: + raise ParseError("Could not parse interval value.") + + # This step validates our maximum granularity. Without this we could see unbounded + # cardinality in our queries. Our maximum granularity is 200 which is more than enough to + # accommodate common aggregation intervals. + # + # Max granularity estimates for a given range (rounded to understandable intervals): + # - One week range -> one hour interval. + # - One day range -> ten minute interval. + # - One hour range -> twenty second interval. + number_of_buckets = (end - start).seconds // interval.seconds + if number_of_buckets > 200: + raise ParseError("The specified granularity is too precise. Increase your interval.") + + def gen_ts(qs: QuerySet[Group], group_by: list[str], date_column_name: str, axis: str): qs = make_timeseries_query( qs, projects, @@ -55,7 +73,7 @@ def gen_ts(qs: QuerySet[Group], group_by: list[str], source: str, axis: str): type_filter, group_by, interval, - source, + date_column_name, start, end, ) @@ -70,7 +88,9 @@ def gen_ts(qs: QuerySet[Group], group_by: list[str], source: str, axis: str): make_timeseries_result( axis=axis, group=key.split("||||") if key else [], - interval=interval.seconds * 1000, + start=start, + end=end, + interval=interval, order=i, values=series, ) @@ -80,13 +100,23 @@ def gen_ts(qs: QuerySet[Group], group_by: list[str], source: str, axis: str): return Response( { "timeseries": chain( - gen_ts(query_new_issues(), [], "first_seen", "new_issues_count"), - gen_ts(query_resolved_issues(), [], "resolved_at", "resolved_issues_count"), gen_ts( - query_issues_by_release(), - ["first_release__version"], - "first_seen", - "new_issues_count_by_release", + Group.objects, + axis="new_issues_count", + date_column_name="first_seen", + group_by=[], + ), + gen_ts( + Group.objects.filter(status=GroupStatus.RESOLVED), + axis="resolved_issues_count", + date_column_name="resolved_at", + group_by=[], + ), + gen_ts( + Group.objects.filter(first_release__isnull=False), + axis="new_issues_count_by_release", + date_column_name="first_seen", + group_by=["first_release__version"], ), ), "meta": { @@ -143,9 +173,6 @@ def make_timeseries_query( order_by.append(group) values.append(group) - order_by.append("timestamp") - values.append("timestamp") - annotations["timestamp"] = Extract( Func( stride, @@ -156,6 +183,8 @@ def make_timeseries_query( ), "epoch", ) + order_by.append("timestamp") + values.append("timestamp") return ( qs.filter( @@ -174,7 +203,9 @@ def make_timeseries_query( def make_timeseries_result( axis: str, group: list[str], - interval: int, + start: datetime, + end: datetime, + interval: timedelta, order: int, values: list[TimeSeries], ) -> TimeSeriesResult: @@ -182,86 +213,41 @@ def make_timeseries_result( "axis": axis, "meta": { "groupBy": group, - "interval": interval, + "interval": interval.seconds * 1000, "isOther": False, "order": order, "type": "integer", "unit": None, }, - "values": values, + "values": fill_timeseries(start, end, interval, values), } -# Series generation. - - -class Series(TypedDict): - bucket: datetime - count: int - - -class SeriesResponseItem(TypedDict): - count: int - - -SeriesResponse = dict[str, list[SeriesResponseItem]] - - -def query_new_issues() -> QuerySet[Group]: - # SELECT count(*), day(first_seen) FROM issues GROUP BY day(first_seen) - return Group.objects - - -def query_resolved_issues() -> QuerySet[Group]: - # SELECT count(*), day(resolved_at) FROM issues WHERE status = resolved GROUP BY day(resolved_at) - return Group.objects.filter(status=GroupStatus.RESOLVED) - - -def query_issues_by_release() -> QuerySet[Group]: - # SELECT count(*), first_release.version FROM issues JOIN release GROUP BY first_release.version - return Group.objects.filter(first_release__isnull=False) - - -# Response filling and formatting. - - -class BucketNotFound(LookupError): +class UnconsumedBuckets(LookupError): pass -def append_series(resp: SeriesResponse, series: list[dict[str, Any]]) -> None: - # We're going to increment this index as we consume the series. - idx = 0 - - for bucket in resp.keys(): - try: - next_bucket = str(int(series[idx]["bucket"].timestamp())) - except IndexError: - next_bucket = "-1" +def fill_timeseries( + start: datetime, + end: datetime, + interval: timedelta, + values: list[TimeSeries], +) -> list[TimeSeries]: + def iter_interval(start: datetime, end: datetime, interval: timedelta) -> Iterator[int]: + while start <= end: + yield int(start.timestamp()) + start = start + interval - # If the buckets match use the series count. - if next_bucket == bucket: - resp[bucket].append({"count": series[idx]["count"]}) + filled_values: list[TimeSeries] = [] + idx = 0 + for ts in iter_interval(start, end, interval): + if idx < len(values) and ts == values[idx]["timestamp"]: + filled_values.append(values[idx]) idx += 1 - # If the buckets do not match generate a value to fill its slot. else: - resp[bucket].append({"count": 0}) - - # Programmer error. Requires code fix. Likely your query is not truncating timestamps the way - # you think it is. - if idx != len(series): - raise BucketNotFound("No buckets matched. Did your query truncate correctly?") - - -def empty_response(start: datetime, end: datetime, interval: timedelta) -> SeriesResponse: - return {bucket: [] for bucket in iter_interval(start, end, interval)} - - -def iter_interval(start: datetime, end: datetime, interval: timedelta) -> Iterator[str]: - while start <= end: - yield str(int(start.timestamp())) - start = start + interval + filled_values.append({"timestamp": ts, "value": 0}) + if idx != len(values): + raise UnconsumedBuckets("Could not fill every bucket.") -def to_series(series: QuerySet[Any, dict[str, Any]]) -> SeriesResponse: - return {s["bucket"]: [{"count": s["count"]}] for s in series} + return filled_values diff --git a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py index bcf822a0c62c9e..54abd3f8a8afba 100644 --- a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py +++ b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @@ -19,7 +19,7 @@ def test_get(self): one = self.create_release(project1, version="1.0.0") two = self.create_release(project2, version="1.2.0") - curr = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) + curr = datetime.now(tz=timezone.utc) prev = curr - timedelta(hours=1) # Release issues. @@ -96,95 +96,99 @@ def test_get(self): "type": "integer", "unit": None, }, - "values": [{"timestamp": int(curr.timestamp()), "value": 2}], + "values": [ + {"timestamp": int(prev.timestamp()), "value": 0}, + {"timestamp": int(curr.timestamp()), "value": 2}, + ], }, ] - def test_issues_by_release(self): - project1 = self.create_project(teams=[self.team], slug="foo") - project2 = self.create_project(teams=[self.team], slug="bar") - release_one = self.create_release(project1, version="1.0.0") - release_two = self.create_release(project2, version="1.2.0") - self.create_group(project=project1, status=0, first_release=release_one, type=1) - self.create_group(project=project1, status=1, first_release=release_one, type=2) - self.create_group(project=project2, status=1, first_release=release_two, type=3) - self.create_group(project=project2, status=2, first_release=release_two, type=4) - self.create_group(project=project2, status=2, first_release=release_two, type=6) - # No release. - self.create_group(project=project1, status=0, type=1) - self.create_group(project=project2, status=2, type=6) - - response = self.client.get(self.url + "?statsPeriod=1h&category=error&group_by=release") - response_json = response.json() - assert response_json["data"] == [ - ["1.0.0", [{"count": 2}]], - ["1.2.0", [{"count": 2}]], - ] - - def test_issues_invalid_group_by(self): - response = self.client.get(self.url + "?statsPeriod=7d&category=error&group_by=test") - assert response.status_code == 404 - def test_issues_by_time_project_filter(self): """Assert the project filter works.""" project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") - today = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) - next = today + timedelta(hours=1) - prev = today - timedelta(hours=1) - self.create_group(project=project1, status=0, first_seen=today, type=1) - self.create_group(project=project2, status=0, first_seen=today, type=1) + curr = datetime.now(tz=timezone.utc) + prev = curr - timedelta(hours=1) + self.create_group(project=project1, status=0, first_seen=curr, type=1) + self.create_group(project=project2, status=0, first_seen=curr, type=1) response = self.client.get( - self.url + f"?statsPeriod=1h&category=error&project={project1.id}" + self.url + + f"?start={prev.isoformat()[:-6]}&end={curr.isoformat()[:-6]}&category=error&project={project1.id}" ) response_json = response.json() - assert response_json["data"] == [ - [str(int(prev.timestamp())), [{"count": 0}, {"count": 0}]], - [str(int(today.timestamp())), [{"count": 1}, {"count": 0}]], - [str(int(next.timestamp())), [{"count": 0}, {"count": 0}]], + assert response_json["timeseries"] == [ + { + "axis": "new_issues_count", + "meta": { + "groupBy": [], + "interval": 3600000, + "isOther": False, + "order": 0, + "type": "integer", + "unit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 0}, + {"timestamp": int(curr.timestamp()), "value": 1}, + ], + } ] def test_new_feedback(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") - today = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) - next = today + timedelta(hours=1) - prev = today - timedelta(hours=1) + curr = datetime.now(tz=timezone.utc) + prev = curr - timedelta(hours=1) # New cohort - self.create_group(project=project1, status=0, first_seen=today, type=1) - self.create_group(project=project1, status=1, first_seen=today, type=2) - self.create_group(project=project2, status=1, first_seen=next, type=3) - self.create_group(project=project2, status=2, first_seen=next, type=4) - self.create_group(project=project2, status=2, first_seen=next, type=6) + self.create_group(project=project1, status=0, first_seen=curr, type=1) + self.create_group(project=project1, status=1, first_seen=curr, type=2) + self.create_group(project=project2, status=1, first_seen=curr, type=3) + self.create_group(project=project2, status=2, first_seen=prev, type=6) + self.create_group(project=project2, status=2, first_seen=curr, type=6) # Resolved cohort - self.create_group(project=project1, status=0, resolved_at=today, type=2) - self.create_group(project=project1, status=1, resolved_at=today, type=3) - self.create_group(project=project2, status=1, resolved_at=today, type=6) - self.create_group(project=project2, status=1, resolved_at=next, type=4) - self.create_group(project=project2, status=2, resolved_at=next, type=5) + self.create_group(project=project1, status=0, resolved_at=curr, type=2) + self.create_group(project=project1, status=1, resolved_at=curr, type=3) + self.create_group(project=project2, status=1, resolved_at=prev, type=6) + self.create_group(project=project2, status=1, resolved_at=curr, type=6) + self.create_group(project=project2, status=2, resolved_at=curr, type=5) - response = self.client.get(self.url + "?statsPeriod=1h&category=feedback&group_by=time") + response = self.client.get( + self.url + + f"?start={prev.isoformat()[:-6]}&end={curr.isoformat()[:-6]}&category=feedback" + ) response_json = response.json() - assert response_json["data"] == [ - [str(int(prev.timestamp())), [{"count": 0}, {"count": 0}]], - [str(int(today.timestamp())), [{"count": 1}, {"count": 1}]], - [str(int(next.timestamp())), [{"count": 1}, {"count": 0}]], + assert response_json["timeseries"] == [ + { + "axis": "new_issues_count", + "meta": { + "groupBy": [], + "interval": 3600000, + "isOther": False, + "order": 0, + "type": "integer", + "unit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 1}, + {"timestamp": int(curr.timestamp()), "value": 1}, + ], + }, + { + "axis": "resolved_issues_count", + "meta": { + "groupBy": [], + "interval": 3600000, + "isOther": False, + "order": 0, + "type": "integer", + "unit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 1}, + {"timestamp": int(curr.timestamp()), "value": 1}, + ], + }, ] - - def test_feedback_by_release(self): - project1 = self.create_project(teams=[self.team], slug="foo") - project2 = self.create_project(teams=[self.team], slug="bar") - release_one = self.create_release(project1, version="1.0.0") - release_two = self.create_release(project2, version="1.2.0") - self.create_group(project=project1, status=0, first_release=release_one, type=1) - self.create_group(project=project1, status=1, first_release=release_one, type=2) - self.create_group(project=project2, status=1, first_release=release_two, type=3) - self.create_group(project=project2, status=2, first_release=release_two, type=4) - self.create_group(project=project2, status=2, first_release=release_two, type=6) - - response = self.client.get(self.url + "?statsPeriod=1h&category=feedback&group_by=release") - response_json = response.json() - assert response_json["data"] == [["1.2.0", [{"count": 1}]]] From 659f0151024d2c2449fa076228f9ced4cdccdd05 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 14 Mar 2025 11:13:53 -0500 Subject: [PATCH 18/24] Update schema --- .../endpoints/organization_issue_metrics.py | 6 +-- .../test_organization_issue_metrics.py | 42 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index 56d74b44644c1a..1f072117cc38c5 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -211,13 +211,13 @@ def make_timeseries_result( ) -> TimeSeriesResult: return { "axis": axis, + "groupBy": group, "meta": { - "groupBy": group, "interval": interval.seconds * 1000, "isOther": False, "order": order, - "type": "integer", - "unit": None, + "valueType": "integer", + "valueUnit": None, }, "values": fill_timeseries(start, end, interval, values), } diff --git a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py index 54abd3f8a8afba..99154e5360e484 100644 --- a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py +++ b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @@ -43,13 +43,13 @@ def test_get(self): assert response_json["timeseries"] == [ { "axis": "new_issues_count", + "groupBy": [], "meta": { - "groupBy": [], "interval": 3600000, "isOther": False, "order": 0, - "type": "integer", - "unit": None, + "valueType": "integer", + "valueUnit": None, }, "values": [ {"timestamp": int(prev.timestamp()), "value": 3}, @@ -58,13 +58,13 @@ def test_get(self): }, { "axis": "resolved_issues_count", + "groupBy": [], "meta": { - "groupBy": [], "interval": 3600000, "isOther": False, "order": 0, - "type": "integer", - "unit": None, + "valueType": "integer", + "valueUnit": None, }, "values": [ {"timestamp": int(prev.timestamp()), "value": 1}, @@ -73,13 +73,13 @@ def test_get(self): }, { "axis": "new_issues_count_by_release", + "groupBy": ["1.0.0"], "meta": { - "groupBy": ["1.0.0"], "interval": 3600000, "isOther": False, "order": 0, - "type": "integer", - "unit": None, + "valueType": "integer", + "valueUnit": None, }, "values": [ {"timestamp": int(prev.timestamp()), "value": 1}, @@ -88,13 +88,13 @@ def test_get(self): }, { "axis": "new_issues_count_by_release", + "groupBy": ["1.2.0"], "meta": { - "groupBy": ["1.2.0"], "interval": 3600000, "isOther": False, "order": 1, - "type": "integer", - "unit": None, + "valueType": "integer", + "valueUnit": None, }, "values": [ {"timestamp": int(prev.timestamp()), "value": 0}, @@ -121,13 +121,13 @@ def test_issues_by_time_project_filter(self): assert response_json["timeseries"] == [ { "axis": "new_issues_count", + "groupBy": [], "meta": { - "groupBy": [], "interval": 3600000, "isOther": False, "order": 0, - "type": "integer", - "unit": None, + "valueType": "integer", + "valueUnit": None, }, "values": [ {"timestamp": int(prev.timestamp()), "value": 0}, @@ -163,13 +163,13 @@ def test_new_feedback(self): assert response_json["timeseries"] == [ { "axis": "new_issues_count", + "groupBy": [], "meta": { - "groupBy": [], "interval": 3600000, "isOther": False, "order": 0, - "type": "integer", - "unit": None, + "valueType": "integer", + "valueUnit": None, }, "values": [ {"timestamp": int(prev.timestamp()), "value": 1}, @@ -178,13 +178,13 @@ def test_new_feedback(self): }, { "axis": "resolved_issues_count", + "groupBy": [], "meta": { - "groupBy": [], "interval": 3600000, "isOther": False, "order": 0, - "type": "integer", - "unit": None, + "valueType": "integer", + "valueUnit": None, }, "values": [ {"timestamp": int(prev.timestamp()), "value": 1}, From 53278bb0504df06f8dd1a15df4b4763eda0593a0 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 14 Mar 2025 11:23:02 -0500 Subject: [PATCH 19/24] Fix typing --- .../endpoints/organization_issue_metrics.py | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index 1f072117cc38c5..bacfbdfec62878 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -2,11 +2,10 @@ from collections.abc import Iterator from datetime import datetime, timedelta from itertools import chain -from typing import Any, TypedDict +from typing import TypedDict from django.db.models import Count, DateTimeField, F, Func, Q from django.db.models.functions import Extract -from django.db.models.query import QuerySet from rest_framework.exceptions import ParseError from rest_framework.request import Request from rest_framework.response import Response @@ -65,7 +64,12 @@ def get(self, request: Request, organization: Organization) -> Response: if number_of_buckets > 200: raise ParseError("The specified granularity is too precise. Increase your interval.") - def gen_ts(qs: QuerySet[Group], group_by: list[str], date_column_name: str, axis: str): + def gen_ts( + qs, + group_by: list[str], + date_column_name: str, + axis: str, + ): qs = make_timeseries_query( qs, projects, @@ -78,7 +82,7 @@ def gen_ts(qs: QuerySet[Group], group_by: list[str], date_column_name: str, axis end, ) - grouped_series = collections.defaultdict(list) + grouped_series: dict[str, list[TimeSeries]] = collections.defaultdict(list) for row in qs: grouping = [row[g] for g in group_by] key = "||||".join(grouping) @@ -135,22 +139,22 @@ class TimeSeries(TypedDict): class TimeSeriesResultMeta(TypedDict): - groupby: list[str] interval: float isOther: bool order: int - type: str - unit: str | None + valueType: str + valueUnit: str | None class TimeSeriesResult(TypedDict): axis: str + groupBy: list[str] meta: TimeSeriesResultMeta values: list[TimeSeries] def make_timeseries_query( - qs: QuerySet[Group], + qs, projects: list[Project], environments: list[int], type_filter: Q, @@ -159,13 +163,13 @@ def make_timeseries_query( source: str, start: datetime, end: datetime, -) -> QuerySet[Group, dict[str, Any]]: +): environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() ) range_filters = {f"{source}__gte": start, f"{source}__lte": end} - annotations = {} + annotations: dict[str, F | Extract] = {} order_by = [] values = [] for group in group_by: @@ -186,7 +190,7 @@ def make_timeseries_query( order_by.append("timestamp") values.append("timestamp") - return ( + qs = ( qs.filter( environment_filter, type_filter, @@ -198,6 +202,7 @@ def make_timeseries_query( .values(*values) .annotate(value=Count("id")) ) + return qs def make_timeseries_result( From 2a0f5d2a6c5d2b34c1d03b6ce8c4847acb028545 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 14 Mar 2025 12:00:33 -0500 Subject: [PATCH 20/24] Add coverage for interval parsing --- .../endpoints/organization_issue_metrics.py | 4 +++- .../test_organization_issue_metrics.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index bacfbdfec62878..085f4684bfe540 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -41,6 +41,8 @@ def get(self, request: Request, organization: Organization) -> Response: try: interval_s = int(request.GET["interval"]) // 1000 + if interval_s == 0: + raise ParseError("Interval must be greater than 1000 milliseconds.") interval = timedelta(seconds=interval_s) except KeyError: # Defaulting for now. Probably better to compute some known interval. I.e. if we're @@ -60,7 +62,7 @@ def get(self, request: Request, organization: Organization) -> Response: # - One week range -> one hour interval. # - One day range -> ten minute interval. # - One hour range -> twenty second interval. - number_of_buckets = (end - start).seconds // interval.seconds + number_of_buckets = (end - start).total_seconds() // interval.total_seconds() if number_of_buckets > 200: raise ParseError("The specified granularity is too precise. Increase your interval.") diff --git a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py index 99154e5360e484..840be319dfa633 100644 --- a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py +++ b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @@ -136,6 +136,23 @@ def test_issues_by_time_project_filter(self): } ] + def test_get_too_much_granularity(self): + response = self.client.get(self.url + "?statsPeriod=14d&interval=1001") + assert response.status_code == 400 + assert response.json() == { + "detail": "The specified granularity is too precise. Increase your interval." + } + + def test_get_invalid_interval(self): + response = self.client.get(self.url + "?interval=foo") + assert response.status_code == 400 + assert response.json() == {"detail": "Could not parse interval value."} + + def test_get_zero_interval(self): + response = self.client.get(self.url + "?interval=0") + assert response.status_code == 400 + assert response.json() == {"detail": "Interval must be greater than 1000 milliseconds."} + def test_new_feedback(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") From 0f00ffce4286bfbed3e62c7bafe131f003231c80 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 14 Mar 2025 12:08:45 -0500 Subject: [PATCH 21/24] Fix names --- .../test_organization_issue_metrics.py | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py index 840be319dfa633..b91d8602c35701 100644 --- a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py +++ b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @@ -13,7 +13,7 @@ def setUp(self): self.login_as(user=self.user) self.url = reverse(self.endpoint, args=(self.organization.slug,)) - def test_get(self): + def test_get_errors(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") one = self.create_release(project1, version="1.0.0") @@ -103,7 +103,7 @@ def test_get(self): }, ] - def test_issues_by_time_project_filter(self): + def test_get_errors_by_project(self): """Assert the project filter works.""" project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") @@ -136,24 +136,7 @@ def test_issues_by_time_project_filter(self): } ] - def test_get_too_much_granularity(self): - response = self.client.get(self.url + "?statsPeriod=14d&interval=1001") - assert response.status_code == 400 - assert response.json() == { - "detail": "The specified granularity is too precise. Increase your interval." - } - - def test_get_invalid_interval(self): - response = self.client.get(self.url + "?interval=foo") - assert response.status_code == 400 - assert response.json() == {"detail": "Could not parse interval value."} - - def test_get_zero_interval(self): - response = self.client.get(self.url + "?interval=0") - assert response.status_code == 400 - assert response.json() == {"detail": "Interval must be greater than 1000 milliseconds."} - - def test_new_feedback(self): + def test_get_feedback(self): project1 = self.create_project(teams=[self.team], slug="foo") project2 = self.create_project(teams=[self.team], slug="bar") @@ -209,3 +192,20 @@ def test_new_feedback(self): ], }, ] + + def test_get_too_much_granularity(self): + response = self.client.get(self.url + "?statsPeriod=14d&interval=1001") + assert response.status_code == 400 + assert response.json() == { + "detail": "The specified granularity is too precise. Increase your interval." + } + + def test_get_invalid_interval(self): + response = self.client.get(self.url + "?interval=foo") + assert response.status_code == 400 + assert response.json() == {"detail": "Could not parse interval value."} + + def test_get_zero_interval(self): + response = self.client.get(self.url + "?interval=0") + assert response.status_code == 400 + assert response.json() == {"detail": "Interval must be greater than 1000 milliseconds."} From 519b0d148c3ba4030bc6fea42e026aadf70b0b14 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 18 Mar 2025 14:23:24 -0500 Subject: [PATCH 22/24] Group by other --- .../endpoints/organization_issue_metrics.py | 28 ++++- .../test_organization_issue_metrics.py | 118 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index 085f4684bfe540..2d3b9abccb0336 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -1,6 +1,7 @@ import collections from collections.abc import Iterator from datetime import datetime, timedelta +from heapq import nlargest from itertools import chain from typing import TypedDict @@ -84,12 +85,37 @@ def gen_ts( end, ) + grouped_counter: collections.defaultdict[str, int] = collections.defaultdict(int) grouped_series: dict[str, list[TimeSeries]] = collections.defaultdict(list) for row in qs: grouping = [row[g] for g in group_by] key = "||||".join(grouping) + grouped_counter[key] += row["value"] grouped_series[key].append({"timestamp": row["timestamp"], "value": row["value"]}) + # Group the smallest series into the "other" bucket. + if len(grouped_series) > 4: + keys = [v[0] for v in nlargest(4, grouped_counter.items(), key=lambda i: i[0])] + + new_grouped_series: dict[str, list[TimeSeries]] = {} + other_series = collections.defaultdict(int) + for key, series in grouped_series.items(): + if key in keys: + new_grouped_series[key] = series + else: + for s in series: + other_series[s["timestamp"]] += s["value"] + + if other_series: + new_grouped_series["other"] = list( + map( + lambda i: {"timestamp": i[0], "value": i[1]}, + sorted(list(other_series.items()), key=lambda i: i[0]), + ) + ) + else: + new_grouped_series = grouped_series + return [ make_timeseries_result( axis=axis, @@ -100,7 +126,7 @@ def gen_ts( order=i, values=series, ) - for i, (key, series) in enumerate(grouped_series.items()) + for i, (key, series) in enumerate(new_grouped_series.items()) ] return Response( diff --git a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py index b91d8602c35701..ef729a8ccadab9 100644 --- a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py +++ b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @@ -209,3 +209,121 @@ def test_get_zero_interval(self): response = self.client.get(self.url + "?interval=0") assert response.status_code == 400 assert response.json() == {"detail": "Interval must be greater than 1000 milliseconds."} + + def test_other_grouping(self): + project1 = self.create_project(teams=[self.team], slug="foo") + project2 = self.create_project(teams=[self.team], slug="bar") + one = self.create_release(project1, version="1.0.0") + two = self.create_release(project2, version="1.1.0") + three = self.create_release(project2, version="1.2.0") + four = self.create_release(project2, version="1.3.0") + fifth = self.create_release(project2, version="1.4.0") + sixth = self.create_release(project2, version="1.5.0") + + curr = datetime.now(tz=timezone.utc) + prev = curr - timedelta(hours=1) + + # Release issues. + self.create_group(project=project1, status=0, first_seen=curr, first_release=one, type=1) + self.create_group(project=project1, status=0, first_seen=curr, first_release=two, type=1) + self.create_group(project=project1, status=0, first_seen=curr, first_release=three, type=1) + self.create_group(project=project1, status=0, first_seen=curr, first_release=four, type=1) + self.create_group(project=project1, status=0, first_seen=curr, first_release=fifth, type=1) + self.create_group(project=project1, status=0, first_seen=curr, first_release=sixth, type=1) + + response = self.client.get( + self.url + f"?start={prev.isoformat()[:-6]}&end={curr.isoformat()[:-6]}&category=error" + ) + response_json = response.json() + assert response_json["timeseries"] == [ + { + "axis": "new_issues_count", + "groupBy": [], + "meta": { + "interval": 3600000, + "isOther": False, + "order": 0, + "valueType": "integer", + "valueUnit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 0}, + {"timestamp": int(curr.timestamp()), "value": 6}, + ], + }, + { + "axis": "new_issues_count_by_release", + "groupBy": ["1.2.0"], + "meta": { + "interval": 3600000, + "isOther": False, + "order": 0, + "valueType": "integer", + "valueUnit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 0}, + {"timestamp": int(curr.timestamp()), "value": 1}, + ], + }, + { + "axis": "new_issues_count_by_release", + "groupBy": ["1.3.0"], + "meta": { + "interval": 3600000, + "isOther": False, + "order": 1, + "valueType": "integer", + "valueUnit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 0}, + {"timestamp": int(curr.timestamp()), "value": 1}, + ], + }, + { + "axis": "new_issues_count_by_release", + "groupBy": ["1.4.0"], + "meta": { + "interval": 3600000, + "isOther": False, + "order": 2, + "valueType": "integer", + "valueUnit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 0}, + {"timestamp": int(curr.timestamp()), "value": 1}, + ], + }, + { + "axis": "new_issues_count_by_release", + "groupBy": ["1.5.0"], + "meta": { + "interval": 3600000, + "isOther": False, + "order": 3, + "valueType": "integer", + "valueUnit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 0}, + {"timestamp": int(curr.timestamp()), "value": 1}, + ], + }, + { + "axis": "new_issues_count_by_release", + "groupBy": ["other"], + "meta": { + "interval": 3600000, + "isOther": False, + "order": 4, + "valueType": "integer", + "valueUnit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 0}, + {"timestamp": int(curr.timestamp()), "value": 2}, + ], + }, + ] From 1d9e108494e8f45bf53b096353dcad4ed8d85cc9 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 19 Mar 2025 09:37:54 -0500 Subject: [PATCH 23/24] Return largest 5 groups --- .../endpoints/organization_issue_metrics.py | 6 +++-- .../test_organization_issue_metrics.py | 27 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index 2d3b9abccb0336..e428b447a9a28e 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -95,7 +95,7 @@ def gen_ts( # Group the smallest series into the "other" bucket. if len(grouped_series) > 4: - keys = [v[0] for v in nlargest(4, grouped_counter.items(), key=lambda i: i[0])] + keys = [v[0] for v in nlargest(5, grouped_counter.items(), key=lambda i: i[0])] new_grouped_series: dict[str, list[TimeSeries]] = {} other_series = collections.defaultdict(int) @@ -123,6 +123,7 @@ def gen_ts( start=start, end=end, interval=interval, + is_other=key == "other", order=i, values=series, ) @@ -239,6 +240,7 @@ def make_timeseries_result( start: datetime, end: datetime, interval: timedelta, + is_other: bool, order: int, values: list[TimeSeries], ) -> TimeSeriesResult: @@ -247,7 +249,7 @@ def make_timeseries_result( "groupBy": group, "meta": { "interval": interval.seconds * 1000, - "isOther": False, + "isOther": is_other, "order": order, "valueType": "integer", "valueUnit": None, diff --git a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py index ef729a8ccadab9..7ea5e633294e03 100644 --- a/tests/sentry/issues/endpoints/test_organization_issue_metrics.py +++ b/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @@ -253,7 +253,7 @@ def test_other_grouping(self): }, { "axis": "new_issues_count_by_release", - "groupBy": ["1.2.0"], + "groupBy": ["1.1.0"], "meta": { "interval": 3600000, "isOther": False, @@ -268,7 +268,7 @@ def test_other_grouping(self): }, { "axis": "new_issues_count_by_release", - "groupBy": ["1.3.0"], + "groupBy": ["1.2.0"], "meta": { "interval": 3600000, "isOther": False, @@ -283,7 +283,7 @@ def test_other_grouping(self): }, { "axis": "new_issues_count_by_release", - "groupBy": ["1.4.0"], + "groupBy": ["1.3.0"], "meta": { "interval": 3600000, "isOther": False, @@ -298,7 +298,7 @@ def test_other_grouping(self): }, { "axis": "new_issues_count_by_release", - "groupBy": ["1.5.0"], + "groupBy": ["1.4.0"], "meta": { "interval": 3600000, "isOther": False, @@ -313,7 +313,7 @@ def test_other_grouping(self): }, { "axis": "new_issues_count_by_release", - "groupBy": ["other"], + "groupBy": ["1.5.0"], "meta": { "interval": 3600000, "isOther": False, @@ -323,7 +323,22 @@ def test_other_grouping(self): }, "values": [ {"timestamp": int(prev.timestamp()), "value": 0}, - {"timestamp": int(curr.timestamp()), "value": 2}, + {"timestamp": int(curr.timestamp()), "value": 1}, + ], + }, + { + "axis": "new_issues_count_by_release", + "groupBy": ["other"], + "meta": { + "interval": 3600000, + "isOther": True, + "order": 5, + "valueType": "integer", + "valueUnit": None, + }, + "values": [ + {"timestamp": int(prev.timestamp()), "value": 0}, + {"timestamp": int(curr.timestamp()), "value": 1}, ], }, ] From 3d38a87da5e4a242ce03bd869005844760e32aed Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 19 Mar 2025 09:44:01 -0500 Subject: [PATCH 24/24] Fix typing --- src/sentry/issues/endpoints/organization_issue_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index e428b447a9a28e..7851d72432ff82 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -98,7 +98,7 @@ def gen_ts( keys = [v[0] for v in nlargest(5, grouped_counter.items(), key=lambda i: i[0])] new_grouped_series: dict[str, list[TimeSeries]] = {} - other_series = collections.defaultdict(int) + other_series: collections.defaultdict[float, float] = collections.defaultdict(float) for key, series in grouped_series.items(): if key in keys: new_grouped_series[key] = series