Add status change trigger type to webhooks (#3920)

Related to https://github.com/grafana/oncall/issues/3395

This should help with upcoming planned integrations work.
This commit is contained in:
Matias Bordese 2024-02-19 14:12:56 -03:00 committed by GitHub
parent ec9d13aa91
commit 0711484a50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 121 additions and 20 deletions

View file

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Add status change trigger type to webhooks ([#3920](https://github.com/grafana/oncall/pull/3920))
### Fixed ### Fixed
- Fix edit default team by admin @mderynck ([#3885](https://github.com/grafana/oncall/pull/3885)) - Fix edit default team by admin @mderynck ([#3885](https://github.com/grafana/oncall/pull/3885))

View file

@ -87,7 +87,8 @@ The type of event that will cause this outgoing webhook to execute. The types of
- [Silenced](#silenced) - [Silenced](#silenced)
- [Unsilenced](#unsilenced) - [Unsilenced](#unsilenced)
- [Unresolved](#unresolved) - [Unresolved](#unresolved)
- [Unacknowledged](#acknowledged) - [Unacknowledged](#unacknowledged)
- [Status Change](#status-change)
For more details about types of triggers see [Event types](#event-types) 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. 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 ## Viewing status of outgoing webhooks
In the outgoing webhooks table if a webhook is enabled **Last Run** will have the following information: In the outgoing webhooks table if a webhook is enabled **Last Run** will have the following information:

View file

@ -115,6 +115,7 @@ For more detail, refer to [Event types][].
- `unsilence` - `unsilence`
- `unresolve` - `unresolve`
- `unacknowledge` - `unacknowledge`
- `status change`
### HTTP Methods ### HTTP Methods

View file

@ -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')]),
),
]

View file

@ -79,7 +79,8 @@ class Webhook(models.Model):
TRIGGER_UNSILENCE, TRIGGER_UNSILENCE,
TRIGGER_UNRESOLVE, TRIGGER_UNRESOLVE,
TRIGGER_UNACKNOWLEDGE, TRIGGER_UNACKNOWLEDGE,
) = range(8) TRIGGER_STATUS_CHANGE,
) = range(9)
# Must be the same order as previous # Must be the same order as previous
TRIGGER_TYPES = ( TRIGGER_TYPES = (
@ -91,9 +92,18 @@ class Webhook(models.Model):
(TRIGGER_UNSILENCE, "Unsilenced"), (TRIGGER_UNSILENCE, "Unsilenced"),
(TRIGGER_UNRESOLVE, "Unresolved"), (TRIGGER_UNRESOLVE, "Unresolved"),
(TRIGGER_UNACKNOWLEDGE, "Unacknowledged"), (TRIGGER_UNACKNOWLEDGE, "Unacknowledged"),
(TRIGGER_STATUS_CHANGE, "Status change"),
) )
ALL_TRIGGER_TYPES = [i[0] for i in TRIGGER_TYPES] 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 = { PUBLIC_TRIGGER_TYPES_MAP = {
TRIGGER_ESCALATION_STEP: "escalation", TRIGGER_ESCALATION_STEP: "escalation",
@ -104,6 +114,7 @@ class Webhook(models.Model):
TRIGGER_UNSILENCE: "unsilence", TRIGGER_UNSILENCE: "unsilence",
TRIGGER_UNRESOLVE: "unresolve", TRIGGER_UNRESOLVE: "unresolve",
TRIGGER_UNACKNOWLEDGE: "unacknowledge", TRIGGER_UNACKNOWLEDGE: "unacknowledge",
TRIGGER_STATUS_CHANGE: "status change",
} }
PUBLIC_ALL_TRIGGER_TYPES = [i for i in PUBLIC_TRIGGER_TYPES_MAP.values()] PUBLIC_ALL_TRIGGER_TYPES = [i for i in PUBLIC_TRIGGER_TYPES_MAP.values()]

View file

@ -59,7 +59,9 @@ def alert_group_status_change(self, action_type, alert_group_id, user_id):
return return
organization_id = alert_group.channel.organization_id 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 # check if there are any webhooks before going on
if not webhooks: if not webhooks:

View file

@ -44,6 +44,7 @@ TRIGGER_TYPE_TO_LABEL = {
Webhook.TRIGGER_UNRESOLVE: "unresolve", Webhook.TRIGGER_UNRESOLVE: "unresolve",
Webhook.TRIGGER_ESCALATION_STEP: "escalation", Webhook.TRIGGER_ESCALATION_STEP: "escalation",
Webhook.TRIGGER_UNACKNOWLEDGE: "unacknowledge", 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, trigger_type=trigger_type,
organization_id=organization_id, organization_id=organization_id,
).exclude(is_webhook_enabled=False) ).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: 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]: def _isoformat_date(date_value: datetime) -> typing.Optional[str]:
return date_value.isoformat() if date_value else None return date_value.isoformat() if date_value else None
def _build_payload(webhook: Webhook, alert_group: AlertGroup, user: User) -> typing.Dict[str, typing.Any]: def _build_payload(
trigger_type = webhook.trigger_type 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 = { 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) 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) 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) 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["time"] = _isoformat_date(alert_group.silenced_at)
event["until"] = _isoformat_date(alert_group.silenced_until) event["until"] = _isoformat_date(alert_group.silenced_until)
@ -195,7 +207,7 @@ def make_request(
@shared_dedicated_queue_retry_task( @shared_dedicated_queue_retry_task(
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else EXECUTE_WEBHOOK_RETRIES 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 from apps.webhooks.models import Webhook
try: 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: if user_id is not None:
user = User.objects.filter(pk=user_id).first() 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) triggered, status, error, exception = make_request(webhook, alert_group, data)
# create response entry # create response entry
WebhookResponse.objects.create( WebhookResponse.objects.create(
alert_group=alert_group, alert_group=alert_group,
trigger_type=webhook.trigger_type, trigger_type=trigger_type or webhook.trigger_type,
**status, **status,
) )

View file

@ -37,17 +37,20 @@ def test_send_webhook_event_filters(
other_organization = make_organization() other_organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization) alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel) alert_group = make_alert_group(alert_receive_channel)
trigger_types = [t for t, _ in Webhook.TRIGGER_TYPES if t != Webhook.TRIGGER_STATUS_CHANGE]
webhooks = {} webhooks = {}
for trigger_type, _ in Webhook.TRIGGER_TYPES: for trigger_type in trigger_types:
webhooks[trigger_type] = make_custom_webhook( webhooks[trigger_type] = make_custom_webhook(
organization=organization, trigger_type=trigger_type, team=make_team(organization) 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: 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) 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
other_org_webhook = make_custom_webhook( other_org_webhook = make_custom_webhook(
@ -58,7 +61,37 @@ def test_send_webhook_event_filters(
alert_group = make_alert_group(alert_receive_channel) alert_group = make_alert_group(alert_receive_channel)
with patch("apps.webhooks.tasks.trigger_webhook.execute_webhook.apply_async") as mock_execute: 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) 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 @pytest.mark.django_db
@ -285,6 +318,10 @@ def test_execute_webhook_via_escalation_ok(
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize(
"webhook_trigger_type",
[Webhook.TRIGGER_ACKNOWLEDGE, Webhook.TRIGGER_STATUS_CHANGE],
)
def test_execute_webhook_ok_forward_all( def test_execute_webhook_ok_forward_all(
make_organization, make_organization,
make_user_for_organization, make_user_for_organization,
@ -292,6 +329,7 @@ def test_execute_webhook_ok_forward_all(
make_alert_group, make_alert_group,
make_user_notification_policy_log_record, make_user_notification_policy_log_record,
make_custom_webhook, make_custom_webhook,
webhook_trigger_type,
): ):
organization = make_organization() organization = make_organization()
user = make_user_for_organization(organization) user = make_user_for_organization(organization)
@ -316,7 +354,7 @@ def test_execute_webhook_ok_forward_all(
organization=organization, organization=organization,
url="https://something/{{ alert_group_id }}/", url="https://something/{{ alert_group_id }}/",
http_method="POST", http_method="POST",
trigger_type=Webhook.TRIGGER_ACKNOWLEDGE, trigger_type=webhook_trigger_type,
forward_all=True, forward_all=True,
) )
@ -325,7 +363,7 @@ def test_execute_webhook_ok_forward_all(
mock_gethostbyname.return_value = "8.8.8.8" mock_gethostbyname.return_value = "8.8.8.8"
with patch("apps.webhooks.models.webhook.requests") as mock_requests: with patch("apps.webhooks.models.webhook.requests") as mock_requests:
mock_requests.post.return_value = mock_response 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 assert mock_requests.post.called
expected_data = { expected_data = {
@ -371,6 +409,7 @@ def test_execute_webhook_ok_forward_all(
assert mock_requests.post.call_args == expected_call assert mock_requests.post.call_args == expected_call
# check logs # check logs
log = webhook.responses.all()[0] log = webhook.responses.all()[0]
assert log.trigger_type == Webhook.TRIGGER_ACKNOWLEDGE
assert log.status_code == 200 assert log.status_code == 200
assert log.content == json.dumps(mock_response.json()) assert log.content == json.dumps(mock_response.json())
assert json.loads(log.request_data) == expected_data assert json.loads(log.request_data) == expected_data