Skip to content

Commit caa42ff

Browse files
authored
feat(anomaly detection):preview chart proxy api endpoint (#77813)
Build out the preview chart proxy endpoint. Calls a placeholder function.
1 parent a5ff039 commit caa42ff

File tree

5 files changed

+212
-1
lines changed

5 files changed

+212
-1
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from drf_spectacular.utils import extend_schema
2+
from rest_framework.request import Request
3+
from rest_framework.response import Response
4+
5+
from sentry import features
6+
from sentry.api.api_owners import ApiOwner
7+
from sentry.api.api_publish_status import ApiPublishStatus
8+
from sentry.api.base import region_silo_endpoint
9+
from sentry.api.bases.organization_events import OrganizationEventsV2EndpointBase
10+
from sentry.api.exceptions import ResourceDoesNotExist
11+
from sentry.api.paginator import OffsetPaginator
12+
from sentry.api.serializers.base import serialize
13+
from sentry.apidocs.constants import (
14+
RESPONSE_BAD_REQUEST,
15+
RESPONSE_FORBIDDEN,
16+
RESPONSE_NOT_FOUND,
17+
RESPONSE_UNAUTHORIZED,
18+
)
19+
from sentry.apidocs.examples.organization_examples import OrganizationExamples
20+
from sentry.apidocs.parameters import GlobalParams
21+
from sentry.apidocs.utils import inline_sentry_response_serializer
22+
from sentry.models.organization import Organization
23+
from sentry.seer.anomaly_detection.get_historical_anomalies import (
24+
get_historical_anomaly_data_from_seer_preview,
25+
)
26+
from sentry.seer.anomaly_detection.types import DetectAnomaliesResponse, TimeSeriesPoint
27+
28+
29+
@region_silo_endpoint
30+
class OrganizationEventsAnomaliesEndpoint(OrganizationEventsV2EndpointBase):
31+
owner = ApiOwner.ALERTS_NOTIFICATIONS
32+
publish_status = {
33+
"POST": ApiPublishStatus.EXPERIMENTAL,
34+
}
35+
36+
@extend_schema(
37+
operation_id="Identify anomalies in historical data",
38+
parameters=[GlobalParams.ORG_ID_OR_SLUG],
39+
responses={
40+
200: inline_sentry_response_serializer(
41+
"ListAlertRuleAnomalies", DetectAnomaliesResponse
42+
),
43+
400: RESPONSE_BAD_REQUEST,
44+
401: RESPONSE_UNAUTHORIZED,
45+
403: RESPONSE_FORBIDDEN,
46+
404: RESPONSE_NOT_FOUND,
47+
},
48+
examples=OrganizationExamples.GET_HISTORICAL_ANOMALIES,
49+
)
50+
def _format_historical_data(self, data) -> list[TimeSeriesPoint] | None:
51+
"""
52+
Format EventsStatsData into the format that the Seer API expects.
53+
EventsStatsData is a list of lists with this format:
54+
[epoch timestamp, {'count': count}]
55+
Convert the data to this format:
56+
list[TimeSeriesPoint]
57+
"""
58+
if data is None:
59+
return data
60+
61+
formatted_data: list[TimeSeriesPoint] = []
62+
for datum in data:
63+
ts_point = TimeSeriesPoint(timestamp=datum[0], value=datum[1].get("count", 0))
64+
formatted_data.append(ts_point)
65+
return formatted_data
66+
67+
def post(self, request: Request, organization: Organization) -> Response:
68+
"""
69+
Return a list of anomalies for a time series of historical event data.
70+
"""
71+
if not features.has("organizations:anomaly-detection-alerts", organization):
72+
raise ResourceDoesNotExist("Your organization does not have access to this feature.")
73+
74+
historical_data = self._format_historical_data(request.data.get("historical_data"))
75+
current_data = self._format_historical_data(request.data.get("current_data"))
76+
77+
config = request.data.get("config")
78+
project_id = request.data.get("project_id")
79+
80+
if project_id is None or not config or not historical_data or not current_data:
81+
return Response(
82+
"Unable to get historical anomaly data: missing required argument(s) project, start, and/or end",
83+
status=400,
84+
)
85+
86+
anomalies = get_historical_anomaly_data_from_seer_preview(
87+
current_data, historical_data, project_id, config
88+
)
89+
# NOTE: returns None if there's a problem with the Seer response
90+
if anomalies is None:
91+
return Response("Unable to get historical anomaly data", status=400)
92+
# NOTE: returns empty list if there is not enough event data
93+
return self.paginate(
94+
request=request,
95+
queryset=anomalies,
96+
paginator_cls=OffsetPaginator,
97+
on_results=lambda x: serialize(x, request.user),
98+
)

src/sentry/api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sentry.api.endpoints.issues.related_issues import RelatedIssuesEndpoint
1111
from sentry.api.endpoints.org_auth_token_details import OrgAuthTokenDetailsEndpoint
1212
from sentry.api.endpoints.org_auth_tokens import OrgAuthTokensEndpoint
13+
from sentry.api.endpoints.organization_events_anomalies import OrganizationEventsAnomaliesEndpoint
1314
from sentry.api.endpoints.organization_events_root_cause_analysis import (
1415
OrganizationEventsRootCauseAnalysisEndpoint,
1516
)
@@ -1416,6 +1417,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
14161417
OrganizationEventsStatsEndpoint.as_view(),
14171418
name="sentry-api-0-organization-events-stats",
14181419
),
1420+
re_path(
1421+
r"^(?P<organization_id_or_slug>[^\/]+)/events/anomalies/$",
1422+
OrganizationEventsAnomaliesEndpoint.as_view(),
1423+
name="sentry-api-0-organization-events-anomalies",
1424+
),
14191425
re_path(
14201426
r"^(?P<organization_id_or_slug>[^\/]+)/project-templates/$",
14211427
OrganizationProjectTemplatesIndexEndpoint.as_view(),

src/sentry/apidocs/examples/organization_examples.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,3 +1084,27 @@ class OrganizationExamples:
10841084
response_only=True,
10851085
)
10861086
]
1087+
1088+
GET_HISTORICAL_ANOMALIES = [
1089+
OpenApiExample(
1090+
"Identify anomalies in historical data",
1091+
value=[
1092+
{
1093+
"anomaly": {
1094+
"anomaly_score": -0.38810767243044786,
1095+
"anomaly_type": "none",
1096+
},
1097+
"timestamp": 169,
1098+
"value": 0.048480431,
1099+
},
1100+
{
1101+
"anomaly": {
1102+
"anomaly_score": -0.3890542800124323,
1103+
"anomaly_type": "none",
1104+
},
1105+
"timestamp": 170,
1106+
"value": 0.047910238,
1107+
},
1108+
],
1109+
)
1110+
]

src/sentry/seer/anomaly_detection/get_historical_anomalies.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from sentry.incidents.models.alert_rule import AlertRule, AlertRuleStatus
99
from sentry.models.project import Project
1010
from sentry.net.http import connection_from_url
11-
from sentry.seer.anomaly_detection.types import AnomalyDetectionConfig, DetectAnomaliesRequest
11+
from sentry.seer.anomaly_detection.types import (
12+
AnomalyDetectionConfig,
13+
DetectAnomaliesRequest,
14+
TimeSeriesPoint,
15+
)
1216
from sentry.seer.anomaly_detection.utils import (
1317
fetch_historical_data,
1418
format_historical_data,
@@ -28,6 +32,31 @@
2832
)
2933

3034

35+
def get_historical_anomaly_data_from_seer_preview(
36+
current_data: list[TimeSeriesPoint],
37+
historical_data: list[TimeSeriesPoint],
38+
project_id: int,
39+
config: AnomalyDetectionConfig,
40+
) -> list | None:
41+
"""
42+
Send current and historical timeseries data to Seer and return anomaly detection response on the current timeseries.
43+
44+
Dummy function. TODO: write out the Seer request logic.
45+
"""
46+
return [
47+
{
48+
"anomaly": {"anomaly_score": -0.38810767243044786, "anomaly_type": "none"},
49+
"timestamp": 169,
50+
"value": 0.048480431,
51+
},
52+
{
53+
"anomaly": {"anomaly_score": -0.3890542800124323, "anomaly_type": "none"},
54+
"timestamp": 170,
55+
"value": 0.047910238,
56+
},
57+
]
58+
59+
3160
def get_historical_anomaly_data_from_seer(
3261
alert_rule: AlertRule, project: Project, start_string: str, end_string: str
3362
) -> list | None:
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import orjson
2+
3+
from sentry.incidents.models.alert_rule import (
4+
AlertRuleSeasonality,
5+
AlertRuleSensitivity,
6+
AlertRuleThresholdType,
7+
)
8+
from sentry.seer.anomaly_detection.types import AnomalyDetectionConfig
9+
from sentry.seer.anomaly_detection.utils import translate_direction
10+
from sentry.testutils.cases import APITestCase
11+
from sentry.testutils.helpers.features import with_feature
12+
from sentry.testutils.outbox import outbox_runner
13+
14+
15+
class OrganizationEventsAnomaliesEndpointTest(APITestCase):
16+
endpoint = "sentry-api-0-organization-events-anomalies"
17+
18+
method = "post"
19+
20+
@with_feature("organizations:anomaly-detection-alerts")
21+
@with_feature("organizations:incidents")
22+
def test_simple(self):
23+
self.create_team(organization=self.organization, members=[self.user])
24+
self.login_as(self.user)
25+
config = AnomalyDetectionConfig(
26+
time_period=60,
27+
sensitivity=AlertRuleSensitivity.LOW,
28+
direction=translate_direction(AlertRuleThresholdType.ABOVE.value),
29+
expected_seasonality=AlertRuleSeasonality.AUTO,
30+
)
31+
data = {
32+
"project_id": 1,
33+
"config": config,
34+
"current_data": [[1, {"count": 0.077881957}], [2, {"count": 0.075652768}]],
35+
"historical_data": [[169, {"count": 0.048480431}], [170, {"count": 0.047910238}]],
36+
}
37+
38+
with outbox_runner():
39+
resp = self.get_success_response(
40+
self.organization.slug, status_code=200, raw_data=orjson.dumps(data)
41+
)
42+
43+
assert resp.data == [
44+
{
45+
"anomaly": {"anomaly_score": -0.38810767243044786, "anomaly_type": "none"},
46+
"timestamp": 169,
47+
"value": 0.048480431,
48+
},
49+
{
50+
"anomaly": {"anomaly_score": -0.3890542800124323, "anomaly_type": "none"},
51+
"timestamp": 170,
52+
"value": 0.047910238,
53+
},
54+
]

0 commit comments

Comments
 (0)