Files
2024-02-15 17:01:50 +00:00

375 lines
16 KiB
Python

from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from unittest import mock
from unittest.mock import patch
from uuid import uuid4
import pytest
from sentry.eventstore.models import Event
from sentry.issues.escalating import (
GroupsCountResponse,
_start_and_end_dates,
get_group_hourly_count,
is_escalating,
query_groups_past_counts,
)
from sentry.issues.escalating_group_forecast import EscalatingGroupForecast
from sentry.issues.grouptype import GroupCategory, ProfileFileIOGroupType
from sentry.models.group import Group, GroupStatus
from sentry.models.groupinbox import GroupInbox
from sentry.sentry_metrics.client.snuba import build_mri
from sentry.sentry_metrics.use_case_id_registry import UseCaseID
from sentry.testutils.cases import BaseMetricsTestCase, PerformanceIssueTestCase, TestCase
from sentry.testutils.helpers.datetime import freeze_time
from sentry.testutils.helpers.features import with_feature
from sentry.types.group import GroupSubStatus
from sentry.utils.cache import cache
from sentry.utils.snuba import to_start_of_hour
from tests.sentry.issues.test_utils import SearchIssueTestMixin
pytestmark = pytest.mark.sentry_metrics
TIME_YESTERDAY = (datetime.now() - timedelta(hours=24)).replace(hour=6)
class BaseGroupCounts(BaseMetricsTestCase, TestCase):
def _create_events_for_group(
self,
project_id: int | None = None,
count: int = 1,
hours_ago: int = 0,
min_ago: int = 0,
group: str = "foo-1",
) -> Event:
"""Creates one or many events for a group.
If the group does not exist create one.
An event will be counted within an hour bucket depending on how many hours ago.
"""
proj_id = project_id or self.project.id
# This time becomes a starting point from which to create other datetimes in the past
datetime_reset_zero = datetime.now().replace(minute=0, second=0, microsecond=0)
data: dict[str, Any] = {"message": "some message", "fingerprint": [group]}
assert count >= 1
for _ in range(count):
data["timestamp"] = (
datetime_reset_zero - timedelta(hours=hours_ago, minutes=min_ago)
).timestamp()
data["event_id"] = uuid4().hex
# assert_no_errors is necessary because of SDK and server time differences due to freeze gun
last_event = self.store_event(data=data, project_id=proj_id, assert_no_errors=False)
self.store_metric(
type="counter",
use_case_id=UseCaseID.ESCALATING_ISSUES,
org_id=last_event.project.organization_id,
project_id=last_event.project.id,
name=build_mri("event_ingested", "c", UseCaseID.ESCALATING_ISSUES, None),
value=1,
tags={"group": str(last_event.group_id)},
timestamp=data["timestamp"],
)
return last_event
class HistoricGroupCounts(
BaseGroupCounts,
PerformanceIssueTestCase,
SearchIssueTestMixin,
):
"""Test that querying Snuba for the hourly counts for groups works as expected."""
def _create_hourly_bucket(self, count: int, event: Event) -> GroupsCountResponse:
"""It simplifies writing the expected data structures"""
assert event.group_id is not None
return {
"count()": count,
"group_id": event.group_id,
"hourBucket": str(to_start_of_hour(event.datetime)),
"project_id": event.project_id,
}
@with_feature("organizations:escalating-issues-v2")
@mock.patch("sentry.issues.escalating.logger")
def test_query_single_group(self, mock_logger) -> None:
event = self._create_events_for_group()
assert query_groups_past_counts(Group.objects.all()) == [
self._create_hourly_bucket(1, event)
]
assert mock_logger.info.call_count == 0
@with_feature("organizations:escalating-issues-v2")
@freeze_time(TIME_YESTERDAY)
@mock.patch("sentry.issues.escalating.logger")
def test_query_different_group_categories(self, mock_logger) -> None:
from django.utils import timezone
timestamp = timezone.now() - timedelta(minutes=1)
# This builds an error group and a profiling group
profile_error_event, _, profile_issue_occurrence = self.store_search_issue(
project_id=self.project.id,
user_id=0,
fingerprints=[f"{ProfileFileIOGroupType.type_id}-group1"],
insert_time=timestamp,
)
self.store_metric(
type="counter",
use_case_id=UseCaseID.ESCALATING_ISSUES,
org_id=profile_error_event.project.organization_id,
project_id=profile_error_event.project.id,
name=build_mri("event_ingested", "c", UseCaseID.ESCALATING_ISSUES, None),
value=1,
tags={"group": str(profile_error_event.group_id)},
timestamp=profile_error_event.data["timestamp"],
)
assert profile_error_event.group is not None
assert profile_issue_occurrence is not None
assert len(Group.objects.all()) == 2
perf_event = self.create_performance_issue()
error_event = self._create_events_for_group()
assert perf_event.group is not None
assert error_event.group is not None
# store_search_issue created two groups
assert len(Group.objects.all()) == 4
assert profile_error_event.group.issue_category == GroupCategory.ERROR
assert error_event.group.issue_category == GroupCategory.ERROR
assert profile_issue_occurrence.group.issue_category == GroupCategory.PERFORMANCE
assert perf_event.group.issue_category == GroupCategory.PERFORMANCE
profile_issue_occurrence_bucket = {
"count()": 1,
"group_id": profile_issue_occurrence.group.id,
"hourBucket": to_start_of_hour(profile_issue_occurrence.group.first_seen),
"project_id": self.project.id,
}
# Error groups will show up at the beginning of the list even if they
# were created later
assert query_groups_past_counts(Group.objects.all()) == [
self._create_hourly_bucket(1, profile_error_event),
self._create_hourly_bucket(1, error_event),
profile_issue_occurrence_bucket,
self._create_hourly_bucket(1, perf_event),
]
assert mock_logger.info.call_count == 0
@with_feature("organizations:escalating-issues-v2")
# This forces to test the iteration over the Snuba data
@mock.patch("sentry.issues.escalating.ELEMENTS_PER_SNUBA_METRICS_QUERY", new=4)
@mock.patch("sentry.issues.escalating.ELEMENTS_PER_SNUBA_PAGE", new=4)
@mock.patch("sentry.issues.escalating.logger")
def test_pagination(self, mock_logger) -> None:
events = []
for i in range(20):
event = self._create_events_for_group(count=1, hours_ago=2, group=f"group-{i}")
events.append(event)
assert query_groups_past_counts(Group.objects.all()) == [
self._create_hourly_bucket(1, event) for event in events
]
assert mock_logger.info.call_count == 0
def test_query_optimization(self) -> None:
px = self.create_project(organization=self.project.organization)
py = self.create_project(organization=self.project.organization)
pz = self.create_project(organization=self.project.organization)
# Two different groups for proj x, one group for proj y and two groups for proj z
self._create_events_for_group(project_id=px.id)
self._create_events_for_group(project_id=px.id, group="group-b")
self._create_events_for_group(project_id=py.id)
self._create_events_for_group(project_id=pz.id)
self._create_events_for_group(project_id=pz.id, group="group-b")
groups = Group.objects.all()
assert len(groups) == 5
# Force pagination to only three elements per page
# Once we get to Python 3.10+ the formating of this multiple with statement will not be an eye sore
with patch("sentry.issues.escalating._query_with_pagination") as query_mock, patch(
"sentry.issues.escalating.ELEMENTS_PER_SNUBA_PAGE", new=3
), patch("sentry.issues.escalating.BUCKETS_PER_GROUP", new=2):
query_groups_past_counts(groups)
# Proj X will expect potentially 4 elements because it has two groups, thus, no other
# project will be called with it.
# Proj Y and Z will be grouped together
assert query_mock.call_count == 2
@with_feature("organizations:escalating-issues-v2")
@mock.patch("sentry.issues.escalating.logger")
def test_query_multiple_projects(self, mock_logger) -> None:
proj_x = self.create_project(organization=self.project.organization)
proj_y = self.create_project(organization=self.project.organization)
event1 = self._create_events_for_group(project_id=proj_x.id)
# This event has the same fingerprint as event1 but
# should be different group IDs since they belong to different projects
event_y_1 = self._create_events_for_group(project_id=proj_y.id, hours_ago=1)
assert event1.group_id != event_y_1.group_id
event_y_2 = self._create_events_for_group(project_id=proj_y.id, group="group-1")
# Increases the count of group-1
self._create_events_for_group(project_id=proj_y.id, group="group-1")
assert query_groups_past_counts(Group.objects.all()) == [
self._create_hourly_bucket(1, event1),
self._create_hourly_bucket(1, event_y_1),
self._create_hourly_bucket(2, event_y_2),
]
assert mock_logger.info.call_count == 0
@with_feature("organizations:escalating-issues-v2")
@mock.patch("sentry.issues.escalating.logger")
def test_query_different_orgs(self, mock_logger) -> None:
proj_a = self.create_project(organization=self.project.organization)
org_b = self.create_organization()
proj_b = self.create_project(organization=org_b)
event1 = self._create_events_for_group(project_id=proj_a, hours_ago=1)
event_proj_org_b_1 = self._create_events_for_group(project_id=proj_b, hours_ago=1)
# Since proj_org_b is created
assert query_groups_past_counts(Group.objects.all()) == [
self._create_hourly_bucket(1, event1),
self._create_hourly_bucket(1, event_proj_org_b_1),
]
assert mock_logger.info.call_count == 0
def test_query_no_groups(self) -> None:
assert query_groups_past_counts([]) == []
def test_datetime_number_of_hours() -> None:
start, end = _start_and_end_dates(5)
assert (end - start).seconds / 3600 == 5
def test_datetime_number_of_days() -> None:
start, end = _start_and_end_dates()
assert (end - start).days == 7
class DailyGroupCountsEscalating(BaseGroupCounts):
def save_mock_escalating_group_forecast(
self, group: Group, forecast_values=list[int], date_added=datetime
) -> None:
"""Save mock data for escalating group forecast in nodestore"""
escalating_forecast = EscalatingGroupForecast(
project_id=group.project.id,
group_id=group.id,
forecast=forecast_values,
date_added=date_added,
)
escalating_forecast.save()
def archive_until_escalating(self, group: Group) -> None:
group.status = GroupStatus.IGNORED
group.substatus = GroupSubStatus.UNTIL_ESCALATING
group.save()
@freeze_time(TIME_YESTERDAY)
def test_is_escalating_issue(self) -> None:
"""Test when an archived until escalating issue starts escalating"""
# The group had 6 events in the last hour
event = self._create_events_for_group(count=6)
assert event.group is not None
archived_group = event.group
self.archive_until_escalating(archived_group)
# The escalating forecast for today is 5, thus, it should escalate
forecast_values = [5] + [6] * 13
self.save_mock_escalating_group_forecast(
group=archived_group, forecast_values=forecast_values, date_added=datetime.now()
)
assert is_escalating(archived_group) == (True, 5)
# Test cache
assert cache.get(f"hourly-group-count:{archived_group.project.id}:{archived_group.id}") == 6
@freeze_time(TIME_YESTERDAY)
def test_not_escalating_issue(self) -> None:
"""Test when an archived until escalating issue is not escalating"""
# Group 1 had 4 events yesterday
self._create_events_for_group(count=4, hours_ago=24)
# Group 2 had 5 events today
event = self._create_events_for_group(count=5, group="group-escalating")
assert event.group is not None
group = event.group
self.archive_until_escalating(group)
# The escalating forecast for today is 6 (since date_added was one day ago)
forecast_values = [5] + [6] * 13
self.save_mock_escalating_group_forecast(
group=group,
forecast_values=forecast_values,
date_added=datetime.now() - timedelta(days=1),
)
assert is_escalating(group) == (False, None)
assert group.substatus == GroupSubStatus.UNTIL_ESCALATING
assert group.status == GroupStatus.IGNORED
assert not GroupInbox.objects.filter(group=group).exists()
@freeze_time(TIME_YESTERDAY.replace(minute=12, second=40, microsecond=0))
def test_hourly_count_query(self) -> None:
"""Test the hourly count query only aggregates events from within the current hour"""
self._create_events_for_group(count=2, hours_ago=1) # An hour ago -> It will not count
group = self._create_events_for_group(count=1).group # This hour -> It will count
assert group is not None
# Events are aggregated in the hourly count query by date rather than the last 24hrs
assert get_group_hourly_count(group) == 1
@freeze_time(TIME_YESTERDAY)
def test_is_forecast_out_of_range(self) -> None:
"""
Test that when an archived until escalating issue does not have a forecast that is in range,
the last forecast is used as a fallback and an error is reported
"""
with patch("sentry.issues.escalating_group_forecast.logger") as logger:
event = self._create_events_for_group(count=2)
assert event.group is not None
archived_group = event.group
self.archive_until_escalating(archived_group)
# The escalating forecast was added 15 days ago, and thus is out of the 14 day range
forecast_values = [10] * 13 + [1]
self.save_mock_escalating_group_forecast(
group=archived_group,
forecast_values=forecast_values,
date_added=datetime.now() - timedelta(15),
)
assert is_escalating(archived_group) == (True, 1)
logger.error.assert_called_once()
@freeze_time(TIME_YESTERDAY)
def test_is_escalating_two_weeks(self) -> None:
"""
Test when an archived until escalating issue starts escalating after exactly 2 weeks.
This can happen when the previous nodestore forecast hasn't expired yet.
"""
# The group had 6 events in the last hour
event = self._create_events_for_group(count=6)
assert event.group is not None
archived_group = event.group
self.archive_until_escalating(archived_group)
# The escalating forecast for today is 5, thus, it should escalate
forecast_values = [5] * 14
self.save_mock_escalating_group_forecast(
group=archived_group,
forecast_values=forecast_values,
date_added=TIME_YESTERDAY - timedelta(days=14),
)
assert is_escalating(archived_group) == (True, 5)
# Test cache
assert cache.get(f"hourly-group-count:{archived_group.project.id}:{archived_group.id}") == 6