Skip to content
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
14 changes: 8 additions & 6 deletions src/sentry/incidents/grouptype.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
from sentry.incidents.metric_issue_detector import MetricIssueDetectorValidator
from sentry.incidents.models.alert_rule import AlertRuleDetectionType, ComparisonDeltaChoices
from sentry.incidents.utils.format_duration import format_duration_idiomatic
from sentry.incidents.utils.types import AnomalyDetectionUpdate, ProcessedSubscriptionUpdate
from sentry.incidents.utils.types import (
AnomalyDetectionUpdate,
AnomalyDetectionValues,
ProcessedSubscriptionUpdate,
)
from sentry.integrations.metric_alerts import TEXT_COMPARISON_DELTA
from sentry.issues.grouptype import GroupCategory, GroupType
from sentry.models.organization import Organization
Expand Down Expand Up @@ -45,7 +49,7 @@


MetricUpdate = ProcessedSubscriptionUpdate | AnomalyDetectionUpdate
MetricResult = float | dict
MetricResult = float | AnomalyDetectionValues


@dataclass
Expand Down Expand Up @@ -241,11 +245,9 @@ def extract_dedupe_value(self, data_packet: DataPacket[MetricUpdate]) -> int:
return int(data_packet.packet.timestamp.timestamp())

def extract_value(self, data_packet: DataPacket[MetricUpdate]) -> MetricResult:
# this is a bit of a hack - anomaly detection data packets send extra data we need to pass along
values = data_packet.packet.values
if isinstance(data_packet.packet, AnomalyDetectionUpdate):
return {None: values}
return values.get("value")
return data_packet.packet.values
return data_packet.packet.values["value"]

def construct_title(
self,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import logging
from datetime import datetime
from typing import Any, TypedDict
from typing import Any

from django.conf import settings

from sentry.incidents.utils.types import AnomalyDetectionValues
from sentry.net.http import connection_from_url
from sentry.seer.anomaly_detection.types import (
AnomalyDetectionSeasonality,
Expand Down Expand Up @@ -31,15 +31,8 @@
}


class AnomalyDetectionUpdate(TypedDict):
value: int
source_id: int
subscription_id: int
timestamp: datetime


@condition_handler_registry.register(Condition.ANOMALY_DETECTION)
class AnomalyDetectionHandler(DataConditionHandler[AnomalyDetectionUpdate]):
class AnomalyDetectionHandler(DataConditionHandler[AnomalyDetectionValues]):
group = DataConditionHandler.Group.DETECTOR_TRIGGER
comparison_json_schema = {
"type": "object",
Expand All @@ -62,15 +55,14 @@ class AnomalyDetectionHandler(DataConditionHandler[AnomalyDetectionUpdate]):
}

@staticmethod
def evaluate_value(update: AnomalyDetectionUpdate, comparison: Any) -> DetectorPriorityLevel:
def evaluate_value(update: AnomalyDetectionValues, comparison: Any) -> DetectorPriorityLevel:
from sentry.seer.anomaly_detection.get_anomaly_data import get_anomaly_data_from_seer

sensitivity = comparison["sensitivity"]
seasonality = comparison["seasonality"]
threshold_type = comparison["threshold_type"]

source_id = update.get("source_id")
assert source_id
source_id = update.source_id

subscription: QuerySubscription = QuerySubscription.objects.get(id=int(source_id))

Expand Down
14 changes: 8 additions & 6 deletions src/sentry/incidents/subscription_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from sentry.incidents.utils.types import (
DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION,
AnomalyDetectionUpdate,
AnomalyDetectionValues,
ProcessedSubscriptionUpdate,
QuerySubscriptionUpdate,
)
Expand Down Expand Up @@ -175,15 +176,16 @@ def process_results_workflow_engine(
) -> list[tuple[Detector, dict[DetectorGroupKey, DetectorEvaluationResult]]]:
detector_cfg: MetricIssueDetectorConfig = detector.config
if detector_cfg["detection_type"] == AlertRuleDetectionType.DYNAMIC.value:
values = AnomalyDetectionValues(
value=aggregation_value,
source_id=str(self.subscription.id),
subscription_id=subscription_update["subscription_id"],
timestamp=self.last_update,
)
anomaly_detection_packet = AnomalyDetectionUpdate(
entity=subscription_update.get("entity", ""),
subscription_id=subscription_update["subscription_id"],
values={
"value": aggregation_value,
"source_id": str(self.subscription.id),
"subscription_id": subscription_update["subscription_id"],
"timestamp": self.last_update,
},
values=values,
timestamp=self.last_update,
)
anomaly_detection_data_packet = DataPacket[AnomalyDetectionUpdate](
Expand Down
17 changes: 11 additions & 6 deletions src/sentry/incidents/typings/metric_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
AlertRuleTriggerAction,
)
from sentry.incidents.models.incident import Incident, IncidentStatus
from sentry.incidents.utils.types import AnomalyDetectionValues
from sentry.models.group import Group, GroupStatus
from sentry.models.groupopenperiod import get_latest_open_period
from sentry.notifications.models.notificationaction import ActionTarget
Expand Down Expand Up @@ -212,7 +213,7 @@ class MetricIssueContext:
snuba_query: SnubaQuery
new_status: IncidentStatus
subscription: QuerySubscription | None
metric_value: float | dict | None
metric_value: float | None
group: Group | None

@classmethod
Expand Down Expand Up @@ -246,11 +247,15 @@ def from_group_event(

subscription = cls._get_subscription(evidence_data)
snuba_query = subscription.snuba_query
metric_value = (
evidence_data.value["value"]
if type(evidence_data.value) is dict
else evidence_data.value
)
# evidence_data.value can be a dict when deserialized from JSON (e.g., from Activity.data)
value: float | AnomalyDetectionValues | dict[str, Any] = evidence_data.value
metric_value: float | None
if isinstance(value, AnomalyDetectionValues):
metric_value = value.value
elif isinstance(value, dict):
metric_value = value.get("value")
else:
metric_value = value

return cls(
id=group.id,
Expand Down
32 changes: 19 additions & 13 deletions src/sentry/incidents/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,42 @@
from typing import Any, TypedDict


@dataclass
class AnomalyDetectionValues:
value: float
source_id: str
subscription_id: str
timestamp: datetime


class SubscriptionUpdateValues(TypedDict):
value: float


class QuerySubscriptionUpdateValues(TypedDict):
data: list[dict[str, Any]]


class QuerySubscriptionUpdate(TypedDict):
entity: str
subscription_id: str
values: Any
values: QuerySubscriptionUpdateValues
timestamp: datetime


@dataclass
class ProcessedSubscriptionUpdate:
entity: str
subscription_id: str
values: Any
values: SubscriptionUpdateValues
timestamp: datetime


@dataclass
class AnomalyDetectionUpdate:
"""
values has format:
{
"value": float,
"source_id": str,
"subscription_id": str,
"timestamp": datetime,
}
"""

entity: str
subscription_id: str
values: Any
values: AnomalyDetectionValues
timestamp: datetime


Expand Down
9 changes: 4 additions & 5 deletions src/sentry/seer/anomaly_detection/get_anomaly_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
SEER_ANOMALY_DETECTION_ALERT_DATA_URL,
SEER_ANOMALY_DETECTION_ENDPOINT_URL,
)
from sentry.incidents.handlers.condition.anomaly_detection_handler import AnomalyDetectionUpdate
from sentry.incidents.utils.types import AnomalyDetectionValues
from sentry.net.http import connection_from_url
from sentry.seer.anomaly_detection.types import (
AlertInSeer,
Expand Down Expand Up @@ -68,10 +68,10 @@ def get_anomaly_data_from_seer(
seasonality: AnomalyDetectionSeasonality,
threshold_type: AnomalyDetectionThresholdType,
subscription: QuerySubscription,
subscription_update: AnomalyDetectionUpdate,
subscription_update: AnomalyDetectionValues,
) -> list[TimeSeriesPoint] | None:
snuba_query: SnubaQuery = subscription.snuba_query
aggregation_value = subscription_update.get("value")
aggregation_value = subscription_update.value
source_id = subscription.id
source_type = DataSourceType.SNUBA_QUERY_SUBSCRIPTION

Expand All @@ -88,8 +88,7 @@ def get_anomaly_data_from_seer(
"source_id": source_id,
"source_type": source_type,
}
timestamp = subscription_update.get("timestamp")
assert timestamp
timestamp = subscription_update.timestamp

anomaly_detection_config = AnomalyDetectionConfig(
time_period=int(snuba_query.time_window / 60),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from urllib3.response import HTTPResponse

from sentry.conf.server import SEER_ANOMALY_DETECTION_ENDPOINT_URL
from sentry.incidents.utils.types import AnomalyDetectionUpdate
from sentry.incidents.utils.types import AnomalyDetectionUpdate, AnomalyDetectionValues
from sentry.seer.anomaly_detection.types import (
AnomalyDetectionSeasonality,
AnomalyDetectionSensitivity,
Expand Down Expand Up @@ -38,12 +38,12 @@ def setUp(self) -> None:

packet = AnomalyDetectionUpdate(
subscription_id=str(self.subscription.id),
values={
"value": 1,
"source_id": str(self.subscription.id),
"subscription_id": str(self.subscription.id),
"timestamp": datetime.now(UTC),
},
values=AnomalyDetectionValues(
value=1,
source_id=str(self.subscription.id),
subscription_id=str(self.subscription.id),
timestamp=datetime.now(UTC),
),
timestamp=datetime.now(UTC),
entity="test-entity",
)
Expand Down Expand Up @@ -77,7 +77,7 @@ def setUp(self) -> None:
"anomaly_type": AnomalyType.HIGH_CONFIDENCE,
},
"timestamp": 1,
"value": self.data_packet.packet.values["value"],
"value": self.data_packet.packet.values.value,
}
],
}
Expand All @@ -90,7 +90,7 @@ def setUp(self) -> None:
"anomaly_type": AnomalyType.LOW_CONFIDENCE,
},
"timestamp": 1,
"value": self.data_packet.packet.values["value"],
"value": self.data_packet.packet.values.value,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:woohoo: types are working. i was going to ask a question about this, but was able to see why we had the .value really clearly in the type definitions 🎉

}
],
}
Expand All @@ -113,7 +113,7 @@ def assert_seer_call(self, deserialized_body: dict[str, Any]) -> None:
)
assert (
deserialized_body["context"]["cur_window"]["value"]
== self.data_packet.packet.values["value"]
== self.data_packet.packet.values.value
)

@mock.patch(
Expand Down Expand Up @@ -190,12 +190,12 @@ def test_seer_call_nan_aggregation_value(

packet = AnomalyDetectionUpdate(
subscription_id=str(self.subscription.id),
values={
"value": float("nan"),
"source_id": str(self.subscription.id),
"subscription_id": str(self.subscription.id),
"timestamp": datetime.now(UTC),
},
values=AnomalyDetectionValues(
value=float("nan"),
source_id=str(self.subscription.id),
subscription_id=str(self.subscription.id),
timestamp=datetime.now(UTC),
),
timestamp=datetime.now(UTC),
entity="test-entity",
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
NotificationContext,
OpenPeriodContext,
)
from sentry.incidents.utils.types import AnomalyDetectionValues
from sentry.issues.issue_occurrence import IssueOccurrence
from sentry.models.activity import Activity
from sentry.models.group import Group, GroupStatus
Expand Down Expand Up @@ -107,12 +108,12 @@ def create_models(self):
)

self.anomaly_detection_evidence_data = MetricIssueEvidenceData(
value={
"source_id": "12345",
"subscription_id": "some-subscription-id-123",
"timestamp": "2025-06-07",
"value": 6789,
},
value=AnomalyDetectionValues(
source_id="12345",
subscription_id="some-subscription-id-123",
timestamp=datetime(2025, 6, 7),
value=6789,
),
detector_id=self.detector.id,
data_packet_source_id=int(self.data_source.source_id),
conditions=[
Expand Down Expand Up @@ -234,7 +235,7 @@ def assert_metric_issue_context(
snuba_query: SnubaQuery,
new_status: IncidentStatus,
title: str,
metric_value: float | dict | None = None,
metric_value: float | None = None,
subscription: QuerySubscription | None = None,
group: Group | None = None,
):
Expand Down Expand Up @@ -464,6 +465,7 @@ def test_invoke_legacy_registry(self, mock_send_alert: mock.MagicMock) -> None:
resolve_threshold=None,
alert_threshold=self.evidence_data.conditions[0]["comparison"],
)
assert isinstance(self.evidence_data.value, float)
self.assert_metric_issue_context(
metric_issue_context,
open_period_identifier=self.open_period.id,
Expand Down Expand Up @@ -549,6 +551,7 @@ def test_invoke_legacy_registry_with_activity(self, mock_send_alert: mock.MagicM
resolve_threshold=None,
alert_threshold=self.evidence_data.conditions[2]["comparison"],
)
assert isinstance(self.evidence_data.value, float)
self.assert_metric_issue_context(
metric_issue_context,
open_period_identifier=self.open_period.id,
Expand All @@ -568,6 +571,8 @@ def test_invoke_legacy_registry_with_activity_anomaly_detection(
) -> None:
# Create an Activity instance with evidence data and priority
activity_data = asdict(self.anomaly_detection_evidence_data)
# Convert datetime to ISO string for JSON serialization
activity_data["value"]["timestamp"] = activity_data["value"]["timestamp"].isoformat()

activity = Activity(
project=self.project,
Expand Down Expand Up @@ -623,13 +628,13 @@ def test_invoke_legacy_registry_with_activity_anomaly_detection(
resolve_threshold=0,
alert_threshold=0,
)
assert type(self.anomaly_detection_evidence_data.value) is dict
assert isinstance(self.anomaly_detection_evidence_data.value, AnomalyDetectionValues)
self.assert_metric_issue_context(
metric_issue_context,
open_period_identifier=self.open_period.id,
snuba_query=self.snuba_query,
new_status=IncidentStatus.CLOSED,
metric_value=self.anomaly_detection_evidence_data.value["value"],
metric_value=self.anomaly_detection_evidence_data.value.value,
title=self.group.title,
group=self.group,
subscription=self.subscription,
Expand Down
Loading