diff --git a/README.md b/README.md index 8aebf65f..a8a96b29 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,16 @@ Counters go up, and reset when the process restarts. ```python from prometheus_client import Counter -c = Counter('my_failures_total', 'Description of counter') +c = Counter('my_failures', 'Description of counter') c.inc() # Increment by 1 c.inc(1.6) # Increment by given value ``` +If there is a suffix of `_total` on the metric name, it will be removed. When +exposing the time series for counter, a `_total` suffix will be added. This is +for compatibility between OpenMetrics and the Prometheus text format, as OpenMetrics +requires the `_total` suffix. + There are utilities to count exceptions raised: ```python @@ -169,6 +174,27 @@ with h.time(): pass ``` +### Info + +Info tracks key-value information, usually about a whole target. + +```python +from prometheus_client import Info +i = Info('my_build_version', 'Description of info') +i.info({'version': '1.2.3', 'buildhost': 'foo@bar'}) +``` + +### Enum + +Enum tracks which of a set of states something is currently in. + +```python +from prometheus_client import Enum +e = Enum('my_task_state', 'Description of enum', + states=['starting', 'running', 'stopped']) +e.state('running') +``` + ### Labels All metrics can have labels, allowing grouping of related time series. @@ -414,6 +440,7 @@ This comes with a number of limitations: - Registries can not be used as normal, all instantiated metrics are exported - Custom collectors do not work (e.g. cpu and memory metrics) +- Info and Enum metrics do not work - The pushgateway cannot be used - Gauges cannot use the `pid` label diff --git a/prometheus_client/__init__.py b/prometheus_client/__init__.py index 689a06d1..8208d458 100644 --- a/prometheus_client/__init__.py +++ b/prometheus_client/__init__.py @@ -5,7 +5,7 @@ from . import process_collector from . import platform_collector -__all__ = ['Counter', 'Gauge', 'Summary', 'Histogram'] +__all__ = ['Counter', 'Gauge', 'Summary', 'Histogram', 'Info', 'Enum'] CollectorRegistry = core.CollectorRegistry REGISTRY = core.REGISTRY @@ -14,6 +14,8 @@ Gauge = core.Gauge Summary = core.Summary Histogram = core.Histogram +Info = core.Info +Enum = core.Enum CONTENT_TYPE_LATEST = exposition.CONTENT_TYPE_LATEST generate_latest = exposition.generate_latest diff --git a/prometheus_client/bridge/graphite.py b/prometheus_client/bridge/graphite.py index 6b8f2417..c73d4931 100755 --- a/prometheus_client/bridge/graphite.py +++ b/prometheus_client/bridge/graphite.py @@ -60,16 +60,16 @@ def push(self, prefix=''): prefixstr = prefix + '.' for metric in self._registry.collect(): - for name, labels, value in metric.samples: - if labels: + for s in metric.samples: + if s.labels: labelstr = '.' + '.'.join( ['{0}.{1}'.format( _sanitize(k), _sanitize(v)) - for k, v in sorted(labels.items())]) + for k, v in sorted(s.labels.items())]) else: labelstr = '' output.append('{0}{1}{2} {3} {4}\n'.format( - prefixstr, _sanitize(name), labelstr, float(value), now)) + prefixstr, _sanitize(s.name), labelstr, float(s.value), now)) conn = socket.create_connection(self._address, self._timeout) conn.sendall(''.join(output).encode('ascii')) diff --git a/prometheus_client/core.py b/prometheus_client/core.py index a1c72333..da3034d0 100644 --- a/prometheus_client/core.py +++ b/prometheus_client/core.py @@ -15,6 +15,7 @@ from threading import Lock from timeit import default_timer +from collections import namedtuple from .decorator import decorate @@ -34,6 +35,39 @@ _unpack_integer = struct.Struct(b'i').unpack_from _unpack_double = struct.Struct(b'd').unpack_from +# Timestamp and exemplar are optional. +# Value can be an int or a float. +# Timestamp can be a float containing a unixtime in seconds, +# a Timestamp object, or None. +# Exemplar can be an Exemplar object, or None. +Sample = namedtuple('Sample', ['name', 'labels', 'value', 'timestamp', 'exemplar']) +Sample.__new__.__defaults__ = (None, None) + + +class Timestamp(object): + '''A nanosecond-resolution timestamp.''' + def __init__(self, sec, nsec): + if nsec < 0 or nsec >= 1e9: + raise ValueError("Invalid value for nanoseconds in Timestamp: {}".format(nsec)) + self.sec = int(sec) + self.nsec = int(nsec) + + def __str__(self): + return "{0}.{1:09d}".format(self.sec, self.nsec) + + def __repr__(self): + return "Timestamp({0}, {1})".format(self.sec, self.nsec) + + def __float__(self): + return float(self.sec) + float(self.nsec) / 1e9 + + def __eq__(self, other): + return type(self) == type(other) and self.sec == other.sec and self.nsec == other.nsec + + +Exemplar = namedtuple('Exemplar', ['labels', 'value', 'timestamp']) +Exemplar.__new__.__defaults__ = (None, ) + class CollectorRegistry(object): '''Metric collector registry. @@ -55,7 +89,7 @@ def register(self, collector): duplicates = set(self._names_to_collectors).intersection(names) if duplicates: raise ValueError( - 'Duplicated timeseries in CollectorRegistry: {}'.format( + 'Duplicated timeseries in CollectorRegistry: {0}'.format( duplicates)) for name in names: self._names_to_collectors[name] = collector @@ -85,8 +119,10 @@ def _get_names(self, collector): result = [] type_suffixes = { - 'summary': ['', '_sum', '_count'], - 'histogram': ['_bucket', '_sum', '_count'] + 'counter': ['_total', '_created'], + 'summary': ['', '_sum', '_count', '_created'], + 'histogram': ['_bucket', '_sum', '_count', '_created'], + 'info': ['_info'], } for metric in desc_func(): for suffix in type_suffixes.get(metric.type, ['']): @@ -140,16 +176,17 @@ def get_sample_value(self, name, labels=None): if labels is None: labels = {} for metric in self.collect(): - for n, l, value in metric.samples: - if n == name and l == labels: - return value + for s in metric.samples: + if s.name == name and s.labels == labels: + return s.value return None REGISTRY = CollectorRegistry(auto_describe=True) '''The default registry.''' -_METRIC_TYPES = ('counter', 'gauge', 'summary', 'histogram', 'untyped') +_METRIC_TYPES = ('counter', 'gauge', 'summary', 'histogram', + 'gaugehistogram', 'unknown', 'info', 'stateset') class Metric(object): @@ -160,37 +197,50 @@ class Metric(object): Custom collectors should use GaugeMetricFamily, CounterMetricFamily and SummaryMetricFamily instead. ''' - def __init__(self, name, documentation, typ): + def __init__(self, name, documentation, typ, unit=''): + if unit and not name.endswith("_" + unit): + name += "_" + unit + if not _METRIC_NAME_RE.match(name): + raise ValueError('Invalid metric name: ' + name) self.name = name self.documentation = documentation + self.unit = unit + if typ == 'untyped': + typ = 'unknown' if typ not in _METRIC_TYPES: raise ValueError('Invalid metric type: ' + typ) self.type = typ + if unit: + if not name.endswith('_' + unit): + raise ValueError('Metric name does not end with unit: ' + name) + self.unit = unit self.samples = [] - def add_sample(self, name, labels, value): + def add_sample(self, name, labels, value, timestamp=None, exemplar=None): '''Add a sample to the metric. Internal-only, do not use.''' - self.samples.append((name, labels, value)) + self.samples.append(Sample(name, labels, value, timestamp, exemplar)) def __eq__(self, other): return (isinstance(other, Metric) and self.name == other.name and self.documentation == other.documentation and self.type == other.type and + self.unit == other.unit and self.samples == other.samples) def __repr__(self): - return "Metric(%s, %s, %s, %s)" % (self.name, self.documentation, - self.type, self.samples) + return "Metric(%s, %s, %s, %s, %s)" % (self.name, self.documentation, + self.type, self.unit, self.samples) + -class UntypedMetricFamily(Metric): - '''A single untyped metric and its samples. +class UnknownMetricFamily(Metric): + '''A single unknwon metric and its samples. For use by custom collectors. ''' - def __init__(self, name, documentation, value=None, labels=None): - Metric.__init__(self, name, documentation, 'untyped') + def __init__(self, name, documentation, value=None, labels=None, unit=''): + Metric.__init__(self, name, documentation, 'unknown', unit) if labels is not None and value is not None: raise ValueError('Can only specify at most one of value and labels.') if labels is None: @@ -199,38 +249,46 @@ def __init__(self, name, documentation, value=None, labels=None): if value is not None: self.add_metric([], value) - def add_metric(self, labels, value): + def add_metric(self, labels, value, timestamp=None): '''Add a metric to the metric family. Args: labels: A list of label values value: The value of the metric. ''' - self.samples.append((self.name, dict(zip(self._labelnames, labels)), value)) + self.samples.append(Sample(self.name, dict(zip(self._labelnames, labels)), value, timestamp)) +# For backward compatibility. +UntypedMetricFamily = UnknownMetricFamily class CounterMetricFamily(Metric): '''A single counter and its samples. For use by custom collectors. ''' - def __init__(self, name, documentation, value=None, labels=None): - Metric.__init__(self, name, documentation, 'counter') + def __init__(self, name, documentation, value=None, labels=None, created=None, unit=''): + # Glue code for pre-OpenMetrics metrics. + if name.endswith('_total'): + name = name[:-6] + Metric.__init__(self, name, documentation, 'counter', unit) if labels is not None and value is not None: raise ValueError('Can only specify at most one of value and labels.') if labels is None: labels = [] self._labelnames = tuple(labels) if value is not None: - self.add_metric([], value) + self.add_metric([], value, created) - def add_metric(self, labels, value): + def add_metric(self, labels, value, created=None, timestamp=None): '''Add a metric to the metric family. Args: labels: A list of label values - value: The value of the metric. + value: The value of the metric + created: Optional unix timestamp the child was created at. ''' - self.samples.append((self.name, dict(zip(self._labelnames, labels)), value)) + self.samples.append(Sample(self.name + '_total', dict(zip(self._labelnames, labels)), value, timestamp)) + if created is not None: + self.samples.append(Sample(self.name + '_created', dict(zip(self._labelnames, labels)), created, timestamp)) class GaugeMetricFamily(Metric): @@ -238,8 +296,8 @@ class GaugeMetricFamily(Metric): For use by custom collectors. ''' - def __init__(self, name, documentation, value=None, labels=None): - Metric.__init__(self, name, documentation, 'gauge') + def __init__(self, name, documentation, value=None, labels=None, unit=''): + Metric.__init__(self, name, documentation, 'gauge', unit) if labels is not None and value is not None: raise ValueError('Can only specify at most one of value and labels.') if labels is None: @@ -248,14 +306,14 @@ def __init__(self, name, documentation, value=None, labels=None): if value is not None: self.add_metric([], value) - def add_metric(self, labels, value): + def add_metric(self, labels, value, timestamp=None): '''Add a metric to the metric family. Args: labels: A list of label values value: A float ''' - self.samples.append((self.name, dict(zip(self._labelnames, labels)), value)) + self.samples.append(Sample(self.name, dict(zip(self._labelnames, labels)), value, timestamp)) class SummaryMetricFamily(Metric): @@ -263,8 +321,8 @@ class SummaryMetricFamily(Metric): For use by custom collectors. ''' - def __init__(self, name, documentation, count_value=None, sum_value=None, labels=None): - Metric.__init__(self, name, documentation, 'summary') + def __init__(self, name, documentation, count_value=None, sum_value=None, labels=None, unit=''): + Metric.__init__(self, name, documentation, 'summary', unit) if (sum_value is None) != (count_value is None): raise ValueError('count_value and sum_value must be provided together.') if labels is not None and count_value is not None: @@ -275,7 +333,7 @@ def __init__(self, name, documentation, count_value=None, sum_value=None, labels if count_value is not None: self.add_metric([], count_value, sum_value) - def add_metric(self, labels, count_value, sum_value): + def add_metric(self, labels, count_value, sum_value, timestamp=None): '''Add a metric to the metric family. Args: @@ -283,8 +341,8 @@ def add_metric(self, labels, count_value, sum_value): count_value: The count value of the metric. sum_value: The sum value of the metric. ''' - self.samples.append((self.name + '_count', dict(zip(self._labelnames, labels)), count_value)) - self.samples.append((self.name + '_sum', dict(zip(self._labelnames, labels)), sum_value)) + self.samples.append(Sample(self.name + '_count', dict(zip(self._labelnames, labels)), count_value, timestamp)) + self.samples.append(Sample(self.name + '_sum', dict(zip(self._labelnames, labels)), sum_value, timestamp)) class HistogramMetricFamily(Metric): @@ -292,8 +350,8 @@ class HistogramMetricFamily(Metric): For use by custom collectors. ''' - def __init__(self, name, documentation, buckets=None, sum_value=None, labels=None): - Metric.__init__(self, name, documentation, 'histogram') + def __init__(self, name, documentation, buckets=None, sum_value=None, labels=None, unit=''): + Metric.__init__(self, name, documentation, 'histogram', unit) if (sum_value is None) != (buckets is None): raise ValueError('buckets and sum_value must be provided together.') if labels is not None and buckets is not None: @@ -304,20 +362,113 @@ def __init__(self, name, documentation, buckets=None, sum_value=None, labels=Non if buckets is not None: self.add_metric([], buckets, sum_value) - def add_metric(self, labels, buckets, sum_value): + def add_metric(self, labels, buckets, sum_value, timestamp=None): '''Add a metric to the metric family. Args: labels: A list of label values - buckets: A list of pairs of bucket names and values. + buckets: A list of lists. + Each inner list can be a pair of bucket name and value, + or a triple of bucket name, value, and exemplar. The buckets must be sorted, and +Inf present. sum_value: The sum value of the metric. ''' - for bucket, value in buckets: - self.samples.append((self.name + '_bucket', dict(list(zip(self._labelnames, labels)) + [('le', bucket)]), value)) + for b in buckets: + bucket, value = b[:2] + exemplar = None + if len(b) == 3: + exemplar = b[2] + self.samples.append(Sample(self.name + '_bucket', + dict(list(zip(self._labelnames, labels)) + [('le', bucket)]), + value, timestamp, exemplar)) # +Inf is last and provides the count value. - self.samples.append((self.name + '_count', dict(zip(self._labelnames, labels)), buckets[-1][1])) - self.samples.append((self.name + '_sum', dict(zip(self._labelnames, labels)), sum_value)) + self.samples.append(Sample(self.name + '_count', dict(zip(self._labelnames, labels)), buckets[-1][1], timestamp)) + self.samples.append(Sample(self.name + '_sum', dict(zip(self._labelnames, labels)), sum_value, timestamp)) + + +class GaugeHistogramMetricFamily(Metric): + '''A single gauge histogram and its samples. + + For use by custom collectors. + ''' + def __init__(self, name, documentation, buckets=None, labels=None, unit=''): + Metric.__init__(self, name, documentation, 'gaugehistogram', unit) + if labels is not None and buckets is not None: + raise ValueError('Can only specify at most one of buckets and labels.') + if labels is None: + labels = [] + self._labelnames = tuple(labels) + if buckets is not None: + self.add_metric([], buckets) + + def add_metric(self, labels, buckets, timestamp=None): + '''Add a metric to the metric family. + + Args: + labels: A list of label values + buckets: A list of pairs of bucket names and values. + The buckets must be sorted, and +Inf present. + ''' + for bucket, value in buckets: + self.samples.append(Sample( + self.name + '_bucket', + dict(list(zip(self._labelnames, labels)) + [('le', bucket)]), + value, timestamp)) + + +class InfoMetricFamily(Metric): + '''A single info and its samples. + + For use by custom collectors. + ''' + def __init__(self, name, documentation, value=None, labels=None): + Metric.__init__(self, name, documentation, 'info') + if labels is not None and value is not None: + raise ValueError('Can only specify at most one of value and labels.') + if labels is None: + labels = [] + self._labelnames = tuple(labels) + if value is not None: + self.add_metric([], value) + + def add_metric(self, labels, value, timestamp=None): + '''Add a metric to the metric family. + + Args: + labels: A list of label values + value: A dict of labels + ''' + self.samples.append(Sample(self.name + '_info', + dict(dict(zip(self._labelnames, labels)), **value), 1, timestamp)) + + +class StateSetMetricFamily(Metric): + '''A single stateset and its samples. + + For use by custom collectors. + ''' + def __init__(self, name, documentation, value=None, labels=None): + Metric.__init__(self, name, documentation, 'stateset') + if labels is not None and value is not None: + raise ValueError('Can only specify at most one of value and labels.') + if labels is None: + labels = [] + self._labelnames = tuple(labels) + if value is not None: + self.add_metric([], value) + + def add_metric(self, labels, value, timestamp=None): + '''Add a metric to the metric family. + + Args: + labels: A list of label values + value: A dict of string state names to booleans + ''' + labels = tuple(labels) + for state, enabled in value.items(): + v = (1 if enabled else 0) + self.samples.append(Sample(self.name, + dict(zip(self._labelnames + (self.name,), labels + (state,))), v, timestamp)) class _MutexValue(object): @@ -584,7 +735,7 @@ def _samples(self): def _MetricWrapper(cls): '''Provides common functionality for metrics.''' - def init(name, documentation, labelnames=(), namespace='', subsystem='', registry=REGISTRY, **kwargs): + def init(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY, **kwargs): full_name = '' if namespace: full_name += namespace + '_' @@ -592,6 +743,14 @@ def init(name, documentation, labelnames=(), namespace='', subsystem='', registr full_name += subsystem + '_' full_name += name + if unit and not full_name.endswith("_" + unit): + full_name += "_" + unit + if unit and cls._type in ('info', 'stateset'): + raise ValueError('Metric name is of a type that cannot have a unit: ' + full_name) + + if cls._type == 'counter' and full_name.endswith('_total'): + full_name = full_name[:-6] # Munge to OpenMetrics. + if labelnames: labelnames = tuple(labelnames) for l in labelnames: @@ -613,7 +772,7 @@ def describe(): collector.describe = describe def collect(): - metric = Metric(full_name, documentation, cls._type) + metric = Metric(full_name, documentation, cls._type, unit) for suffix, labels, value in collector._samples(): metric.add_sample(full_name + suffix, labels, value) return [metric] @@ -664,7 +823,10 @@ def f(): _reserved_labelnames = [] def __init__(self, name, labelnames, labelvalues): - self._value = _ValueClass(self._type, name, name, labelnames, labelvalues) + if name.endswith('_total'): + name = name[:-6] + self._value = _ValueClass(self._type, name, name + '_total', labelnames, labelvalues) + self._created = time.time() def inc(self, amount=1): '''Increment counter by the given amount.''' @@ -682,7 +844,8 @@ def count_exceptions(self, exception=Exception): return _ExceptionCounter(self, exception) def _samples(self): - return (('', {}, self._value.get()), ) + return (('_total', {}, self._value.get()), + ('_created', {}, self._created)) @_MetricWrapper @@ -818,6 +981,7 @@ def create_response(request): def __init__(self, name, labelnames, labelvalues): self._count = _ValueClass(self._type, name, name + '_count', labelnames, labelvalues) self._sum = _ValueClass(self._type, name, name + '_sum', labelnames, labelvalues) + self._created = time.time() def observe(self, amount): '''Observe the given amount.''' @@ -834,7 +998,8 @@ def time(self): def _samples(self): return ( ('_count', {}, self._count.get()), - ('_sum', {}, self._sum.get())) + ('_sum', {}, self._sum.get()), + ('_created', {}, self._created)) def _floatToGoString(d): @@ -883,13 +1048,12 @@ def create_response(request): The default buckets are intended to cover a typical web/rpc request from milliseconds to seconds. They can be overridden by passing `buckets` keyword argument to `Histogram`. - - **NB** The Python client doesn't store or expose quantile information at this time. ''' _type = 'histogram' - _reserved_labelnames = ['histogram'] + _reserved_labelnames = ['le'] def __init__(self, name, labelnames, labelvalues, buckets=(.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, _INF)): + self._created = time.time() self._sum = _ValueClass(self._type, name, name + '_sum', labelnames, labelvalues) buckets = [float(b) for b in buckets] if buckets != sorted(buckets): @@ -929,9 +1093,87 @@ def _samples(self): samples.append(('_bucket', {'le': _floatToGoString(bound)}, acc)) samples.append(('_count', {}, acc)) samples.append(('_sum', {}, self._sum.get())) + samples.append(('_created', {}, self._created)) return tuple(samples) +@_MetricWrapper +class Info(object): + '''Info metric, key-value pairs. + + Examples of Info include: + - Build information + - Version information + - Potential target metadata + + Example usage: + from prometheus_client import Info + + i = Info('my_build', 'Description of info') + i.info({'version': '1.2.3', 'buildhost': 'foo@bar'}) + + Info metrics do not work in multiprocess mode. + ''' + _type = 'info' + _reserved_labelnames = [] + + def __init__(self, name, labelnames, labelvalues): + self._labelnames = set(labelnames) + self._lock = Lock() + self._value = {} + + def info(self, val): + '''Set info metric.''' + if self._labelnames.intersection(val.keys()): + raise ValueError('Overlapping labels for Info metric, metric: %s child: %s' % ( + self._labelnames, val)) + with self._lock: + self._value = dict(val) + + + def _samples(self): + with self._lock: + return (('_info', self._value, 1.0,), ) + + +@_MetricWrapper +class Enum(object): + '''Enum metric, which of a set of states is true. + + Example usage: + from prometheus_client import Enum + + e = Enum('task_state', 'Description of enum', + states=['starting', 'running', 'stopped']) + e.state('running') + + The first listed state will be the default. + Enum metrics do not work in multiprocess mode. + ''' + _type = 'stateset' + _reserved_labelnames = [] + + def __init__(self, name, labelnames, labelvalues, states=None): + if name in labelnames: + raise ValueError('Overlapping labels for Enum metric: %s' % (name,)) + if not states: + raise ValueError('No states provided for Enum metric: %s' % (name,)) + self._name = name + self._states = states + self._value = 0 + self._lock = Lock() + + def state(self, state): + '''Set enum metric state.''' + with self._lock: + self._value = self._states.index(state) + + def _samples(self): + with self._lock: + return [('', {self._name: s}, 1 if i == self._value else 0,) + for i, s in enumerate(self._states)] + + class _ExceptionCounter(object): def __init__(self, counter, exception): self._counter = counter diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 96661e03..1ebeba29 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -10,7 +10,8 @@ from contextlib import closing from wsgiref.simple_server import make_server, WSGIRequestHandler -from . import core +from prometheus_client import core +from prometheus_client import openmetrics try: from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer from SocketServer import ThreadingMixIn @@ -36,12 +37,13 @@ def make_wsgi_app(registry=core.REGISTRY): def prometheus_app(environ, start_response): params = parse_qs(environ.get('QUERY_STRING', '')) r = registry + encoder, content_type = choose_encoder(environ.get['HTTP_ACCEPT']) if 'name[]' in params: r = r.restricted_registry(params['name[]']) - output = generate_latest(r) + output = encoder(r) status = str('200 OK') - headers = [(str('Content-type'), CONTENT_TYPE_LATEST)] + headers = [(str('Content-type'), content_type)] start_response(status, headers) return [output] return prometheus_app @@ -67,21 +69,54 @@ def generate_latest(registry=core.REGISTRY): '''Returns the metrics from the registry in latest text format as a string.''' output = [] for metric in registry.collect(): + mname = metric.name + mtype = metric.type + # Munging from OpenMetrics into Prometheus format. + if mtype == 'counter': + mname = mname + '_total' + elif mtype == 'info': + mname = mname + '_info' + mtype = 'gauge' + elif mtype == 'stateset': + mtype = 'gauge' + elif mtype == 'gaugehistogram': + # A gauge histogram is really a gauge, + # but this captures the strucutre better. + mtype = 'histogram' + elif mtype == 'unknown': + mtype = 'untyped' + output.append('# HELP {0} {1}'.format( - metric.name, metric.documentation.replace('\\', r'\\').replace('\n', r'\n'))) - output.append('\n# TYPE {0} {1}\n'.format(metric.name, metric.type)) - for name, labels, value in metric.samples: - if labels: + mname, metric.documentation.replace('\\', r'\\').replace('\n', r'\n'))) + output.append('\n# TYPE {0} {1}\n'.format(mname, mtype)) + for s in metric.samples: + if s.name == metric.name + '_created': + continue # Ignore OpenMetrics specific sample. TODO: Make these into a gauge. + if s.labels: labelstr = '{{{0}}}'.format(','.join( ['{0}="{1}"'.format( k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')) - for k, v in sorted(labels.items())])) + for k, v in sorted(s.labels.items())])) else: labelstr = '' - output.append('{0}{1} {2}\n'.format(name, labelstr, core._floatToGoString(value))) + timestamp = '' + if s.timestamp is not None: + # Convert to milliseconds. + timestamp = ' {0:d}'.format(int(float(s.timestamp) * 1000)) + output.append('{0}{1} {2}{3}\n'.format( + s.name, labelstr, core._floatToGoString(s.value), timestamp)) return ''.join(output).encode('utf-8') +def choose_encoder(accept_header): + accept_header = accept_header or '' + for accepted in accept_header.split(','): + if accepted == 'text/openmetrics; version=0.0.1': + return (openmetrics.exposition.generate_latest, + openmetrics.exposition.CONTENT_TYPE_LATEST) + return (generate_latest, CONTENT_TYPE_LATEST) + + class MetricsHandler(BaseHTTPRequestHandler): """HTTP handler that gives metrics from ``core.REGISTRY``.""" registry = core.REGISTRY @@ -89,15 +124,16 @@ class MetricsHandler(BaseHTTPRequestHandler): def do_GET(self): registry = self.registry params = parse_qs(urlparse(self.path).query) + encoder, content_type = choose_encoder(self.headers.get('Accept')) if 'name[]' in params: registry = registry.restricted_registry(params['name[]']) try: - output = generate_latest(registry) + output = encoder(registry) except: self.send_error(500, 'error generating metric output') raise self.send_response(200) - self.send_header('Content-Type', CONTENT_TYPE_LATEST) + self.send_header('Content-Type', content_type) self.end_headers() self.wfile.write(output) diff --git a/prometheus_client/multiprocess.py b/prometheus_client/multiprocess.py index b605a8f3..7dd74b2b 100644 --- a/prometheus_client/multiprocess.py +++ b/prometheus_client/multiprocess.py @@ -48,17 +48,18 @@ def collect(self): for metric in metrics.values(): samples = defaultdict(float) buckets = {} - for name, labels, value in metric.samples: + for s in metric.samples: + name, labels, value = s.name, s.labels, s.value if metric.type == 'gauge': without_pid = tuple(l for l in labels if l[0] != 'pid') if metric._multiprocess_mode == 'min': current = samples.setdefault((name, without_pid), value) if value < current: - samples[(name, without_pid)] = value + samples[(s.name, without_pid)] = value elif metric._multiprocess_mode == 'max': current = samples.setdefault((name, without_pid), value) if value > current: - samples[(name, without_pid)] = value + samples[(s.name, without_pid)] = value elif metric._multiprocess_mode == 'livesum': samples[(name, without_pid)] += value else: # all/liveall @@ -74,11 +75,11 @@ def collect(self): buckets[without_le][bucket[0]] += value else: # _sum/_count - samples[(name, labels)] += value + samples[(s.name, labels)] += value else: # Counter and Summary. - samples[(name, labels)] += value + samples[(s.name, labels)] += value # Accumulate bucket values. if metric.type == 'histogram': @@ -90,7 +91,7 @@ def collect(self): samples[(metric.name + '_count', labels)] = acc # Convert to correct sample format. - metric.samples = [(name, dict(labels), value) for (name, labels), value in samples.items()] + metric.samples = [core.Sample(name, dict(labels), value) for (name, labels), value in samples.items()] return metrics.values() diff --git a/prometheus_client/openmetrics/__init__.py b/prometheus_client/openmetrics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py new file mode 100644 index 00000000..8b37867e --- /dev/null +++ b/prometheus_client/openmetrics/exposition.py @@ -0,0 +1,51 @@ +#!/usr/bin/python + +from __future__ import unicode_literals + +from .. import core + +CONTENT_TYPE_LATEST = str('text/openmetrics; version=0.0.1; charset=utf-8') +'''Content type of the latest OpenMetrics text format''' + +def generate_latest(registry): + '''Returns the metrics from the registry in latest text format as a string.''' + output = [] + for metric in registry.collect(): + mname = metric.name + output.append('# HELP {0} {1}\n'.format( + mname, metric.documentation.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"'))) + output.append('# TYPE {0} {1}\n'.format(mname, metric.type)) + if metric.unit: + output.append('# UNIT {0} {1}\n'.format(mname, metric.unit)) + for s in metric.samples: + if s.labels: + labelstr = '{{{0}}}'.format(','.join( + ['{0}="{1}"'.format( + k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')) + for k, v in sorted(s.labels.items())])) + else: + labelstr = '' + if s.exemplar: + if metric.type != 'histogram' or not s.name.endswith('_bucket'): + raise ValueError("Metric {0} has exemplars, but is not a histogram bucket".format(metric.name)) + labels = '{{{0}}}'.format(','.join( + ['{0}="{1}"'.format( + k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')) + for k, v in sorted(s.exemplar.labels.items())])) + if s.exemplar.timestamp is not None: + exemplarstr = ' # {0} {1} {2}'.format(labels, + core._floatToGoString(s.exemplar.value), s.exemplar.timestamp) + else: + exemplarstr = ' # {0} {1}'.format(labels, + core._floatToGoString(s.exemplar.value)) + else: + exemplarstr = '' + timestamp = '' + if s.timestamp is not None: + # Convert to milliseconds. + timestamp = ' {0}'.format(s.timestamp) + output.append('{0}{1} {2}{3}{4}\n'.format(s.name, labelstr, + core._floatToGoString(s.value), timestamp, exemplarstr)) + output.append('# EOF\n') + return ''.join(output).encode('utf-8') + diff --git a/prometheus_client/openmetrics/parser.py b/prometheus_client/openmetrics/parser.py new file mode 100644 index 00000000..8edf3a64 --- /dev/null +++ b/prometheus_client/openmetrics/parser.py @@ -0,0 +1,339 @@ +#!/usr/bin/python + +from __future__ import unicode_literals + +try: + import StringIO +except ImportError: + # Python 3 + import io as StringIO + +from .. import core + + +def text_string_to_metric_families(text): + """Parse Openmetrics text format from a unicode string. + + See text_fd_to_metric_families. + """ + for metric_family in text_fd_to_metric_families(StringIO.StringIO(text)): + yield metric_family + + +def _unescape_help(text): + result = [] + slash = False + + for char in text: + if slash: + if char == '\\': + result.append('\\') + elif char == '"': + result.append('"') + elif char == 'n': + result.append('\n') + else: + result.append('\\' + char) + slash = False + else: + if char == '\\': + slash = True + else: + result.append(char) + + if slash: + result.append('\\') + + return ''.join(result) + + +def _parse_value(value): + value = ''.join(value) + if value != value.strip(): + raise ValueError("Invalid value: {0!r}".format(value)) + try: + return int(value) + except ValueError: + return float(value) + + +def _parse_timestamp(timestamp): + timestamp = ''.join(timestamp) + if not timestamp: + return None + if timestamp != timestamp.strip(): + raise ValueError("Invalid timestamp: {0!r}".format(timestamp)) + try: + # Simple int. + return core.Timestamp(int(timestamp), 0) + except ValueError: + try: + # aaaa.bbbb. Nanosecond resolution supported. + parts = timestamp.split('.', 1) + return core.Timestamp(int(parts[0]), int(parts[1][:9].ljust(9, "0"))) + except ValueError: + # Float. + return float(timestamp) + + +def _parse_labels(it, text): + # The { has already been parsed. + state = 'startoflabelname' + labelname = [] + labelvalue = [] + labels = {} + + for char in it: + if state == 'startoflabelname': + if char == '}': + state = 'endoflabels' + else: + state = 'labelname' + labelname.append(char) + elif state == 'labelname': + if char == '=': + state = 'labelvaluequote' + else: + labelname.append(char) + elif state == 'labelvaluequote': + if char == '"': + state = 'labelvalue' + else: + raise ValueError("Invalid line: " + text) + elif state == 'labelvalue': + if char == '\\': + state = 'labelvalueslash' + elif char == '"': + if not core._METRIC_LABEL_NAME_RE.match(''.join(labelname)): + raise ValueError("Invalid line: " + text) + labels[''.join(labelname)] = ''.join(labelvalue) + labelname = [] + labelvalue = [] + state = 'endoflabelvalue' + else: + labelvalue.append(char) + elif state == 'endoflabelvalue': + if char == ',': + state = 'labelname' + elif char == '}': + state = 'endoflabels' + else: + raise ValueError("Invalid line: " + text) + elif state == 'labelvalueslash': + state = 'labelvalue' + if char == '\\': + labelvalue.append('\\') + elif char == 'n': + labelvalue.append('\n') + elif char == '"': + labelvalue.append('"') + else: + labelvalue.append('\\' + char) + elif state == 'endoflabels': + if char == ' ': + break + else: + raise ValueError("Invalid line: " + text) + return labels + + +def _parse_sample(text): + name = [] + value = [] + timestamp = [] + labels = {} + exemplar_value = [] + exemplar_timestamp = [] + exemplar_labels = None + + state = 'name' + + it = iter(text) + for char in it: + if state == 'name': + if char == '{': + labels = _parse_labels(it, text) + # Space has already been parsed. + state = 'value' + elif char == ' ': + state = 'value' + else: + name.append(char) + elif state == 'value': + if char == ' ': + state = 'timestamp' + else: + value.append(char) + elif state == 'timestamp': + if char == '#' and not timestamp: + state = 'exemplarspace' + elif char == ' ': + state = 'exemplarhash' + else: + timestamp.append(char) + elif state == 'exemplarhash': + if char == '#': + state = 'exemplarspace' + else: + raise ValueError("Invalid line: " + text) + elif state == 'exemplarspace': + if char == ' ': + state = 'exemplarstartoflabels' + else: + raise ValueError("Invalid line: " + text) + elif state == 'exemplarstartoflabels': + if char == '{': + exemplar_labels = _parse_labels(it, text) + # Space has already been parsed. + state = 'exemplarvalue' + else: + raise ValueError("Invalid line: " + text) + elif state == 'exemplarvalue': + if char == ' ': + state = 'exemplartimestamp' + else: + exemplar_value.append(char) + elif state == 'exemplartimestamp': + exemplar_timestamp.append(char) + + # Trailing space after value. + if state == 'timestamp' and not timestamp: + raise ValueError("Invalid line: " + text) + + # Trailing space after value. + if state == 'exemplartimestamp' and not exemplar_timestamp: + raise ValueError("Invalid line: " + text) + + # Incomplete exemplar. + if state in ['exemplarhash', 'exemplarspace', 'exemplarstartoflabels']: + raise ValueError("Invalid line: " + text) + + if not value: + raise ValueError("Invalid line: " + text) + value = ''.join(value) + val = _parse_value(value) + ts = _parse_timestamp(timestamp) + exemplar = None + if exemplar_labels is not None: + exemplar = core.Exemplar(exemplar_labels, + _parse_value(exemplar_value), + _parse_timestamp(exemplar_timestamp)) + + return core.Sample(''.join(name), labels, val, ts, exemplar) + + +def text_fd_to_metric_families(fd): + """Parse Prometheus text format from a file descriptor. + + This is a laxer parser than the main Go parser, + so successful parsing does not imply that the parsed + text meets the specification. + + Yields core.Metric's. + """ + name = '' + documentation = '' + typ = 'untyped' + unit = '' + samples = [] + allowed_names = [] + eof = False + + seen_metrics = set() + def build_metric(name, documentation, typ, unit, samples): + if name in seen_metrics: + raise ValueError("Duplicate metric: " + name) + seen_metrics.add(name) + if unit and not name.endswith("_" + unit): + raise ValueError("Unit does not match metric name: " + name) + if unit and typ in ['info', 'stateset']: + raise ValueError("Units not allowed for this metric type: " + name) + metric = core.Metric(name, documentation, typ, unit) + # TODO: check labelvalues are valid utf8 + # TODO: check only histogram buckets have exemplars. + # TODO: check samples are appropriately grouped and ordered + # TODO: check info/stateset values are 1/0 + # TODO: check for metadata in middle of samples + # TODO: Check histogram bucket rules being followed + # TODO: Check for duplicate metrics + # TODO: Check for dupliate samples + # TODO: Check for decresing timestamps + metric.samples = samples + return metric + + for line in fd: + if line[-1] == '\n': + line = line[:-1] + + if eof: + raise ValueError("Received line after # EOF: " + line) + + if line == '# EOF': + eof = True + elif line.startswith('#'): + parts = line.split(' ', 3) + if len(parts) < 4: + raise ValueError("Invalid line: " + line) + if parts[1] == 'HELP': + if parts[2] != name: + if name != '': + yield build_metric(name, documentation, typ, unit, samples) + # New metric + name = parts[2] + unit = '' + typ = 'untyped' + samples = [] + allowed_names = [parts[2]] + if len(parts) == 4: + documentation = _unescape_help(parts[3]) + elif len(parts) == 3: + raise ValueError("Invalid line: " + line) + elif parts[1] == 'TYPE': + if parts[2] != name: + if name != '': + yield build_metric(name, documentation, typ, unit, samples) + # New metric + name = parts[2] + documentation = '' + unit = '' + samples = [] + typ = parts[3] + allowed_names = { + 'counter': ['_total', '_created'], + 'summary': ['_count', '_sum', '', '_created'], + 'histogram': ['_count', '_sum', '_bucket', 'created'], + 'gaugehistogram': ['_bucket'], + }.get(typ, ['']) + allowed_names = [name + n for n in allowed_names] + elif parts[1] == 'UNIT': + if parts[2] != name: + if name != '': + yield build_metric(name, documentation, typ, unit, samples) + # New metric + name = parts[2] + typ = 'untyped' + samples = [] + allowed_names = [parts[2]] + unit = parts[3] + else: + raise ValueError("Invalid line: " + line) + else: + sample = _parse_sample(line) + if sample[0] not in allowed_names: + if name != '': + yield build_metric(name, documentation, typ, unit, samples) + # Start an untyped metric. + name = sample[0] + documentation = '' + unit = '' + typ = 'untyped' + samples = [sample] + allowed_names = [sample[0]] + else: + samples.append(sample) + + if name != '': + yield build_metric(name, documentation, typ, unit, samples) + + if not eof: + raise ValueError("Missing # EOF at end") diff --git a/prometheus_client/parser.py b/prometheus_client/parser.py index 2fea3954..06b81eb8 100755 --- a/prometheus_client/parser.py +++ b/prometheus_client/parser.py @@ -125,7 +125,7 @@ def _parse_sample(text): label = text[label_start + 1:label_end] # The value is after the label end (ignoring curly brace and space) value = float(_parse_value(text[label_end + 2:])) - return name, _parse_labels(label), value + return core.Sample(name, _parse_labels(label), value) # We don't have labels except ValueError: @@ -137,7 +137,7 @@ def _parse_sample(text): name = text[:name_end] # The value is after the name value = float(_parse_value(text[name_end:])) - return name, {}, value + return core.Sample(name, {}, value) def text_fd_to_metric_families(fd): @@ -156,6 +156,16 @@ def text_fd_to_metric_families(fd): allowed_names = [] def build_metric(name, documentation, typ, samples): + # Munge counters into OpenMetrics representation + # used internally. + if typ == 'counter': + if name.endswith('_total'): + name = name[:-6] + else: + new_samples = [] + for s in samples: + new_samples.append(tuple((s[0] + '_total', ) + s[1:])) + samples = new_samples metric = core.Metric(name, documentation, typ) metric.samples = samples return metric @@ -204,7 +214,7 @@ def build_metric(name, documentation, typ, samples): pass else: sample = _parse_sample(line) - if sample[0] not in allowed_names: + if sample.name not in allowed_names: if name != '': yield build_metric(name, documentation, typ, samples) # New metric, yield immediately as untyped singleton diff --git a/prometheus_client/twisted/_exposition.py b/prometheus_client/twisted/_exposition.py index 66c548c7..714a7f98 100644 --- a/prometheus_client/twisted/_exposition.py +++ b/prometheus_client/twisted/_exposition.py @@ -1,5 +1,5 @@ from __future__ import absolute_import, unicode_literals -from .. import REGISTRY, generate_latest, CONTENT_TYPE_LATEST +from .. import REGISTRY, exposition from twisted.web.resource import Resource @@ -14,5 +14,6 @@ def __init__(self, registry=REGISTRY): self.registry = registry def render_GET(self, request): - request.setHeader(b'Content-Type', CONTENT_TYPE_LATEST.encode('ascii')) - return generate_latest(self.registry) + encoder, content_type = exposition.choose_encoder(request.getHeader('Accept')) + request.setHeader(b'Content-Type', content_type.encode('ascii')) + return encoder(self.registry) diff --git a/setup.py b/setup.py index a940a151..0ceb07f7 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,7 @@ packages=[ 'prometheus_client', 'prometheus_client.bridge', + 'prometheus_client.openmetrics', 'prometheus_client.twisted', ], extras_require={ diff --git a/tests/openmetrics/__init__.py b/tests/openmetrics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py new file mode 100644 index 00000000..56462722 --- /dev/null +++ b/tests/openmetrics/test_exposition.py @@ -0,0 +1,228 @@ +from __future__ import unicode_literals + +import sys +import time + +if sys.version_info < (2, 7): + # We need the skip decorators from unittest2 on Python 2.6. + import unittest2 as unittest +else: + import unittest + +from prometheus_client import Gauge, Counter, Summary, Histogram, Info, Enum, Metric +from prometheus_client import CollectorRegistry +from prometheus_client.core import GaugeHistogramMetricFamily, Timestamp, Exemplar +from prometheus_client.openmetrics.exposition import ( + generate_latest, +) + +class TestGenerateText(unittest.TestCase): + def setUp(self): + self.registry = CollectorRegistry() + + # Mock time so _created values are fixed. + self.old_time = time.time + time.time = lambda: 123.456 + + def tearDown(self): + time.time = self.old_time + + def custom_collector(self, metric_family): + class CustomCollector(object): + def collect(self): + return [metric_family] + self.registry.register(CustomCollector()) + + def test_counter(self): + c = Counter('cc', 'A counter', registry=self.registry) + c.inc() + self.assertEqual(b'# HELP cc A counter\n# TYPE cc counter\ncc_total 1.0\ncc_created 123.456\n# EOF\n', generate_latest(self.registry)) + + def test_counter_total(self): + c = Counter('cc_total', 'A counter', registry=self.registry) + c.inc() + self.assertEqual(b'# HELP cc A counter\n# TYPE cc counter\ncc_total 1.0\ncc_created 123.456\n# EOF\n', generate_latest(self.registry)) + + def test_gauge(self): + g = Gauge('gg', 'A gauge', registry=self.registry) + g.set(17) + self.assertEqual(b'# HELP gg A gauge\n# TYPE gg gauge\ngg 17.0\n# EOF\n', generate_latest(self.registry)) + + def test_summary(self): + s = Summary('ss', 'A summary', ['a', 'b'], registry=self.registry) + s.labels('c', 'd').observe(17) + self.assertEqual(b'''# HELP ss A summary +# TYPE ss summary +ss_count{a="c",b="d"} 1.0 +ss_sum{a="c",b="d"} 17.0 +ss_created{a="c",b="d"} 123.456 +# EOF +''', generate_latest(self.registry)) + + @unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.") + def test_histogram(self): + s = Histogram('hh', 'A histogram', registry=self.registry) + s.observe(0.05) + self.assertEqual(b'''# HELP hh A histogram +# TYPE hh histogram +hh_bucket{le="0.005"} 0.0 +hh_bucket{le="0.01"} 0.0 +hh_bucket{le="0.025"} 0.0 +hh_bucket{le="0.05"} 1.0 +hh_bucket{le="0.075"} 1.0 +hh_bucket{le="0.1"} 1.0 +hh_bucket{le="0.25"} 1.0 +hh_bucket{le="0.5"} 1.0 +hh_bucket{le="0.75"} 1.0 +hh_bucket{le="1.0"} 1.0 +hh_bucket{le="2.5"} 1.0 +hh_bucket{le="5.0"} 1.0 +hh_bucket{le="7.5"} 1.0 +hh_bucket{le="10.0"} 1.0 +hh_bucket{le="+Inf"} 1.0 +hh_count 1.0 +hh_sum 0.05 +hh_created 123.456 +# EOF +''', generate_latest(self.registry)) + + def test_histogram_exemplar(self): + class MyCollector(object): + def collect(self): + metric = Metric("hh", "help", 'histogram') + # This is not sane, but it covers all the cases. + metric.add_sample("hh_bucket", {"le": "1"}, 0, None, Exemplar({'a': 'b'}, 0.5)) + metric.add_sample("hh_bucket", {"le": "2"}, 0, None, Exemplar({'le': '7'}, 0.5, 12)) + metric.add_sample("hh_bucket", {"le": "3"}, 0, 123, Exemplar({'a': 'b'}, 2.5, 12)) + metric.add_sample("hh_bucket", {"le": "4"}, 0, None, Exemplar({'a': '\n"\\'}, 3.5)) + metric.add_sample("hh_bucket", {"le": "+Inf"}, 0, None, None) + yield metric + + self.registry.register(MyCollector()) + self.assertEqual(b'''# HELP hh help +# TYPE hh histogram +hh_bucket{le="1"} 0.0 # {a="b"} 0.5 +hh_bucket{le="2"} 0.0 # {le="7"} 0.5 12 +hh_bucket{le="3"} 0.0 123 # {a="b"} 2.5 12 +hh_bucket{le="4"} 0.0 # {a="\\n\\"\\\\"} 3.5 +hh_bucket{le="+Inf"} 0.0 +# EOF +''', generate_latest(self.registry)) + + def test_nonhistogram_exemplar(self): + class MyCollector(object): + def collect(self): + metric = Metric("hh", "help", 'untyped') + # This is not sane, but it covers all the cases. + metric.add_sample("hh_bucket", {}, 0, None, Exemplar({'a': 'b'}, 0.5)) + yield metric + + self.registry.register(MyCollector()) + with self.assertRaises(ValueError): + generate_latest(self.registry) + + def test_nonhistogram_bucket_exemplar(self): + class MyCollector(object): + def collect(self): + metric = Metric("hh", "help", 'histogram') + # This is not sane, but it covers all the cases. + metric.add_sample("hh_count", {}, 0, None, Exemplar({'a': 'b'}, 0.5)) + yield metric + + self.registry.register(MyCollector()) + with self.assertRaises(ValueError): + generate_latest(self.registry) + + def test_gaugehistogram(self): + self.custom_collector(GaugeHistogramMetricFamily('gh', 'help', buckets=[('1.0', 4), ('+Inf', (5))])) + self.assertEqual(b'''# HELP gh help +# TYPE gh gaugehistogram +gh_bucket{le="1.0"} 4.0 +gh_bucket{le="+Inf"} 5.0 +# EOF +''', generate_latest(self.registry)) + + def test_info(self): + i = Info('ii', 'A info', ['a', 'b'], registry=self.registry) + i.labels('c', 'd').info({'foo': 'bar'}) + self.assertEqual(b'''# HELP ii A info +# TYPE ii info +ii_info{a="c",b="d",foo="bar"} 1.0 +# EOF +''', generate_latest(self.registry)) + + def test_enum(self): + i = Enum('ee', 'An enum', ['a', 'b'], registry=self.registry, states=['foo', 'bar']) + i.labels('c', 'd').state('bar') + self.assertEqual(b'''# HELP ee An enum +# TYPE ee stateset +ee{a="c",b="d",ee="foo"} 0.0 +ee{a="c",b="d",ee="bar"} 1.0 +# EOF +''', generate_latest(self.registry)) + + def test_unicode(self): + c = Counter('cc', '\u4500', ['l'], registry=self.registry) + c.labels('\u4500').inc() + self.assertEqual(b'''# HELP cc \xe4\x94\x80 +# TYPE cc counter +cc_total{l="\xe4\x94\x80"} 1.0 +cc_created{l="\xe4\x94\x80"} 123.456 +# EOF +''', generate_latest(self.registry)) + + def test_escaping(self): + c = Counter('cc', 'A\ncount\\er\"', ['a'], registry=self.registry) + c.labels('\\x\n"').inc(1) + self.assertEqual(b'''# HELP cc A\\ncount\\\\er\\" +# TYPE cc counter +cc_total{a="\\\\x\\n\\""} 1.0 +cc_created{a="\\\\x\\n\\""} 123.456 +# EOF +''', generate_latest(self.registry)) + + def test_nonnumber(self): + + class MyNumber(object): + def __repr__(self): + return "MyNumber(123)" + + def __float__(self): + return 123.0 + + class MyCollector(object): + def collect(self): + metric = Metric("nonnumber", "Non number", 'untyped') + metric.add_sample("nonnumber", {}, MyNumber()) + yield metric + + self.registry.register(MyCollector()) + self.assertEqual(b'# HELP nonnumber Non number\n# TYPE nonnumber unknown\nnonnumber 123.0\n# EOF\n', generate_latest(self.registry)) + + def test_timestamp(self): + class MyCollector(object): + def collect(self): + metric = Metric("ts", "help", 'unknown') + metric.add_sample("ts", {"foo": "a"}, 0, 123.456) + metric.add_sample("ts", {"foo": "b"}, 0, -123.456) + metric.add_sample("ts", {"foo": "c"}, 0, 123) + metric.add_sample("ts", {"foo": "d"}, 0, Timestamp(123, 456000000)) + metric.add_sample("ts", {"foo": "e"}, 0, Timestamp(123, 456000)) + metric.add_sample("ts", {"foo": "f"}, 0, Timestamp(123, 456)) + yield metric + + self.registry.register(MyCollector()) + self.assertEqual(b'''# HELP ts help +# TYPE ts unknown +ts{foo="a"} 0.0 123.456 +ts{foo="b"} 0.0 -123.456 +ts{foo="c"} 0.0 123 +ts{foo="d"} 0.0 123.456000000 +ts{foo="e"} 0.0 123.000456000 +ts{foo="f"} 0.0 123.000000456 +# EOF +''', generate_latest(self.registry)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py new file mode 100644 index 00000000..300965af --- /dev/null +++ b/tests/openmetrics/test_parser.py @@ -0,0 +1,415 @@ +from __future__ import unicode_literals + +import math +import sys + +if sys.version_info < (2, 7): + # We need the skip decorators from unittest2 on Python 2.6. + import unittest2 as unittest +else: + import unittest + +from prometheus_client.core import ( + CollectorRegistry, + CounterMetricFamily, + Exemplar, + GaugeMetricFamily, + HistogramMetricFamily, + Metric, + Sample, + SummaryMetricFamily, + Timestamp, +) +from prometheus_client.openmetrics.exposition import ( + generate_latest, +) +from prometheus_client.openmetrics.parser import ( + text_string_to_metric_families, +) + + +class TestParse(unittest.TestCase): + + def test_simple_counter(self): + families = text_string_to_metric_families("""# TYPE a counter +# HELP a help +a_total 1 +# EOF +""") + self.assertEqual([CounterMetricFamily("a", "help", value=1)], list(families)) + + def test_uint64_counter(self): + families = text_string_to_metric_families("""# TYPE a counter +# HELP a help +a_total 9223372036854775808 +# EOF +""") + self.assertEqual([CounterMetricFamily("a", "help", value=9223372036854775808)], list(families)) + + def test_simple_gauge(self): + families = text_string_to_metric_families("""# TYPE a gauge +# HELP a help +a 1 +# EOF +""") + self.assertEqual([GaugeMetricFamily("a", "help", value=1)], list(families)) + + def test_float_gauge(self): + families = text_string_to_metric_families("""# TYPE a gauge +# HELP a help +a 1.2 +# EOF +""") + self.assertEqual([GaugeMetricFamily("a", "help", value=1.2)], list(families)) + + def test_unit_gauge(self): + families = text_string_to_metric_families("""# TYPE a_seconds gauge +# UNIT a_seconds seconds +# HELP a_seconds help +a_seconds 1 +# EOF +""") + self.assertEqual([GaugeMetricFamily("a_seconds", "help", value=1, unit='seconds')], list(families)) + + def test_simple_summary(self): + families = text_string_to_metric_families("""# TYPE a summary +# HELP a help +a_count 1 +a_sum 2 +# EOF +""") + summary = SummaryMetricFamily("a", "help", count_value=1, sum_value=2) + self.assertEqual([summary], list(families)) + + def test_summary_quantiles(self): + families = text_string_to_metric_families("""# TYPE a summary +# HELP a help +a_count 1 +a_sum 2 +a{quantile="0.5"} 0.7 +# EOF +""") + # The Python client doesn't support quantiles, but we + # still need to be able to parse them. + metric_family = SummaryMetricFamily("a", "help", count_value=1, sum_value=2) + metric_family.add_sample("a", {"quantile": "0.5"}, 0.7) + self.assertEqual([metric_family], list(families)) + + def test_simple_histogram(self): + families = text_string_to_metric_families("""# TYPE a histogram +# HELP a help +a_bucket{le="1"} 0 +a_bucket{le="+Inf"} 3 +a_count 3 +a_sum 2 +# EOF +""") + self.assertEqual([HistogramMetricFamily("a", "help", sum_value=2, buckets=[("1", 0.0), ("+Inf", 3.0)])], list(families)) + + def test_histogram_exemplars(self): + families = text_string_to_metric_families("""# TYPE a histogram +# HELP a help +a_bucket{le="1"} 0 # {a="b"} 0.5 +a_bucket{le="2"} 2 123 # {a="c"} 0.5 +a_bucket{le="+Inf"} 3 # {a="d"} 4 123 +# EOF +""") + hfm = HistogramMetricFamily("a", "help") + hfm.add_sample("a_bucket", {"le": "1"}, 0.0, None, Exemplar({"a": "b"}, 0.5)) + hfm.add_sample("a_bucket", {"le": "2"}, 2.0, Timestamp(123, 0), Exemplar({"a": "c"}, 0.5)), + hfm.add_sample("a_bucket", {"le": "+Inf"}, 3.0, None, Exemplar({"a": "d"}, 4, Timestamp(123, 0))) + self.assertEqual([hfm], list(families)) + + def test_no_metadata(self): + families = text_string_to_metric_families("""a 1 +# EOF +""") + metric_family = Metric("a", "", "untyped") + metric_family.add_sample("a", {}, 1) + self.assertEqual([metric_family], list(families)) + + def test_untyped(self): + # https://github.com/prometheus/client_python/issues/79 + families = text_string_to_metric_families("""# HELP redis_connected_clients Redis connected clients +# TYPE redis_connected_clients untyped +redis_connected_clients{instance="rough-snowflake-web",port="6380"} 10.0 +redis_connected_clients{instance="rough-snowflake-web",port="6381"} 12.0 +# EOF +""") + m = Metric("redis_connected_clients", "Redis connected clients", "untyped") + m.samples = [ + Sample("redis_connected_clients", {"instance": "rough-snowflake-web", "port": "6380"}, 10), + Sample("redis_connected_clients", {"instance": "rough-snowflake-web", "port": "6381"}, 12), + ] + self.assertEqual([m], list(families)) + + def test_type_help_switched(self): + families = text_string_to_metric_families("""# HELP a help +# TYPE a counter +a_total 1 +# EOF +""") + self.assertEqual([CounterMetricFamily("a", "help", value=1)], list(families)) + + def test_labels_with_curly_braces(self): + families = text_string_to_metric_families("""# TYPE a counter +# HELP a help +a_total{foo="bar",bar="b{a}z"} 1 +# EOF +""") + metric_family = CounterMetricFamily("a", "help", labels=["foo", "bar"]) + metric_family.add_metric(["bar", "b{a}z"], 1) + self.assertEqual([metric_family], list(families)) + + def test_empty_help(self): + families = text_string_to_metric_families("""# TYPE a counter +# HELP a +a_total 1 +# EOF +""") + self.assertEqual([CounterMetricFamily("a", "", value=1)], list(families)) + + def test_labels_and_infinite(self): + families = text_string_to_metric_families("""# TYPE a counter +# HELP a help +a_total{foo="bar"} +Inf +a_total{foo="baz"} -Inf +# EOF +""") + metric_family = CounterMetricFamily("a", "help", labels=["foo"]) + metric_family.add_metric(["bar"], float('inf')) + metric_family.add_metric(["baz"], float('-inf')) + self.assertEqual([metric_family], list(families)) + + def test_empty_brackets(self): + families = text_string_to_metric_families("""# TYPE a counter +# HELP a help +a_total{} 1 +# EOF +""") + self.assertEqual([CounterMetricFamily("a", "help", value=1)], list(families)) + + def test_nan(self): + families = text_string_to_metric_families("""a NaN +# EOF +""") + self.assertTrue(math.isnan(list(families)[0].samples[0][2])) + + def test_no_newline_after_eof(self): + families = text_string_to_metric_families("""# TYPE a gauge +# HELP a help +a 1 +# EOF""") + self.assertEqual([GaugeMetricFamily("a", "help", value=1)], list(families)) + + def test_empty_label(self): + families = text_string_to_metric_families("""# TYPE a counter +# HELP a help +a_total{foo="bar"} 1 +a_total{foo=""} 2 +# EOF +""") + metric_family = CounterMetricFamily("a", "help", labels=["foo"]) + metric_family.add_metric(["bar"], 1) + metric_family.add_metric([""], 2) + self.assertEqual([metric_family], list(families)) + + def test_label_escaping(self): + for escaped_val, unescaped_val in [ + ('foo', 'foo'), + ('\\foo', '\\foo'), + ('\\\\foo', '\\foo'), + ('foo\\\\', 'foo\\'), + ('\\\\', '\\'), + ('\\n', '\n'), + ('\\\\n', '\\n'), + ('\\\\\\n', '\\\n'), + ('\\"', '"'), + ('\\\\\\"', '\\"')]: + families = list(text_string_to_metric_families("""# TYPE a counter +# HELP a help +a_total{foo="%s",bar="baz"} 1 +# EOF +""" % escaped_val)) + metric_family = CounterMetricFamily( + "a", "help", labels=["foo", "bar"]) + metric_family.add_metric([unescaped_val, "baz"], 1) + self.assertEqual([metric_family], list(families)) + + def test_help_escaping(self): + for escaped_val, unescaped_val in [ + ('foo', 'foo'), + ('\\foo', '\\foo'), + ('\\\\foo', '\\foo'), + ('foo\\', 'foo\\'), + ('foo\\\\', 'foo\\'), + ('\\n', '\n'), + ('\\\\n', '\\n'), + ('\\\\\\n', '\\\n'), + ('\\"', '"'), + ('\\\\"', '\\"'), + ('\\\\\\"', '\\"')]: + families = list(text_string_to_metric_families("""# TYPE a counter +# HELP a %s +a_total{foo="bar"} 1 +# EOF +""" % escaped_val)) + metric_family = CounterMetricFamily("a", unescaped_val, labels=["foo"]) + metric_family.add_metric(["bar"], 1) + self.assertEqual([metric_family], list(families)) + + def test_escaping(self): + families = text_string_to_metric_families("""# TYPE a counter +# HELP a he\\n\\\\l\\tp +a_total{foo="b\\"a\\nr"} 1 +a_total{foo="b\\\\a\\z"} 2 +# EOF +""") + metric_family = CounterMetricFamily("a", "he\n\\l\\tp", labels=["foo"]) + metric_family.add_metric(["b\"a\nr"], 1) + metric_family.add_metric(["b\\a\\z"], 2) + self.assertEqual([metric_family], list(families)) + + def test_timestamps(self): + families = text_string_to_metric_families("""# TYPE a counter +# HELP a help +a_total{foo="1"} 1 000 +a_total{foo="2"} 1 0.0 +a_total{foo="3"} 1 1.1 +a_total{foo="4"} 1 12345678901234567890.1234567890 +a_total{foo="5"} 1 1.5e3 +# TYPE b counter +# HELP b help +b_total 2 1234567890 +# EOF +""") + a = CounterMetricFamily("a", "help", labels=["foo"]) + a.add_metric(["1"], 1, timestamp=Timestamp(0, 0)) + a.add_metric(["2"], 1, timestamp=Timestamp(0, 0)) + a.add_metric(["3"], 1, timestamp=Timestamp(1, 100000000)) + a.add_metric(["4"], 1, timestamp=Timestamp(12345678901234567890, 123456789)) + a.add_metric(["5"], 1, timestamp=1500.0) + b = CounterMetricFamily("b", "help") + b.add_metric([], 2, timestamp=Timestamp(1234567890, 0)) + self.assertEqual([a, b], list(families)) + + @unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.") + def test_roundtrip(self): + text = """# HELP go_gc_duration_seconds A summary of the GC invocation durations. +# TYPE go_gc_duration_seconds summary +go_gc_duration_seconds{quantile="0"} 0.013300656000000001 +go_gc_duration_seconds{quantile="0.25"} 0.013638736 +go_gc_duration_seconds{quantile="0.5"} 0.013759906 +go_gc_duration_seconds{quantile="0.75"} 0.013962066 +go_gc_duration_seconds{quantile="1"} 0.021383540000000003 +go_gc_duration_seconds_sum 56.12904785 +go_gc_duration_seconds_count 7476.0 +# HELP go_goroutines Number of goroutines that currently exist. +# TYPE go_goroutines gauge +go_goroutines 166.0 +# HELP prometheus_local_storage_indexing_batch_duration_milliseconds Quantiles for batch indexing duration in milliseconds. +# TYPE prometheus_local_storage_indexing_batch_duration_milliseconds summary +prometheus_local_storage_indexing_batch_duration_milliseconds{quantile="0.5"} NaN +prometheus_local_storage_indexing_batch_duration_milliseconds{quantile="0.9"} NaN +prometheus_local_storage_indexing_batch_duration_milliseconds{quantile="0.99"} NaN +prometheus_local_storage_indexing_batch_duration_milliseconds_sum 871.5665949999999 +prometheus_local_storage_indexing_batch_duration_milliseconds_count 229.0 +# HELP process_cpu_seconds Total user and system CPU time spent in seconds. +# TYPE process_cpu_seconds counter +process_cpu_seconds_total 29323.4 +# HELP process_virtual_memory_bytes Virtual memory size in bytes. +# TYPE process_virtual_memory_bytes gauge +process_virtual_memory_bytes 2478268416.0 +# HELP prometheus_build_info A metric with a constant '1' value labeled by version, revision, and branch from which Prometheus was built. +# TYPE prometheus_build_info gauge +prometheus_build_info{branch="HEAD",revision="ef176e5",version="0.16.0rc1"} 1.0 +# HELP prometheus_local_storage_chunk_ops The total number of chunk operations by their type. +# TYPE prometheus_local_storage_chunk_ops counter +prometheus_local_storage_chunk_ops_total{type="clone"} 28.0 +prometheus_local_storage_chunk_ops_total{type="create"} 997844.0 +prometheus_local_storage_chunk_ops_total{type="drop"} 1345758.0 +prometheus_local_storage_chunk_ops_total{type="load"} 1641.0 +prometheus_local_storage_chunk_ops_total{type="persist"} 981408.0 +prometheus_local_storage_chunk_ops_total{type="pin"} 32662.0 +prometheus_local_storage_chunk_ops_total{type="transcode"} 980180.0 +prometheus_local_storage_chunk_ops_total{type="unpin"} 32662.0 +# EOF +""" + families = list(text_string_to_metric_families(text)) + + class TextCollector(object): + def collect(self): + return families + + registry = CollectorRegistry() + registry.register(TextCollector()) + self.assertEqual(text.encode('utf-8'), generate_latest(registry)) + + def test_invalid_input(self): + for case in [ + # No EOF. + (''), + # Text after EOF. + ('a 1\n# EOF\nblah'), + ('a 1\n# EOFblah'), + # Missing or wrong quotes on label value. + ('a{a=1} 1\n# EOF\n'), + ('a{a="1} 1\n# EOF\n'), + ('a{a=\'1\'} 1\n# EOF\n'), + # Missing or extra commas. + ('a{a="1"b="2"} 1\n# EOF\n'), + ('a{a="1",,b="2"} 1\n# EOF\n'), + ('a{a="1",b="2",} 1\n# EOF\n'), + # Missing value. + ('a\n# EOF\n'), + ('a \n# EOF\n'), + # Bad HELP. + ('# HELP\n# EOF\n'), + ('# HELP \n# EOF\n'), + ('# HELP a\n# EOF\n'), + ('# HELP a\t\n# EOF\n'), + (' # HELP a meh\n# EOF\n'), + # Bad TYPE. + ('# TYPE\n# EOF\n'), + ('# TYPE \n# EOF\n'), + ('# TYPE a\n# EOF\n'), + ('# TYPE a\t\n# EOF\n'), + ('# TYPE a meh\n# EOF\n'), + ('# TYPE a meh \n# EOF\n'), + ('# TYPE a gauge \n# EOF\n'), + # Bad UNIT. + ('# UNIT\n# EOF\n'), + ('# UNIT \n# EOF\n'), + ('# UNIT a\n# EOF\n'), + ('# UNIT a\t\n# EOF\n'), + ('# UNIT a seconds\n# EOF\n'), + ('# UNIT a_seconds seconds \n# EOF\n'), + ('# TYPE x_u info\n# UNIT x_u u\n# EOF\n'), + ('# TYPE x_u stateset\n# UNIT x_u u\n# EOF\n'), + # Bad metric names. + ('0a 1\n# EOF\n'), + ('a.b 1\n# EOF\n'), + ('a-b 1\n# EOF\n'), + # Bad value. + ('a a\n# EOF\n'), + ('a 1\n# EOF\n'), + ('a 1\t\n# EOF\n'), + ('a 1 \n# EOF\n'), + # Bad timestamp. + ('a 1 z\n# EOF\n'), + ('a 1 1z\n# EOF\n'), + ('a 1 1.1.1\n# EOF\n'), + # Bad exemplars. + ('# TYPE a histogram\na_bucket{le="+Inf"} 1 #\n# EOF\n'), + ('# TYPE a histogram\na_bucket{le="+Inf"} 1# {} 1\n# EOF\n'), + ('# TYPE a histogram\na_bucket{le="+Inf"} 1 #{} 1\n# EOF\n'), + ('# TYPE a histogram\na_bucket{le="+Inf"} 1 # {}1\n# EOF\n'), + ('# TYPE a histogram\na_bucket{le="+Inf"} 1 # {} 1 \n# EOF\n'), + ('# TYPE a histogram\na_bucket{le="+Inf"} 1 # {} 1 1 \n# EOF\n'), + ]: + with self.assertRaises(ValueError): + list(text_string_to_metric_families(case)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_core.py b/tests/test_core.py index f85a3e1b..fceca7ae 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,11 +14,17 @@ CollectorRegistry, Counter, CounterMetricFamily, + Enum, Gauge, + GaugeHistogramMetricFamily, GaugeMetricFamily, Histogram, HistogramMetricFamily, + Info, + InfoMetricFamily, Metric, + StateSetMetricFamily, + Sample, Summary, SummaryMetricFamily, UntypedMetricFamily, @@ -28,14 +34,14 @@ class TestCounter(unittest.TestCase): def setUp(self): self.registry = CollectorRegistry() - self.counter = Counter('c', 'help', registry=self.registry) + self.counter = Counter('c_total', 'help', registry=self.registry) def test_increment(self): - self.assertEqual(0, self.registry.get_sample_value('c')) + self.assertEqual(0, self.registry.get_sample_value('c_total')) self.counter.inc() - self.assertEqual(1, self.registry.get_sample_value('c')) + self.assertEqual(1, self.registry.get_sample_value('c_total')) self.counter.inc(7) - self.assertEqual(8, self.registry.get_sample_value('c')) + self.assertEqual(8, self.registry.get_sample_value('c_total')) def test_negative_increment_raises(self): self.assertRaises(ValueError, self.counter.inc, -1) @@ -54,18 +60,18 @@ def f(r): f(False) except TypeError: pass - self.assertEqual(0, self.registry.get_sample_value('c')) + self.assertEqual(0, self.registry.get_sample_value('c_total')) try: f(True) except ValueError: pass - self.assertEqual(1, self.registry.get_sample_value('c')) + self.assertEqual(1, self.registry.get_sample_value('c_total')) def test_block_decorator(self): with self.counter.count_exceptions(): pass - self.assertEqual(0, self.registry.get_sample_value('c')) + self.assertEqual(0, self.registry.get_sample_value('c_total')) raised = False try: @@ -74,7 +80,7 @@ def test_block_decorator(self): except: raised = True self.assertTrue(raised) - self.assertEqual(1, self.registry.get_sample_value('c')) + self.assertEqual(1, self.registry.get_sample_value('c_total')) class TestGauge(unittest.TestCase): @@ -287,6 +293,8 @@ def test_setting_buckets(self): self.assertRaises(ValueError, Histogram, 'h', 'help', registry=None, buckets=[3, 1]) def test_labels(self): + self.assertRaises(ValueError, Histogram, 'h', 'help', registry=None, labelnames=['le']) + self.labels.labels('a').observe(2) self.assertEqual(0, self.registry.get_sample_value('hl_bucket', {'le': '1.0', 'l': 'a'})) self.assertEqual(1, self.registry.get_sample_value('hl_bucket', {'le': '2.5', 'l': 'a'})) @@ -295,6 +303,7 @@ def test_labels(self): self.assertEqual(1, self.registry.get_sample_value('hl_count', {'l': 'a'})) self.assertEqual(2, self.registry.get_sample_value('hl_sum', {'l': 'a'})) + def test_function_decorator(self): self.assertEqual(0, self.registry.get_sample_value('h_count')) self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) @@ -339,26 +348,74 @@ def test_block_decorator(self): self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) +class TestInfo(unittest.TestCase): + def setUp(self): + self.registry = CollectorRegistry() + self.info = Info('i', 'help', registry=self.registry) + self.labels = Info('il', 'help', ['l'], registry=self.registry) + + def test_info(self): + self.assertEqual(1, self.registry.get_sample_value('i_info', {})) + self.info.info({'a': 'b', 'c': 'd'}) + self.assertEqual(None, self.registry.get_sample_value('i_info', {})) + self.assertEqual(1, self.registry.get_sample_value('i_info', {'a': 'b', 'c': 'd'})) + + def test_labels(self): + self.assertRaises(ValueError, self.labels.labels('a').info, {'l': ''}) + + self.labels.labels('a').info({'foo': 'bar'}) + self.assertEqual(1, self.registry.get_sample_value('il_info', {'l': 'a', 'foo': 'bar'})) + + +class TestEnum(unittest.TestCase): + def setUp(self): + self.registry = CollectorRegistry() + self.enum = Enum('e', 'help', states=['a', 'b', 'c'], registry=self.registry) + self.labels = Enum('el', 'help', ['l'], states=['a', 'b', 'c'], registry=self.registry) + + def test_enum(self): + self.assertEqual(1, self.registry.get_sample_value('e', {'e': 'a'})) + self.assertEqual(0, self.registry.get_sample_value('e', {'e': 'b'})) + self.assertEqual(0, self.registry.get_sample_value('e', {'e': 'c'})) + + self.enum.state('b') + self.assertEqual(0, self.registry.get_sample_value('e', {'e': 'a'})) + self.assertEqual(1, self.registry.get_sample_value('e', {'e': 'b'})) + self.assertEqual(0, self.registry.get_sample_value('e', {'e': 'c'})) + + self.assertRaises(ValueError, self.enum.state, 'd') + self.assertRaises(ValueError, Enum, 'e', 'help', registry=None) + + def test_labels(self): + self.labels.labels('a').state('c') + self.assertEqual(0, self.registry.get_sample_value('el', {'l': 'a', 'el': 'a'})) + self.assertEqual(0, self.registry.get_sample_value('el', {'l': 'a', 'el': 'b'})) + self.assertEqual(1, self.registry.get_sample_value('el', {'l': 'a', 'el': 'c'})) + + e = Enum('e', 'help', registry=None, labelnames=['e']) + self.assertRaises(ValueError, e.labels, '') + + class TestMetricWrapper(unittest.TestCase): def setUp(self): self.registry = CollectorRegistry() - self.counter = Counter('c', 'help', labelnames=['l'], registry=self.registry) + self.counter = Counter('c_total', 'help', labelnames=['l'], registry=self.registry) self.two_labels = Counter('two', 'help', labelnames=['a', 'b'], registry=self.registry) def test_child(self): self.counter.labels('x').inc() - self.assertEqual(1, self.registry.get_sample_value('c', {'l': 'x'})) + self.assertEqual(1, self.registry.get_sample_value('c_total', {'l': 'x'})) self.two_labels.labels('x', 'y').inc(2) - self.assertEqual(2, self.registry.get_sample_value('two', {'a': 'x', 'b': 'y'})) + self.assertEqual(2, self.registry.get_sample_value('two_total', {'a': 'x', 'b': 'y'})) def test_remove(self): self.counter.labels('x').inc() self.counter.labels('y').inc(2) - self.assertEqual(1, self.registry.get_sample_value('c', {'l': 'x'})) - self.assertEqual(2, self.registry.get_sample_value('c', {'l': 'y'})) + self.assertEqual(1, self.registry.get_sample_value('c_total', {'l': 'x'})) + self.assertEqual(2, self.registry.get_sample_value('c_total', {'l': 'y'})) self.counter.remove('x') - self.assertEqual(None, self.registry.get_sample_value('c', {'l': 'x'})) - self.assertEqual(2, self.registry.get_sample_value('c', {'l': 'y'})) + self.assertEqual(None, self.registry.get_sample_value('c_total', {'l': 'x'})) + self.assertEqual(2, self.registry.get_sample_value('c_total', {'l': 'y'})) def test_incorrect_label_count_raises(self): self.assertRaises(ValueError, self.counter.labels) @@ -369,10 +426,10 @@ def test_incorrect_label_count_raises(self): def test_labels_coerced_to_string(self): self.counter.labels(None).inc() self.counter.labels(l=None).inc() - self.assertEqual(2, self.registry.get_sample_value('c', {'l': 'None'})) + self.assertEqual(2, self.registry.get_sample_value('c_total', {'l': 'None'})) self.counter.remove(None) - self.assertEqual(None, self.registry.get_sample_value('c', {'l': 'None'})) + self.assertEqual(None, self.registry.get_sample_value('c_total', {'l': 'None'})) def test_non_string_labels_raises(self): class Test(object): @@ -381,18 +438,18 @@ class Test(object): self.assertRaises(TypeError, self.counter.labels, l=Test()) def test_namespace_subsystem_concatenated(self): - c = Counter('c', 'help', namespace='a', subsystem='b', registry=self.registry) + c = Counter('c_total', 'help', namespace='a', subsystem='b', registry=self.registry) c.inc() - self.assertEqual(1, self.registry.get_sample_value('a_b_c')) + self.assertEqual(1, self.registry.get_sample_value('a_b_c_total')) def test_labels_by_kwarg(self): self.counter.labels(l='x').inc() - self.assertEqual(1, self.registry.get_sample_value('c', {'l': 'x'})) + self.assertEqual(1, self.registry.get_sample_value('c_total', {'l': 'x'})) self.assertRaises(ValueError, self.counter.labels, l='x', m='y') self.assertRaises(ValueError, self.counter.labels, m='y') self.assertRaises(ValueError, self.counter.labels) self.two_labels.labels(a='x', b='y').inc() - self.assertEqual(1, self.registry.get_sample_value('two', {'a': 'x', 'b': 'y'})) + self.assertEqual(1, self.registry.get_sample_value('two_total', {'a': 'x', 'b': 'y'})) self.assertRaises(ValueError, self.two_labels.labels, a='x', b='y', c='z') self.assertRaises(ValueError, self.two_labels.labels, a='x', c='z') self.assertRaises(ValueError, self.two_labels.labels, b='y', c='z') @@ -405,15 +462,26 @@ def test_invalid_names_raise(self): self.assertRaises(ValueError, Counter, '^', 'help') self.assertRaises(ValueError, Counter, '', 'help', namespace='&') self.assertRaises(ValueError, Counter, '', 'help', subsystem='(') - self.assertRaises(ValueError, Counter, 'c', '', labelnames=['^']) - self.assertRaises(ValueError, Counter, 'c', '', labelnames=['a:b']) - self.assertRaises(ValueError, Counter, 'c', '', labelnames=['__reserved']) - self.assertRaises(ValueError, Summary, 'c', '', labelnames=['quantile']) - + self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['^']) + self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['a:b']) + self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['__reserved']) + self.assertRaises(ValueError, Summary, 'c_total', '', labelnames=['quantile']) def test_empty_labels_list(self): Histogram('h', 'help', [], registry=self.registry) self.assertEqual(0, self.registry.get_sample_value('h_sum')) + def test_unit_appended(self): + Histogram('h', 'help', [], registry=self.registry, unit="seconds") + self.assertEqual(0, self.registry.get_sample_value('h_seconds_sum')) + + def test_unit_notappended(self): + Histogram('h_seconds', 'help', [], registry=self.registry, unit="seconds") + self.assertEqual(0, self.registry.get_sample_value('h_seconds_sum')) + + def test_no_units_for_info_enum(self): + self.assertRaises(ValueError, Info, 'foo', 'help', unit="x") + self.assertRaises(ValueError, Enum, 'foo', 'help', unit="x") + def test_wrapped_original_class(self): self.assertEqual(Counter.__wrapped__, Counter('foo', 'bar').__class__) @@ -438,15 +506,23 @@ def test_untyped_labels(self): self.custom_collector(cmf) self.assertEqual(2, self.registry.get_sample_value('u', {'a': 'b', 'c': 'd'})) + def test_untyped_unit(self): + self.custom_collector(UntypedMetricFamily('u', 'help', value=1, unit='unit')) + self.assertEqual(1, self.registry.get_sample_value('u_unit', {})) + def test_counter(self): - self.custom_collector(CounterMetricFamily('c', 'help', value=1)) - self.assertEqual(1, self.registry.get_sample_value('c', {})) + self.custom_collector(CounterMetricFamily('c_total', 'help', value=1)) + self.assertEqual(1, self.registry.get_sample_value('c_total', {})) + + def test_counter_total(self): + self.custom_collector(CounterMetricFamily('c_total', 'help', value=1)) + self.assertEqual(1, self.registry.get_sample_value('c_total', {})) def test_counter_labels(self): - cmf = CounterMetricFamily('c', 'help', labels=['a', 'c']) + cmf = CounterMetricFamily('c_total', 'help', labels=['a', 'c_total']) cmf.add_metric(['b', 'd'], 2) self.custom_collector(cmf) - self.assertEqual(2, self.registry.get_sample_value('c', {'a': 'b', 'c': 'd'})) + self.assertEqual(2, self.registry.get_sample_value('c_total', {'a': 'b', 'c_total': 'd'})) def test_gauge(self): self.custom_collector(GaugeMetricFamily('g', 'help', value=1)) @@ -486,12 +562,46 @@ def test_histogram_labels(self): self.assertEqual(2, self.registry.get_sample_value('h_count', {'a': 'b'})) self.assertEqual(3, self.registry.get_sample_value('h_sum', {'a': 'b'})) + def test_gaugehistogram(self): + self.custom_collector(GaugeHistogramMetricFamily('h', 'help', buckets=[('0', 1), ('+Inf', 2)])) + self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '0'})) + self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + + def test_gaugehistogram_labels(self): + cmf = GaugeHistogramMetricFamily('h', 'help', labels=['a']) + cmf.add_metric(['b'], buckets=[('0', 1), ('+Inf', 2)]) + self.custom_collector(cmf) + self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'a': 'b', 'le': '0'})) + self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'a': 'b', 'le': '+Inf'})) + + def test_info(self): + self.custom_collector(InfoMetricFamily('i', 'help', value={'a': 'b'})) + self.assertEqual(1, self.registry.get_sample_value('i_info', {'a': 'b'})) + + def test_info_labels(self): + cmf = InfoMetricFamily('i', 'help', labels=['a']) + cmf.add_metric(['b'], {'c': 'd'}) + self.custom_collector(cmf) + self.assertEqual(1, self.registry.get_sample_value('i_info', {'a': 'b', 'c': 'd'})) + + def test_stateset(self): + self.custom_collector(StateSetMetricFamily('s', 'help', value={'a': True, 'b': True,})) + self.assertEqual(1, self.registry.get_sample_value('s', {'s': 'a'})) + self.assertEqual(1, self.registry.get_sample_value('s', {'s': 'b'})) + + def test_stateset_labels(self): + cmf = StateSetMetricFamily('s', 'help', labels=['foo']) + cmf.add_metric(['bar'], {'a': False, 'b': False,}) + self.custom_collector(cmf) + self.assertEqual(0, self.registry.get_sample_value('s', {'foo': 'bar', 's': 'a'})) + self.assertEqual(0, self.registry.get_sample_value('s', {'foo': 'bar', 's': 'b'})) + def test_bad_constructors(self): self.assertRaises(ValueError, UntypedMetricFamily, 'u', 'help', value=1, labels=[]) self.assertRaises(ValueError, UntypedMetricFamily, 'u', 'help', value=1, labels=['a']) - self.assertRaises(ValueError, CounterMetricFamily, 'c', 'help', value=1, labels=[]) - self.assertRaises(ValueError, CounterMetricFamily, 'c', 'help', value=1, labels=['a']) + self.assertRaises(ValueError, CounterMetricFamily, 'c_total', 'help', value=1, labels=[]) + self.assertRaises(ValueError, CounterMetricFamily, 'c_total', 'help', value=1, labels=['a']) self.assertRaises(ValueError, GaugeMetricFamily, 'g', 'help', value=1, labels=[]) self.assertRaises(ValueError, GaugeMetricFamily, 'g', 'help', value=1, labels=['a']) @@ -509,10 +619,16 @@ def test_bad_constructors(self): self.assertRaises(ValueError, HistogramMetricFamily, 'h', 'help', buckets={}, sum_value=1, labels=['a']) self.assertRaises(KeyError, HistogramMetricFamily, 'h', 'help', buckets={}, sum_value=1) + self.assertRaises(ValueError, InfoMetricFamily, 'i', 'help', value={}, labels=[]) + self.assertRaises(ValueError, InfoMetricFamily, 'i', 'help', value={}, labels=['a']) + + self.assertRaises(ValueError, StateSetMetricFamily, 's', 'help', value={'a': True}, labels=[]) + self.assertRaises(ValueError, StateSetMetricFamily, 's', 'help', value={'a': True}, labels=['a']) + def test_labelnames(self): cmf = UntypedMetricFamily('u', 'help', labels=iter(['a'])) self.assertEqual(('a',), cmf._labelnames) - cmf = CounterMetricFamily('c', 'help', labels=iter(['a'])) + cmf = CounterMetricFamily('c_total', 'help', labels=iter(['a'])) self.assertEqual(('a',), cmf._labelnames) gmf = GaugeMetricFamily('g', 'help', labels=iter(['a'])) self.assertEqual(('a',), gmf._labelnames) @@ -525,16 +641,20 @@ def test_labelnames(self): class TestCollectorRegistry(unittest.TestCase): def test_duplicate_metrics_raises(self): registry = CollectorRegistry() - Counter('c', 'help', registry=registry) - self.assertRaises(ValueError, Counter, 'c', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 'c', 'help', registry=registry) + Counter('c_total', 'help', registry=registry) + self.assertRaises(ValueError, Counter, 'c_total', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 'c_total', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 'c_created', 'help', registry=registry) - Gauge('g', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 'g', 'help', registry=registry) + Gauge('g_created', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 'g_created', 'help', registry=registry) self.assertRaises(ValueError, Counter, 'g', 'help', registry=registry) Summary('s', 'help', registry=registry) self.assertRaises(ValueError, Summary, 's', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 's_created', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 's_sum', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 's_count', 'help', registry=registry) # We don't currently expose quantiles, but let's prevent future # clashes anyway. self.assertRaises(ValueError, Gauge, 's', 'help', registry=registry) @@ -543,18 +663,19 @@ def test_duplicate_metrics_raises(self): self.assertRaises(ValueError, Histogram, 'h', 'help', registry=registry) # Clashes aggaint various suffixes. self.assertRaises(ValueError, Summary, 'h', 'help', registry=registry) - self.assertRaises(ValueError, Counter, 'h_count', 'help', registry=registry) - self.assertRaises(ValueError, Counter, 'h_sum', 'help', registry=registry) - self.assertRaises(ValueError, Counter, 'h_bucket', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 'h_count', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 'h_sum', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 'h_bucket', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 'h_created', 'help', registry=registry) # The name of the histogram itself isn't taken. - Counter('h', 'help', registry=registry) + Gauge('h', 'help', registry=registry) def test_unregister_works(self): registry = CollectorRegistry() s = Summary('s', 'help', registry=registry) - self.assertRaises(ValueError, Counter, 's_count', 'help', registry=registry) + self.assertRaises(ValueError, Gauge, 's_count', 'help', registry=registry) registry.unregister(s) - Counter('s_count', 'help', registry=registry) + Gauge('s_count', 'help', registry=registry) def custom_collector(self, metric_family, registry): class CustomCollector(object): @@ -564,20 +685,20 @@ def collect(self): def test_autodescribe_disabled_by_default(self): registry = CollectorRegistry() - self.custom_collector(CounterMetricFamily('c', 'help', value=1), registry) - self.custom_collector(CounterMetricFamily('c', 'help', value=1), registry) + self.custom_collector(CounterMetricFamily('c_total', 'help', value=1), registry) + self.custom_collector(CounterMetricFamily('c_total', 'help', value=1), registry) registry = CollectorRegistry(auto_describe=True) - self.custom_collector(CounterMetricFamily('c', 'help', value=1), registry) - self.assertRaises(ValueError, self.custom_collector, CounterMetricFamily('c', 'help', value=1), registry) + self.custom_collector(CounterMetricFamily('c_total', 'help', value=1), registry) + self.assertRaises(ValueError, self.custom_collector, CounterMetricFamily('c_total', 'help', value=1), registry) def test_restricted_registry(self): registry = CollectorRegistry() - Counter('c', 'help', registry=registry) + Counter('c_total', 'help', registry=registry) Summary('s', 'help', registry=registry).observe(7) m = Metric('s', 'help', 'summary') - m.samples = [('s_sum', {}, 7)] + m.samples = [Sample('s_sum', {}, 7)] self.assertEquals([m], registry.restricted_registry(['s_sum']).collect()) diff --git a/tests/test_exposition.py b/tests/test_exposition.py index b94af875..cd5e7ebc 100644 --- a/tests/test_exposition.py +++ b/tests/test_exposition.py @@ -9,10 +9,11 @@ else: import unittest -from prometheus_client import Gauge, Counter, Summary, Histogram, Metric +from prometheus_client import Gauge, Counter, Summary, Histogram, Info, Enum, Metric from prometheus_client import CollectorRegistry, generate_latest from prometheus_client import push_to_gateway, pushadd_to_gateway, delete_from_gateway from prometheus_client import CONTENT_TYPE_LATEST, instance_ip_grouping_key +from prometheus_client.core import GaugeHistogramMetricFamily, Timestamp from prometheus_client.exposition import default_handler, basic_auth_handler, MetricsHandler try: @@ -28,11 +29,21 @@ class TestGenerateText(unittest.TestCase): def setUp(self): self.registry = CollectorRegistry() + def custom_collector(self, metric_family): + class CustomCollector(object): + def collect(self): + return [metric_family] + self.registry.register(CustomCollector()) + def test_counter(self): c = Counter('cc', 'A counter', registry=self.registry) c.inc() - self.assertEqual(b'# HELP cc A counter\n# TYPE cc counter\ncc 1.0\n', generate_latest(self.registry)) + self.assertEqual(b'# HELP cc_total A counter\n# TYPE cc_total counter\ncc_total 1.0\n', generate_latest(self.registry)) + def test_counter_total(self): + c = Counter('cc_total', 'A counter', registry=self.registry) + c.inc() + self.assertEqual(b'# HELP cc_total A counter\n# TYPE cc_total counter\ncc_total 1.0\n', generate_latest(self.registry)) def test_gauge(self): g = Gauge('gg', 'A gauge', registry=self.registry) g.set(17) @@ -68,15 +79,29 @@ def test_histogram(self): hh_sum 0.05 ''', generate_latest(self.registry)) + def test_gaugehistogram(self): + self.custom_collector(GaugeHistogramMetricFamily('gh', 'help', buckets=[('1.0', 4), ('+Inf', (5))])) + self.assertEqual(b'''# HELP gh help\n# TYPE gh histogram\ngh_bucket{le="1.0"} 4.0\ngh_bucket{le="+Inf"} 5.0\n''', generate_latest(self.registry)) + + def test_info(self): + i = Info('ii', 'A info', ['a', 'b'], registry=self.registry) + i.labels('c', 'd').info({'foo': 'bar'}) + self.assertEqual(b'# HELP ii_info A info\n# TYPE ii_info gauge\nii_info{a="c",b="d",foo="bar"} 1.0\n', generate_latest(self.registry)) + + def test_enum(self): + i = Enum('ee', 'An enum', ['a', 'b'], registry=self.registry, states=['foo', 'bar']) + i.labels('c', 'd').state('bar') + self.assertEqual(b'# HELP ee An enum\n# TYPE ee gauge\nee{a="c",b="d",ee="foo"} 0.0\nee{a="c",b="d",ee="bar"} 1.0\n', generate_latest(self.registry)) + def test_unicode(self): c = Counter('cc', '\u4500', ['l'], registry=self.registry) c.labels('\u4500').inc() - self.assertEqual(b'# HELP cc \xe4\x94\x80\n# TYPE cc counter\ncc{l="\xe4\x94\x80"} 1.0\n', generate_latest(self.registry)) + self.assertEqual(b'# HELP cc_total \xe4\x94\x80\n# TYPE cc_total counter\ncc_total{l="\xe4\x94\x80"} 1.0\n', generate_latest(self.registry)) def test_escaping(self): c = Counter('cc', 'A\ncount\\er', ['a'], registry=self.registry) c.labels('\\x\n"').inc(1) - self.assertEqual(b'# HELP cc A\\ncount\\\\er\n# TYPE cc counter\ncc{a="\\\\x\\n\\""} 1.0\n', generate_latest(self.registry)) + self.assertEqual(b'# HELP cc_total A\\ncount\\\\er\n# TYPE cc_total counter\ncc_total{a="\\\\x\\n\\""} 1.0\n', generate_latest(self.registry)) def test_nonnumber(self): @@ -96,6 +121,29 @@ def collect(self): self.registry.register(MyCollector()) self.assertEqual(b'# HELP nonnumber Non number\n# TYPE nonnumber untyped\nnonnumber 123.0\n', generate_latest(self.registry)) + def test_timestamp(self): + class MyCollector(object): + def collect(self): + metric = Metric("ts", "help", 'untyped') + metric.add_sample("ts", {"foo": "a"}, 0, 123.456) + metric.add_sample("ts", {"foo": "b"}, 0, -123.456) + metric.add_sample("ts", {"foo": "c"}, 0, 123) + metric.add_sample("ts", {"foo": "d"}, 0, Timestamp(123, 456000000)) + metric.add_sample("ts", {"foo": "e"}, 0, Timestamp(123, 456000)) + metric.add_sample("ts", {"foo": "f"}, 0, Timestamp(123, 456)) + yield metric + + self.registry.register(MyCollector()) + self.assertEqual(b'''# HELP ts help +# TYPE ts untyped +ts{foo="a"} 0.0 123456 +ts{foo="b"} 0.0 -123456 +ts{foo="c"} 0.0 123000 +ts{foo="d"} 0.0 123456 +ts{foo="e"} 0.0 123000 +ts{foo="f"} 0.0 123000 +''', generate_latest(self.registry)) + class TestPushGateway(unittest.TestCase): def setUp(self): diff --git a/tests/test_graphite_bridge.py b/tests/test_graphite_bridge.py index cce368cc..66bf7204 100644 --- a/tests/test_graphite_bridge.py +++ b/tests/test_graphite_bridge.py @@ -5,7 +5,7 @@ except ImportError: import socketserver as SocketServer -from prometheus_client import Counter, CollectorRegistry +from prometheus_client import Gauge, CollectorRegistry from prometheus_client.bridge.graphite import GraphiteBridge @@ -38,16 +38,16 @@ def run(self): self.gb = GraphiteBridge(address, self.registry, _timer=fake_timer) def test_nolabels(self): - counter = Counter('c', 'help', registry=self.registry) - counter.inc() + gauge = Gauge('g', 'help', registry=self.registry) + gauge.inc() self.gb.push() self.t.join() - self.assertEqual(b'c 1.0 1434898897\n', self.data) + self.assertEqual(b'g 1.0 1434898897\n', self.data) def test_labels(self): - labels = Counter('labels', 'help', ['a', 'b'], registry=self.registry) + labels = Gauge('labels', 'help', ['a', 'b'], registry=self.registry) labels.labels('c', 'd').inc() self.gb.push() @@ -56,7 +56,7 @@ def test_labels(self): self.assertEqual(b'labels.a.c.b.d 1.0 1434898897\n', self.data) def test_prefix(self): - labels = Counter('labels', 'help', ['a', 'b'], registry=self.registry) + labels = Gauge('labels', 'help', ['a', 'b'], registry=self.registry) labels.labels('c', 'd').inc() self.gb.push(prefix='pre.fix') @@ -65,7 +65,7 @@ def test_prefix(self): self.assertEqual(b'pre.fix.labels.a.c.b.d 1.0 1434898897\n', self.data) def test_sanitizing(self): - labels = Counter('labels', 'help', ['a'], registry=self.registry) + labels = Gauge('labels', 'help', ['a'], registry=self.registry) labels.labels('c.:8').inc() self.gb.push() diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index db5d0fd8..ca84913f 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -36,10 +36,10 @@ def test_counter_adds(self): c1 = Counter('c', 'help', registry=None) core._ValueClass = core._MultiProcessValue(lambda: 456) c2 = Counter('c', 'help', registry=None) - self.assertEqual(0, self.registry.get_sample_value('c')) + self.assertEqual(0, self.registry.get_sample_value('c_total')) c1.inc(1) c2.inc(2) - self.assertEqual(3, self.registry.get_sample_value('c')) + self.assertEqual(3, self.registry.get_sample_value('c_total')) def test_summary_adds(self): s1 = Summary('s', 'help', registry=None) @@ -121,20 +121,20 @@ def test_gauge_livesum(self): self.assertEqual(2, self.registry.get_sample_value('g')) def test_namespace_subsystem(self): - c1 = Counter('c', 'help', registry=None, namespace='ns', subsystem='ss') - c1.inc(1) - self.assertEqual(1, self.registry.get_sample_value('ns_ss_c')) + c1 = Counter('c', 'help', registry=None, namespace='ns', subsystem='ss') + c1.inc(1) + self.assertEqual(1, self.registry.get_sample_value('ns_ss_c_total')) def test_counter_across_forks(self): pid = 0 core._ValueClass = core._MultiProcessValue(lambda: pid) c1 = Counter('c', 'help', registry=None) - self.assertEqual(0, self.registry.get_sample_value('c')) + self.assertEqual(0, self.registry.get_sample_value('c_total')) c1.inc(1) c1.inc(1) pid = 1 c1.inc(1) - self.assertEqual(3, self.registry.get_sample_value('c')) + self.assertEqual(3, self.registry.get_sample_value('c_total')) self.assertEqual(1, c1._value.get()) diff --git a/tests/test_parser.py b/tests/test_parser.py index 3218a2bc..a421678d 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -15,6 +15,7 @@ GaugeMetricFamily, HistogramMetricFamily, Metric, + Sample, SummaryMetricFamily, ) from prometheus_client.exposition import ( @@ -89,8 +90,8 @@ def test_untyped(self): """) m = Metric("redis_connected_clients", "Redis connected clients", "untyped") m.samples = [ - ("redis_connected_clients", {"instance": "rough-snowflake-web", "port": "6380"}, 10), - ("redis_connected_clients", {"instance": "rough-snowflake-web", "port": "6381"}, 12), + Sample("redis_connected_clients", {"instance": "rough-snowflake-web", "port": "6380"}, 10), + Sample("redis_connected_clients", {"instance": "rough-snowflake-web", "port": "6381"}, 12), ] self.assertEqual([m], list(families)) diff --git a/tests/test_platform_collector.py b/tests/test_platform_collector.py index 9eda5bb5..529d397c 100644 --- a/tests/test_platform_collector.py +++ b/tests/test_platform_collector.py @@ -37,9 +37,9 @@ def test_system_info_java(self): def assertLabels(self, name, labels): for metric in self.registry.collect(): - for n, l, value in metric.samples: - if n == name: - assert l == labels + for s in metric.samples: + if s.name == name: + assert s.labels == labels return assert False