Skip to content

Commit

Permalink
Merge pull request #4683 from grafana/dev
Browse files Browse the repository at this point in the history
v1.8.5
  • Loading branch information
matiasb authored Jul 16, 2024
2 parents d2c4a94 + 35ddfab commit dd57942
Show file tree
Hide file tree
Showing 82 changed files with 2,275 additions and 506 deletions.
2 changes: 1 addition & 1 deletion .github/actions/install-frontend-dependencies/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ runs:
# yamllint enable rule:line-length
- uses: actions/setup-node@v4
with:
node-version: 18.16.0
node-version: 20.15.1
cache: "yarn"
cache-dependency-path: ${{ steps.yarn-lock-location.outputs.yarn-lock-location }}
- name: Use cached frontend dependencies
Expand Down
2 changes: 1 addition & 1 deletion Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ cmd_button(
helm_oncall_values = ["./dev/helm-local.yml", "./dev/helm-local.dev.yml"]
if is_ci:
helm_oncall_values = helm_oncall_values + ["./.github/helm-ci.yml"]
yaml = helm("helm/oncall", name=HELM_PREFIX, values=helm_oncall_values, set=twilio_values)
yaml = helm("helm/oncall", name=HELM_PREFIX, values=helm_oncall_values, set=twilio_values, namespace="default")

k8s_yaml(yaml)

Expand Down
2 changes: 1 addition & 1 deletion dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Related: [How to develop integrations](/engine/config_integrations/README.md)
- [Tilt | Kubernetes for Prod, Tilt for Dev](https://tilt.dev/)
- [tilt-dev/ctlptl: Making local Kubernetes clusters fun and easy to set up](https://github.com/tilt-dev/ctlptl)
- [Kind](https://kind.sigs.k8s.io)
- [Node.js v18.x](https://nodejs.org/en/download)
- [Node.js v20.x](https://nodejs.org/en/download)
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)

### Launch the environment
Expand Down
2 changes: 2 additions & 0 deletions engine/apps/alerts/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class ActionSource(IntegerChoices):

NEXT_ESCALATION_DELAY = 5

BUNDLED_NOTIFICATION_DELAY_SECONDS = 60 * 2 # 2 min


# AlertGroup states verbal
class AlertGroupState(str, Enum):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import typing
from abc import ABC, abstractmethod

from django.db.models import QuerySet
from django.utils.functional import cached_property

if typing.TYPE_CHECKING:
from apps.alerts.models import Alert, AlertGroup
from apps.alerts.models import Alert, AlertGroup, BundledNotification


class AlertBaseRenderer(ABC):
Expand Down Expand Up @@ -33,3 +34,11 @@ def __init__(self, alert_group: "AlertGroup", alert: typing.Optional["Alert"] =
@abstractmethod
def alert_renderer_class(self):
raise NotImplementedError


class AlertGroupBundleBaseRenderer:
MAX_ALERT_GROUPS_TO_RENDER = 3
MAX_CHANNELS_TO_RENDER = 1

def __init__(self, notifications: "QuerySet[BundledNotification]"):
self.notifications = notifications
58 changes: 57 additions & 1 deletion engine/apps/alerts/incident_appearance/renderers/sms_renderer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
from django.db.models import Count

from apps.alerts.incident_appearance.renderers.base_renderer import (
AlertBaseRenderer,
AlertGroupBaseRenderer,
AlertGroupBundleBaseRenderer,
)
from apps.alerts.incident_appearance.renderers.constants import DEFAULT_BACKUP_TITLE
from apps.alerts.incident_appearance.templaters import AlertSmsTemplater
from common.utils import str_or_backup
Expand All @@ -24,3 +30,53 @@ def render(self):
f"integration: {self.alert_group.channel.short_name}, "
f"alerts registered: {self.alert_group.alerts.count()}."
)


class AlertGroupSMSBundleRenderer(AlertGroupBundleBaseRenderer):
def render(self) -> str:
"""
Renders SMS message for notification bundle: gets total count of unique alert groups and alert receive channels
in the bundle, renders text with `inside_organization_number` of 3 alert groups (MAX_ALERT_GROUPS_TO_RENDER) and
`short_name` of 1 alert receive channel (MAX_CHANNELS_TO_RENDER). If there are more unique alert groups / alert
receive channels to notify about, adds "and X more" to the SMS message
"""

channels_and_alert_groups_count = self.notifications.aggregate(
channels_count=Count("alert_receive_channel", distinct=True),
alert_groups_count=Count("alert_group", distinct=True),
)
alert_groups_count = channels_and_alert_groups_count["alert_groups_count"]
channels_count = channels_and_alert_groups_count["channels_count"]

# get 3 unique alert groups from notifications
alert_groups_to_render = []
for notification in self.notifications:
if notification.alert_group not in alert_groups_to_render:
alert_groups_to_render.append(notification.alert_group)
if len(alert_groups_to_render) == self.MAX_ALERT_GROUPS_TO_RENDER:
break
# render text for alert groups
alert_group_inside_organization_numbers = [
alert_group.inside_organization_number for alert_group in alert_groups_to_render
]
numbers_str = ", ".join(f"#{x}" for x in alert_group_inside_organization_numbers)
alert_groups_text = "Alert groups " if alert_groups_count > 1 else "Alert group "
alert_groups_text += numbers_str

if alert_groups_count > self.MAX_ALERT_GROUPS_TO_RENDER:
alert_groups_text += f" and {alert_groups_count - self.MAX_ALERT_GROUPS_TO_RENDER} more"

# render text for alert receive channels
channels_to_render = [alert_groups_to_render[i].channel for i in range(self.MAX_CHANNELS_TO_RENDER)]
channel_names = ", ".join([channel.short_name for channel in channels_to_render])
channels_text = "integrations: " if channels_count > 1 else "integration: "
channels_text += channel_names

if channels_count > self.MAX_CHANNELS_TO_RENDER:
channels_text += f" and {channels_count - self.MAX_CHANNELS_TO_RENDER} more"

return (
f"Grafana OnCall: {alert_groups_text} "
f"from stack: {channels_to_render[0].organization.stack_slug}, "
f"{channels_text}."
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2024-07-04 20:20

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('alerts', '0051_remove_escalationpolicy_custom_button_trigger'),
]

operations = [
migrations.AlterField(
model_name='channelfilter',
name='filtering_term_type',
field=models.IntegerField(choices=[(0, 'regex'), (1, 'jinja2'), (2, 'labels')], default=0),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2024-07-08 18:20

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('alerts', '0052_alter_channelfilter_filtering_term_type'),
]

operations = [
migrations.AddField(
model_name='channelfilter',
name='filtering_labels',
field=models.JSONField(default=None, null=True),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Generated by Django 4.2.10 on 2024-06-20 11:00

import apps.base.models.user_notification_policy
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('base', '0005_drop_unused_dynamic_settings'),
('user_management', '0022_alter_team_unique_together'),
('alerts', '0053_channelfilter_filtering_labels'),
]

operations = [
migrations.CreateModel(
name='UserNotificationBundle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('important', models.BooleanField()),
('notification_channel', models.PositiveSmallIntegerField(default=None, null=True, validators=[apps.base.models.user_notification_policy.validate_channel_choice])),
('last_notified_at', models.DateTimeField(default=None, null=True)),
('notification_task_id', models.CharField(default=None, max_length=100, null=True)),
('eta', models.DateTimeField(default=None, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_bundles', to='user_management.user')),
],
),
migrations.CreateModel(
name='BundledNotification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('bundle_uuid', models.CharField(db_index=True, default=None, max_length=100, null=True)),
('alert_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='alerts.alertgroup')),
('alert_receive_channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='alerts.alertreceivechannel')),
('notification_bundle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='alerts.usernotificationbundle')),
('notification_policy', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='base.usernotificationpolicy')),
],
),
migrations.AddConstraint(
model_name='usernotificationbundle',
constraint=models.UniqueConstraint(fields=('user', 'important', 'notification_channel'), name='unique_user_notification_bundle'),
),
]
1 change: 1 addition & 0 deletions engine/apps/alerts/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
from .maintainable_object import MaintainableObject # noqa: F401
from .resolution_note import ResolutionNote, ResolutionNoteSlackMessage # noqa: F401
from .user_has_notification import UserHasNotification # noqa: F401
from .user_notification_bundle import BundledNotification, UserNotificationBundle # noqa: F401
19 changes: 18 additions & 1 deletion engine/apps/alerts/models/channel_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from django.db.models.manager import RelatedManager

from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel
from apps.labels.types import AlertLabels
from apps.labels.types import AlertLabels, LabelPair

logger = logging.getLogger(__name__)

Expand All @@ -45,6 +45,7 @@ class ChannelFilter(OrderedModel):
"""

alert_groups: "RelatedManager['AlertGroup']"
filtering_labels: typing.Optional[list["LabelPair"]]

order_with_respect_to = ["alert_receive_channel_id", "is_default"]

Expand Down Expand Up @@ -87,11 +88,14 @@ class ChannelFilter(OrderedModel):

FILTERING_TERM_TYPE_REGEX = 0
FILTERING_TERM_TYPE_JINJA2 = 1
FILTERING_TERM_TYPE_LABELS = 2
FILTERING_TERM_TYPE_CHOICES = [
(FILTERING_TERM_TYPE_REGEX, "regex"),
(FILTERING_TERM_TYPE_JINJA2, "jinja2"),
(FILTERING_TERM_TYPE_LABELS, "labels"),
]
filtering_term_type = models.IntegerField(choices=FILTERING_TERM_TYPE_CHOICES, default=FILTERING_TERM_TYPE_REGEX)
filtering_labels = models.JSONField(null=True, default=None)

is_default = models.BooleanField(default=False)

Expand Down Expand Up @@ -145,6 +149,15 @@ def check_filter(
except re.error:
logger.error(f"channel_filter={self.id} failed to parse regex={self.filtering_term}")
return False
if self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_LABELS:
if not self.filtering_labels or alert_labels is None:
return False
for item in self.filtering_labels:
key = item["key"]["name"]
value = item["value"]["name"]
if key not in alert_labels or alert_labels[key] != value:
return False
return True
return False

@property
Expand All @@ -164,6 +177,10 @@ def str_for_clients(self):
return "default"
if self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2:
return str(self.filtering_term)
elif self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_LABELS:
if not self.filtering_labels:
return "{}"
return ", ".join(f"{item['key']['name']}={item['value']['name']}" for item in self.filtering_labels)
elif self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX or self.filtering_term_type is None:
return str(self.filtering_term).replace("`", "")
raise Exception("Unknown filtering term")
Expand Down
8 changes: 8 additions & 0 deletions engine/apps/alerts/models/user_has_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,11 @@ class UserHasNotification(models.Model):

class Meta:
unique_together = ("user", "alert_group")

def update_active_task_id(self, task_id):
"""
`active_notification_policy_id` keeps celery task_id of the next scheduled `notify_user_task`
for the current user
"""
self.active_notification_policy_id = task_id
self.save(update_fields=["active_notification_policy_id"])
87 changes: 87 additions & 0 deletions engine/apps/alerts/models/user_notification_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import datetime
import typing

from django.db import models
from django.utils import timezone

from apps.alerts.constants import BUNDLED_NOTIFICATION_DELAY_SECONDS
from apps.base.models import UserNotificationPolicy
from apps.base.models.user_notification_policy import validate_channel_choice

if typing.TYPE_CHECKING:
from django.db.models.manager import RelatedManager

from apps.alerts.models import AlertGroup, AlertReceiveChannel
from apps.user_management.models import User


class UserNotificationBundle(models.Model):
user: "User"
notifications: "RelatedManager['BundledNotification']"

NOTIFICATION_CHANNELS_TO_BUNDLE = [
UserNotificationPolicy.NotificationChannel.SMS,
]

user = models.ForeignKey("user_management.User", on_delete=models.CASCADE, related_name="notification_bundles")
important = models.BooleanField()
notification_channel = models.PositiveSmallIntegerField(
validators=[validate_channel_choice], null=True, default=None
)
last_notified_at = models.DateTimeField(default=None, null=True)
notification_task_id = models.CharField(max_length=100, null=True, default=None)
# estimated time of arrival for scheduled send_bundled_notification task
eta = models.DateTimeField(default=None, null=True)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "important", "notification_channel"], name="unique_user_notification_bundle"
)
]

def notified_recently(self) -> bool:
return (
timezone.now() - self.last_notified_at < timezone.timedelta(seconds=BUNDLED_NOTIFICATION_DELAY_SECONDS)
if self.last_notified_at
else False
)

def eta_is_valid(self) -> bool:
"""
`eta` shows eta of scheduled send_bundled_notification task and should never be less than the current time
(with a 1 minute buffer provided).
`eta` is None means that there is no scheduled task.
"""
if not self.eta or self.eta + timezone.timedelta(minutes=1) >= timezone.now():
return True
return False

def get_notification_eta(self) -> datetime.datetime:
last_notified = self.last_notified_at if self.last_notified_at else timezone.now()
return last_notified + timezone.timedelta(seconds=BUNDLED_NOTIFICATION_DELAY_SECONDS)

def append_notification(self, alert_group: "AlertGroup", notification_policy: "UserNotificationPolicy"):
self.notifications.create(
alert_group=alert_group, notification_policy=notification_policy, alert_receive_channel=alert_group.channel
)

@classmethod
def notification_is_bundleable(cls, notification_channel):
return notification_channel in cls.NOTIFICATION_CHANNELS_TO_BUNDLE


class BundledNotification(models.Model):
alert_group: "AlertGroup"
alert_receive_channel: "AlertReceiveChannel"
notification_policy: typing.Optional["UserNotificationPolicy"]
notification_bundle: "UserNotificationBundle"

alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.CASCADE)
alert_receive_channel = models.ForeignKey("alerts.AlertReceiveChannel", on_delete=models.CASCADE)
notification_policy = models.ForeignKey("base.UserNotificationPolicy", on_delete=models.SET_NULL, null=True)
notification_bundle = models.ForeignKey(
UserNotificationBundle, on_delete=models.CASCADE, related_name="notifications"
)
created_at = models.DateTimeField(auto_now_add=True)
bundle_uuid = models.CharField(max_length=100, null=True, default=None, db_index=True)
3 changes: 1 addition & 2 deletions engine/apps/alerts/paging.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,7 @@ def unpage_user(alert_group: AlertGroup, user: User, from_user: User) -> None:
user_has_notification = UserHasNotification.objects.filter(
user=user, alert_group=alert_group
).select_for_update()[0]
user_has_notification.active_notification_policy_id = None
user_has_notification.save(update_fields=["active_notification_policy_id"])
user_has_notification.update_active_task_id(task_id=None)
except IndexError:
return
finally:
Expand Down
Loading

0 comments on commit dd57942

Please sign in to comment.