Skip to content

Commit 2bc4d63

Browse files
authored
feat(open periods): Expect open periods to exist (#99548)
Now that we are no longer calling `get_open_periods_for_group` in the `GroupDetailsEndpoint` (#99519) we can clean up the code that constructs an open period when there isn't actually a `GroupOpenPeriod` (this code was used before we ran the backfill) to return the objects and serialize them normally. This PR also adds the `id` to the response. A near future PR will add a serializer for `GroupOpenPeriodActivity` and add that to the response as well.
1 parent 7ba5aff commit 2bc4d63

File tree

4 files changed

+167
-179
lines changed

4 files changed

+167
-179
lines changed

src/sentry/models/groupopenperiod.py

Lines changed: 12 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import logging
2-
from dataclasses import dataclass
32
from datetime import datetime, timedelta
4-
from typing import Any
53

64
from django.conf import settings
75
from django.contrib.postgres.constraints import ExclusionConstraint
@@ -14,31 +12,13 @@
1412
from sentry.backup.scopes import RelocationScope
1513
from sentry.db.models import DefaultFieldsModel, FlexibleForeignKey, region_silo_model, sane_repr
1614
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
15+
from sentry.db.models.manager.base_query_set import BaseQuerySet
1716
from sentry.models.activity import Activity
1817
from sentry.models.group import Group, GroupStatus
19-
from sentry.types.activity import ActivityType
2018

2119
logger = logging.getLogger(__name__)
2220

2321

24-
@dataclass
25-
class OpenPeriod:
26-
start: datetime
27-
end: datetime | None
28-
duration: timedelta | None
29-
is_open: bool
30-
last_checked: datetime
31-
32-
def to_dict(self) -> dict[str, Any]:
33-
return {
34-
"start": self.start,
35-
"end": self.end,
36-
"duration": self.duration,
37-
"isOpen": self.is_open,
38-
"lastChecked": self.last_checked,
39-
}
40-
41-
4222
class TsTzRange(models.Func):
4323
function = "TSTZRANGE"
4424
output_field = DateTimeRangeField()
@@ -144,104 +124,23 @@ def get_open_periods_for_group(
144124
group: Group,
145125
query_start: datetime | None = None,
146126
query_end: datetime | None = None,
147-
offset: int | None = None,
148127
limit: int | None = None,
149-
) -> list[Any]:
150-
128+
) -> BaseQuerySet[GroupOpenPeriod] | list[None]:
151129
if not features.has("organizations:issue-open-periods", group.organization):
152130
return []
153131

154-
# Try to get open periods from the GroupOpenPeriod table first
155-
group_open_periods = GroupOpenPeriod.objects.filter(group=group)
156-
if group_open_periods.exists() and query_start:
157-
group_open_periods = group_open_periods.filter(
158-
date_started__gte=query_start, date_ended__lte=query_end, id__gte=offset or 0
159-
).order_by("-date_started")[:limit]
160-
161-
return [
162-
OpenPeriod(
163-
start=period.date_started,
164-
end=period.date_ended,
165-
duration=period.date_ended - period.date_started if period.date_ended else None,
166-
is_open=period.date_ended is None,
167-
last_checked=get_last_checked_for_open_period(group),
168-
)
169-
for period in group_open_periods
170-
]
171-
172-
# If there are no open periods in the table, we need to calculate them
173-
# from the activity log.
174-
# TODO(snigdha): This is temporary until we have backfilled the GroupOpenPeriod table
175-
logger.warning("Open periods not fully backfilled", extra={"group_id": group.id})
176-
177-
if query_start is None or query_end is None:
178-
query_start = timezone.now() - timedelta(days=90)
179-
query_end = timezone.now()
180-
181-
query_limit = limit * 2 if limit else None
182-
# Filter to REGRESSION and RESOLVED activties to find the bounds of each open period.
183-
# The only UNRESOLVED activity we would care about is the first UNRESOLVED activity for the group creation,
184-
# but we don't create an entry for that .
185-
activities = Activity.objects.filter(
186-
group=group,
187-
type__in=[ActivityType.SET_REGRESSION.value, ActivityType.SET_RESOLVED.value],
188-
datetime__gte=query_start,
189-
datetime__lte=query_end,
190-
).order_by("-datetime")[:query_limit]
191-
192-
open_periods = []
193-
start: datetime | None = None
194-
end: datetime | None = None
195-
last_checked = get_last_checked_for_open_period(group)
196-
197-
# Handle currently open period
198-
if group.status == GroupStatus.UNRESOLVED and len(activities) > 0:
199-
open_periods.append(
200-
OpenPeriod(
201-
start=activities[0].datetime,
202-
end=None,
203-
duration=None,
204-
is_open=True,
205-
last_checked=last_checked,
206-
)
207-
)
208-
activities = activities[1:]
209-
210-
for activity in activities:
211-
if activity.type == ActivityType.SET_RESOLVED.value:
212-
end = activity.datetime
213-
elif activity.type == ActivityType.SET_REGRESSION.value:
214-
start = activity.datetime
215-
if end is not None:
216-
open_periods.append(
217-
OpenPeriod(
218-
start=start,
219-
end=end,
220-
duration=end - start,
221-
is_open=False,
222-
last_checked=end,
223-
)
224-
)
225-
end = None
226-
227-
# Add the very first open period, which has no UNRESOLVED activity for the group creation
228-
open_periods.append(
229-
OpenPeriod(
230-
start=group.first_seen,
231-
end=end if end else None,
232-
duration=end - group.first_seen if end else None,
233-
is_open=False if end else True,
234-
last_checked=end if end else last_checked,
235-
)
236-
)
237-
238-
if offset and limit:
239-
return open_periods[offset : offset + limit]
132+
if not query_start:
133+
# use whichever date is more recent to reduce the query range. first_seen could be > 90 days ago
134+
query_start = max(group.first_seen, timezone.now() - timedelta(days=90))
240135

241-
if limit:
242-
return open_periods[:limit]
136+
group_open_periods = GroupOpenPeriod.objects.filter(
137+
group=group,
138+
date_started__gte=query_start,
139+
).order_by("-date_started")
140+
if query_end:
141+
group_open_periods = group_open_periods.filter(date_ended__lte=query_end)
243142

244-
return open_periods
143+
return group_open_periods[:limit]
245144

246145

247146
def create_open_period(group: Group, start_time: datetime) -> None:

src/sentry/workflow_engine/endpoints/organization_open_periods.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
from __future__ import annotations
22

3-
from typing import Any
4-
53
from drf_spectacular.utils import OpenApiParameter, extend_schema
4+
from rest_framework import status
65
from rest_framework.exceptions import ParseError, ValidationError
76
from rest_framework.request import Request
87
from rest_framework.response import Response
@@ -11,7 +10,8 @@
1110
from sentry.api.api_publish_status import ApiPublishStatus
1211
from sentry.api.base import region_silo_endpoint
1312
from sentry.api.bases import OrganizationDetectorPermission, OrganizationEndpoint
14-
from sentry.api.paginator import GenericOffsetPaginator
13+
from sentry.api.paginator import OffsetPaginator
14+
from sentry.api.serializers import serialize
1515
from sentry.api.utils import get_date_range_from_params
1616
from sentry.apidocs.constants import (
1717
RESPONSE_BAD_REQUEST,
@@ -20,11 +20,11 @@
2020
RESPONSE_UNAUTHORIZED,
2121
)
2222
from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, VisibilityParams
23-
from sentry.apidocs.utils import inline_sentry_response_serializer
2423
from sentry.exceptions import InvalidParams
2524
from sentry.models.group import Group
26-
from sentry.models.groupopenperiod import OpenPeriod, get_open_periods_for_group
25+
from sentry.models.groupopenperiod import get_open_periods_for_group
2726
from sentry.models.organization import Organization
27+
from sentry.workflow_engine.endpoints.serializers import GroupOpenPeriodSerializer
2828
from sentry.workflow_engine.models import Detector
2929
from sentry.workflow_engine.models.detector_group import DetectorGroup
3030

@@ -92,7 +92,7 @@ def get_group_from_group_id(self, group_id: str, organization: Organization) ->
9292
),
9393
],
9494
responses={
95-
200: inline_sentry_response_serializer("ListOpenPeriods", list[OpenPeriod]),
95+
200: GroupOpenPeriodSerializer,
9696
400: RESPONSE_BAD_REQUEST,
9797
401: RESPONSE_UNAUTHORIZED,
9898
403: RESPONSE_FORBIDDEN,
@@ -125,20 +125,27 @@ def get(self, request: Request, organization: Organization) -> Response:
125125
else None
126126
)
127127
)
128-
129-
def data_fn(offset: int, limit: int) -> list[OpenPeriod] | list[Any]:
130-
if target_group is None:
131-
return []
132-
return get_open_periods_for_group(
133-
group=target_group,
134-
query_start=start,
135-
query_end=end,
136-
offset=offset,
137-
limit=limit,
128+
if not target_group:
129+
return Response(
130+
{"detail": "Group not found. Could not query open periods."},
131+
status=status.HTTP_404_NOT_FOUND,
138132
)
139-
133+
limit = None
134+
per_page = request.GET.get("per_page")
135+
if per_page:
136+
limit = int(per_page)
137+
assert limit > 0
138+
139+
open_periods = get_open_periods_for_group(
140+
group=target_group,
141+
query_start=start,
142+
query_end=end,
143+
limit=limit,
144+
)
140145
return self.paginate(
141146
request=request,
142-
on_results=lambda results: [result.to_dict() for result in results],
143-
paginator=GenericOffsetPaginator(data_fn=data_fn),
147+
queryset=open_periods,
148+
paginator_cls=OffsetPaginator,
149+
on_results=lambda x: serialize(x, request.user),
150+
count_hits=True,
144151
)

src/sentry/workflow_engine/endpoints/serializers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case
1717
from sentry.grouping.grouptype import ErrorGroupType
1818
from sentry.models.group import Group, GroupStatus
19+
from sentry.models.groupopenperiod import GroupOpenPeriod, get_last_checked_for_open_period
1920
from sentry.models.options.project_option import ProjectOption
2021
from sentry.rules.actions.notify_event_service import PLUGINS_WITH_FIRST_PARTY_EQUIVALENTS
2122
from sentry.rules.history.base import TimeSeriesValue
@@ -682,3 +683,27 @@ def serialize(
682683
"detectorId": str(obj.detector.id),
683684
"workflowId": str(obj.workflow.id),
684685
}
686+
687+
688+
class GroupOpenPeriodResponse(TypedDict):
689+
id: str
690+
start: datetime
691+
end: datetime | None
692+
duration: timedelta | None
693+
isOpen: bool
694+
lastChecked: datetime
695+
696+
697+
@register(GroupOpenPeriod)
698+
class GroupOpenPeriodSerializer(Serializer):
699+
def serialize(
700+
self, obj: GroupOpenPeriod, attrs: Mapping[str, Any], user, **kwargs
701+
) -> GroupOpenPeriodResponse:
702+
return {
703+
"id": str(obj.id),
704+
"start": obj.date_started,
705+
"end": obj.date_ended,
706+
"duration": obj.date_ended - obj.date_started if obj.date_ended else None,
707+
"isOpen": obj.date_ended is None,
708+
"lastChecked": get_last_checked_for_open_period(obj.group),
709+
}

0 commit comments

Comments
 (0)