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
|
||||
|
||||
### 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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ For more detail, refer to [Event types][].
|
|||
- `unsilence`
|
||||
- `unresolve`
|
||||
- `unacknowledge`
|
||||
- `status change`
|
||||
|
||||
### 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_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()]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue