From abe6e77ba6d9654770733c8e136b2f54c715debe Mon Sep 17 00:00:00 2001 From: Michelle Fu Date: Thu, 19 Sep 2024 13:46:10 -0700 Subject: [PATCH 1/7] build out endpoint (placeholder function) --- .../organization_events_anomalies.py | 98 +++++++++++++++++++ src/sentry/api/urls.py | 6 ++ .../get_historical_anomalies.py | 31 +++++- 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src/sentry/api/endpoints/organization_events_anomalies.py diff --git a/src/sentry/api/endpoints/organization_events_anomalies.py b/src/sentry/api/endpoints/organization_events_anomalies.py new file mode 100644 index 00000000000000..dbca1b5cea0f2c --- /dev/null +++ b/src/sentry/api/endpoints/organization_events_anomalies.py @@ -0,0 +1,98 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import features +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization_events import OrganizationEventsV2EndpointBase +from sentry.api.exceptions import ResourceDoesNotExist +from sentry.api.paginator import OffsetPaginator +from sentry.api.serializers.base import serialize +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.organization import Organization +from sentry.seer.anomaly_detection.get_historical_anomalies import ( + get_historical_anomaly_data_from_seer_preview, +) +from sentry.seer.anomaly_detection.types import DetectAnomaliesResponse, TimeSeriesPoint + + +@region_silo_endpoint +class OrganizationEventsAnomaliesEndpoint(OrganizationEventsV2EndpointBase): + owner = ApiOwner.ALERTS_NOTIFICATIONS + publish_status = { + "POST": ApiPublishStatus.EXPERIMENTAL, + } + + @extend_schema( + operation_id="Identify anomalies in historical data", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + responses={ + 200: inline_sentry_response_serializer( + "ListAlertRuleAnomalies", DetectAnomaliesResponse + ), + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + ) + def _format_historical_data(self, data) -> list[TimeSeriesPoint]: + """ + Format EventsStatsData into the format that the Seer API expects. + EventsStatsData is a list of lists with this format: + [epoch timestamp, {'count': count}] + Convert the data to this format: + list[TimeSeriesPoint] + """ + if data is None: + # TODO: figure out error handling here + pass + formatted_data: list[TimeSeriesPoint] = [] + for datum in data: + ts_point = TimeSeriesPoint(timestamp=datum[0], value=datum[1].get("count", 0)) + formatted_data.append(ts_point) + return formatted_data + + def post(self, request: Request, organization: Organization) -> Response: + """ + Return a list of anomalies for a time series of historical event data. + """ + # TODO: we need to pass in the config + + if not features.has("organizations:anomaly-detection-alerts", organization): + raise ResourceDoesNotExist("Your organization does not have access to this feature.") + + historical_data = self._format_historical_data(request.data.get("historical_data")) + current_data = self._format_historical_data(request.data.get("current_data")) + + config = request.data.get("config") + project_id = request.data.get("project_id") + + if project_id is None or not config or not historical_data or not current_data: + return Response( + "Unable to get historical anomaly data: missing required argument(s) project, start, and/or end", + status=400, + ) + + anomalies = get_historical_anomaly_data_from_seer_preview( + project_id, config, historical_data, current_data + ) + # NOTE: returns None if there's a problem with the Seer response + if anomalies is None: + return Response("Unable to get historical anomaly data", status=400) + # NOTE: returns empty list if there is not enough event data + return self.paginate( + request=request, + queryset=anomalies, + paginator_cls=OffsetPaginator, + on_results=lambda x: serialize(x, request.user), + ) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index d43fe5ff2928e1..e5c00ae2d05226 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -10,6 +10,7 @@ from sentry.api.endpoints.issues.related_issues import RelatedIssuesEndpoint from sentry.api.endpoints.org_auth_token_details import OrgAuthTokenDetailsEndpoint from sentry.api.endpoints.org_auth_tokens import OrgAuthTokensEndpoint +from sentry.api.endpoints.organization_events_anomalies import OrganizationEventsAnomaliesEndpoint from sentry.api.endpoints.organization_events_root_cause_analysis import ( OrganizationEventsRootCauseAnalysisEndpoint, ) @@ -1415,6 +1416,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationEventsStatsEndpoint.as_view(), name="sentry-api-0-organization-events-stats", ), + re_path( + r"^(?P[^\/]+)/events/anomalies/$", + OrganizationEventsAnomaliesEndpoint.as_view(), + name="sentry-api-0-organization-events-anomalies", + ), re_path( r"^(?P[^\/]+)/project-templates/$", OrganizationProjectTemplatesIndexEndpoint.as_view(), diff --git a/src/sentry/seer/anomaly_detection/get_historical_anomalies.py b/src/sentry/seer/anomaly_detection/get_historical_anomalies.py index 5306e26ed21198..2d3fe4882f99b8 100644 --- a/src/sentry/seer/anomaly_detection/get_historical_anomalies.py +++ b/src/sentry/seer/anomaly_detection/get_historical_anomalies.py @@ -8,7 +8,11 @@ from sentry.incidents.models.alert_rule import AlertRule, AlertRuleStatus from sentry.models.project import Project from sentry.net.http import connection_from_url -from sentry.seer.anomaly_detection.types import AnomalyDetectionConfig, DetectAnomaliesRequest +from sentry.seer.anomaly_detection.types import ( + AnomalyDetectionConfig, + DetectAnomaliesRequest, + TimeSeriesPoint, +) from sentry.seer.anomaly_detection.utils import ( fetch_historical_data, format_historical_data, @@ -28,6 +32,31 @@ ) +def get_historical_anomaly_data_from_seer_preview( + current_data: list[TimeSeriesPoint], + historical_data: list[TimeSeriesPoint], + project_id: int, + config: AnomalyDetectionConfig, +) -> list | None: + """ + Send current and historical timeseries data to Seer and return anomaly detection response on the current timeseries. + + Dummy function. TODO: write out the Seer request logic. + """ + return [ + { + "anomaly": {"anomaly_score": -0.38810767243044786, "anomaly_type": "none"}, + "timestamp": 169, + "value": 0.048480431, + }, + { + "anomaly": {"anomaly_score": -0.3890542800124323, "anomaly_type": "none"}, + "timestamp": 170, + "value": 0.047910238, + }, + ] + + def get_historical_anomaly_data_from_seer( alert_rule: AlertRule, project: Project, start_string: str, end_string: str ) -> list | None: From 0c83da9eac74c76b51f914961b5b5df89fc09ce6 Mon Sep 17 00:00:00 2001 From: Michelle Fu Date: Thu, 19 Sep 2024 13:53:14 -0700 Subject: [PATCH 2/7] remove todo --- src/sentry/api/endpoints/organization_events_anomalies.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_anomalies.py b/src/sentry/api/endpoints/organization_events_anomalies.py index dbca1b5cea0f2c..80ef2a9f6bf033 100644 --- a/src/sentry/api/endpoints/organization_events_anomalies.py +++ b/src/sentry/api/endpoints/organization_events_anomalies.py @@ -66,8 +66,6 @@ def post(self, request: Request, organization: Organization) -> Response: """ Return a list of anomalies for a time series of historical event data. """ - # TODO: we need to pass in the config - if not features.has("organizations:anomaly-detection-alerts", organization): raise ResourceDoesNotExist("Your organization does not have access to this feature.") From 466755d5725cf5e9a516b90742838ae2a08b96f2 Mon Sep 17 00:00:00 2001 From: Michelle Fu Date: Thu, 19 Sep 2024 16:03:18 -0700 Subject: [PATCH 3/7] add example --- .../organization_events_anomalies.py | 6 +++-- .../apidocs/examples/organization_examples.py | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_anomalies.py b/src/sentry/api/endpoints/organization_events_anomalies.py index 80ef2a9f6bf033..cc8871c11d4a9d 100644 --- a/src/sentry/api/endpoints/organization_events_anomalies.py +++ b/src/sentry/api/endpoints/organization_events_anomalies.py @@ -16,6 +16,7 @@ RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED, ) +from sentry.apidocs.examples.organization_examples import OrganizationExamples from sentry.apidocs.parameters import GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.organization import Organization @@ -44,6 +45,7 @@ class OrganizationEventsAnomaliesEndpoint(OrganizationEventsV2EndpointBase): 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, }, + examples=OrganizationExamples.GET_HISTORICAL_ANOMALIES, ) def _format_historical_data(self, data) -> list[TimeSeriesPoint]: """ @@ -54,8 +56,8 @@ def _format_historical_data(self, data) -> list[TimeSeriesPoint]: list[TimeSeriesPoint] """ if data is None: - # TODO: figure out error handling here - pass + return data + formatted_data: list[TimeSeriesPoint] = [] for datum in data: ts_point = TimeSeriesPoint(timestamp=datum[0], value=datum[1].get("count", 0)) diff --git a/src/sentry/apidocs/examples/organization_examples.py b/src/sentry/apidocs/examples/organization_examples.py index 14d82d063a208d..412201645f5d56 100644 --- a/src/sentry/apidocs/examples/organization_examples.py +++ b/src/sentry/apidocs/examples/organization_examples.py @@ -1084,3 +1084,27 @@ class OrganizationExamples: response_only=True, ) ] + + GET_HISTORICAL_ANOMALIES = [ + OpenApiExample( + "Identify anomalies in historical data", + value=[ + { + "anomaly": { + "anomaly_score": -0.38810767243044786, + "anomaly_type": "none", + }, + "timestamp": 169, + "value": 0.048480431, + }, + { + "anomaly": { + "anomaly_score": -0.3890542800124323, + "anomaly_type": "none", + }, + "timestamp": 170, + "value": 0.047910238, + }, + ], + ) + ] From b8415dbb796bcd0b3453248069601ca223b54d8f Mon Sep 17 00:00:00 2001 From: Michelle Fu Date: Fri, 20 Sep 2024 09:49:52 -0700 Subject: [PATCH 4/7] add test with placeholder response --- .../test_organization_historical_anomalies.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/sentry/api/endpoints/test_organization_historical_anomalies.py diff --git a/tests/sentry/api/endpoints/test_organization_historical_anomalies.py b/tests/sentry/api/endpoints/test_organization_historical_anomalies.py new file mode 100644 index 00000000000000..695ae960967885 --- /dev/null +++ b/tests/sentry/api/endpoints/test_organization_historical_anomalies.py @@ -0,0 +1,52 @@ +import orjson + +from sentry.incidents.models.alert_rule import ( + AlertRuleSeasonality, + AlertRuleSensitivity, + AlertRuleThresholdType, +) +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature +from sentry.testutils.outbox import outbox_runner + + +class OrganizationEventsAnomaliesEndpointTest(APITestCase): + endpoint = "sentry-api-0-organization-events-anomalies" + + method = "post" + + @with_feature("organizations:anomaly-detection-alerts") + @with_feature("organizations:incidents") + def test_simple(self): + self.create_team(organization=self.organization, members=[self.user]) + self.login_as(self.user) + + data = { + "project_id": 1, + "config": { + "time_period": 60, + "sensitivity": AlertRuleSensitivity.LOW, + "direction": AlertRuleThresholdType.ABOVE, + "expected_seasonality": AlertRuleSeasonality.AUTO, + }, + "current_data": [[1, {"count": 0.077881957}], [2, {"count": 0.075652768}]], + "historical_data": [[169, {"count": 0.048480431}], [170, {"count": 0.047910238}]], + } + + with outbox_runner(): + resp = self.get_success_response( + self.organization.slug, status_code=200, raw_data=orjson.dumps(data) + ) + + assert resp.data == [ + { + "anomaly": {"anomaly_score": -0.38810767243044786, "anomaly_type": "none"}, + "timestamp": 169, + "value": 0.048480431, + }, + { + "anomaly": {"anomaly_score": -0.3890542800124323, "anomaly_type": "none"}, + "timestamp": 170, + "value": 0.047910238, + }, + ] From 47fcc7af4c78e5967defcb20773b19b8c70b0592 Mon Sep 17 00:00:00 2001 From: Michelle Fu Date: Fri, 20 Sep 2024 09:57:44 -0700 Subject: [PATCH 5/7] fix --- src/sentry/api/endpoints/organization_events_anomalies.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_anomalies.py b/src/sentry/api/endpoints/organization_events_anomalies.py index cc8871c11d4a9d..33599c87d4a0f2 100644 --- a/src/sentry/api/endpoints/organization_events_anomalies.py +++ b/src/sentry/api/endpoints/organization_events_anomalies.py @@ -47,7 +47,7 @@ class OrganizationEventsAnomaliesEndpoint(OrganizationEventsV2EndpointBase): }, examples=OrganizationExamples.GET_HISTORICAL_ANOMALIES, ) - def _format_historical_data(self, data) -> list[TimeSeriesPoint]: + def _format_historical_data(self, data) -> list[TimeSeriesPoint] | None: """ Format EventsStatsData into the format that the Seer API expects. EventsStatsData is a list of lists with this format: @@ -84,7 +84,7 @@ def post(self, request: Request, organization: Organization) -> Response: ) anomalies = get_historical_anomaly_data_from_seer_preview( - project_id, config, historical_data, current_data + current_data, historical_data, project_id, config ) # NOTE: returns None if there's a problem with the Seer response if anomalies is None: From 1e566cb8cd3cb7af4cfe1283697393cc44bb266a Mon Sep 17 00:00:00 2001 From: Michelle Fu Date: Fri, 20 Sep 2024 10:36:24 -0700 Subject: [PATCH 6/7] replace config with typed config dict --- .../test_organization_historical_anomalies.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/sentry/api/endpoints/test_organization_historical_anomalies.py b/tests/sentry/api/endpoints/test_organization_historical_anomalies.py index 695ae960967885..7312dfbe807051 100644 --- a/tests/sentry/api/endpoints/test_organization_historical_anomalies.py +++ b/tests/sentry/api/endpoints/test_organization_historical_anomalies.py @@ -5,6 +5,7 @@ AlertRuleSensitivity, AlertRuleThresholdType, ) +from sentry.seer.anomaly_detection.types import AnomalyDetectionConfig from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature from sentry.testutils.outbox import outbox_runner @@ -20,15 +21,15 @@ class OrganizationEventsAnomaliesEndpointTest(APITestCase): def test_simple(self): self.create_team(organization=self.organization, members=[self.user]) self.login_as(self.user) - + config = AnomalyDetectionConfig( + time_period=60, + sensitivity=AlertRuleSensitivity.LOW, + direction=AlertRuleThresholdType.ABOVE, + expected_seasonality=AlertRuleSeasonality.AUTO, + ) data = { "project_id": 1, - "config": { - "time_period": 60, - "sensitivity": AlertRuleSensitivity.LOW, - "direction": AlertRuleThresholdType.ABOVE, - "expected_seasonality": AlertRuleSeasonality.AUTO, - }, + "config": config, "current_data": [[1, {"count": 0.077881957}], [2, {"count": 0.075652768}]], "historical_data": [[169, {"count": 0.048480431}], [170, {"count": 0.047910238}]], } From 5820fc895d6d23dccff32b4a713138a83809c153 Mon Sep 17 00:00:00 2001 From: Michelle Fu Date: Fri, 20 Sep 2024 10:49:36 -0700 Subject: [PATCH 7/7] mypy :cat-scream: --- .../api/endpoints/test_organization_historical_anomalies.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/sentry/api/endpoints/test_organization_historical_anomalies.py b/tests/sentry/api/endpoints/test_organization_historical_anomalies.py index 7312dfbe807051..b4a66cbfcb599b 100644 --- a/tests/sentry/api/endpoints/test_organization_historical_anomalies.py +++ b/tests/sentry/api/endpoints/test_organization_historical_anomalies.py @@ -6,6 +6,7 @@ AlertRuleThresholdType, ) from sentry.seer.anomaly_detection.types import AnomalyDetectionConfig +from sentry.seer.anomaly_detection.utils import translate_direction from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature from sentry.testutils.outbox import outbox_runner @@ -24,7 +25,7 @@ def test_simple(self): config = AnomalyDetectionConfig( time_period=60, sensitivity=AlertRuleSensitivity.LOW, - direction=AlertRuleThresholdType.ABOVE, + direction=translate_direction(AlertRuleThresholdType.ABOVE.value), expected_seasonality=AlertRuleSeasonality.AUTO, ) data = {