diff --git a/monasca_notification/plugins/email_notifier.py b/monasca_notification/plugins/email_notifier.py index 28be0d6..f99e872 100644 --- a/monasca_notification/plugins/email_notifier.py +++ b/monasca_notification/plugins/email_notifier.py @@ -16,17 +16,21 @@ import email.header import email.mime.text import email.utils +import six import smtplib import time + from monasca_notification.plugins import abstract_notifier EMAIL_SINGLE_HOST_BASE = u'''On host "{hostname}" for target "{target_host}" {message} Alarm "{alarm_name}" transitioned to the {state} state at {timestamp} UTC alarm_id: {alarm_id} + Lifecycle state: {lifecycle_state} Link: {link} +Link to Grafana: {grafana_url} With dimensions: {metric_dimensions}''' @@ -35,8 +39,10 @@ EMAIL_MULTIPLE_HOST_BASE = u'''On host "{hostname}" {message} Alarm "{alarm_name}" transitioned to the {state} state at {timestamp} UTC alarm_id: {alarm_id} + Lifecycle state: {lifecycle_state} Link: {link} +Link to Grafana: {grafana_url} With dimensions: {metric_dimensions}''' @@ -45,8 +51,10 @@ EMAIL_NO_HOST_BASE = u'''On multiple hosts {message} Alarm "{alarm_name}" transitioned to the {state} state at {timestamp} UTC Alarm_id: {alarm_id} + Lifecycle state: {lifecycle_state} Link: {link} +Link to Grafana: {grafana_url} With dimensions {metric_dimensions}''' @@ -152,6 +160,12 @@ class EmailNotifier(abstract_notifier.AbstractNotifier): be treated as type #2. """ timestamp = time.asctime(time.gmtime(notification.alarm_timestamp)) + + alarm_seconds = notification.alarm_timestamp + alarm_ms = int(round(alarm_seconds * 1000)) + + graf_url = self._get_link_url(notification.metrics[0], alarm_ms) + dimensions = _format_dimensions(notification) if len(hostname) == 1: # Type 1 @@ -166,6 +180,7 @@ class EmailNotifier(abstract_notifier.AbstractNotifier): alarm_id=notification.alarm_id, metric_dimensions=dimensions, link=notification.link, + grafana_url=graf_url, lifecycle_state=notification.lifecycle_state ) subject = u'{} {} "{}" for Host: {} Target: {}'.format( @@ -183,6 +198,7 @@ class EmailNotifier(abstract_notifier.AbstractNotifier): alarm_id=notification.alarm_id, metric_dimensions=dimensions, link=notification.link, + grafana_url=graf_url, lifecycle_state=notification.lifecycle_state ) subject = u'{} {} "{}" for Host: {}'.format( @@ -197,6 +213,7 @@ class EmailNotifier(abstract_notifier.AbstractNotifier): alarm_id=notification.alarm_id, metric_dimensions=dimensions, link=notification.link, + grafana_url=graf_url, lifecycle_state=notification.lifecycle_state ) subject = u'{} {} "{}" '.format(notification.state, @@ -211,6 +228,38 @@ class EmailNotifier(abstract_notifier.AbstractNotifier): return msg + def _get_link_url(self, metric, timestamp_ms): + """Returns the url to Grafana including a query with the + respective metric info (name, dimensions, timestamp) + :param metric: the metric for which to display the graph in Grafana + :param timestamp_ms: timestamp of the alarm for the metric in milliseconds + :return: the url to the graph for the given metric or None if no Grafana host + has been defined. + """ + + grafana_url = self._config.get('grafana_url', None) + if grafana_url is None: + return None + + url = '' + metric_query = '' + + metric_query = "?metric=%s" % metric['name'] + + dimensions = metric['dimensions'] + for key, value in six.iteritems(dimensions): + metric_query += "&dim_%s=%s" % (key, value) + + # Show the graph within a range of ten minutes before and after the alarm occurred. + offset = 600000 + from_ms = timestamp_ms - offset + to_ms = timestamp_ms + offset + time_query = "&from=%s&to=%s" % (from_ms, to_ms) + + url = grafana_url + '/dashboard/script/drilldown.js' + + return url + metric_query + time_query + def _format_dimensions(notification): dimension_sets = [] diff --git a/notification.yaml b/notification.yaml index 1b75e88..10c6a6a 100644 --- a/notification.yaml +++ b/notification.yaml @@ -45,6 +45,7 @@ notification_types: password: timeout: 60 from_addr: monasca-notification@none.invalid + grafana_url: 'http://127.0.0.1:3000' webhook: timeout: 5 @@ -125,4 +126,4 @@ logging: # Used in logging.dictConfig level: DEBUG statsd: host: 'localhost' - port: 8125 + port: 8125 \ No newline at end of file diff --git a/tests/test_email_notification.py b/tests/test_email_notification.py index cde5fd7..baef179 100644 --- a/tests/test_email_notification.py +++ b/tests/test_email_notification.py @@ -24,6 +24,14 @@ import unittest import six +import datetime + +if six.PY2: + import urlparse +else: + from urllib import parse + from urllib.parse import urlparse + from monasca_notification.notification import Notification from monasca_notification.plugins import email_notifier @@ -114,7 +122,8 @@ class TestEmail(unittest.TestCase): 'user': None, 'password': None, 'timeout': 60, - 'from_addr': 'hpcs.mon@hp.com'} + 'from_addr': 'hpcs.mon@hp.com', + 'grafana_url': 'http://127.0.0.1:3000'} def tearDown(self): pass @@ -134,7 +143,6 @@ class TestEmail(unittest.TestCase): mock_log.error = self.trap.append email = email_notifier.EmailNotifier(mock_log) - email.config(self.email_config) alarm_dict = alarm(metric) @@ -148,7 +156,9 @@ class TestEmail(unittest.TestCase): """ metrics = [] - metric_data = {'dimensions': {'hostname': u'foo1' + UNICODE_CHAR, u'service' + UNICODE_CHAR: 'bar1'}} + metric_data = {'name': 'cpu.percent', + 'dimensions': {'hostname': u'foo1' + UNICODE_CHAR, + u'service' + UNICODE_CHAR: 'bar1'}} metrics.append(metric_data) self.notify(self._smtpStub, metrics) @@ -176,9 +186,9 @@ class TestEmail(unittest.TestCase): """ metrics = [] - metric_data = {'dimensions': {'hostname': u'foo1' + UNICODE_CHAR, - u'service' + UNICODE_CHAR: 'bar1', - u'target_host': u'some_where'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': u'foo1' + UNICODE_CHAR, + u'service' + UNICODE_CHAR: 'bar1', + u'target_host': u'some_where'}} metrics.append(metric_data) self.notify(self._smtpStub, metrics) @@ -204,9 +214,9 @@ class TestEmail(unittest.TestCase): """ metrics = [] - metric_data = {'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) - metric_data = {'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) self.notify(self._smtpStub, metrics) @@ -232,9 +242,9 @@ class TestEmail(unittest.TestCase): """ metrics = [] - metric_data = {'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) - metric_data = {'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) mock_log = mock.MagicMock() @@ -276,9 +286,9 @@ class TestEmail(unittest.TestCase): """ metrics = [] - metric_data = {'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) - metric_data = {'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) mock_log = mock.MagicMock() @@ -324,9 +334,9 @@ class TestEmail(unittest.TestCase): """ metrics = [] - metric_data = {'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) - metric_data = {'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) mock_log = mock.MagicMock() @@ -366,9 +376,9 @@ class TestEmail(unittest.TestCase): """ metrics = [] - metric_data = {'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) - metric_data = {'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) mock_log = mock.MagicMock() @@ -406,9 +416,9 @@ class TestEmail(unittest.TestCase): """ metrics = [] - metric_data = {'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} metrics.append(metric_data) - metric_data = {'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} + metric_data = {'name': 'cpu.percent', 'dimensions': {'hostname': 'foo2', 'service': 'bar2'}} metrics.append(metric_data) mock_log = mock.MagicMock() @@ -438,3 +448,77 @@ class TestEmail(unittest.TestCase): self.assertNotIn("SMTP server disconnected. Will reconnect and retry message.", self.trap) self.assertIn("Error sending Email Notification", self.trap) + + @mock.patch('monasca_notification.plugins.email_notifier.smtplib') + def test_get_link_url(self, mock_smtp): + # Given one metric with name and dimensions + metrics = [] + metric = {'name': 'cpu.percent', + 'dimensions': {'hostname': 'foo1', 'service': 'bar1'}} + metrics.append(metric) + + mock_log = mock.MagicMock() + mock_log.warn = self.trap.append + mock_log.error = self.trap.append + mock_log.debug = self.trap.append + mock_log.info = self.trap.append + mock_log.exception = self.trap.append + + mock_smtp.SMTP.return_value = mock_smtp + mock_smtp.sendmail.side_effect = smtplib.SMTPException + + mock_smtp.SMTPServerDisconnected = smtplib.SMTPServerDisconnected + mock_smtp.SMTPException = smtplib.SMTPException + + email = email_notifier.EmailNotifier(mock_log) + email.config(self.email_config) + + # Create alarm timestamp and timestamp for 'from' and 'to' dates in milliseconds. + alarm_date = datetime.datetime(2017, 6, 7, 18, 0) + alarm_ms, expected_from_ms, expected_to_ms = self.create_time_data(alarm_date) + + # When retrieving the link to Grafana for the first metric and given timestamp + result_url = email._get_link_url(metrics[0], alarm_ms) + self.assertIsNotNone(result_url) + + # Then the following link to Grafana (including the metric info and timestamp) is expected. + expected_url = "http://127.0.0.1:3000/dashboard/script/drilldown.js" \ + "?metric=cpu.percent&dim_hostname=foo1&dim_service=bar1" \ + "&from=%s&to=%s" % (expected_from_ms, expected_to_ms) + self._assert_equal_urls(expected_url, result_url) + + def create_time_data(self, alarm_date): + epoch = datetime.datetime.utcfromtimestamp(0) + alarm_ms = int(round((alarm_date - epoch).total_seconds() * 1000)) + + # From and to dates are 10 minutes before and after the alarm occurred. + from_date = alarm_date - datetime.timedelta(minutes=10) + to_date = alarm_date + datetime.timedelta(minutes=10) + + expected_from_ms = int(round((from_date - epoch).total_seconds() * 1000)) + expected_to_ms = int(round((to_date - epoch).total_seconds() * 1000)) + + return alarm_ms, expected_from_ms, expected_to_ms + + def _assert_equal_urls(self, expected_url, result_url): + if six.PY2: + expected_parsed = urlparse.urlparse(expected_url) + result_parsed = urlparse.urlparse(result_url) + else: + expected_parsed = urlparse(expected_url) + result_parsed = urlparse(result_url) + + self.assertEqual(expected_parsed.netloc, result_parsed.netloc) + self.assertEqual(expected_parsed.path, result_parsed.path) + + if six.PY2: + expected_parsed_query = urlparse.parse_qs(expected_parsed.query) + result_parsed_query = urlparse.parse_qs(result_parsed.query) + else: + expected_parsed_query = parse.parse_qs(expected_parsed.query) + result_parsed_query = parse.parse_qs(result_parsed.query) + + self.assertEqual(len(expected_parsed_query), len(result_parsed_query)) + + for key in six.iterkeys(result_parsed_query): + self.assertEqual(expected_parsed_query[key], result_parsed_query[key])