Skip to content

Commit

Permalink
Add support for generic webhooks
Browse files Browse the repository at this point in the history
This can be useful with push notification services such as Gotify.
  • Loading branch information
nijel committed Apr 30, 2020
1 parent 5b9a356 commit d556c04
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 2 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ endpoints:
cachet:
api_url: http://status.cachethq.io/api/v1
token: mytoken
webhooks:
- url: "https://push.example.com/message?token=<apptoken>"
params:
title: "{title}"
message: "{message}"
priority: 5
```

- **endpoints**, the configuration about the URL/Urls that will be monitored.
Expand All @@ -88,6 +94,9 @@ cachet:
- **cachet**, this is the settings for our cachet server.
- **api_url**, the cachet API endpoint. *mandatory*
- **token**, the API token. *mandatory*
- **webhooks**, generic webhooks to be notified about incident updates
- **url**, webhook URL, will be interpolated
- **params**, POST parameters, will be interpolated

Each `expectation` has their own default incident status. It can be overridden by setting the `incident` property to any of the following values:
- `PARTIAL`
Expand All @@ -102,6 +111,13 @@ By choosing any of the aforementioned statuses, it will let you control the kind
| LATENCY | PERFORMANCE |
| REGEX | PARTIAL |

Following parameters are available in webhook interpolation

| Parameter | Description |
| --------- | ----------- |
| `{title}` | Event title, includes endpoint name and short status |
| `{message}` | Event message, same as sent to Cachet |

## Setting up

The application should be installed using **virtualenv**, through the following command:
Expand Down
Empty file modified cachet_url_monitor/client.py
100644 → 100755
Empty file.
29 changes: 28 additions & 1 deletion cachet_url_monitor/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import logging
import time
from typing import Dict
from typing import List
from typing import Optional

import requests
from yaml import dump
Expand All @@ -12,6 +14,7 @@
from cachet_url_monitor.exceptions import ConfigurationValidationError
from cachet_url_monitor.expectation import Expectation
from cachet_url_monitor.status import ComponentStatus
from cachet_url_monitor.webhook import Webhook

# This is the mandatory fields that must be in the configuration file in this
# same exact structure.
Expand All @@ -25,6 +28,7 @@ class Configuration(object):
endpoint_index: int
endpoint: str
client: CachetClient
webhooks: List[Webhook]
current_fails: int
trigger_update: bool

Expand All @@ -43,11 +47,12 @@ class Configuration(object):
previous_status: ComponentStatus
message: str

def __init__(self, config, endpoint_index: int, client: CachetClient):
def __init__(self, config, endpoint_index: int, client: CachetClient, webhooks : Optional[List[Webhook]] = None):
self.endpoint_index = endpoint_index
self.data = config
self.endpoint = self.data['endpoints'][endpoint_index]
self.client = client
self.webhooks = webhooks or []

self.current_fails = 0
self.trigger_update = True
Expand Down Expand Up @@ -232,6 +237,24 @@ def push_metrics(self):
else:
self.logger.warning(f'Metric upload failed with status [{metrics_request.status_code}]')

def trigger_webhooks(self):
"""Trigger webhooks."""
if self.status == st.ComponentStatus.PERFORMANCE_ISSUES:
message = self.message
title = f'{self.endpoint["name"]} degraded'
elif self.status == st.ComponentStatus.OPERATIONAL:
message = 'Incident resolved'
title = f'{self.endpoint["name"]} OK'
else:
message = self.message
title = f'{self.endpoint["name"]} unavailable'
for webhook in self.webhooks:
webhook_request = webhook.push_incident(title, message)
if webhook_request.ok:
self.logger.info(f'Webhook {webhook.url} triggered with {title}')
else:
self.logger.warning(f'Webhook {webhook.url} failed with status [{webhook_request.status_code}]')

def push_incident(self):
"""If the component status has changed, we create a new incident (if this is the first time it becomes unstable)
or updates the existing incident once it becomes healthy again.
Expand All @@ -250,6 +273,8 @@ def push_incident(self):
else:
self.logger.warning(
f'Incident update failed with status [{incident_request.status_code}], message: "{self.message}"')

self.trigger_webhooks()
elif not hasattr(self, 'incident_id') and self.status != st.ComponentStatus.OPERATIONAL:
incident_request = self.client.push_incident(self.status, self.public_incidents, self.component_id,
message=self.message)
Expand All @@ -261,3 +286,5 @@ def push_incident(self):
else:
self.logger.warning(
f'Incident upload failed with status [{incident_request.status_code}], message: "{self.message}"')

self.trigger_webhooks()
7 changes: 6 additions & 1 deletion cachet_url_monitor/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from cachet_url_monitor.client import CachetClient
from cachet_url_monitor.configuration import Configuration
from cachet_url_monitor.webhook import Webhook

cachet_mandatory_fields = ['api_url', 'token']

Expand Down Expand Up @@ -133,9 +134,13 @@ def fatal_error(message):

validate_config()

webhooks = []
for webhook in config_data.get('webhooks', []):
webhooks.append(Webhook(webhook['url'], webhook.get('params', {})))

for endpoint_index in range(len(config_data['endpoints'])):
token = os.environ.get('CACHET_TOKEN') or config_data['cachet']['token']
api_url = os.environ.get('CACHET_API_URL') or config_data['cachet']['api_url']
configuration = Configuration(config_data, endpoint_index, CachetClient(api_url, token))
configuration = Configuration(config_data, endpoint_index, CachetClient(api_url, token), webhooks)
NewThread(Scheduler(configuration,
build_agent(configuration, logging.getLogger('cachet_url_monitor.scheduler')))).start()
26 changes: 26 additions & 0 deletions cachet_url_monitor/webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from typing import Dict, Optional

import requests


class Webhook:
url: str
params: Dict[str, str]

def __init__(self, url: str, params: Dict[str, str]):
self.url = url
self.params = params

def push_incident(self, title: str, message: str):
format_args = {
"title": title,
"message": message,
}
# Interpolate URL and params
url = self.url.format(**format_args)
params = {
name: str(value).format(**format_args)
for name, value in self.params.items()
}

return requests.post(url, params=params)
32 changes: 32 additions & 0 deletions tests/configs/config_webhooks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@

endpoints:
- name: foo
url: http://localhost:8080/swagger
method: GET
header:
SOME-HEADER: SOME-VALUE
timeout: 0.01
expectation:
- type: HTTP_STATUS
status_range: 200-300
incident: MAJOR
- type: LATENCY
threshold: 1
- type: REGEX
regex: '.*(<body).*'
allowed_fails: 0
component_id: 1
action:
- CREATE_INCIDENT
- UPDATE_STATUS
public_incidents: true
latency_unit: ms
frequency: 30
cachet:
api_url: https://demo.cachethq.io/api/v1
token: my_token
webhooks:
- url: "https://push.example.com/{title}"
- url: "https://push.example.com/message?token=<apptoken>"
params:
title: "{title}"
33 changes: 33 additions & 0 deletions tests/test_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import cachet_url_monitor.exceptions
import cachet_url_monitor.status
from cachet_url_monitor.webhook import Webhook

sys.modules['logging'] = mock.Mock()
from cachet_url_monitor.configuration import Configuration
Expand Down Expand Up @@ -43,6 +44,13 @@ def invalid_config_file():
yield config_file_data


@pytest.fixture()
def webhooks_config_file():
with open(os.path.join(os.path.dirname(__file__), 'configs/config_webhooks.yml'), 'rt') as yaml_file:
config_file_data = load(yaml_file, SafeLoader)
yield config_file_data


@pytest.fixture()
def mock_logger():
mock_logger = mock.Mock()
Expand All @@ -59,6 +67,14 @@ def configuration(config_file, mock_client, mock_logger):
yield Configuration(config_file, 0, mock_client)


@pytest.fixture()
def webhooks_configuration(webhooks_config_file, mock_client, mock_logger):
webhooks = []
for webhook in webhooks_config_file.get('webhooks', []):
webhooks.append(Webhook(webhook['url'], webhook.get('params', {})))
yield Configuration(webhooks_config_file, 0, mock_client, webhooks)


@pytest.fixture()
def multiple_urls_configuration(multiple_urls_config_file, mock_client, mock_logger):
yield [Configuration(multiple_urls_config_file, index, mock_client) for index in
Expand Down Expand Up @@ -130,6 +146,23 @@ def test_evaluate_with_http_error(configuration, mock_logger):
mock_logger.exception.assert_called_with('Unexpected HTTP response')


def test_webhooks(webhooks_configuration, mock_logger, mock_client):
assert len(webhooks_configuration.webhooks) == 2
push_incident_response = mock.Mock()
push_incident_response.ok = False
mock_client.push_incident.return_value = push_incident_response
with requests_mock.mock() as m:
m.get('http://localhost:8080/swagger', exc=requests.HTTPError)
m.post('https://push.example.com/foo%20unavailable', text='')
m.post('https://push.example.com/message?token=%3Capptoken%3E&title=foo+unavailable', text='')
webhooks_configuration.evaluate()

assert webhooks_configuration.status == cachet_url_monitor.status.ComponentStatus.PARTIAL_OUTAGE, 'Component status set incorrectly'
mock_logger.exception.assert_called_with('Unexpected HTTP response')
webhooks_configuration.push_incident()
mock_logger.info.assert_called_with('Webhook https://push.example.com/message?token=<apptoken> triggered with foo unavailable')


def test_push_status(configuration, mock_client):
mock_client.get_component_status.return_value = cachet_url_monitor.status.ComponentStatus.PARTIAL_OUTAGE
push_status_response = mock.Mock()
Expand Down

0 comments on commit d556c04

Please sign in to comment.