Multithreading #66 (#76)

* feat(multihreading): each url has it's own thread

* Fixing broken unit tests

* Improving readability when there are multiple URLs registerd and creating new action to upload metrics

* Improving error message when there's no file found

* Bumping the version

Co-authored-by: Alex Berenshtein <aberenshtein@yotpo.com>
This commit is contained in:
mtakaki
2020-01-05 10:25:06 -08:00
committed by GitHub
parent 9a73063a6f
commit a13a42d51c
7 changed files with 188 additions and 116 deletions

View File

@@ -8,18 +8,13 @@ import time
import requests
from yaml import dump
from yaml import load
from yaml import FullLoader
import cachet_url_monitor.latency_unit as latency_unit
import cachet_url_monitor.status as st
# This is the mandatory fields that must be in the configuration file in this
# same exact structure.
configuration_mandatory_fields = {
'endpoint': ['url', 'method', 'timeout', 'expectation'],
'cachet': ['api_url', 'token', 'component_id'],
'frequency': []}
configuration_mandatory_fields = ['url', 'method', 'timeout', 'expectation', 'component_id', 'frequency']
class ConfigurationValidationError(Exception):
@@ -78,13 +73,19 @@ class Configuration(object):
of assessing the API and pushing the results to cachet.
"""
def __init__(self, config_file):
self.logger = logging.getLogger('cachet_url_monitor.configuration.Configuration')
self.config_file = config_file
self.data = load(open(self.config_file, 'r'), Loader=FullLoader)
def __init__(self, config_file, endpoint_index):
self.endpoint_index = endpoint_index
self.data = config_file
self.endpoint = self.data['endpoints'][endpoint_index]
self.current_fails = 0
self.trigger_update = True
if 'name' not in self.endpoint:
# We have to make this mandatory, otherwise the logs are confusing when there are multiple URLs.
raise ConfigurationValidationError('name')
self.logger = logging.getLogger(f'cachet_url_monitor.configuration.Configuration.{self.endpoint["name"]}')
# Exposing the configuration to confirm it's parsed as expected.
self.print_out()
@@ -94,33 +95,32 @@ class Configuration(object):
# We store the main information from the configuration file, so we don't keep reading from the data dictionary.
self.headers = {'X-Cachet-Token': os.environ.get('CACHET_TOKEN') or self.data['cachet']['token']}
self.endpoint_method = os.environ.get('ENDPOINT_METHOD') or self.data['endpoint']['method']
self.endpoint_url = os.environ.get('ENDPOINT_URL') or self.data['endpoint']['url']
self.endpoint_method = self.endpoint['method']
self.endpoint_url = self.endpoint['url']
self.endpoint_url = normalize_url(self.endpoint_url)
self.endpoint_timeout = os.environ.get('ENDPOINT_TIMEOUT') or self.data['endpoint'].get('timeout') or 1
self.endpoint_header = self.data['endpoint'].get('header') or None
self.allowed_fails = os.environ.get('ALLOWED_FAILS') or self.data['endpoint'].get('allowed_fails') or 0
self.endpoint_timeout = self.endpoint.get('timeout') or 1
self.endpoint_header = self.endpoint.get('header') or None
self.allowed_fails = self.endpoint.get('allowed_fails') or 0
self.api_url = os.environ.get('CACHET_API_URL') or self.data['cachet']['api_url']
self.component_id = os.environ.get('CACHET_COMPONENT_ID') or self.data['cachet']['component_id']
self.metric_id = os.environ.get('CACHET_METRIC_ID') or self.data['cachet'].get('metric_id')
self.component_id = self.endpoint['component_id']
self.metric_id = self.endpoint.get('metric_id')
if self.metric_id is not None:
self.default_metric_value = self.get_default_metric_value(self.metric_id)
# The latency_unit configuration is not mandatory and we fallback to seconds, by default.
self.latency_unit = os.environ.get('LATENCY_UNIT') or self.data['cachet'].get('latency_unit') or 's'
self.latency_unit = self.data['cachet'].get('latency_unit') or 's'
# We need the current status so we monitor the status changes. This is necessary for creating incidents.
self.status = get_current_status(self.api_url, self.component_id, self.headers)
self.previous_status = self.status
# Get remaining settings
self.public_incidents = int(
os.environ.get('CACHET_PUBLIC_INCIDENTS') or self.data['cachet']['public_incidents'])
self.public_incidents = int(self.endpoint['public_incidents'])
self.logger.info('Monitoring URL: %s %s' % (self.endpoint_method, self.endpoint_url))
self.expectations = [Expectation.create(expectation) for expectation in self.data['endpoint']['expectation']]
self.expectations = [Expectation.create(expectation) for expectation in self.endpoint['expectation']]
for expectation in self.expectations:
self.logger.info('Registered expectation: %s' % (expectation,))
@@ -137,10 +137,10 @@ class Configuration(object):
"""Retrieves the action list from the configuration. If it's empty, returns an empty list.
:return: The list of actions, which can be an empty list.
"""
if self.data['cachet'].get('action') is None:
if self.endpoint.get('action') is None:
return []
else:
return self.data['cachet']['action']
return self.endpoint['action']
def validate(self):
"""Validates the configuration by verifying the mandatory fields are
@@ -148,24 +148,20 @@ class Configuration(object):
ConfigurationValidationError is raised. Otherwise nothing will happen.
"""
configuration_errors = []
for key, sub_entries in configuration_mandatory_fields.items():
if key not in self.data:
for key in configuration_mandatory_fields:
if key not in self.endpoint:
configuration_errors.append(key)
for sub_key in sub_entries:
if sub_key not in self.data[key]:
configuration_errors.append('%s.%s' % (key, sub_key))
if ('endpoint' in self.data and 'expectation' in
self.data['endpoint']):
if (not isinstance(self.data['endpoint']['expectation'], list) or
(isinstance(self.data['endpoint']['expectation'], list) and
len(self.data['endpoint']['expectation']) == 0)):
if 'expectation' in self.endpoint:
if (not isinstance(self.endpoint['expectation'], list) or
(isinstance(self.endpoint['expectation'], list) and
len(self.endpoint['expectation']) == 0)):
configuration_errors.append('endpoint.expectation')
if len(configuration_errors) > 0:
raise ConfigurationValidationError(
f"Config file [{self.config_file}] failed validation. Missing keys: {', '.join(configuration_errors)}")
'Endpoint [%s] failed validation. Missing keys: %s' % (self.endpoint,
', '.join(configuration_errors)))
def evaluate(self):
"""Sends the request to the URL set in the configuration and executes
@@ -214,6 +210,8 @@ class Configuration(object):
temporary_data = copy.deepcopy(self.data)
# Removing the token so we don't leak it in the logs.
del temporary_data['cachet']['token']
temporary_data['endpoints'] = temporary_data['endpoints'][self.endpoint_index]
return dump(temporary_data, default_flow_style=False)
def if_trigger_update(self):
@@ -361,6 +359,10 @@ class HttpStatus(Expectation):
@staticmethod
def parse_range(range_string):
if isinstance(range_string, int):
# This happens when there's no range and no dash character, it will be parsed as int already.
return range_string, range_string + 1
statuses = range_string.split("-")
if len(statuses) == 1:
# When there was no range given, we should treat the first number as a single status check.
@@ -382,7 +384,7 @@ class HttpStatus(Expectation):
return f'Unexpected HTTP status ({response.status_code})'
def __str__(self):
return repr(f'HTTP status range: {self.status_range}')
return repr(f'HTTP status range: [{self.status_range[0]}, {self.status_range[1]}[')
class Latency(Expectation):

