Skip to content

Commit

Permalink
chore (anomaly detection): create proxy API endpoint (#76934)
Browse files Browse the repository at this point in the history
Create a proxy API endpoint for us to query the backend about anomalies.
`get_historical_anomaly_data_from_seer` returns a placeholder.

---------

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
  • Loading branch information
mifu67 and getsantry[bot] authored Sep 4, 2024
1 parent 40eeee8 commit 9425d73
Show file tree
Hide file tree
Showing 9 changed files with 357 additions and 83 deletions.
8 changes: 8 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@
from sentry.incidents.endpoints.organization_alert_rule_activations import (
OrganizationAlertRuleActivationsEndpoint,
)
from sentry.incidents.endpoints.organization_alert_rule_anomalies import (
OrganizationAlertRuleAnomaliesEndpoint,
)
from sentry.incidents.endpoints.organization_alert_rule_available_action_index import (
OrganizationAlertRuleAvailableActionIndexEndpoint,
)
Expand Down Expand Up @@ -1169,6 +1172,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
OrganizationAlertRuleActivationsEndpoint.as_view(),
name="sentry-api-0-organization-alert-rule-activations",
),
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/alert-rules/(?P<alert_rule_id>[^\/]+)/anomalies/$",
OrganizationAlertRuleAnomaliesEndpoint.as_view(),
name="sentry-api-0-organization-alert-rule-anomalies",
),
re_path( # fetch combined metric and issue alert rules
r"^(?P<organization_id_or_slug>[^\/]+)/combined-rules/$",
OrganizationCombinedRuleIndexEndpoint.as_view(),
Expand Down
16 changes: 16 additions & 0 deletions src/sentry/apidocs/examples/metric_alert_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,19 @@ class MetricAlertExamples:
],
)
]

GET_METRIC_ALERT_ANOMALIES = [
OpenApiExample(
"Fetch a list of anomalies for a metric alert rule",
value=[
{
"timestamp": 0.1,
"value": 100.0,
"anomaly": {
"anomaly_type": "anomaly_higher_confidence",
"anomaly_value": 100,
},
}
],
)
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from drf_spectacular.utils import extend_schema
from pydantic import BaseModel
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.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.metric_alert_examples import MetricAlertExamples
from sentry.apidocs.parameters import GlobalParams, MetricAlertParams
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.incidents.endpoints.bases import OrganizationAlertRuleEndpoint
from sentry.incidents.models.alert_rule import AlertRule
from sentry.models.organization import Organization
from sentry.seer.anomaly_detection.get_historical_anomalies import (
get_historical_anomaly_data_from_seer,
)
from sentry.seer.anomaly_detection.types import TimeSeriesPoint


class DetectAnomaliesResponse(BaseModel):
timeseries: list[TimeSeriesPoint]


@region_silo_endpoint
class OrganizationAlertRuleAnomaliesEndpoint(OrganizationAlertRuleEndpoint):
owner = ApiOwner.ALERTS_NOTIFICATIONS
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
}

@extend_schema(
operation_id="Retrieve anomalies for a Metric Alert Rule",
parameters=[GlobalParams.ORG_ID_OR_SLUG, MetricAlertParams.METRIC_RULE_ID],
responses={
200: inline_sentry_response_serializer(
"ListAlertRuleAnomalies", DetectAnomaliesResponse
),
400: RESPONSE_BAD_REQUEST,
401: RESPONSE_UNAUTHORIZED,
403: RESPONSE_FORBIDDEN,
404: RESPONSE_NOT_FOUND,
},
examples=MetricAlertExamples.GET_METRIC_ALERT_ANOMALIES,
)
def get(self, request: Request, organization: Organization, alert_rule: AlertRule) -> Response:
"""
Return a list of anomalies for a metric alert rule.
"""
if not features.has("organizations:anomaly-detection-alerts", organization):
raise ResourceDoesNotExist("Your organization does not have access to this feature.")

# NOTE: this will break if we ever do more than one project per alert rule
project = alert_rule.projects.first()
start = request.GET.get("start", None)
end = request.GET.get("end", None)

if not project or start is None or end is None:
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(alert_rule, project, start, end)
# 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),
)
24 changes: 24 additions & 0 deletions src/sentry/seer/anomaly_detection/get_historical_anomalies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from sentry.incidents.models.alert_rule import AlertRule, AlertRuleStatus
from sentry.models.project import Project
from sentry.seer.anomaly_detection.types import AnomalyType


def get_historical_anomaly_data_from_seer(
alert_rule: AlertRule, project: Project, start_string: str, end_string: str
) -> list | None:
"""
Send time series data to Seer and return anomaly detection response (PLACEHOLDER).
"""
if alert_rule.status == AlertRuleStatus.NOT_ENOUGH_DATA.value:
return []

return [
{
"timestamp": 0.1,
"value": 100.0,
"anomaly": {
"anomaly_type": AnomalyType.HIGH_CONFIDENCE.value,
"anomaly_value": 100,
},
}
]
87 changes: 5 additions & 82 deletions src/sentry/seer/anomaly_detection/store_data.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,30 @@
import logging
from datetime import datetime, timedelta
from typing import Any

from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.datastructures import MultiValueDict
from urllib3.exceptions import MaxRetryError, TimeoutError

from sentry import release_health
from sentry.conf.server import SEER_ANOMALY_DETECTION_STORE_DATA_URL
from sentry.incidents.models.alert_rule import AlertRule, AlertRuleStatus
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.net.http import connection_from_url
from sentry.search.events.types import SnubaParams
from sentry.seer.anomaly_detection.types import (
AlertInSeer,
AnomalyDetectionConfig,
StoreDataRequest,
TimeSeriesPoint,
)
from sentry.seer.anomaly_detection.utils import translate_direction
from sentry.seer.anomaly_detection.utils import (
format_historical_data,
get_crash_free_historical_data,
translate_direction,
)
from sentry.seer.signed_seer_api import make_signed_seer_api_request
from sentry.snuba import metrics_performance
from sentry.snuba.models import SnubaQuery
from sentry.snuba.referrer import Referrer
from sentry.snuba.sessions_v2 import QueryDefinition
from sentry.snuba.utils import get_dataset
from sentry.utils import json
from sentry.utils.snuba import SnubaTSResult
Expand All @@ -40,81 +38,6 @@
NUM_DAYS = 28


def get_crash_free_historical_data(
start: datetime, end: datetime, project: Project, organization: Organization, granularity: int
):
"""
Fetch the historical metrics data from Snuba for crash free user rate and crash free session rate metrics
"""

params = {
"start": start,
"end": end,
"project_id": [project.id],
"project_objects": [project],
"organization_id": organization.id,
}
query_params: MultiValueDict[str, Any] = MultiValueDict(
{
"project": [project.id],
"statsPeriod": [f"{NUM_DAYS}d"],
"field": ["sum(session)"],
"groupBy": ["release"],
}
)
query = QueryDefinition(
query=query_params,
params=params,
offset=None,
limit=None,
query_config=release_health.backend.sessions_query_config(organization),
)
result = release_health.backend.run_sessions_query(
organization.id, query, span_op="sessions.anomaly_detection"
)
return SnubaTSResult(
{
"data": result,
},
result.get("start"),
result.get("end"),
granularity,
)


def format_historical_data(data: SnubaTSResult, dataset: Any) -> list[TimeSeriesPoint]:
"""
Format Snuba data into the format the Seer API expects.
For errors data:
If there are no results, it's just the timestamp
{'time': 1719012000}, {'time': 1719018000}, {'time': 1719024000}
If there are results, the count is added
{'time': 1721300400, 'count': 2}
For metrics_performance dataset/sessions data:
The count is stored separately from the timestamps, if there is no data the count is 0
"""
formatted_data: list[TimeSeriesPoint] = []
nested_data = data.data.get("data", [])

if dataset == metrics_performance:
groups = nested_data.get("groups")
if not len(groups):
return formatted_data
series = groups[0].get("series")

for time, count in zip(nested_data.get("intervals"), series.get("sum(session)")):
date = datetime.strptime(time, "%Y-%m-%dT%H:%M:%SZ")
ts_point = TimeSeriesPoint(timestamp=date.timestamp(), value=count)
formatted_data.append(ts_point)
else:
for datum in nested_data:
ts_point = TimeSeriesPoint(timestamp=datum.get("time"), value=datum.get("count", 0))
formatted_data.append(ts_point)
return formatted_data


def _get_start_and_end_indices(data: SnubaTSResult) -> tuple[int, int]:
"""
Helper to return the first and last data points that have event counts.
Expand Down
5 changes: 5 additions & 0 deletions src/sentry/seer/anomaly_detection/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
from typing import NotRequired, TypedDict


class Anomaly(TypedDict):
anomaly_type: str
anomaly_value: float


class TimeSeriesPoint(TypedDict):
timestamp: float
value: float
Expand Down
90 changes: 90 additions & 0 deletions src/sentry/seer/anomaly_detection/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
from datetime import datetime
from typing import Any

from django.utils.datastructures import MultiValueDict

from sentry import release_health
from sentry.incidents.models.alert_rule import AlertRuleThresholdType
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.seer.anomaly_detection.types import TimeSeriesPoint
from sentry.snuba import metrics_performance
from sentry.snuba.sessions_v2 import QueryDefinition
from sentry.utils.snuba import SnubaTSResult


def translate_direction(direction: int) -> str:
Expand All @@ -11,3 +23,81 @@ def translate_direction(direction: int) -> str:
AlertRuleThresholdType.ABOVE_AND_BELOW: "both",
}
return direction_map[AlertRuleThresholdType(direction)]


NUM_DAYS = 28


def get_crash_free_historical_data(
start: datetime, end: datetime, project: Project, organization: Organization, granularity: int
):
"""
Fetch the historical metrics data from Snuba for crash free user rate and crash free session rate metrics
"""

params = {
"start": start,
"end": end,
"project_id": [project.id],
"project_objects": [project],
"organization_id": organization.id,
}
query_params: MultiValueDict[str, Any] = MultiValueDict(
{
"project": [project.id],
"statsPeriod": [f"{NUM_DAYS}d"],
"field": ["sum(session)"],
"groupBy": ["release"],
}
)
query = QueryDefinition(
query=query_params,
params=params,
offset=None,
limit=None,
query_config=release_health.backend.sessions_query_config(organization),
)
result = release_health.backend.run_sessions_query(
organization.id, query, span_op="sessions.anomaly_detection"
)
return SnubaTSResult(
{
"data": result,
},
result.get("start"),
result.get("end"),
granularity,
)


def format_historical_data(data: SnubaTSResult, dataset: Any) -> list[TimeSeriesPoint]:
"""
Format Snuba data into the format the Seer API expects.
For errors data:
If there are no results, it's just the timestamp
{'time': 1719012000}, {'time': 1719018000}, {'time': 1719024000}
If there are results, the count is added
{'time': 1721300400, 'count': 2}
For metrics_performance dataset/sessions data:
The count is stored separately from the timestamps, if there is no data the count is 0
"""
formatted_data: list[TimeSeriesPoint] = []
nested_data = data.data.get("data", [])

if dataset == metrics_performance:
groups = nested_data.get("groups")
if not len(groups):
return formatted_data
series = groups[0].get("series")

for time, count in zip(nested_data.get("intervals"), series.get("sum(session)")):
date = datetime.strptime(time, "%Y-%m-%dT%H:%M:%SZ")
ts_point = TimeSeriesPoint(timestamp=date.timestamp(), value=count)
formatted_data.append(ts_point)
else:
for datum in nested_data:
ts_point = TimeSeriesPoint(timestamp=datum.get("time"), value=datum.get("count", 0))
formatted_data.append(ts_point)
return formatted_data
Loading

0 comments on commit 9425d73

Please sign in to comment.