diff --git a/CHANGELOG.md b/CHANGELOG.md index 788bca3d..57ee5001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Add status change trigger type to webhooks ([#3920](https://github.com/grafana/oncall/pull/3920)) + ### Fixed - Fix edit default team by admin @mderynck ([#3885](https://github.com/grafana/oncall/pull/3885)) diff --git a/docs/sources/configure/outgoing-webhooks/index.md b/docs/sources/configure/outgoing-webhooks/index.md index 0d80576c..21029315 100644 --- a/docs/sources/configure/outgoing-webhooks/index.md +++ b/docs/sources/configure/outgoing-webhooks/index.md @@ -87,7 +87,8 @@ The type of event that will cause this outgoing webhook to execute. The types of - [Silenced](#silenced) - [Unsilenced](#unsilenced) - [Unresolved](#unresolved) -- [Unacknowledged](#acknowledged) +- [Unacknowledged](#unacknowledged) +- [Status Change](#status-change) For more details about types of triggers see [Event types](#event-types) @@ -449,6 +450,14 @@ This event will trigger when a user unresolves an alert group. This event will trigger when a user unacknowledges an alert group. +### Status Change + +`event.type` `status change` + +This event will trigger when any of the status change actions happen (acknowledge, resolve, silence, +unacknowledge, unresolve, or unsilence). The event details included in the payload will match those of +the original action triggering the event. + ## Viewing status of outgoing webhooks In the outgoing webhooks table if a webhook is enabled **Last Run** will have the following information: diff --git a/docs/sources/oncall-api-reference/outgoing_webhooks.md b/docs/sources/oncall-api-reference/outgoing_webhooks.md index 7e4bd114..31aaac76 100644 --- a/docs/sources/oncall-api-reference/outgoing_webhooks.md +++ b/docs/sources/oncall-api-reference/outgoing_webhooks.md @@ -115,6 +115,7 @@ For more detail, refer to [Event types][]. - `unsilence` - `unresolve` - `unacknowledge` +- `status change` ### HTTP Methods diff --git a/engine/apps/webhooks/migrations/0013_alter_webhook_trigger_type_and_more.py b/engine/apps/webhooks/migrations/0013_alter_webhook_trigger_type_and_more.py new file mode 100644 index 00000000..617720f8 --- /dev/null +++ b/engine/apps/webhooks/migrations/0013_alter_webhook_trigger_type_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 on 2024-02-19 14:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0012_alter_webhook_team'), + ] + + operations = [ + migrations.AlterField( + model_name='webhook', + name='trigger_type', + field=models.IntegerField(choices=[(0, 'Escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged'), (8, 'Status change')], default=0, null=True), + ), + migrations.AlterField( + model_name='webhookresponse', + name='trigger_type', + field=models.IntegerField(choices=[(0, 'Escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged'), (8, 'Status change')]), + ), + ] diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index d2f1bb96..fc12f2f0 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -79,7 +79,8 @@ class Webhook(models.Model): TRIGGER_UNSILENCE, TRIGGER_UNRESOLVE, TRIGGER_UNACKNOWLEDGE, - ) = range(8) + TRIGGER_STATUS_CHANGE, + ) = range(9) # Must be the same order as previous TRIGGER_TYPES = ( @@ -91,9 +92,18 @@ class Webhook(models.Model): (TRIGGER_UNSILENCE, "Unsilenced"), (TRIGGER_UNRESOLVE, "Unresolved"), (TRIGGER_UNACKNOWLEDGE, "Unacknowledged"), + (TRIGGER_STATUS_CHANGE, "Status change"), ) ALL_TRIGGER_TYPES = [i[0] for i in TRIGGER_TYPES] + STATUS_CHANGE_TRIGGERS = { + TRIGGER_ACKNOWLEDGE, + TRIGGER_RESOLVE, + TRIGGER_SILENCE, + TRIGGER_UNSILENCE, + TRIGGER_UNRESOLVE, + TRIGGER_UNACKNOWLEDGE, + } PUBLIC_TRIGGER_TYPES_MAP = { TRIGGER_ESCALATION_STEP: "escalation", @@ -104,6 +114,7 @@ class Webhook(models.Model): TRIGGER_UNSILENCE: "unsilence", TRIGGER_UNRESOLVE: "unresolve", TRIGGER_UNACKNOWLEDGE: "unacknowledge", + TRIGGER_STATUS_CHANGE: "status change", } PUBLIC_ALL_TRIGGER_TYPES = [i for i in PUBLIC_TRIGGER_TYPES_MAP.values()] diff --git a/engine/apps/webhooks/tasks/alert_group_status.py b/engine/apps/webhooks/tasks/alert_group_status.py index df324c28..8a528410 100644 --- a/engine/apps/webhooks/tasks/alert_group_status.py +++ b/engine/apps/webhooks/tasks/alert_group_status.py @@ -59,7 +59,9 @@ def alert_group_status_change(self, action_type, alert_group_id, user_id): return organization_id = alert_group.channel.organization_id - webhooks = Webhook.objects.filter(trigger_type=trigger_type, organization_id=organization_id) + webhooks = Webhook.objects.filter( + trigger_type=trigger_type, organization_id=organization_id + ) | Webhook.objects.filter(trigger_type=Webhook.TRIGGER_STATUS_CHANGE, organization_id=organization_id) # check if there are any webhooks before going on if not webhooks: diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index 8e2ffa83..756107de 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -44,6 +44,7 @@ TRIGGER_TYPE_TO_LABEL = { Webhook.TRIGGER_UNRESOLVE: "unresolve", Webhook.TRIGGER_ESCALATION_STEP: "escalation", Webhook.TRIGGER_UNACKNOWLEDGE: "unacknowledge", + Webhook.TRIGGER_STATUS_CHANGE: "status change", } @@ -68,27 +69,38 @@ def send_webhook_event(trigger_type, alert_group_id, organization_id=None, user_ trigger_type=trigger_type, organization_id=organization_id, ).exclude(is_webhook_enabled=False) + # include status change triggered webhooks if needed + if trigger_type in Webhook.STATUS_CHANGE_TRIGGERS: + webhooks_qs |= Webhook.objects.filter( + trigger_type=Webhook.TRIGGER_STATUS_CHANGE, + organization_id=organization_id, + ).exclude(is_webhook_enabled=False) for webhook in webhooks_qs: - execute_webhook.apply_async((webhook.pk, alert_group_id, user_id, None)) + execute_webhook.apply_async((webhook.pk, alert_group_id, user_id, None), kwargs={"trigger_type": trigger_type}) def _isoformat_date(date_value: datetime) -> typing.Optional[str]: return date_value.isoformat() if date_value else None -def _build_payload(webhook: Webhook, alert_group: AlertGroup, user: User) -> typing.Dict[str, typing.Any]: - trigger_type = webhook.trigger_type +def _build_payload( + webhook: Webhook, alert_group: AlertGroup, user: User, trigger_type: int | None +) -> typing.Dict[str, typing.Any]: + payload_trigger_type = webhook.trigger_type + if payload_trigger_type == Webhook.TRIGGER_STATUS_CHANGE and trigger_type is not None: + # use original trigger type when generating the payload if status change is set + payload_trigger_type = trigger_type event = { - "type": TRIGGER_TYPE_TO_LABEL[trigger_type], + "type": TRIGGER_TYPE_TO_LABEL[payload_trigger_type], } - if trigger_type == Webhook.TRIGGER_ALERT_GROUP_CREATED: + if payload_trigger_type == Webhook.TRIGGER_ALERT_GROUP_CREATED: event["time"] = _isoformat_date(alert_group.started_at) - elif trigger_type == Webhook.TRIGGER_ACKNOWLEDGE: + elif payload_trigger_type == Webhook.TRIGGER_ACKNOWLEDGE: event["time"] = _isoformat_date(alert_group.acknowledged_at) - elif trigger_type == Webhook.TRIGGER_RESOLVE: + elif payload_trigger_type == Webhook.TRIGGER_RESOLVE: event["time"] = _isoformat_date(alert_group.resolved_at) - elif trigger_type == Webhook.TRIGGER_SILENCE: + elif payload_trigger_type == Webhook.TRIGGER_SILENCE: event["time"] = _isoformat_date(alert_group.silenced_at) event["until"] = _isoformat_date(alert_group.silenced_until) @@ -195,7 +207,7 @@ def make_request( @shared_dedicated_queue_retry_task( autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else EXECUTE_WEBHOOK_RETRIES ) -def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id, manual_retry_num=0): +def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id, trigger_type=None, manual_retry_num=0): from apps.webhooks.models import Webhook try: @@ -224,13 +236,13 @@ def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id, m if user_id is not None: user = User.objects.filter(pk=user_id).first() - data = _build_payload(webhook, alert_group, user) + data = _build_payload(webhook, alert_group, user, trigger_type) triggered, status, error, exception = make_request(webhook, alert_group, data) # create response entry WebhookResponse.objects.create( alert_group=alert_group, - trigger_type=webhook.trigger_type, + trigger_type=trigger_type or webhook.trigger_type, **status, ) diff --git a/engine/apps/webhooks/tests/test_trigger_webhook.py b/engine/apps/webhooks/tests/test_trigger_webhook.py index 1ed22bcd..e24bd58f 100644 --- a/engine/apps/webhooks/tests/test_trigger_webhook.py +++ b/engine/apps/webhooks/tests/test_trigger_webhook.py @@ -37,17 +37,20 @@ def test_send_webhook_event_filters( other_organization = make_organization() alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) + trigger_types = [t for t, _ in Webhook.TRIGGER_TYPES if t != Webhook.TRIGGER_STATUS_CHANGE] webhooks = {} - for trigger_type, _ in Webhook.TRIGGER_TYPES: + for trigger_type in trigger_types: webhooks[trigger_type] = make_custom_webhook( organization=organization, trigger_type=trigger_type, team=make_team(organization) ) - for trigger_type, _ in Webhook.TRIGGER_TYPES: + for trigger_type in trigger_types: with patch("apps.webhooks.tasks.trigger_webhook.execute_webhook.apply_async") as mock_execute: send_webhook_event(trigger_type, alert_group.pk, organization_id=organization.pk) - assert mock_execute.call_args == call((webhooks[trigger_type].pk, alert_group.pk, None, None)) + assert mock_execute.call_args == call( + (webhooks[trigger_type].pk, alert_group.pk, None, None), kwargs={"trigger_type": trigger_type} + ) # other org other_org_webhook = make_custom_webhook( @@ -58,7 +61,37 @@ def test_send_webhook_event_filters( alert_group = make_alert_group(alert_receive_channel) with patch("apps.webhooks.tasks.trigger_webhook.execute_webhook.apply_async") as mock_execute: send_webhook_event(Webhook.TRIGGER_ALERT_GROUP_CREATED, alert_group.pk, organization_id=other_organization.pk) - assert mock_execute.call_args == call((other_org_webhook.pk, alert_group.pk, None, None)) + assert mock_execute.call_args == call( + (other_org_webhook.pk, alert_group.pk, None, None), kwargs={"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED} + ) + + +@pytest.mark.django_db +def test_send_webhook_event_status_change( + make_organization, make_team, make_alert_receive_channel, make_alert_group, make_custom_webhook +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + + webhooks = {} + for trigger_type, _ in Webhook.TRIGGER_TYPES: + webhooks[trigger_type] = make_custom_webhook( + organization=organization, trigger_type=trigger_type, team=make_team(organization) + ) + + for trigger_type in Webhook.STATUS_CHANGE_TRIGGERS: + with patch("apps.webhooks.tasks.trigger_webhook.execute_webhook.apply_async") as mock_execute: + send_webhook_event(trigger_type, alert_group.pk, organization_id=organization.pk) + # execute is called for the trigger type itself and the status change trigger too (with the original type passed) + assert mock_execute.call_count == 2 + mock_execute.assert_any_call( + (webhooks[trigger_type].pk, alert_group.pk, None, None), kwargs={"trigger_type": trigger_type} + ) + status_change_trigger_type = Webhook.TRIGGER_STATUS_CHANGE + mock_execute.assert_any_call( + (webhooks[status_change_trigger_type].pk, alert_group.pk, None, None), kwargs={"trigger_type": trigger_type} + ) @pytest.mark.django_db @@ -285,6 +318,10 @@ def test_execute_webhook_via_escalation_ok( @pytest.mark.django_db +@pytest.mark.parametrize( + "webhook_trigger_type", + [Webhook.TRIGGER_ACKNOWLEDGE, Webhook.TRIGGER_STATUS_CHANGE], +) def test_execute_webhook_ok_forward_all( make_organization, make_user_for_organization, @@ -292,6 +329,7 @@ def test_execute_webhook_ok_forward_all( make_alert_group, make_user_notification_policy_log_record, make_custom_webhook, + webhook_trigger_type, ): organization = make_organization() user = make_user_for_organization(organization) @@ -316,7 +354,7 @@ def test_execute_webhook_ok_forward_all( organization=organization, url="https://something/{{ alert_group_id }}/", http_method="POST", - trigger_type=Webhook.TRIGGER_ACKNOWLEDGE, + trigger_type=webhook_trigger_type, forward_all=True, ) @@ -325,7 +363,7 @@ def test_execute_webhook_ok_forward_all( mock_gethostbyname.return_value = "8.8.8.8" with patch("apps.webhooks.models.webhook.requests") as mock_requests: mock_requests.post.return_value = mock_response - execute_webhook(webhook.pk, alert_group.pk, user.pk, None) + execute_webhook(webhook.pk, alert_group.pk, user.pk, None, trigger_type=Webhook.TRIGGER_ACKNOWLEDGE) assert mock_requests.post.called expected_data = { @@ -371,6 +409,7 @@ def test_execute_webhook_ok_forward_all( assert mock_requests.post.call_args == expected_call # check logs log = webhook.responses.all()[0] + assert log.trigger_type == Webhook.TRIGGER_ACKNOWLEDGE assert log.status_code == 200 assert log.content == json.dumps(mock_response.json()) assert json.loads(log.request_data) == expected_data