Skip to content

Commit 5ee3441

Browse files
authored
feat: Add anomaly detection for log alerts (#95244)
Add anomaly detection support for log alerts. Uses dataset label + event type to determine when to query logs. Removes timestamp rounding now that the timeseries API handles stable quantization.
1 parent 4058f20 commit 5ee3441

File tree

3 files changed

+76
-21
lines changed

3 files changed

+76
-21
lines changed

src/sentry/seer/anomaly_detection/store_data.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from sentry.seer.anomaly_detection.utils import (
2525
fetch_historical_data,
2626
format_historical_data,
27-
get_dataset_from_label,
27+
get_dataset_from_label_and_event_types,
2828
get_event_types,
2929
translate_direction,
3030
)
@@ -156,9 +156,9 @@ def send_historical_data_to_seer(
156156
if not snuba_query:
157157
snuba_query = SnubaQuery.objects.get(id=alert_rule.snuba_query_id)
158158
window_min = int(snuba_query.time_window / 60)
159-
dataset = get_dataset_from_label(snuba_query.dataset)
160-
query_columns = get_query_columns([snuba_query.aggregate], window_min)
161159
event_types = get_event_types(snuba_query, event_types)
160+
dataset = get_dataset_from_label_and_event_types(snuba_query.dataset, event_types)
161+
query_columns = get_query_columns([snuba_query.aggregate], window_min)
162162
if not alert_rule.organization:
163163
raise ValidationError("Alert rule doesn't belong to an organization")
164164

src/sentry/seer/anomaly_detection/utils.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import UTC, datetime, timedelta
1+
from datetime import datetime, timedelta
22
from typing import Any
33

44
from django.utils import timezone
@@ -14,7 +14,7 @@
1414
from sentry.search.eap.types import SearchResolverConfig
1515
from sentry.search.events.types import SnubaParams
1616
from sentry.seer.anomaly_detection.types import AnomalyType, TimeSeriesPoint
17-
from sentry.snuba import metrics_performance, spans_rpc
17+
from sentry.snuba import metrics_performance, ourlogs, spans_rpc
1818
from sentry.snuba.metrics.extraction import MetricSpecType
1919
from sentry.snuba.models import SnubaQuery, SnubaQueryEventType
2020
from sentry.snuba.referrer import Referrer
@@ -229,12 +229,17 @@ def format_historical_data(
229229
)
230230

231231

232-
def get_dataset_from_label(dataset_label: str):
232+
def get_dataset_from_label_and_event_types(
233+
dataset_label: str, event_types: list[SnubaQueryEventType.EventType] | None = None
234+
):
233235
if dataset_label == "events":
234236
# DATASET_OPTIONS expects the name 'errors'
235237
dataset_label = "errors"
236238
elif dataset_label == "events_analytics_platform":
237-
dataset_label = "spans"
239+
if event_types and SnubaQueryEventType.EventType.TRACE_ITEM_LOG in event_types:
240+
dataset_label = "ourlogs"
241+
else:
242+
dataset_label = "spans"
238243
elif dataset_label in ["generic_metrics", "transactions"]:
239244
# XXX: performance alerts dataset differs locally vs in prod
240245
dataset_label = "metricsEnhanced"
@@ -266,8 +271,8 @@ def fetch_historical_data(
266271
if start is None:
267272
start = end - timedelta(days=NUM_DAYS)
268273
granularity = snuba_query.time_window
269-
270-
dataset = get_dataset_from_label(snuba_query.dataset)
274+
event_types = get_event_types(snuba_query, event_types)
275+
dataset = get_dataset_from_label_and_event_types(snuba_query.dataset, event_types)
271276

272277
if not project or not dataset or not organization:
273278
return None
@@ -289,16 +294,6 @@ def fetch_historical_data(
289294
if dataset == metrics_performance:
290295
return get_crash_free_historical_data(start, end, project, organization, granularity)
291296
elif dataset == spans_rpc:
292-
# EAP timeseries don't round time buckets to the nearest time window but seer expects
293-
# that. So for example, if start was 7:01 with a 15 min interval, EAP would
294-
# bucket it as 7:01, 7:16 etc. Force rounding the start and end times so we
295-
# get the buckets seer expects.
296-
rounded_end = int(end.timestamp() / granularity) * granularity
297-
rounded_start = int(start.timestamp() / granularity) * granularity
298-
299-
snuba_params.end = datetime.fromtimestamp(rounded_end, UTC)
300-
snuba_params.start = datetime.fromtimestamp(rounded_start, UTC)
301-
302297
results = spans_rpc.run_timeseries_query(
303298
params=snuba_params,
304299
query_string=snuba_query.query,
@@ -315,8 +310,24 @@ def fetch_historical_data(
315310
sampling_mode="NORMAL",
316311
)
317312
return results
313+
elif dataset == ourlogs:
314+
results = ourlogs.run_timeseries_query(
315+
params=snuba_params,
316+
query_string=snuba_query.query,
317+
y_axes=query_columns,
318+
referrer=(
319+
Referrer.ANOMALY_DETECTION_HISTORICAL_DATA_QUERY.value
320+
if is_store_data_request
321+
else Referrer.ANOMALY_DETECTION_RETURN_HISTORICAL_ANOMALIES.value
322+
),
323+
config=SearchResolverConfig(
324+
auto_fields=False,
325+
use_aggregate_conditions=False,
326+
),
327+
sampling_mode="NORMAL",
328+
)
329+
return results
318330
else:
319-
event_types = get_event_types(snuba_query, event_types)
320331
snuba_query_string = get_snuba_query_string(snuba_query, event_types)
321332
historical_data = dataset.timeseries_query(
322333
selected_columns=query_columns,

tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
from sentry.snuba.dataset import Dataset
5050
from sentry.snuba.metrics.naming_layer.mri import SessionMRI
5151
from sentry.snuba.models import SnubaQueryEventType
52+
from sentry.snuba.ourlogs import run_timeseries_query as ourlogs_run_timeseries_query
53+
from sentry.snuba.spans_rpc import run_timeseries_query as spans_rpc_run_timeseries_query
5254
from sentry.snuba.tasks import create_subscription_in_snuba
5355
from sentry.testutils.abstract import Abstract
5456
from sentry.testutils.cases import APITestCase, SnubaTestCase
@@ -393,7 +395,13 @@ def test_anomaly_detection_alert(self, mock_seer_request):
393395
@patch(
394396
"sentry.seer.anomaly_detection.store_data.seer_anomaly_detection_connection_pool.urlopen"
395397
)
396-
def test_anomaly_detection_alert_eap(self, mock_seer_request):
398+
@patch(
399+
"sentry.seer.anomaly_detection.utils.spans_rpc.run_timeseries_query",
400+
wraps=spans_rpc_run_timeseries_query,
401+
)
402+
def test_anomaly_detection_alert_eap_spans(
403+
self, mock_spans_timeseries_query, mock_seer_request
404+
):
397405
data = deepcopy(self.dynamic_alert_rule_dict)
398406
data["dataset"] = "events_analytics_platform"
399407
data["alertType"] = "eap_metrics"
@@ -412,6 +420,42 @@ def test_anomaly_detection_alert_eap(self, mock_seer_request):
412420
assert alert_rule.seasonality == resp.data.get("seasonality")
413421
assert alert_rule.sensitivity == resp.data.get("sensitivity")
414422
assert mock_seer_request.call_count == 1
423+
assert mock_spans_timeseries_query.call_count == 1
424+
425+
@with_feature("organizations:anomaly-detection-alerts")
426+
@with_feature("organizations:anomaly-detection-rollout")
427+
@with_feature("organizations:ourlogs-alerts")
428+
@with_feature("organizations:incidents")
429+
@patch(
430+
"sentry.seer.anomaly_detection.store_data.seer_anomaly_detection_connection_pool.urlopen"
431+
)
432+
@patch(
433+
"sentry.seer.anomaly_detection.utils.ourlogs.run_timeseries_query",
434+
wraps=ourlogs_run_timeseries_query,
435+
)
436+
def test_anomaly_detection_alert_ourlogs(
437+
self, mock_ourlogs_run_timeseries_query, mock_seer_request
438+
):
439+
data = deepcopy(self.dynamic_alert_rule_dict)
440+
data["dataset"] = "events_analytics_platform"
441+
data["alertType"] = "trace_item_logs"
442+
data["eventTypes"] = ["trace_item_log"]
443+
seer_return_value: StoreDataResponse = {"success": True}
444+
mock_seer_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
445+
446+
with outbox_runner():
447+
resp = self.get_success_response(
448+
self.organization.slug,
449+
status_code=201,
450+
**data,
451+
)
452+
assert "id" in resp.data
453+
alert_rule = AlertRule.objects.get(id=resp.data["id"])
454+
assert resp.data == serialize(alert_rule, self.user)
455+
assert alert_rule.seasonality == resp.data.get("seasonality")
456+
assert alert_rule.sensitivity == resp.data.get("sensitivity")
457+
assert mock_seer_request.call_count == 1
458+
assert mock_ourlogs_run_timeseries_query.call_count == 1
415459

416460
@patch(
417461
"sentry.snuba.subscriptions.create_subscription_in_snuba.delay",

0 commit comments

Comments
 (0)