View File

@@ -1,12 +1,16 @@
#!/usr/bin/env python
import logging
import sys
import threading
import time
import schedule
from yaml import load, SafeLoader
from cachet_url_monitor.configuration import Configuration
cachet_mandatory_fields = ['api_url', 'token']
class Agent(object):
"""Monitor agent that will be constantly verifying if the URL is healthy
@@ -32,7 +36,7 @@ class Agent(object):
def start(self):
"""Sets up the schedule based on the configuration file."""
schedule.every(self.configuration.data['frequency']).seconds.do(self.execute)
schedule.every(self.configuration.endpoint['frequency']).seconds.do(self.execute)
class Decorator(object):
@@ -50,10 +54,15 @@ class CreateIncidentDecorator(Decorator):
configuration.push_incident()
class PushMetricsDecorator(Decorator):
def execute(self, configuration):
configuration.push_metrics()
class Scheduler(object):
def __init__(self, config_file):
def __init__(self, config_file, endpoint_index):
self.logger = logging.getLogger('cachet_url_monitor.scheduler.Scheduler')
self.configuration = Configuration(config_file)
self.configuration = Configuration(config_file, endpoint_index)
self.agent = self.get_agent()
self.stop = False
@@ -62,10 +71,11 @@ class Scheduler(object):
action_names = {
'CREATE_INCIDENT': CreateIncidentDecorator,
'UPDATE_STATUS': UpdateStatusDecorator,
'PUSH_METRICS': PushMetricsDecorator,
}
actions = []
for action in self.configuration.get_action():
self.logger.info('Registering action %s' % (action))
self.logger.info(f'Registering action {action}')
actions.append(action_names[action]())
return Agent(self.configuration, decorators=actions)
@@ -74,7 +84,33 @@ class Scheduler(object):
self.logger.info('Starting monitor agent...')
while not self.stop:
schedule.run_pending()
time.sleep(self.configuration.data['frequency'])
time.sleep(self.configuration.endpoint['frequency'])
class NewThread(threading.Thread):
def __init__(self, scheduler):
threading.Thread.__init__(self)
self.scheduler = scheduler
def run(self):
self.scheduler.start()
def validate_config():
if 'endpoints' not in config_file.keys():
fatal_error('Endpoints is a mandatory field')
if config_file['endpoints'] is None:
fatal_error('Endpoints array can not be empty')
for key in cachet_mandatory_fields:
if key not in config_file['cachet']:
fatal_error('Missing cachet mandatory fields')
def fatal_error(message):
logging.getLogger('cachet_url_monitor.scheduler').fatal("%s", message)
sys.exit(1)
if __name__ == "__main__":
@@ -87,5 +123,13 @@ if __name__ == "__main__":
logging.getLogger('cachet_url_monitor.scheduler').fatal('Missing configuration file argument')
sys.exit(1)
scheduler = Scheduler(sys.argv[1])
scheduler.start()
try:
config_file = load(open(sys.argv[1], 'r'), SafeLoader)
except FileNotFoundError:
logging.getLogger('cachet_url_monitor.scheduler').fatal(f'File not found: {sys.argv[1]}')
sys.exit(1)
validate_config()
for endpoint_index in range(len(config_file['endpoints'])):
NewThread(Scheduler(config_file, endpoint_index)).start()