diff --git a/engine/apps/alerts/migrations/0072_alertgroup_custom_fields_customfield.py b/engine/apps/alerts/migrations/0072_alertgroup_custom_fields_customfield.py new file mode 100644 index 0000000000..2fb928890f --- /dev/null +++ b/engine/apps/alerts/migrations/0072_alertgroup_custom_fields_customfield.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.11 on 2024-12-04 13:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0071_migrate_labels'), + ] + + operations = [ + migrations.AddField( + model_name='alertgroup', + name='custom_fields', + field=models.JSONField(default=None, null=True), + ), + migrations.CreateModel( + name='CustomField', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('metaname', models.CharField(max_length=200)), + ('dynamic_template', models.TextField(default=None, null=True)), + ('static_value', models.CharField(default=None, max_length=200, null=True)), + ('integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='custom_fields', to='alerts.alertreceivechannel')), + ], + options={ + 'unique_together': {('integration', 'metaname')}, + }, + ), + ] diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index 844cbf6771..729a11085c 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -20,6 +20,7 @@ from common.jinja_templater.apply_jinja_template import ( JinjaTemplateError, JinjaTemplateWarning, + apply_jinja_template, templated_value_is_truthy, ) from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length @@ -142,6 +143,8 @@ def create( if group_created: assign_labels(group, alert_receive_channel, parsed_labels) + group.custom_fields = parse_custom_fields(alert_receive_channel, raw_request_data) + group.save(update_fields=["custom_fields"]) group.log_records.create(type=AlertGroupLogRecord.TYPE_REGISTERED) group.log_records.create(type=AlertGroupLogRecord.TYPE_ROUTE_ASSIGNED) @@ -321,3 +324,27 @@ def insert_random_uuid(distinction: typing.Optional[str]) -> str: distinction = str(uuid4()) return distinction + + +# parse_custom_fields parses custom fields from the alert payload. +# It returns a dictionary of custom fields_id:parsed_value +def parse_custom_fields( + alert_receive_channel: "AlertReceiveChannel", raw_request_data: "Alert.RawRequestData" +) -> typing.List: + fields = [] + # parse custom fields + for field_config in alert_receive_channel.custom_fields.all(): + if field_config.static_value: + f = {"name": field_config.metaname, "value": field_config.static_value} + fields.append(f) + elif field_config.dynamic_template: + try: + result = apply_jinja_template(field_config.dynamic_template, raw_request_data) + f = {"name": field_config.metaname, "value": result} + if result: + fields.append(f) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + logger.warning("parse_custom_fields: failed to apply template: %s", e.fallback_message) + continue + fields.sort(key=lambda x: x["metaname"]) + return fields diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 3db1d9edfb..a72df48a9d 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -1,4 +1,5 @@ import datetime +import json import logging import typing import urllib @@ -358,6 +359,10 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. received_at = models.DateTimeField(blank=True, null=True, default=None) + # custom_fields is a dict of custom fields applied to the group. + # currently it does not support referral integrity, storing just string repr of a custom field key & value + custom_fields = models.JSONField(null=True, default=None) + @property def is_silenced_forever(self): return self.silenced and self.silenced_until is None @@ -558,12 +563,18 @@ def declare_incident_link(self) -> str: """ Generate a link for AlertGroup to declare Grafana Incident by click """ + print("HELLO") caption = urllib.parse.quote_plus("OnCall Alert Group") title = urllib.parse.quote_plus(self.web_title_cache) if self.web_title_cache else DEFAULT_BACKUP_TITLE title = title[:2000] # set max title length to avoid exceptions with too long declare incident link link = urllib.parse.quote_plus(self.web_link) - - return UIURLBuilder(self.channel.organization).declare_incident(f"?caption={caption}&url={link}&title={title}") + params = f"?caption={caption}&url={link}&title={title}" + if self.custom_fields is not None: + jsonCustomFields = json.dumps(self.custom_fields) + print(jsonCustomFields) + custom_fields = urllib.parse.quote_plus(jsonCustomFields) + params += f"&cf={custom_fields}" + return UIURLBuilder(self.channel.organization).declare_incident(params) @property def happened_while_maintenance(self): diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index f4661a3a10..6d84cfaf16 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -202,6 +202,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): organization: "Organization" team: typing.Optional["Team"] labels: "RelatedManager['AlertReceiveChannelAssociatedLabel']" + custom_fields: "RelatedManager['CustomField']" objects = AlertReceiveChannelManager() objects_with_maintenance = AlertReceiveChannelManagerWithMaintenance() @@ -795,3 +796,17 @@ def listen_for_alertreceivechannel_model_save( metrics_remove_deleted_integration_from_cache(instance) else: metrics_update_integration_cache(instance) + + +class CustomField(models.Model): + integration = models.ForeignKey(AlertReceiveChannel, on_delete=models.CASCADE, related_name="custom_fields") + # metadata.name of the custom field + metaname = models.CharField(max_length=200) + # template to parse dynamic value of a custom field + dynamic_template = models.TextField(null=True, default=None) + # static value is an identifier of selected option. + # Probably static_value should be split into the ID & display_name, but it's merged for sake of hackathon + static_value = models.CharField(null=True, default=None, max_length=200) + + class Meta: + unique_together = ["integration", "metaname"] diff --git a/engine/apps/alerts/utils.py b/engine/apps/alerts/utils.py index 5317c22b3f..57ab274008 100644 --- a/engine/apps/alerts/utils.py +++ b/engine/apps/alerts/utils.py @@ -23,4 +23,5 @@ def render_relative_timeline(log_created_at, alert_group_started_at): def is_declare_incident_step_enabled(organization: "Organization") -> bool: + return True return organization.is_grafana_incident_enabled and settings.FEATURE_DECLARE_INCIDENT_STEP_ENABLED diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index c0882658fb..ea55afd2fa 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -245,6 +245,7 @@ class Meta(AlertGroupListSerializer.Meta): "last_alert_at", "paged_users", "external_urls", + "custom_fields", ] def get_last_alert_at(self, obj: "AlertGroup") -> datetime.datetime: diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index 9065ca6801..deca8db29a 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -11,6 +11,7 @@ from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager from apps.alerts.models import AlertReceiveChannel +from apps.alerts.models.alert_receive_channel import CustomField from apps.base.messaging import get_messaging_backends from apps.integrations.legacy_prefix import has_legacy_prefix from apps.labels.models import AlertReceiveChannelAssociatedLabel, LabelKeyCache, LabelValueCache @@ -262,6 +263,15 @@ def _custom_labels_to_representation( ] +class CustomFieldSerializer(serializers.ModelSerializer): + class Meta: + model = CustomField + fields = ["metaname", "dynamic_template", "static_value"] + extra_kwargs = { + "metaname": {"required": True}, + } + + class AlertReceiveChannelSerializer( EagerLoadingMixin, LabelsSerializerMixin, serializers.ModelSerializer[AlertReceiveChannel] ): @@ -288,6 +298,7 @@ class AlertReceiveChannelSerializer( is_legacy = serializers.SerializerMethodField() alert_group_labels = IntegrationAlertGroupLabelsSerializer(source="*", required=False) additional_settings = AdditionalSettingsField(allow_null=True, allow_empty=False, required=False, default=None) + custom_fields = CustomFieldSerializer(many=True, required=False) # integration heartbeat is in PREFETCH_RELATED not by mistake. # With using of select_related ORM builds strange join @@ -331,6 +342,7 @@ class Meta: "alert_group_labels", "alertmanager_v2_migrated_at", "additional_settings", + "custom_fields", ] read_only_fields = [ "created_at", @@ -413,7 +425,8 @@ def create(self, validated_data): # pop associated labels and alert group labels, so they are not passed to AlertReceiveChannel.create labels = validated_data.pop("labels", None) alert_group_labels = IntegrationAlertGroupLabelsSerializer.pop_alert_group_labels(validated_data) - + # Extract custom fields data + custom_fields_data = validated_data.pop("custom_fields", []) try: instance = AlertReceiveChannel.create( **validated_data, @@ -432,6 +445,10 @@ def create(self, validated_data): if create_default_webhooks and hasattr(instance.config, "create_default_webhooks"): instance.config.create_default_webhooks(instance) + # Create custom fields + for custom_field_data in custom_fields_data: + CustomField.objects.create(integration=instance, **custom_field_data) + return instance def update(self, instance, validated_data): @@ -444,6 +461,28 @@ def update(self, instance, validated_data): instance, IntegrationAlertGroupLabelsSerializer.pop_alert_group_labels(validated_data) ) + # update custom fields + # Extract custom fields data + custom_fields_data = validated_data.pop("custom_fields", []) + # Update custom fields + existing_custom_fields = {cf.metaname: cf for cf in instance.custom_fields.all()} + for custom_field_data in custom_fields_data: + custom_field_name = custom_field_data.get("metaname") + if custom_field_name and custom_field_name in existing_custom_fields: + # Update existing custom field + custom_field_instance = existing_custom_fields[custom_field_name] + for attr, value in custom_field_data.items(): + setattr(custom_field_instance, attr, value) + custom_field_instance.save() + else: + # Create new custom field + CustomField.objects.create(integration=instance, **custom_field_data) + # Delete custom fields not included in the update + provided_custom_fields = {cf.get("metaname") for cf in custom_fields_data} + for custom_field_metaname in existing_custom_fields: + if custom_field_metaname not in provided_custom_fields: + existing_custom_fields[custom_field_metaname].delete() + try: updated_instance = super().update(instance, validated_data) except AlertReceiveChannel.DuplicateDirectPagingError: diff --git a/engine/apps/grafana_plugin/ui_url_builder.py b/engine/apps/grafana_plugin/ui_url_builder.py index e37f8e7542..a1d5a6f5d9 100644 --- a/engine/apps/grafana_plugin/ui_url_builder.py +++ b/engine/apps/grafana_plugin/ui_url_builder.py @@ -13,7 +13,7 @@ class UIURLBuilder: """ def __init__(self, organization: "Organization", base_url: typing.Optional[str] = None) -> None: - self.base_url = base_url if base_url else organization.grafana_url + self.base_url = "http://localhost:3000" self.is_grafana_irm_enabled = organization.is_grafana_irm_enabled def _build_url(self, page: str, path_extra: str = "", plugin_id: typing.Optional[str] = None) -> str: