Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore (anomaly detection): get anomalies proxy API #76707

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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": "high",
"anomaly_value": 100,
},
}
],
)
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# from datetime import datetime

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.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
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.PUBLIC,
}

@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.
"""
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)
if anomalies is None:
return Response("Unable to get historical anomaly data", status=400)
return self.paginate(
request=request,
queryset=anomalies,
paginator_cls=OffsetPaginator,
on_results=lambda x: serialize(x, request.user),
)
163 changes: 163 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,163 @@
import logging
from datetime import datetime

from django.conf import settings
from django.core.exceptions import ValidationError
from urllib3.exceptions import MaxRetryError, TimeoutError

from sentry.conf.server import SEER_ANOMALY_DETECTION_ENDPOINT_URL
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.search.events.types import SnubaParams
from sentry.seer.anomaly_detection.types import AnomalyDetectionConfig
from sentry.seer.anomaly_detection.utils import format_historical_data, translate_direction
from sentry.seer.signed_seer_api import make_signed_seer_api_request
from sentry.snuba.models import SnubaQuery
from sentry.snuba.referrer import Referrer
from sentry.snuba.utils import get_dataset
from sentry.utils import json
from sentry.utils.json import JSONDecodeError
from sentry.utils.snuba import SnubaTSResult

logger = logging.getLogger(__name__)

seer_anomaly_detection_connection_pool = connection_from_url(
settings.SEER_ANOMALY_DETECTION_URL,
timeout=settings.SEER_ANOMALY_DETECTION_TIMEOUT,
)


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.
"""
if alert_rule.status == AlertRuleStatus.NOT_ENOUGH_DATA.value:
return []
# don't think this can happen but mypy is yelling
if not alert_rule.snuba_query:
raise ValidationError("No snuba query associated with alert rule.")
subscription = alert_rule.snuba_query.subscriptions.first()
# same deal as above
if not subscription:
raise ValidationError("No subscription associated with alert rule.")
snuba_query = SnubaQuery.objects.get(id=alert_rule.snuba_query_id)
window_min = int(snuba_query.time_window / 60)
start = datetime.fromisoformat(start_string)
end = datetime.fromisoformat(end_string)
historical_data = fetch_historical_data(
alert_rule=alert_rule, snuba_query=snuba_query, project=project, start=start, end=end
)

if not historical_data:
raise ValidationError("No historical data available.")
formatted_data = format_historical_data(historical_data)
if (
not alert_rule.sensitivity
or not alert_rule.seasonality
or alert_rule.threshold_type is None
or alert_rule.organization is None
):
# this won't happen because we've already gone through the serializer, but mypy insists
raise ValidationError("Missing expected configuration for a dynamic alert.")

anomaly_detection_config = AnomalyDetectionConfig(
time_period=window_min,
sensitivity=alert_rule.sensitivity,
direction=translate_direction(alert_rule.threshold_type),
expected_seasonality=alert_rule.seasonality,
)
try:
response = make_signed_seer_api_request(
seer_anomaly_detection_connection_pool,
SEER_ANOMALY_DETECTION_ENDPOINT_URL,
json.dumps(
{
"organization_id": alert_rule.organization.id,
"project_id": project.id,
"config": anomaly_detection_config,
"context": formatted_data,
}
).encode("utf-8"),
)
except (TimeoutError, MaxRetryError):
logger.warning(
"Timeout error when hitting anomaly detection endpoint",
extra={
"subscription_id": subscription.id,
"dataset": alert_rule.snuba_query.dataset,
"organization_id": alert_rule.organization.id,
"project_id": project.id,
"alert_rule_id": alert_rule.id,
},
)
return None

if response.status != 200:
logger.error(
f"Received {response.status} when calling Seer endpoint {SEER_ANOMALY_DETECTION_ENDPOINT_URL}.", # noqa
extra={"response_data": response.data},
)
return None

