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..33599c87d4a0f2 --- /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.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 +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, + }, + examples=OrganizationExamples.GET_HISTORICAL_ANOMALIES, + ) + 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: + [epoch timestamp, {'count': count}] + Convert the data to this format: + list[TimeSeriesPoint] + """ + if data is None: + return data + + 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. + """ + 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( + current_data, historical_data, project_id, config + ) + # 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/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, + }, + ], + ) + ] 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: 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..b4a66cbfcb599b --- /dev/null +++ b/tests/sentry/api/endpoints/test_organization_historical_anomalies.py @@ -0,0 +1,54 @@ +import orjson + +from sentry.incidents.models.alert_rule import ( + AlertRuleSeasonality, + AlertRuleSensitivity, + 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 + + +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) + config = AnomalyDetectionConfig( + time_period=60, + sensitivity=AlertRuleSensitivity.LOW, + direction=translate_direction(AlertRuleThresholdType.ABOVE.value), + expected_seasonality=AlertRuleSeasonality.AUTO, + ) + data = { + "project_id": 1, + "config": config, + "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, + }, + ]