Skip to content

Commit

Permalink
Add status change trigger type to webhooks (#3920)
Browse files Browse the repository at this point in the history
Related to #3395

This should help with upcoming planned integrations work.
  • Loading branch information
matiasb authored Feb 19, 2024
1 parent ec9d13a commit 0711484
Show file tree
Hide file tree
Showing 8 changed files with 121 additions and 20 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
11 changes: 10 additions & 1 deletion docs/sources/configure/outgoing-webhooks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/sources/oncall-api-reference/outgoing_webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ For more detail, refer to [Event types][].
- `unsilence`
- `unresolve`
- `unacknowledge`
- `status change`

### HTTP Methods

Expand Down
Original file line number Diff line number Diff line change
@@ -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')]),
),
]
13 changes: 12 additions & 1 deletion engine/apps/webhooks/models/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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",
Expand All @@ -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()]
Expand Down
4 changes: 3 additions & 1 deletion engine/apps/webhooks/tasks/alert_group_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 23 additions & 11 deletions engine/apps/webhooks/tasks/trigger_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
Webhook.TRIGGER_UNRESOLVE: "unresolve",
Webhook.TRIGGER_ESCALATION_STEP: "escalation",
Webhook.TRIGGER_UNACKNOWLEDGE: "unacknowledge",
Webhook.TRIGGER_STATUS_CHANGE: "status change",
}


Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)

Expand Down
51 changes: 45 additions & 6 deletions engine/apps/webhooks/tests/test_trigger_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -285,13 +318,18 @@ 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,
make_alert_receive_channel,
make_alert_group,
make_user_notification_policy_log_record,
make_custom_webhook,
webhook_trigger_type,
):
organization = make_organization()
user = make_user_for_organization(organization)
Expand All @@ -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,
)

Expand All @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 0711484

Please sign in to comment.