try:
results = json.loads(response.data.decode("utf-8")).get("anomalies")
if not results:
logger.warning(
"Seer anomaly detection response returned no potential anomalies",
extra={
"ad_config": anomaly_detection_config,
"context": formatted_data,
"response_data": response.data,
"reponse_code": response.status,
},
)
return None
return results
except (
AttributeError,
UnicodeError,
JSONDecodeError,
):
logger.exception(
"Failed to parse Seer anomaly detection response",
extra={
"ad_config": anomaly_detection_config,
"context": formatted_data,
"response_data": response.data,
"reponse_code": response.status,
},
)
return None


def fetch_historical_data(
alert_rule: AlertRule, snuba_query: SnubaQuery, project: Project, start: datetime, end: datetime
) -> SnubaTSResult | None:
granularity = snuba_query.time_window

dataset_label = snuba_query.dataset
if dataset_label == "events":
# DATSET_OPTIONS expects the name 'errors'
dataset_label = "errors"
dataset = get_dataset(dataset_label)

if not project or not dataset or not alert_rule.organization:
return None

historical_data = dataset.timeseries_query(
selected_columns=[snuba_query.aggregate],
query=snuba_query.query,
snuba_params=SnubaParams(
organization=alert_rule.organization,
projects=[project],
start=start,
end=end,
),
rollup=granularity,
referrer=Referrer.ANOMALY_DETECTION_RETURN_HISTORICAL_ANOMALIES.value,
zerofill_results=True,
)
return historical_data
19 changes: 1 addition & 18 deletions src/sentry/seer/anomaly_detection/store_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@
AlertInSeer,
AnomalyDetectionConfig,
StoreDataRequest,
TimeSeriesPoint,
)
from sentry.seer.anomaly_detection.utils import translate_direction
from sentry.seer.anomaly_detection.utils import format_historical_data, translate_direction
from sentry.seer.signed_seer_api import make_signed_seer_api_request
from sentry.snuba.models import SnubaQuery
from sentry.snuba.referrer import Referrer
Expand All @@ -33,22 +32,6 @@
)


def format_historical_data(data: SnubaTSResult) -> list[TimeSeriesPoint]:
"""
Format Snuba data into the format the Seer API expects.
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}
"""
formatted_data = []
for datum in data.data.get("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 @@ -6,6 +6,11 @@ class AlertInSeer(TypedDict):
id: int


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


class TimeSeriesPoint(TypedDict):
timestamp: float
value: float
Expand Down
18 changes: 18 additions & 0 deletions src/sentry/seer/anomaly_detection/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from sentry.incidents.models.alert_rule import AlertRuleThresholdType
from sentry.seer.anomaly_detection.types import TimeSeriesPoint
from sentry.utils.snuba import SnubaTSResult


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


def format_historical_data(data: SnubaTSResult) -> list[TimeSeriesPoint]:
"""
Format Snuba data into the format the Seer API expects.
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}
"""
formatted_data = []
for datum in data.data.get("data", []):
ts_point = TimeSeriesPoint(timestamp=datum.get("time"), value=datum.get("count", 0))
formatted_data.append(ts_point)
return formatted_data
3 changes: 3 additions & 0 deletions src/sentry/snuba/referrer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ class Referrer(Enum):
ALERTRULESERIALIZER_TEST_QUERY_PRIMARY = "alertruleserializer.test_query.primary"
ALERTRULESERIALIZER_TEST_QUERY = "alertruleserializer.test_query"
ANOMALY_DETECTION_HISTORICAL_DATA_QUERY = "anomaly_detection_historical_data_query"
ANOMALY_DETECTION_RETURN_HISTORICAL_ANOMALIES = (
"anomaly_detection_get_historical_anomalies_query"
)
API_ALERTS_ALERT_RULE_CHART_METRICS_ENHANCED = "api.alerts.alert-rule-chart.metrics-enhanced"
API_ALERTS_ALERT_RULE_CHART = "api.alerts.alert-rule-chart"
API_ALERTS_CHARTCUTERIE = "api.alerts.chartcuterie"
Expand Down
Loading
Loading