Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add personal webhook notification backend #5426

Merged
merged 21 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/sources/configure/integrations/outgoing-webhooks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ refs:
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/labels/#alert-group-labels
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations/labels/#alert-group-labels
personal-webhook:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/manage/notify/webhook
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/manage/notify/webhook
integration-labels:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/labels/
Expand Down Expand Up @@ -109,6 +114,7 @@ This setting does not restrict outgoing webhook execution to events from the sel
The type of event that will cause this outgoing webhook to execute. The types of triggers are:

- [Manual or Escalation Step](#escalation-step)
- [Personal Notification](#personal-notification)
- [Alert Group Created](#alert-group-created)
- [Acknowledged](#acknowledged)
- [Resolved](#resolved)
Expand Down Expand Up @@ -310,6 +316,7 @@ Context information about the event that triggered the outgoing webhook.

- `{{ event.type }}` - Lower case string matching [type of event](#event-types)
- `{{ event.time }}` - Time event was triggered
- `{{ event.user.* }}` - Context data as provided by the user for [Personal Notification](ref:personal-webhook) webhooks

#### `user`

Expand Down Expand Up @@ -482,6 +489,12 @@ Now the result is correct:
This event will trigger when the outgoing webhook is included as a step in an escalation chain.
Webhooks with this trigger type can also be manually triggered in the context of an alert group in the web UI.

### Personal Notification

`event.type` `personal notification`

This event will trigger when the outgoing webhook is included as a step in a user's personal notification rules.

### Alert Group Created

`event.type` `alert group created`
Expand Down
37 changes: 37 additions & 0 deletions docs/sources/manage/notify/webhook/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
title: Webhook as personal notification channel
menuTitle: Webhook
description: Learn more about using webhooks as a personal notification channel in Grafana OnCall.
weight: 700
keywords:
- OnCall
- Notifications
- ChatOps
- Webhook
- Channels
canonical: https://grafana.com/docs/oncall/latest/manage/notify/webhook/
aliases:
- /docs/grafana-cloud/alerting-and-irm/oncall/manage/notify/webhook/
- /docs/grafana-cloud/alerting-and-irm/oncall/notify/webhook/
refs:
outgoing-webhooks:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/outgoing-webhooks/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations/outgoing-webhooks/
---

# Webhook as a personal notification channel

It is possible to setup a webhook as a personal notification channel in your user profile.
The webhook will be triggered as a personal notification rule according to your notification policy configuration.

## Configure a webhook to be used as personal notification

In the webhooks page, you (or a user with the right permissions) need to define a [webhook](ref:outgoing-webhooks) as usual,
but with the `Personal Notification` trigger type.

Each user will then be able to choose a webhook (between those with the above trigger type) as a notification channel in
their profile. Optionally, they can also provide additional context data (as a JSON dict, e.g. `{"user_ID": "some-id"}`)
which will be available when evaluating the webhook templates. This data can be referenced via `{{ event.user.<key> }}`
(e.g. `{{ event.user.user_ID }}`).
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 @@ -132,6 +132,7 @@ curl "{{API_URL}}/api/v1/webhooks/" \
For more detail, refer to [Event types](ref:event-types).

- `escalation`
- `personal notification`
- `alert group created`
- `acknowledge`
- `resolve`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The above command returns JSON structured in the following way:
| ----------- | :------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user_id` | Yes | User ID |
| `position` | Optional | Personal notification rules execute one after another starting from `position=0`. `Position=-1` will put the escalation policy to the end of the list. A new escalation policy created with a position of an existing escalation policy will move the old one (and all following) down on the list. |
| `type` | Yes | One of: `wait`, `notify_by_slack`, `notify_by_sms`, `notify_by_phone_call`, `notify_by_telegram`, `notify_by_email`, `notify_by_mobile_app`, `notify_by_mobile_app_critical`, or `notify_by_msteams` (**NOTE** `notify_by_msteams` is only available on Grafana Cloud). |
| `type` | Yes | One of: `wait`, `notify_by_slack`, `notify_by_sms`, `notify_by_phone_call`, `notify_by_telegram`, `notify_by_email`, `notify_by_mobile_app`, `notify_by_mobile_app_critical`, `notify_by_webhook` or `notify_by_msteams` (**NOTE** `notify_by_msteams` is only available on Grafana Cloud). |
| `duration` | Optional | A time in seconds to wait (when `type=wait`). Can be one of 60, 300, 900, 1800, or 3600. |
| `important` | Optional | Boolean value indicates if a rule is "important". Default is `false`. |

Expand Down
97 changes: 97 additions & 0 deletions engine/apps/api/tests/test_webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from unittest.mock import patch

import pytest
from django.core.exceptions import ObjectDoesNotExist
from django.urls import reverse
from rest_framework import status
from rest_framework.response import Response
Expand Down Expand Up @@ -1253,3 +1254,99 @@ def test_webhook_trigger_manual(
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert mock_execute.apply_async.call_count == 0


@pytest.mark.django_db
def test_current_personal_notification(
make_organization_and_user_with_plugin_token,
make_custom_webhook,
make_user_auth_headers,
make_personal_notification_webhook,
):
organization, user, token = make_organization_and_user_with_plugin_token()
with pytest.raises(ObjectDoesNotExist):
user.personal_webhook

webhook = make_custom_webhook(organization, trigger_type=Webhook.TRIGGER_PERSONAL_NOTIFICATION)

client = APIClient()
url = reverse("api-internal:webhooks-current-personal-notification")

# no webhook setup
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"webhook": None, "context": None}

# setup personal webhook
personal_webhook = make_personal_notification_webhook(user, webhook)
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"webhook": webhook.public_primary_key, "context": {}}

# update context data
personal_webhook.context_data = {"test": "test"}
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"webhook": webhook.public_primary_key, "context": {"test": "test"}}


@pytest.mark.django_db
def test_set_personal_notification(
make_organization_and_user_with_plugin_token,
make_custom_webhook,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
with pytest.raises(ObjectDoesNotExist):
user.personal_webhook

webhook = make_custom_webhook(organization, trigger_type=Webhook.TRIGGER_PERSONAL_NOTIFICATION)
other_webhook = make_custom_webhook(organization, trigger_type=Webhook.TRIGGER_MANUAL)

client = APIClient()
url = reverse("api-internal:webhooks-set-personal-notification")

# webhook id is required
data = {}
response = client.post(
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["webhook"] == "This field is required."

# invalid webhook type
data = {"webhook": other_webhook.public_primary_key}
response = client.post(
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["webhook"] == "Webhook not found."

# check backend info
data = {"webhook": webhook.public_primary_key}
response = client.post(
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
)
assert response.status_code == status.HTTP_200_OK
user.refresh_from_db()
assert user.personal_webhook.webhook == webhook
assert user.personal_webhook.context_data == {}

# update context data
data = {"webhook": webhook.public_primary_key, "context": {"test": "test"}}
response = client.post(
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
)
assert response.status_code == status.HTTP_200_OK
user.refresh_from_db()
assert user.personal_webhook.context_data == {"test": "test"}

# invalid context
data = {"webhook": webhook.public_primary_key, "context": "not-json"}
response = client.post(
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["context"] == "Invalid context."
user.refresh_from_db()
assert user.personal_webhook.context_data == {"test": "test"}
4 changes: 4 additions & 0 deletions engine/apps/api/views/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Feature(enum.StrEnum):
LABELS = "labels"
GOOGLE_OAUTH2 = "google_oauth2"
SERVICE_DEPENDENCIES = "service_dependencies"
PERSONAL_WEBHOOK = "personal_webhook"


class FeaturesAPIView(APIView):
Expand Down Expand Up @@ -76,4 +77,7 @@ def _get_enabled_features(self, request):
if settings.FEATURE_SERVICE_DEPENDENCIES_ENABLED:
enabled_features.append(Feature.SERVICE_DEPENDENCIES)

if settings.FEATURE_PERSONAL_WEBHOOK_ENABLED:
enabled_features.append(Feature.PERSONAL_WEBHOOK)

return enabled_features
81 changes: 80 additions & 1 deletion engine/apps/api/views/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from apps.api.views.labels import schedule_update_label_cache
from apps.auth_token.auth import PluginAuthentication
from apps.labels.utils import is_labels_feature_enabled
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.models import PersonalNotificationWebhook, Webhook, WebhookResponse
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from apps.webhooks.tasks import execute_webhook
from apps.webhooks.utils import apply_jinja_template_for_json
Expand Down Expand Up @@ -89,6 +89,8 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin[Webhook], ModelView
"preview_template": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
"preset_options": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"trigger_manual": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"current_personal_notification": [RBACPermission.Permissions.USER_SETTINGS_READ],
"set_personal_notification": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
}

model = Webhook
Expand Down Expand Up @@ -336,3 +338,80 @@ def trigger_manual(self, request, pk):
(webhook.pk, alert_group.pk, user.pk, None), kwargs={"trigger_type": Webhook.TRIGGER_MANUAL}
)
return Response(status=status.HTTP_200_OK)

@extend_schema(
responses={
status.HTTP_200_OK: inline_serializer(
name="PersonalNotificationWebhook",
fields={
"webhook": serializers.CharField(),
"context": serializers.DictField(required=False, allow_null=True),
},
)
},
)
@action(methods=["get"], detail=False)
def current_personal_notification(self, request):
user = self.request.user
notification_channel = {
"webhook": None,
"context": None,
}
try:
personal_webhook = PersonalNotificationWebhook.objects.get(user=user)
except PersonalNotificationWebhook.DoesNotExist:
personal_webhook = None

if personal_webhook is not None:
notification_channel["webhook"] = personal_webhook.webhook.public_primary_key
notification_channel["context"] = personal_webhook.context_data

return Response(notification_channel)

@extend_schema(
request=inline_serializer(
name="PersonalNotificationWebhookRequest",
fields={
"webhook": serializers.CharField(),
"context": serializers.DictField(required=False, allow_null=True),
},
),
responses={status.HTTP_200_OK: None},
)
@action(methods=["post"], detail=False)
def set_personal_notification(self, request):
"""Set up a webhook as personal notification channel for the user."""
user = self.request.user

webhook_id = request.data.get("webhook")
if not webhook_id:
raise BadRequest(detail={"webhook": "This field is required."})

try:
webhook = Webhook.objects.get(
organization=user.organization,
public_primary_key=webhook_id,
trigger_type=Webhook.TRIGGER_PERSONAL_NOTIFICATION,
)
except Webhook.DoesNotExist:
raise BadRequest(detail={"webhook": "Webhook not found."})

context = request.data.get("context", None)
if context is not None:
if not isinstance(context, dict):
raise BadRequest(detail={"context": "Invalid context."})

try:
context = json.dumps(context)
except TypeError:
raise BadRequest(detail={"context": "Invalid context."})

# set or update personal webhook for user
PersonalNotificationWebhook.objects.update_or_create(
user=user,
defaults={
"webhook": webhook,
"additional_context_data": context,
},
)
return Response(status=status.HTTP_200_OK)
41 changes: 41 additions & 0 deletions engine/apps/webhooks/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import typing

from django.core.exceptions import ObjectDoesNotExist

from apps.base.messaging import BaseMessagingBackend

if typing.TYPE_CHECKING:
from apps.alerts.models import AlertGroup
from apps.base.models import UserNotificationPolicy
from apps.user_management.models import User


class PersonalWebhookBackend(BaseMessagingBackend):
backend_id = "WEBHOOK"
label = "Webhook"
short_label = "Webhook"
available_for_use = True

def serialize_user(self, user: "User"):
try:
personal_webhook = user.personal_webhook
except ObjectDoesNotExist:
return None
return {"id": personal_webhook.webhook.public_primary_key, "name": personal_webhook.webhook.name}

def unlink_user(self, user):
try:
user.personal_webhook.delete()
except ObjectDoesNotExist:
pass

def notify_user(
self, user: "User", alert_group: "AlertGroup", notification_policy: typing.Optional["UserNotificationPolicy"]
):
from apps.webhooks.tasks import notify_user_async

notify_user_async.delay(
user_pk=user.pk,
alert_group_pk=alert_group.pk,
notification_policy_pk=notification_policy.pk if notification_policy else None,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 4.2.16 on 2025-01-27 18:46

from django.db import migrations, models
import django.db.models.deletion
import mirage.fields


class Migration(migrations.Migration):

dependencies = [
('user_management', '0029_remove_organization_general_log_channel_id_db'),
('webhooks', '0017_alter_webhook_trigger_type_and_more'),
]

operations = [
migrations.AlterField(
model_name='webhook',
name='trigger_type',
field=models.IntegerField(choices=[(0, 'Manual or escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged'), (8, 'Status change'), (9, 'Personal notification')], default=0, null=True),
),
migrations.AlterField(
model_name='webhookresponse',
name='trigger_type',
field=models.IntegerField(choices=[(0, 'Manual or escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged'), (8, 'Status change'), (9, 'Personal notification')]),
),
migrations.CreateModel(
name='PersonalNotificationWebhook',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('additional_context_data', mirage.fields.EncryptedTextField(null=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='personal_webhook', to='user_management.user')),
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='personal_channels', to='webhooks.webhook')),
],
),
]
2 changes: 1 addition & 1 deletion engine/apps/webhooks/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .webhook import Webhook, WebhookResponse # noqa: F401
from .webhook import PersonalNotificationWebhook, Webhook, WebhookResponse # noqa: F401
Loading
Loading