From 05c50c640cc26924b1e6d762700c24818a88f6b1 Mon Sep 17 00:00:00 2001 From: Julien Balestra Date: Tue, 2 Jan 2018 14:11:30 +0100 Subject: [PATCH] Fix prometheus check summary and histogram, replace the text parsing (#3617) * prometheus_check: Improve the unittesting strategy of the text parsing * prometheus_check: Use the prometheus client parser * prometheus_check: Fix the sum and count of histogram and summary * prometheus_check: Poll returns Response --- checks/prometheus_check.py | 181 ++++---- requirements.txt | 1 + tests/core/test_prometheus.py | 765 +++++++++++++++++++++++++++++++--- 3 files changed, 816 insertions(+), 131 deletions(-) diff --git a/checks/prometheus_check.py b/checks/prometheus_check.py index 1e78654104..577f5857ff 100644 --- a/checks/prometheus_check.py +++ b/checks/prometheus_check.py @@ -4,10 +4,14 @@ import re import requests +from collections import defaultdict from google.protobuf.internal.decoder import _DecodeVarint32 # pylint: disable=E0611,E0401 from checks import AgentCheck from utils.prometheus import metrics_pb2 +from prometheus_client.parser import text_fd_to_metric_families + + # Prometheus check is a mother class providing a structure and some helpers # to collect metrics, events and service checks exposed via Prometheus. # @@ -29,7 +33,15 @@ class PrometheusFormat: PROTOBUF = "PROTOBUF" TEXT = "TEXT" + +class UnknownFormatError(TypeError): + pass + + class PrometheusCheck(AgentCheck): + UNWANTED_LABELS = ["le", "quantile"] # are specifics keys for prometheus itself + REQUESTS_CHUNK_SIZE = 1024 * 10 # use 10kb as chunk size when using the Stream feature in requests.get + def __init__(self, name, init_config, agentConfig, instances=None): AgentCheck.__init__(self, name, init_config, agentConfig, instances) # message.type is the index in this array @@ -45,7 +57,7 @@ def __init__(self, name, init_config, agentConfig, instances=None): # child check class. self.NAMESPACE = '' - # `metrics_mapper` is a dictionnary where the keys are the metrics to capture + # `metrics_mapper` is a dictionary where the keys are the metrics to capture # and the values are the corresponding metrics names to have in datadog. # Note: it is empty in the mother class but will need to be # overloaded/hardcoded in the final check not to be counted as custom metric. @@ -83,24 +95,24 @@ def prometheus_metric_name(self, message, **kwargs): """ Example method""" pass - class UnknownFormatError(Exception): - def __init__(self, arg): - self.args = arg - - def parse_metric_family(self, buf, content_type): + def parse_metric_family(self, response): """ - Gets the output data from a prometheus endpoint response along with its - Content-type header and parses it into Prometheus classes (see [0]) + Parse the MetricFamily from a valid requests.Response object to provide a MetricFamily object (see [0]) - Parse the binary buffer in input, searching for Prometheus messages - of type MetricFamily [0] delimited by a varint32 [1] when the - content-type is a `application/vnd.google.protobuf`. + The text format uses iter_lines() generator. + + The protobuf format directly parse the response.content property searching for Prometheus messages of type + MetricFamily [0] delimited by a varint32 [1] when the content-type is a `application/vnd.google.protobuf`. [0] https://github.com/prometheus/client_model/blob/086fe7ca28bde6cec2acd5223423c1475a362858/metrics.proto#L76-%20%20L81 [1] https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/AbstractMessageLite#writeDelimitedTo(java.io.OutputStream) + + :param response: requests.Response + :return: metrics_pb2.MetricFamily() """ - if 'application/vnd.google.protobuf' in content_type: + if 'application/vnd.google.protobuf' in response.headers['Content-Type']: n = 0 + buf = response.content while n < len(buf): msg_len, new_pos = _DecodeVarint32(buf, n) n = new_pos @@ -118,26 +130,56 @@ def parse_metric_family(self, buf, content_type): else: self.log.debug("type override %s for %s is not a valid type name" % (new_type, message.name)) yield message - elif 'text/plain' in content_type: - messages = {} # map with the name of the element (before the labels) and the list of occurrences with labels and values - obj_map = {} # map of the types of each metrics - obj_help = {} # help for the metrics - for line in buf.splitlines(): - self._extract_metrics_from_string(line, messages, obj_map, obj_help) - # Add type overrides: - for m_name, m_type in self.type_overrides.iteritems(): - if m_type in self.METRIC_TYPES: - obj_map[m_name] = m_type - else: - self.log.debug("type override %s for %s is not a valid type name" % (m_type,m_name)) + elif 'text/plain' in response.headers['Content-Type']: + messages = defaultdict(list) # map with the name of the element (before the labels) + # and the list of occurrences with labels and values + + obj_map = {} # map of the types of each metrics + obj_help = {} # help for the metrics + for metric in text_fd_to_metric_families(response.iter_lines(chunk_size=self.REQUESTS_CHUNK_SIZE)): + metric_name = "%s_bucket" % metric.name if metric.type == "histogram" else metric.name + metric_type = self.type_overrides.get(metric_name, metric.type) + if metric_type == "untyped" or metric_type not in self.METRIC_TYPES: + continue + + for sample in metric.samples: + if (sample[0].endswith("_sum") or sample[0].endswith("_count")) and \ + metric_type in ["histogram", "summary"]: + messages[sample[0]].append({"labels": sample[1], 'value': sample[2]}) + else: + messages[metric_name].append({"labels": sample[1], 'value': sample[2]}) + obj_map[metric.name] = metric_type + obj_help[metric.name] = metric.documentation for _m in obj_map: - if _m in messages or (obj_map[_m] == 'histogram' and '{}_bucket'.format(_m) in messages): + if _m in messages or (obj_map[_m] == 'histogram' and ('{}_bucket'.format(_m) in messages)): yield self._extract_metric_from_map(_m, messages, obj_map, obj_help) else: - raise self.UnknownFormatError('Unsupported content-type provided: {}'.format(content_type)) + raise UnknownFormatError('Unsupported content-type provided: {}'.format( + response.headers['Content-Type'])) + + @staticmethod + def get_metric_value_by_labels(messages, _metric, _m, metric_suffix): + """ + :param messages: dictionary as metric_name: {labels: {}, value: 10} + :param _metric: dictionary as {labels: {le: '0.001', 'custom': 'value'}} + :param _m: str as metric name + :param metric_suffix: str must be in (count or sum) + :return: value of the metric_name matched by the labels + """ + metric_name = '{}_{}'.format(_m, metric_suffix) + expected_labels = set([(k, v) for k, v in _metric["labels"].iteritems() + if k not in PrometheusCheck.UNWANTED_LABELS]) + for elt in messages[metric_name]: + current_labels = set([(k, v) for k, v in elt["labels"].iteritems() + if k not in PrometheusCheck.UNWANTED_LABELS]) + # As we have two hashable objects we can compare them without any side effects + if current_labels == expected_labels: + return float(elt["value"]) + + raise AttributeError("cannot find expected labels for metric %s with suffix %s" % (metric_name, metric_suffix)) def _extract_metric_from_map(self, _m, messages, obj_map, obj_help): """ @@ -178,15 +220,15 @@ def _extract_metric_from_map(self, _m, messages, obj_map, obj_help): _g.gauge.value = float(_metric['value']) elif obj_map[_m] == 'summary': if '{}_count'.format(_m) in messages: - _g.summary.sample_count = long(float(messages['{}_count'.format(_m)][0]['value'])) + _g.summary.sample_count = long(self.get_metric_value_by_labels(messages, _metric, _m, 'count')) if '{}_sum'.format(_m) in messages: - _g.summary.sample_sum = float(messages['{}_sum'.format(_m)][0]['value']) + _g.summary.sample_sum = self.get_metric_value_by_labels(messages, _metric, _m, 'sum') # TODO: see what can be done with the untyped metrics elif obj_map[_m] == 'histogram': if '{}_count'.format(_m) in messages: - _g.histogram.sample_count = long(float(messages['{}_count'.format(_m)][0]['value'])) + _g.histogram.sample_count = long(self.get_metric_value_by_labels(messages, _metric, _m, 'count')) if '{}_sum'.format(_m) in messages: - _g.histogram.sample_sum = float(messages['{}_sum'.format(_m)][0]['value']) + _g.histogram.sample_sum = self.get_metric_value_by_labels(messages, _metric, _m, 'sum') # last_metric = len(_obj.metric) - 1 # if last_metric >= 0: for lbl in _metric['labels']: @@ -214,42 +256,6 @@ def _extract_metric_from_map(self, _m, messages, obj_map, obj_help): _l.value = _metric['labels'][lbl] return _obj - def _extract_metrics_from_string(self, line, messages, obj_map, obj_help): - """ - Extracts the metrics from a line of metric and update the given - dictionnaries (we take advantage of the reference of the dictionary here) - """ - if line.startswith('# TYPE'): - metric = line.split(' ') - if len(metric) == 4: - obj_map[metric[2]] = metric[3] # line = # TYPE metric_name metric_type - elif line.startswith('# HELP'): - _h = line.split(' ', 3) - if len(_h) == 4: - obj_help[_h[2]] = _h[3] # line = # HELP metric_name Help message... - elif not line.startswith('#'): - _match = self.metrics_pattern.match(line) - if _match is not None: - _g = _match.groups() - _msg = [] - _lbls = self._extract_labels_from_string(_g[1]) - if _g[0] in messages: - _msg = messages[_g[0]] - _msg.append({'labels': _lbls, 'value': _g[2]}) - messages[_g[0]] = _msg - - def _extract_labels_from_string(self,labels): - """ - Extracts the labels from a string that looks like: - {label_name_1="value 1", label_name_2="value 2"} - """ - lbls = {} - labels = labels.lstrip('{').rstrip('}') - _lbls = self.lbl_pattern.findall(labels) - for _lbl in _lbls: - lbls[_lbl[0]] = _lbl[1] - return lbls - def process(self, endpoint, send_histograms_buckets=True, instance=None): """ Polls the data from prometheus and pushes them as gauges @@ -258,12 +264,15 @@ def process(self, endpoint, send_histograms_buckets=True, instance=None): Note that if the instance has a 'tags' attribute, it will be pushed automatically as additionnal custom tags and added to the metrics """ - content_type, data = self.poll(endpoint) - tags = [] - if instance is not None: - tags = instance.get('tags', []) - for metric in self.parse_metric_family(data, content_type): - self.process_metric(metric, send_histograms_buckets=send_histograms_buckets, custom_tags=tags, instance=instance) + response = self.poll(endpoint) + try: + tags = [] + if instance is not None: + tags = instance.get('tags', []) + for metric in self.parse_metric_family(response): + self.process_metric(metric, send_histograms_buckets=send_histograms_buckets, custom_tags=tags, instance=instance) + finally: + response.close() def process_metric(self, message, send_histograms_buckets=True, custom_tags=None, **kwargs): """ @@ -284,23 +293,39 @@ def process_metric(self, message, send_histograms_buckets=True, custom_tags=None except AttributeError as err: self.log.debug("Unable to handle metric: {} - error: {}".format(message.name, err)) - def poll(self, endpoint, pFormat=PrometheusFormat.PROTOBUF, headers={}): + def poll(self, endpoint, pFormat=PrometheusFormat.PROTOBUF, headers=None): """ Polls the metrics from the prometheus metrics endpoint provided. Defaults to the protobuf format, but can use the formats specified by the PrometheusFormat class. Custom headers can be added to the default headers. - Returns the content-type of the response and the content of the reponse itself. + Returns a valid requests.Response, raise requests.HTTPError if the status code of the requests.Response + isn't valid - see response.raise_for_status() + + The caller needs to close the requests.Response + + :param endpoint: string url endpoint + :param pFormat: the preferred format defined in PrometheusFormat + :param headers: extra headers + :return: requests.Response """ + if headers is None: + headers = {} if 'accept-encoding' not in headers: headers['accept-encoding'] = 'gzip' if pFormat == PrometheusFormat.PROTOBUF: - headers['accept'] = 'application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited' + headers['accept'] = 'application/vnd.google.protobuf; ' \ + 'proto=io.prometheus.client.MetricFamily; ' \ + 'encoding=delimited' - req = requests.get(endpoint, headers=headers) - req.raise_for_status() - return req.headers['Content-Type'], req.content + response = requests.get(endpoint, headers=headers, stream=True) + try: + response.raise_for_status() + return response + except requests.HTTPError: + response.close() + raise def _submit(self, metric_name, message, send_histograms_buckets=True, custom_tags=None): """ diff --git a/requirements.txt b/requirements.txt index 55a08cbd46..bfc9674907 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,3 +37,4 @@ simplejson==3.6.5 supervisor==3.3.3 tornado==3.2.2 uptime==3.0.1 +prometheus-client==0.1.0 \ No newline at end of file diff --git a/tests/core/test_prometheus.py b/tests/core/test_prometheus.py index 155d6f836b..3279998a0d 100644 --- a/tests/core/test_prometheus.py +++ b/tests/core/test_prometheus.py @@ -1,16 +1,32 @@ # (C) Datadog, Inc. 2016 # All rights reserved # Licensed under Simplified BSD License (see LICENSE) - import logging -from mock import MagicMock, patch, call -import unittest import os +import unittest -from checks.prometheus_check import PrometheusCheck +from mock import MagicMock, patch, call + +from checks.prometheus_check import PrometheusCheck, UnknownFormatError from utils.prometheus import parse_metric_family, metrics_pb2 +class MockResponse: + """ + MockResponse is used to simulate the object requests.Response commonly returned by requests.get + """ + def __init__(self, content, content_type): + self.content = content + self.headers = {'Content-Type': content_type} + + def iter_lines(self, **_): + for elt in self.content.split("\n"): + yield elt + + def close(self): + pass + + class TestPrometheusFuncs(unittest.TestCase): def test_parse_metric_family(self): f_name = os.path.join(os.path.dirname(__file__), 'fixtures', 'prometheus', 'protobuf.bin') @@ -21,6 +37,7 @@ def test_parse_metric_family(self): self.assertEqual(len(messages), 61) self.assertEqual(messages[-1].name, 'process_virtual_memory_bytes') + class TestPrometheusProcessor(unittest.TestCase): def setUp(self): @@ -35,7 +52,7 @@ def setUp(self): self.ref_gauge = metrics_pb2.MetricFamily() self.ref_gauge.name = 'process_virtual_memory_bytes' self.ref_gauge.help = 'Virtual memory size in bytes.' - self.ref_gauge.type = 1 # GAUGE + self.ref_gauge.type = 1 # GAUGE _m = self.ref_gauge.metric.add() _m.gauge.value = 39211008.0 # Loading test binary data @@ -45,6 +62,13 @@ def setUp(self): self.bin_data = f.read() self.assertEqual(len(self.bin_data), 51855) + self.text_data = None + # Loading test text data + f_name = os.path.join(os.path.dirname(__file__), 'fixtures', 'prometheus', 'metrics.txt') + with open(f_name, 'rb') as f: + self.text_data = f.read() + self.assertEqual(len(self.text_data), 14494) + def tearDown(self): # Cleanup self.check = None @@ -57,40 +81,39 @@ def test_check(self): self.check.check(None) def test_parse_metric_family_protobuf(self): - messages = list(self.check.parse_metric_family(self.bin_data, self.protobuf_content_type)) + response = MockResponse(self.bin_data, self.protobuf_content_type) + messages = list(self.check.parse_metric_family(response)) self.assertEqual(len(messages), 61) self.assertEqual(messages[-1].name, 'process_virtual_memory_bytes') # check type overriding is working # original type: self.assertEqual(messages[1].name, 'go_goroutines') - self.assertEqual(messages[1].type, 1) # gauge + self.assertEqual(messages[1].type, 1) # gauge # override the type: self.check.type_overrides = {"go_goroutines": "summary"} - messages = list(self.check.parse_metric_family(self.bin_data, self.protobuf_content_type)) + response = MockResponse(self.bin_data, self.protobuf_content_type) + messages = list(self.check.parse_metric_family(response)) self.assertEqual(len(messages), 61) self.assertEqual(messages[1].name, 'go_goroutines') - self.assertEqual(messages[1].type, 2) # summary + self.assertEqual(messages[1].type, 2) # summary def test_parse_metric_family_text(self): ''' Test the high level method for loading metrics from text format ''' - _text_data = None - f_name = os.path.join(os.path.dirname(__file__), 'fixtures', 'prometheus', 'metrics.txt') - with open(f_name, 'r') as f: - _text_data = f.read() - self.assertEqual(len(_text_data), 14494) - messages = list(self.check.parse_metric_family(_text_data, 'text/plain; version=0.0.4')) + response = MockResponse(self.text_data, 'text/plain; version=0.0.4') + messages = list(self.check.parse_metric_family(response)) # total metrics are 41 but one is typeless and we expect it not to be # parsed... self.assertEqual(len(messages), 40) # ...unless the check ovverrides the type manually self.check.type_overrides = {"go_goroutines": "gauge"} - messages = list(self.check.parse_metric_family(_text_data, 'text/plain; version=0.0.4')) + response = MockResponse(self.text_data, 'text/plain; version=0.0.4') + messages = list(self.check.parse_metric_family(response)) self.assertEqual(len(messages), 41) # Tests correct parsing of counters _counter = metrics_pb2.MetricFamily() _counter.name = 'skydns_skydns_dns_cachemiss_count_total' _counter.help = 'Counter of DNS requests that result in a cache miss.' - _counter.type = 0 # COUNTER + _counter.type = 0 # COUNTER _c = _counter.metric.add() _c.counter.value = 1359194.0 _lc = _c.label.add() @@ -101,14 +124,14 @@ def test_parse_metric_family_text(self): _gauge = metrics_pb2.MetricFamily() _gauge.name = 'go_memstats_heap_alloc_bytes' _gauge.help = 'Number of heap bytes allocated and still in use.' - _gauge.type = 1 # GAUGE + _gauge.type = 1 # GAUGE _gauge.metric.add().gauge.value = 6396288.0 self.assertIn(_gauge, messages) # Tests correct parsing of summaries _summary = metrics_pb2.MetricFamily() _summary.name = 'http_response_size_bytes' _summary.help = 'The HTTP response sizes in bytes.' - _summary.type = 2 # SUMMARY + _summary.type = 2 # SUMMARY _sm = _summary.metric.add() _lsm = _sm.label.add() _lsm.name = 'handler' @@ -129,17 +152,17 @@ def test_parse_metric_family_text(self): _histo = metrics_pb2.MetricFamily() _histo.name = 'skydns_skydns_dns_response_size_bytes' _histo.help = 'Size of the returns response in bytes.' - _histo.type = 4 # HISTOGRAM + _histo.type = 4 # HISTOGRAM _sample_data = [ - {'ct':1359194,'sum':199427281.0, 'lbl': {'system':'auth'}, - 'buckets':{0.0: 0, 512.0:1359194, 1024.0:1359194, - 1500.0:1359194, 2048.0:1359194, float('+Inf'):1359194}}, - {'ct':1359194,'sum':199427281.0, 'lbl': {'system':'recursive'}, - 'buckets':{0.0: 0, 512.0:520924, 1024.0:520924, 1500.0:520924, - 2048.0:520924, float('+Inf'):520924}}, - {'ct':1359194,'sum':199427281.0, 'lbl': {'system':'reverse'}, - 'buckets':{0.0: 0, 512.0:67648, 1024.0:67648, 1500.0:67648, - 2048.0:67648, float('+Inf'):67648}}, + {'ct': 1359194, 'sum': 199427281.0, 'lbl': {'system': 'auth'}, + 'buckets': {0.0: 0, 512.0: 1359194, 1024.0: 1359194, + 1500.0: 1359194, 2048.0: 1359194, float('+Inf'): 1359194}}, + {'ct': 520924, 'sum': 41527128.0, 'lbl': {'system': 'recursive'}, + 'buckets': {0.0: 0, 512.0: 520924, 1024.0: 520924, 1500.0: 520924, + 2048.0: 520924, float('+Inf'): 520924}}, + {'ct': 67648, 'sum': 6075182.0, 'lbl': {'system': 'reverse'}, + 'buckets': {0.0: 0, 512.0: 67648, 1024.0: 67648, 1500.0: 67648, + 2048.0: 67648, float('+Inf'): 67648}}, ] for _data in _sample_data: _h = _histo.metric.add() @@ -156,35 +179,41 @@ def test_parse_metric_family_text(self): self.assertIn(_histo, messages) def test_parse_metric_family_unsupported(self): - with self.assertRaises(PrometheusCheck.UnknownFormatError): - list(self.check.parse_metric_family(self.bin_data, 'application/json')) + with self.assertRaises(UnknownFormatError): + response = MockResponse(self.bin_data, 'application/json') + list(self.check.parse_metric_family(response)) def test_process(self): endpoint = "http://fake.endpoint:10055/metrics" - self.check.poll = MagicMock(return_value=[self.protobuf_content_type, self.bin_data]) + self.check.poll = MagicMock(return_value=MockResponse(self.bin_data, self.protobuf_content_type)) self.check.process_metric = MagicMock() self.check.process(endpoint, instance=None) self.check.poll.assert_called_with(endpoint) - self.check.process_metric.assert_called_with(self.ref_gauge, custom_tags=[], instance=None, send_histograms_buckets=True) + self.check.process_metric.assert_called_with(self.ref_gauge, custom_tags=[], instance=None, + send_histograms_buckets=True) def test_process_send_histograms_buckets(self): """ Cheks that the send_histograms_buckets parameter is passed along """ endpoint = "http://fake.endpoint:10055/metrics" - self.check.poll = MagicMock(return_value=[self.protobuf_content_type, self.bin_data]) + self.check.poll = MagicMock( + return_value=MockResponse(self.bin_data, self.protobuf_content_type)) self.check.process_metric = MagicMock() self.check.process(endpoint, send_histograms_buckets=False, instance=None) self.check.poll.assert_called_with(endpoint) - self.check.process_metric.assert_called_with(self.ref_gauge, custom_tags=[], instance=None, send_histograms_buckets=False) + self.check.process_metric.assert_called_with(self.ref_gauge, custom_tags=[], instance=None, + send_histograms_buckets=False) def test_process_instance_with_tags(self): """ Checks that an instances with tags passes them as custom tag """ endpoint = "http://fake.endpoint:10055/metrics" - self.check.poll = MagicMock(return_value=[self.protobuf_content_type, self.bin_data]) + self.check.poll = MagicMock( + return_value=MockResponse(self.bin_data, self.protobuf_content_type)) self.check.process_metric = MagicMock() instance = {'endpoint': 'IgnoreMe', 'tags': ['tag1:tagValue1', 'tag2:tagValue2']} self.check.process(endpoint, instance=instance) self.check.poll.assert_called_with(endpoint) - self.check.process_metric.assert_called_with(self.ref_gauge, custom_tags=['tag1:tagValue1', 'tag2:tagValue2'], instance=instance, send_histograms_buckets=True) + self.check.process_metric.assert_called_with(self.ref_gauge, custom_tags=['tag1:tagValue1', 'tag2:tagValue2'], + instance=instance, send_histograms_buckets=True) def test_process_metric_gauge(self): ''' Gauge ref submission ''' @@ -196,22 +225,39 @@ def test_process_metric_filtered(self): filtered_gauge = metrics_pb2.MetricFamily() filtered_gauge.name = "process_start_time_seconds" filtered_gauge.help = "Start time of the process since unix epoch in seconds." - filtered_gauge.type = 1 # GAUGE + filtered_gauge.type = 1 # GAUGE _m = filtered_gauge.metric.add() _m.gauge.value = 39211008.0 self.check.process_metric(filtered_gauge) - self.check.log.debug.assert_called_with("Unable to handle metric: process_start_time_seconds - error: 'PrometheusCheck' object has no attribute 'process_start_time_seconds'") + self.check.log.debug.assert_called_with( + "Unable to handle metric: process_start_time_seconds - error: 'PrometheusCheck' object has no attribute 'process_start_time_seconds'") self.check.gauge.assert_not_called() @patch('requests.get') def test_poll_protobuf(self, mock_get): ''' Tests poll using the protobuf format ''' - mock_get.return_value = MagicMock(status_code=200, content=self.bin_data, headers={'Content-Type': self.protobuf_content_type}) - ct, data = self.check.poll("http://fake.endpoint:10055/metrics") - messages = list(self.check.parse_metric_family(data, ct)) + mock_get.return_value = MagicMock( + status_code=200, + content=self.bin_data, + headers={'Content-Type': self.protobuf_content_type}) + response = self.check.poll("http://fake.endpoint:10055/metrics") + messages = list(self.check.parse_metric_family(response)) self.assertEqual(len(messages), 61) self.assertEqual(messages[-1].name, 'process_virtual_memory_bytes') + @patch('requests.get') + def test_poll_text_plain(self, mock_get): + """Tests poll using the text format""" + mock_get.return_value = MagicMock( + status_code=200, + iter_lines=lambda **kwargs: self.text_data.split("\n"), + headers={'Content-Type': "text/plain"}) + response = self.check.poll("http://fake.endpoint:10055/metrics") + messages = list(self.check.parse_metric_family(response)) + messages.sort(key=lambda x: x.name) + self.assertEqual(len(messages), 40) + self.assertEqual(messages[-1].name, 'skydns_skydns_dns_response_size_bytes') + def test_submit_gauge_with_labels(self): ''' submitting metrics that contain labels should result in tags on the gauge call ''' _l1 = self.ref_gauge.metric[0].label.add() @@ -222,7 +268,7 @@ def test_submit_gauge_with_labels(self): _l2.value = 'my_2nd_label_value' self.check._submit(self.check.metrics_mapper[self.ref_gauge.name], self.ref_gauge) self.check.gauge.assert_called_with('prometheus.process.vm.bytes', 39211008.0, - ['my_1st_label:my_1st_label_value', 'my_2nd_label:my_2nd_label_value']) + ['my_1st_label:my_1st_label_value', 'my_2nd_label:my_2nd_label_value']) def test_labels_not_added_as_tag_once_for_each_metric(self): _l1 = self.ref_gauge.metric[0].label.add() @@ -237,14 +283,15 @@ def test_labels_not_added_as_tag_once_for_each_metric(self): # avoid regression on https://github.com/DataDog/dd-agent/pull/3359 self.check._submit(self.check.metrics_mapper[self.ref_gauge.name], self.ref_gauge, custom_tags=tags) self.check.gauge.assert_called_with('prometheus.process.vm.bytes', 39211008.0, - ['test', 'my_1st_label:my_1st_label_value', 'my_2nd_label:my_2nd_label_value']) + ['test', 'my_1st_label:my_1st_label_value', + 'my_2nd_label:my_2nd_label_value']) def test_submit_gauge_with_custom_tags(self): ''' Providing custom tags should add them as is on the gauge call ''' tags = ['env:dev', 'app:my_pretty_app'] self.check._submit(self.check.metrics_mapper[self.ref_gauge.name], self.ref_gauge, custom_tags=tags) self.check.gauge.assert_called_with('prometheus.process.vm.bytes', 39211008.0, - ['env:dev', 'app:my_pretty_app']) + ['env:dev', 'app:my_pretty_app']) def test_submit_gauge_with_labels_mapper(self): ''' @@ -257,11 +304,13 @@ def test_submit_gauge_with_labels_mapper(self): _l2 = self.ref_gauge.metric[0].label.add() _l2.name = 'my_2nd_label' _l2.value = 'my_2nd_label_value' - self.check.labels_mapper = {'my_1st_label': 'transformed_1st', 'non_existent': 'should_not_matter', 'env': 'dont_touch_custom_tags'} + self.check.labels_mapper = {'my_1st_label': 'transformed_1st', 'non_existent': 'should_not_matter', + 'env': 'dont_touch_custom_tags'} tags = ['env:dev', 'app:my_pretty_app'] self.check._submit(self.check.metrics_mapper[self.ref_gauge.name], self.ref_gauge, custom_tags=tags) self.check.gauge.assert_called_with('prometheus.process.vm.bytes', 39211008.0, - ['env:dev', 'app:my_pretty_app', 'transformed_1st:my_1st_label_value', 'my_2nd_label:my_2nd_label_value']) + ['env:dev', 'app:my_pretty_app', 'transformed_1st:my_1st_label_value', + 'my_2nd_label:my_2nd_label_value']) def test_submit_gauge_with_exclude_labels(self): ''' @@ -274,18 +323,19 @@ def test_submit_gauge_with_exclude_labels(self): _l2 = self.ref_gauge.metric[0].label.add() _l2.name = 'my_2nd_label' _l2.value = 'my_2nd_label_value' - self.check.labels_mapper = {'my_1st_label': 'transformed_1st', 'non_existent': 'should_not_matter', 'env': 'dont_touch_custom_tags'} + self.check.labels_mapper = {'my_1st_label': 'transformed_1st', 'non_existent': 'should_not_matter', + 'env': 'dont_touch_custom_tags'} tags = ['env:dev', 'app:my_pretty_app'] - self.check.exclude_labels = ['my_2nd_label', 'whatever_else', 'env'] # custom tags are not filtered out + self.check.exclude_labels = ['my_2nd_label', 'whatever_else', 'env'] # custom tags are not filtered out self.check._submit(self.check.metrics_mapper[self.ref_gauge.name], self.ref_gauge, custom_tags=tags) self.check.gauge.assert_called_with('prometheus.process.vm.bytes', 39211008.0, - ['env:dev', 'app:my_pretty_app', 'transformed_1st:my_1st_label_value']) + ['env:dev', 'app:my_pretty_app', 'transformed_1st:my_1st_label_value']) def test_submit_counter(self): _counter = metrics_pb2.MetricFamily() _counter.name = 'my_counter' _counter.help = 'Random counter' - _counter.type = 0 # COUNTER + _counter.type = 0 # COUNTER _met = _counter.metric.add() _met.counter.value = 42 self.check._submit('custom.counter', _counter) @@ -295,7 +345,7 @@ def test_submits_summary(self): _sum = metrics_pb2.MetricFamily() _sum.name = 'my_summary' _sum.help = 'Random summary' - _sum.type = 2 # SUMMARY + _sum.type = 2 # SUMMARY _met = _sum.metric.add() _met.summary.sample_count = 42 _met.summary.sample_sum = 3.14 @@ -317,7 +367,7 @@ def test_submit_histogram(self): _histo = metrics_pb2.MetricFamily() _histo.name = 'my_histogram' _histo.help = 'Random histogram' - _histo.type = 4 # HISTOGRAM + _histo.type = 4 # HISTOGRAM _met = _histo.metric.add() _met.histogram.sample_count = 42 _met.histogram.sample_sum = 3.14 @@ -334,3 +384,612 @@ def test_submit_histogram(self): call('prometheus.custom.histogram.count', 33, ['upper_bound:12.7']), call('prometheus.custom.histogram.count', 666, ['upper_bound:18.2']) ]) + + +class TestPrometheusTextParsing(unittest.TestCase): + """ + The docstrings of each test_* method is a string representation of the expected MetricFamily (if present) + """ + def setUp(self): + self.check = PrometheusCheck('prometheus_check', {}, {}, {}) + + def test_parse_one_gauge(self): + """ + name: "etcd_server_has_leader" + help: "Whether or not a leader exists. 1 is existence, 0 is not." + type: GAUGE + metric { + gauge { + value: 1.0 + } + } + """ + text_data = ( + "# HELP etcd_server_has_leader Whether or not a leader exists. 1 is existence, 0 is not.\n" + "# TYPE etcd_server_has_leader gauge\n" + "etcd_server_has_leader 1\n") + + expected_etcd_metric = metrics_pb2.MetricFamily() + expected_etcd_metric.help = "Whether or not a leader exists. 1 is existence, 0 is not." + expected_etcd_metric.name = "etcd_server_has_leader" + expected_etcd_metric.type = 1 + expected_etcd_metric.metric.add().gauge.value = 1 + + # Iter on the generator to get all metrics + response = MockResponse(text_data, 'text/plain; version=0.0.4') + metrics = [k for k in self.check.parse_metric_family(response)] + + self.assertEqual(1, len(metrics)) + current_metric = metrics[0] + self.assertEqual(expected_etcd_metric, current_metric) + + # Remove the old metric and add a new one with a different value + expected_etcd_metric.metric.pop() + expected_etcd_metric.metric.add().gauge.value = 0 + self.assertNotEqual(expected_etcd_metric, current_metric) + + # Re-add the expected value but as different type: it should works + expected_etcd_metric.metric.pop() + expected_etcd_metric.metric.add().gauge.value = 1.0 + self.assertEqual(expected_etcd_metric, current_metric) + + def test_parse_one_counter(self): + """ + name: "go_memstats_mallocs_total" + help: "Total number of mallocs." + type: COUNTER + metric { + counter { + value: 18713.0 + } + } + """ + text_data = ( + "# HELP go_memstats_mallocs_total Total number of mallocs.\n" + "# TYPE go_memstats_mallocs_total counter\n" + "go_memstats_mallocs_total 18713\n") + + expected_etcd_metric = metrics_pb2.MetricFamily() + expected_etcd_metric.help = "Total number of mallocs." + expected_etcd_metric.name = "go_memstats_mallocs_total" + expected_etcd_metric.type = 0 + expected_etcd_metric.metric.add().counter.value = 18713 + + # Iter on the generator to get all metrics + response = MockResponse(text_data, 'text/plain; version=0.0.4') + metrics = [k for k in self.check.parse_metric_family(response)] + + self.assertEqual(1, len(metrics)) + current_metric = metrics[0] + self.assertEqual(expected_etcd_metric, current_metric) + + # Remove the old metric and add a new one with a different value + expected_etcd_metric.metric.pop() + expected_etcd_metric.metric.add().counter.value = 18714 + self.assertNotEqual(expected_etcd_metric, current_metric) + + def test_parse_one_histograms_with_label(self): + text_data = ( + '# HELP etcd_disk_wal_fsync_duration_seconds The latency distributions of fsync called by wal.\n' + '# TYPE etcd_disk_wal_fsync_duration_seconds histogram\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="0.001"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="0.002"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="0.004"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="0.008"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="0.016"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="0.032"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="0.064"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="0.128"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="0.256"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="0.512"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="1.024"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="2.048"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="4.096"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="8.192"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{app="vault",le="+Inf"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_sum{app="vault"} 0.026131671\n' + 'etcd_disk_wal_fsync_duration_seconds_count{app="vault"} 4\n') + + expected_etcd_vault_metric = metrics_pb2.MetricFamily() + expected_etcd_vault_metric.help = "The latency distributions of fsync called by wal." + expected_etcd_vault_metric.name = "etcd_disk_wal_fsync_duration_seconds" + expected_etcd_vault_metric.type = 4 + + histogram_metric = expected_etcd_vault_metric.metric.add() + + # Label for app vault + summary_label = histogram_metric.label.add() + summary_label.name, summary_label.value = "app", "vault" + + for upper_bound, cumulative_count in [ + (0.001, 2), + (0.002, 2), + (0.004, 2), + (0.008, 2), + (0.016, 4), + (0.032, 4), + (0.064, 4), + (0.128, 4), + (0.256, 4), + (0.512, 4), + (1.024, 4), + (2.048, 4), + (4.096, 4), + (8.192, 4), + (float('inf'), 4), + ]: + bucket = histogram_metric.histogram.bucket.add() + bucket.upper_bound = upper_bound + bucket.cumulative_count = cumulative_count + + # Root histogram sample + histogram_metric.histogram.sample_count = 4 + histogram_metric.histogram.sample_sum = 0.026131671 + + # Iter on the generator to get all metrics + response = MockResponse(text_data, 'text/plain; version=0.0.4') + metrics = [k for k in self.check.parse_metric_family(response)] + + self.assertEqual(1, len(metrics)) + current_metric = metrics[0] + self.assertEqual(expected_etcd_vault_metric, current_metric) + + def test_parse_one_histogram(self): + """ + name: "etcd_disk_wal_fsync_duration_seconds" + help: "The latency distributions of fsync called by wal." + type: HISTOGRAM + metric { + histogram { + sample_count: 4 + sample_sum: 0.026131671 + bucket { + cumulative_count: 2 + upper_bound: 0.001 + } + bucket { + cumulative_count: 2 + upper_bound: 0.002 + } + bucket { + cumulative_count: 2 + upper_bound: 0.004 + } + bucket { + cumulative_count: 2 + upper_bound: 0.008 + } + bucket { + cumulative_count: 4 + upper_bound: 0.016 + } + bucket { + cumulative_count: 4 + upper_bound: 0.032 + } + bucket { + cumulative_count: 4 + upper_bound: 0.064 + } + bucket { + cumulative_count: 4 + upper_bound: 0.128 + } + bucket { + cumulative_count: 4 + upper_bound: 0.256 + } + bucket { + cumulative_count: 4 + upper_bound: 0.512 + } + bucket { + cumulative_count: 4 + upper_bound: 1.024 + } + bucket { + cumulative_count: 4 + upper_bound: 2.048 + } + bucket { + cumulative_count: 4 + upper_bound: 4.096 + } + bucket { + cumulative_count: 4 + upper_bound: 8.192 + } + bucket { + cumulative_count: 4 + upper_bound: inf + } + } + } + """ + text_data = ( + '# HELP etcd_disk_wal_fsync_duration_seconds The latency distributions of fsync called by wal.\n' + '# TYPE etcd_disk_wal_fsync_duration_seconds histogram\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="0.001"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="0.002"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="0.004"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="0.008"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="0.016"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="0.032"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="0.064"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="0.128"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="0.256"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="0.512"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="1.024"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="2.048"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="4.096"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="8.192"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{le="+Inf"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_sum 0.026131671\n' + 'etcd_disk_wal_fsync_duration_seconds_count 4\n') + + expected_etcd_metric = metrics_pb2.MetricFamily() + expected_etcd_metric.help = "The latency distributions of fsync called by wal." + expected_etcd_metric.name = "etcd_disk_wal_fsync_duration_seconds" + expected_etcd_metric.type = 4 + + histogram_metric = expected_etcd_metric.metric.add() + for upper_bound, cumulative_count in [ + (0.001, 2), + (0.002, 2), + (0.004, 2), + (0.008, 2), + (0.016, 4), + (0.032, 4), + (0.064, 4), + (0.128, 4), + (0.256, 4), + (0.512, 4), + (1.024, 4), + (2.048, 4), + (4.096, 4), + (8.192, 4), + (float('inf'), 4), + ]: + bucket = histogram_metric.histogram.bucket.add() + bucket.upper_bound = upper_bound + bucket.cumulative_count = cumulative_count + + # Root histogram sample + histogram_metric.histogram.sample_count = 4 + histogram_metric.histogram.sample_sum = 0.026131671 + + # Iter on the generator to get all metrics + response = MockResponse(text_data, 'text/plain; version=0.0.4') + metrics = [k for k in self.check.parse_metric_family(response)] + + self.assertEqual(1, len(metrics)) + current_metric = metrics[0] + self.assertEqual(expected_etcd_metric, current_metric) + + def test_parse_two_histograms_with_label(self): + text_data = ( + '# HELP etcd_disk_wal_fsync_duration_seconds The latency distributions of fsync called by wal.\n' + '# TYPE etcd_disk_wal_fsync_duration_seconds histogram\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="0.001"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="0.002"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="0.004"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="0.008"} 2\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="0.016"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="0.032"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="0.064"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="0.128"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="0.256"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="0.512"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="1.024"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="2.048"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="4.096"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="8.192"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="vault",le="+Inf"} 4\n' + 'etcd_disk_wal_fsync_duration_seconds_sum{kind="fs",app="vault"} 0.026131671\n' + 'etcd_disk_wal_fsync_duration_seconds_count{kind="fs",app="vault"} 4\n' + + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="0.001"} 718\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="0.002"} 740\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="0.004"} 743\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="0.008"} 748\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="0.016"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="0.032"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="0.064"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="0.128"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="0.256"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="0.512"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="1.024"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="2.048"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="4.096"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="8.192"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_bucket{kind="fs",app="kubernetes",le="+Inf"} 751\n' + 'etcd_disk_wal_fsync_duration_seconds_sum{kind="fs",app="kubernetes"} 0.3097010759999998\n' + 'etcd_disk_wal_fsync_duration_seconds_count{kind="fs",app="kubernetes"} 751\n') + + expected_etcd_metric = metrics_pb2.MetricFamily() + expected_etcd_metric.help = "The latency distributions of fsync called by wal." + expected_etcd_metric.name = "etcd_disk_wal_fsync_duration_seconds" + expected_etcd_metric.type = 4 + + # Vault + histogram_metric = expected_etcd_metric.metric.add() + + # Label for app vault + summary_label = histogram_metric.label.add() + summary_label.name, summary_label.value = "kind", "fs" + summary_label = histogram_metric.label.add() + summary_label.name, summary_label.value = "app", "vault" + + for upper_bound, cumulative_count in [ + (0.001, 2), + (0.002, 2), + (0.004, 2), + (0.008, 2), + (0.016, 4), + (0.032, 4), + (0.064, 4), + (0.128, 4), + (0.256, 4), + (0.512, 4), + (1.024, 4), + (2.048, 4), + (4.096, 4), + (8.192, 4), + (float('inf'), 4), + ]: + bucket = histogram_metric.histogram.bucket.add() + bucket.upper_bound = upper_bound + bucket.cumulative_count = cumulative_count + + # Root histogram sample + histogram_metric.histogram.sample_count = 4 + histogram_metric.histogram.sample_sum = 0.026131671 + + # Kubernetes + histogram_metric = expected_etcd_metric.metric.add() + + # Label for app kubernetes + summary_label = histogram_metric.label.add() + summary_label.name, summary_label.value = "kind", "fs" + summary_label = histogram_metric.label.add() + summary_label.name, summary_label.value = "app", "kubernetes" + + for upper_bound, cumulative_count in [ + (0.001, 718), + (0.002, 740), + (0.004, 743), + (0.008, 748), + (0.016, 751), + (0.032, 751), + (0.064, 751), + (0.128, 751), + (0.256, 751), + (0.512, 751), + (1.024, 751), + (2.048, 751), + (4.096, 751), + (8.192, 751), + (float('inf'), 751), + ]: + bucket = histogram_metric.histogram.bucket.add() + bucket.upper_bound = upper_bound + bucket.cumulative_count = cumulative_count + + # Root histogram sample + histogram_metric.histogram.sample_count = 751 + histogram_metric.histogram.sample_sum = 0.3097010759999998 + + # Iter on the generator to get all metrics + response = MockResponse(text_data, 'text/plain; version=0.0.4') + metrics = [k for k in self.check.parse_metric_family(response)] + + self.assertEqual(1, len(metrics)) + current_metric = metrics[0] + self.assertEqual(expected_etcd_metric, current_metric) + + def test_parse_one_summary(self): + """ + name: "http_response_size_bytes" + help: "The HTTP response sizes in bytes." + type: SUMMARY + metric { + label { + name: "handler" + value: "prometheus" + } + summary { + sample_count: 5 + sample_sum: 120512.0 + quantile { + quantile: 0.5 + value: 24547.0 + } + quantile { + quantile: 0.9 + value: 25763.0 + } + quantile { + quantile: 0.99 + value: 25763.0 + } + } + } + """ + text_data = ( + '# HELP http_response_size_bytes The HTTP response sizes in bytes.\n' + '# TYPE http_response_size_bytes summary\n' + 'http_response_size_bytes{handler="prometheus",quantile="0.5"} 24547\n' + 'http_response_size_bytes{handler="prometheus",quantile="0.9"} 25763\n' + 'http_response_size_bytes{handler="prometheus",quantile="0.99"} 25763\n' + 'http_response_size_bytes_sum{handler="prometheus"} 120512\n' + 'http_response_size_bytes_count{handler="prometheus"} 5\n') + + expected_etcd_metric = metrics_pb2.MetricFamily() + expected_etcd_metric.help = "The HTTP response sizes in bytes." + expected_etcd_metric.name = "http_response_size_bytes" + expected_etcd_metric.type = 2 + + summary_metric = expected_etcd_metric.metric.add() + + # Label for prometheus handler + summary_label = summary_metric.label.add() + summary_label.name, summary_label.value = "handler", "prometheus" + + # Root summary sample + summary_metric.summary.sample_count = 5 + summary_metric.summary.sample_sum = 120512 + + # Create quantiles 0.5, 0.9, 0.99 + quantile_05 = summary_metric.summary.quantile.add() + quantile_05.quantile = 0.5 + quantile_05.value = 24547 + + quantile_09 = summary_metric.summary.quantile.add() + quantile_09.quantile = 0.9 + quantile_09.value = 25763 + + quantile_099 = summary_metric.summary.quantile.add() + quantile_099.quantile = 0.99 + quantile_099.value = 25763 + + # Iter on the generator to get all metrics + response = MockResponse(text_data, 'text/plain; version=0.0.4') + metrics = [k for k in self.check.parse_metric_family(response)] + + self.assertEqual(1, len(metrics)) + + current_metric = metrics[0] + self.assertEqual(expected_etcd_metric, current_metric) + + def test_parse_two_summaries_with_labels(self): + text_data = ( + '# HELP http_response_size_bytes The HTTP response sizes in bytes.\n' + '# TYPE http_response_size_bytes summary\n' + 'http_response_size_bytes{from="internet",handler="prometheus",quantile="0.5"} 24547\n' + 'http_response_size_bytes{from="internet",handler="prometheus",quantile="0.9"} 25763\n' + 'http_response_size_bytes{from="internet",handler="prometheus",quantile="0.99"} 25763\n' + 'http_response_size_bytes_sum{from="internet",handler="prometheus"} 120512\n' + 'http_response_size_bytes_count{from="internet",handler="prometheus"} 5\n' + + 'http_response_size_bytes{from="cluster",handler="prometheus",quantile="0.5"} 24615\n' + 'http_response_size_bytes{from="cluster",handler="prometheus",quantile="0.9"} 24627\n' + 'http_response_size_bytes{from="cluster",handler="prometheus",quantile="0.99"} 24627\n' + 'http_response_size_bytes_sum{from="cluster",handler="prometheus"} 94913\n' + 'http_response_size_bytes_count{from="cluster",handler="prometheus"} 4\n') + + expected_etcd_metric = metrics_pb2.MetricFamily() + expected_etcd_metric.help = "The HTTP response sizes in bytes." + expected_etcd_metric.name = "http_response_size_bytes" + expected_etcd_metric.type = 2 + + # Metric from internet # + summary_metric_from_internet = expected_etcd_metric.metric.add() + + # Label for prometheus handler + summary_label = summary_metric_from_internet.label.add() + summary_label.name, summary_label.value = "handler", "prometheus" + + summary_label = summary_metric_from_internet.label.add() + summary_label.name, summary_label.value = "from", "internet" + + # Root summary sample + summary_metric_from_internet.summary.sample_count = 5 + summary_metric_from_internet.summary.sample_sum = 120512 + + # Create quantiles 0.5, 0.9, 0.99 + quantile_05 = summary_metric_from_internet.summary.quantile.add() + quantile_05.quantile = 0.5 + quantile_05.value = 24547 + + quantile_09 = summary_metric_from_internet.summary.quantile.add() + quantile_09.quantile = 0.9 + quantile_09.value = 25763 + + quantile_099 = summary_metric_from_internet.summary.quantile.add() + quantile_099.quantile = 0.99 + quantile_099.value = 25763 + + # Metric from cluster # + summary_metric_from_cluster = expected_etcd_metric.metric.add() + + # Label for prometheus handler + summary_label = summary_metric_from_cluster.label.add() + summary_label.name, summary_label.value = "handler", "prometheus" + + summary_label = summary_metric_from_cluster.label.add() + summary_label.name, summary_label.value = "from", "cluster" + + # Root summary sample + summary_metric_from_cluster.summary.sample_count = 4 + summary_metric_from_cluster.summary.sample_sum = 94913 + + # Create quantiles 0.5, 0.9, 0.99 + quantile_05 = summary_metric_from_cluster.summary.quantile.add() + quantile_05.quantile = 0.5 + quantile_05.value = 24615 + + quantile_09 = summary_metric_from_cluster.summary.quantile.add() + quantile_09.quantile = 0.9 + quantile_09.value = 24627 + + quantile_099 = summary_metric_from_cluster.summary.quantile.add() + quantile_099.quantile = 0.99 + quantile_099.value = 24627 + + # Iter on the generator to get all metrics + response = MockResponse(text_data, 'text/plain; version=0.0.4') + metrics = [k for k in self.check.parse_metric_family(response)] + + self.assertEqual(1, len(metrics)) + + current_metric = metrics[0] + self.assertEqual(expected_etcd_metric, current_metric) + + def test_parse_one_summary_with_none_values(self): + text_data = ( + '# HELP http_response_size_bytes The HTTP response sizes in bytes.\n' + '# TYPE http_response_size_bytes summary\n' + 'http_response_size_bytes{handler="prometheus",quantile="0.5"} NaN\n' + 'http_response_size_bytes{handler="prometheus",quantile="0.9"} NaN\n' + 'http_response_size_bytes{handler="prometheus",quantile="0.99"} NaN\n' + 'http_response_size_bytes_sum{handler="prometheus"} 0\n' + 'http_response_size_bytes_count{handler="prometheus"} 0\n') + + expected_etcd_metric = metrics_pb2.MetricFamily() + expected_etcd_metric.help = "The HTTP response sizes in bytes." + expected_etcd_metric.name = "http_response_size_bytes" + expected_etcd_metric.type = 2 + + summary_metric = expected_etcd_metric.metric.add() + + # Label for prometheus handler + summary_label = summary_metric.label.add() + summary_label.name, summary_label.value = "handler", "prometheus" + + # Root summary sample + summary_metric.summary.sample_count = 0 + summary_metric.summary.sample_sum = 0. + + # Create quantiles 0.5, 0.9, 0.99 + quantile_05 = summary_metric.summary.quantile.add() + quantile_05.quantile = 0.5 + quantile_05.value = float('nan') + + quantile_09 = summary_metric.summary.quantile.add() + quantile_09.quantile = 0.9 + quantile_09.value = float('nan') + + quantile_099 = summary_metric.summary.quantile.add() + quantile_099.quantile = 0.99 + quantile_099.value = float('nan') + + # Iter on the generator to get all metrics + response = MockResponse(text_data, 'text/plain; version=0.0.4') + metrics = [k for k in self.check.parse_metric_family(response)] + + self.assertEqual(1, len(metrics)) + + current_metric = metrics[0] + # As the NaN value isn't supported when we are calling assertEqual + # we need to compare the object representation instead of the object itself + self.assertEqual(expected_etcd_metric.__repr__(), current_metric.__repr__())