Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(releases): Add organization issue-metrics endpoint #86626

Open
wants to merge 25 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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/issues/endpoints/organization_issue_metrics.py @getsentry/replay-backend
/tests/sentry/issues/endpoints/test_organization_issue_metrics.py @getsentry/replay-backend
## End of Replays


Expand Down
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -1609,6 +1610,11 @@ 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<organization_id_or_slug>[^\/]+)/issues-metrics/$",
OrganizationIssueMetricsEndpoint.as_view(),
name="sentry-api-0-organization-issue-metrics",
),
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/integrations/$",
OrganizationIntegrationsEndpoint.as_view(),
Expand Down
260 changes: 260 additions & 0 deletions src/sentry/issues/endpoints/organization_issue_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import collections
from collections.abc import Iterator
from datetime import datetime, timedelta
from itertools import chain
from typing import TypedDict

from django.db.models import Count, DateTimeField, F, Func, Q
from django.db.models.functions import Extract
from rest_framework.exceptions import ParseError
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.issues.grouptype import GroupCategory
from sentry.models.group import Group, GroupStatus
from sentry.models.organization import Organization
from sentry.models.project import Project


@region_silo_endpoint
class OrganizationIssueMetricsEndpoint(OrganizationEndpoint, EnvironmentMixin):
owner = ApiOwner.REPLAY
publish_status = {"GET": ApiPublishStatus.PRIVATE}

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)
if issue_category == "error"
else Q(type=GroupCategory.FEEDBACK)
)

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
# 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).total_seconds() // interval.total_seconds()
if number_of_buckets > 200:
raise ParseError("The specified granularity is too precise. Increase your interval.")

def gen_ts(
qs,
group_by: list[str],
date_column_name: str,
axis: str,
):
qs = make_timeseries_query(
qs,
projects,
environments,
type_filter,
group_by,
interval,
date_column_name,
start,
end,
)

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_series[key].append({"timestamp": row["timestamp"], "value": row["value"]})

return [
make_timeseries_result(
axis=axis,
group=key.split("||||") if key else [],
start=start,
end=end,
interval=interval,
order=i,
values=series,
)
for i, (key, series) in enumerate(grouped_series.items())
]

return Response(
{
"timeseries": chain(
gen_ts(
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": {
"dataset": "issues",
"end": end.timestamp(),
"start": start.timestamp(),
},
},
status=200,
)


class TimeSeries(TypedDict):
timestamp: float
value: float


class TimeSeriesResultMeta(TypedDict):
interval: float
isOther: bool
order: int
valueType: str
valueUnit: str | None


class TimeSeriesResult(TypedDict):
axis: str
groupBy: list[str]
meta: TimeSeriesResultMeta
values: list[TimeSeries]


def make_timeseries_query(
qs,
projects: list[Project],
environments: list[int],
type_filter: Q,
group_by: list[str],
stride: timedelta,
source: str,
start: datetime,
end: datetime,
):
environment_filter = (
Q(groupenvironment__environment_id=environments[0]) if environments else Q()
)
range_filters = {f"{source}__gte": start, f"{source}__lte": end}

annotations: dict[str, F | Extract] = {}
order_by = []
values = []
for group in group_by:
annotations[group] = F(group)
order_by.append(group)
values.append(group)

annotations["timestamp"] = Extract(
Func(
stride,
source,
start,
function="date_bin",
output_field=DateTimeField(),
),
"epoch",
)
order_by.append("timestamp")
values.append("timestamp")

qs = (
qs.filter(
environment_filter,
type_filter,
project_id__in=[p.id for p in projects],
**range_filters,
)
.annotate(**annotations)
.order_by(*order_by)
.values(*values)
.annotate(value=Count("id"))
)
return qs


def make_timeseries_result(
axis: str,
group: list[str],
start: datetime,
end: datetime,
interval: timedelta,
order: int,
values: list[TimeSeries],
) -> TimeSeriesResult:
return {
"axis": axis,
"groupBy": group,
"meta": {
"interval": interval.seconds * 1000,
"isOther": False,
"order": order,
"valueType": "integer",
"valueUnit": None,
},
"values": fill_timeseries(start, end, interval, values),
}


class UnconsumedBuckets(LookupError):
pass


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

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
else:
filled_values.append({"timestamp": ts, "value": 0})

if idx != len(values):
raise UnconsumedBuckets("Could not fill every bucket.")

return filled_values
Loading
Loading