Skip to content

Commit

Permalink
feat(anomaly detection): backend call for historical anomalies endpoi…
Browse files Browse the repository at this point in the history
…nt (#77879)

Replace the dummy function with the real Seer call.

---------

Co-authored-by: Colleen O'Rourke <colleen@sentry.io>
  • Loading branch information
mifu67 and ceorourke authored Sep 24, 2024
1 parent 7dd9748 commit 827b22e
Show file tree
Hide file tree
Showing 7 changed files with 423 additions and 77 deletions.
8 changes: 6 additions & 2 deletions src/sentry/api/endpoints/organization_events_anomalies.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,16 @@ def post(self, request: Request, organization: Organization) -> Response:

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",
"Unable to get historical anomaly data: missing required argument(s) project_id, config, historical_data, and/or current_data",
status=400,
)

anomalies = get_historical_anomaly_data_from_seer_preview(
current_data, historical_data, project_id, config
current_data=current_data,
historical_data=historical_data,
project_id=project_id,
organization_id=organization.id,
config=config,
)
# NOTE: returns None if there's a problem with the Seer response
if anomalies is None:
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/incidents/subscription_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,7 @@ def get_anomaly_data_from_seer(
"ad_config": anomaly_detection_config,
"context": context,
"response_data": response.data,
"reponse_code": response.status,
"response_code": response.status,
},
)
return None
Expand All @@ -766,7 +766,7 @@ def get_anomaly_data_from_seer(
"ad_config": anomaly_detection_config,
"context": context,
"response_data": decoded_data,
"reponse_code": response.status,
"response_code": response.status,
},
)
return None
Expand Down
119 changes: 102 additions & 17 deletions src/sentry/seer/anomaly_detection/get_historical_anomalies.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from datetime import datetime
from datetime import datetime, timedelta

from django.conf import settings
from urllib3.exceptions import MaxRetryError, TimeoutError
Expand All @@ -8,9 +8,13 @@
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.store_data import _get_start_and_end_indices
from sentry.seer.anomaly_detection.types import (
AnomalyDetectionConfig,
DetectAnomaliesRequest,
DetectAnomaliesResponse,
DetectHistoricalAnomaliesContext,
DetectHistoricalAnomaliesRequest,
TimeSeriesPoint,
)
from sentry.seer.anomaly_detection.utils import (
Expand All @@ -32,29 +36,110 @@
)


def handle_seer_error_responses(response, config, context, log_params):
def log_statement(log_level, text, extra_data=None):
log_data = {**log_params}
if extra_data:
log_data.update(**extra_data)
if log_level == "error":
logger.error(text, extra=log_data)
elif log_level == "warning":
logger.warning(text, extra=log_data)

extra_response_data = {"response_data": response.data, "response_code": response.status}
if response.status > 400:
log_statement(
"error", "Error when hitting Seer detect anomalies endpoint", extra_response_data
)
return True

try:
decoded_data = response.data.decode("utf-8")
except AttributeError:
extra_data = {**log_params, **extra_response_data}
logger.exception("Failed to parse Seer anomaly detection response", extra=extra_data)
return True

try:
results: DetectAnomaliesResponse = json.loads(decoded_data)
except JSONDecodeError:
extra_response_data["response_data"] = decoded_data
log_statement(
"exception", "Failed to parse Seer anomaly detection response", extra_response_data
)
return True

if not results.get("success"):
extra_data = {"message": results.get("message", "")}
log_statement("error", "Error when hitting Seer detect anomalies endpoint", extra_data)
return True

if not results.get("timeseries"):
extra_data = {
"response_data": results.get("message"),
}
log_statement(
"warning", "Seer anomaly detection response returned no potential anomalies", extra_data
)
return True
return False


def get_historical_anomaly_data_from_seer_preview(
current_data: list[TimeSeriesPoint],
historical_data: list[TimeSeriesPoint],
organization_id: int,
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.
Used for rendering the preview charts of anomaly detection alert rules.
"""
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,
},
]
# Check if historical data has at least seven days of data. Return early if not.
MIN_DAYS = 7
data_start_index, data_end_index = _get_start_and_end_indices(historical_data)
if data_start_index == -1:
return []

data_start_time = datetime.fromtimestamp(historical_data[data_start_index]["timestamp"])
data_end_time = datetime.fromtimestamp(historical_data[data_end_index]["timestamp"])
if data_end_time - data_start_time < timedelta(days=MIN_DAYS):
return []

# Send data to Seer
context = DetectHistoricalAnomaliesContext(
history=historical_data,
current=current_data,
)
body = DetectHistoricalAnomaliesRequest(
organization_id=organization_id,
project_id=project_id,
config=config,
context=context,
)
extra_data = {
"organization_id": organization_id,
"project_id": project_id,
"config": config,
"context": context,
}
try:
response = make_signed_seer_api_request(
connection_pool=seer_anomaly_detection_connection_pool,
path=SEER_ANOMALY_DETECTION_ENDPOINT_URL,
body=json.dumps(body).encode("utf-8"),
)
except (TimeoutError, MaxRetryError):
logger.warning("Timeout error when hitting anomaly detection endpoint", extra=extra_data)
return None

error = handle_seer_error_responses(response, config, context, extra_data)
if error:
return None

results: DetectAnomaliesResponse = json.loads(response.data.decode("utf-8"))
return results.get("timeseries")


def get_historical_anomaly_data_from_seer(
Expand Down Expand Up @@ -161,7 +246,7 @@ def get_historical_anomaly_data_from_seer(
"ad_config": anomaly_detection_config,
"context": formatted_data,
"response_data": response.data,
"reponse_code": response.status,
"response_code": response.status,
},
)
return None
Expand All @@ -177,7 +262,7 @@ def get_historical_anomaly_data_from_seer(
"ad_config": anomaly_detection_config,
"context": formatted_data,
"response_data": response.data,
"reponse_code": response.status,
"response_code": response.status,
},
)
return None
4 changes: 2 additions & 2 deletions src/sentry/seer/anomaly_detection/store_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def send_historical_data_to_seer(alert_rule: AlertRule, project: Project) -> Ale
"ad_config": anomaly_detection_config,
"alert": alert_rule.id,
"response_data": response.data,
"reponse_code": response.status,
"response_code": response.status,
},
)
raise AttributeError(data_format_error_string)
Expand All @@ -154,7 +154,7 @@ def send_historical_data_to_seer(alert_rule: AlertRule, project: Project) -> Ale
"ad_config": anomaly_detection_config,
"alert": alert_rule.id,
"response_data": response.data,
"reponse_code": response.status,
"response_code": response.status,
"dataset": snuba_query.dataset,
"meta": json.dumps(historical_data.data.get("meta", {}).get("fields", {})),
},
Expand Down
12 changes: 12 additions & 0 deletions src/sentry/seer/anomaly_detection/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ class DetectAnomaliesRequest(TypedDict):
context: AlertInSeer | list[TimeSeriesPoint]


class DetectHistoricalAnomaliesContext(TypedDict):
history: list[TimeSeriesPoint]
current: list[TimeSeriesPoint]


class DetectHistoricalAnomaliesRequest(TypedDict):
organization_id: int
project_id: int
config: AnomalyDetectionConfig
context: DetectHistoricalAnomaliesContext


class DeleteAlertDataRequest(TypedDict):
organization_id: int
project_id: NotRequired[int]
Expand Down
Loading

0 comments on commit 827b22e

Please sign in to comment.