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:
parent
ec9d13aa91
commit
0711484a50
8 changed files with 121 additions and 20 deletions
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ For more detail, refer to [Event types][].
|
||||||
- `unsilence`
|
- `unsilence`
|
||||||
- `unresolve`
|
- `unresolve`
|
||||||
- `unacknowledge`
|
- `unacknowledge`
|
||||||
|
- `status change`
|
||||||
|
|
||||||
### HTTP Methods
|
### HTTP Methods
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')]),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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()]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue