diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index 5d276e2f..bc6cce2f 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -16,6 +16,10 @@ ESCAPING_HEADER_TAG = 'escaping' +# TODO: remove once every metric type is supported by _generate_latest_2_0, so +# that version 2.0.0 is chosen by content negotiation alone. +_openmetrics_2_enabled = False + ALLOWUTF8 = 'allow-utf-8' UNDERSCORES = 'underscores' DOTS = 'dots' @@ -57,6 +61,12 @@ def _compose_exemplar_string(metric, sample, exemplar): def generate_latest(registry, escaping=UNDERSCORES, version="1.0.0"): '''Returns the metrics from the registry in latest text format as a string.''' + if _openmetrics_2_enabled and parse_version(version) == (2, 0, 0): + return _generate_latest_2_0(registry, escaping) + return _generate_latest_1_0(registry, escaping, version) + + +def _generate_latest_1_0(registry, escaping, version): output = [] for metric in registry.collect(): try: @@ -170,6 +180,99 @@ def generate_latest(registry, escaping=UNDERSCORES, version="1.0.0"): return ''.join(output).encode('utf-8') +# OpenMetrics 2.0 requires the family name to equal the sample name, while +# metric.name omits the suffix these types add to their samples. +_FAMILY_NAME_SUFFIXES_2_0 = {'counter': '_total', 'info': '_info'} + + +def _generate_latest_2_0(registry, escaping): + """EXPERIMENTAL: targets OpenMetrics 2.0 and may change without notice.""" + formatters = { + 'counter': _format_counter_2_0, + 'gauge': _format_simple_samples_2_0, + 'summary': _format_summary_2_0, + 'histogram': _format_histogram_2_0, + 'gaugehistogram': _format_gaugehistogram_2_0, + 'unknown': _format_simple_samples_2_0, + 'info': _format_simple_samples_2_0, + 'stateset': _format_stateset_2_0, + } + output = [] + for metric in registry.collect(): + try: + family_name = metric.name + _FAMILY_NAME_SUFFIXES_2_0.get(metric.type, '') + output.append(_format_metadata_2_0(metric, family_name, escaping)) + output.append(formatters[metric.type](metric, family_name, escaping)) + except Exception as exception: + exception.args = (exception.args or ('',)) + (metric,) + raise + output.append('# EOF\n') + return ''.join(output).encode('utf-8') + + +def _format_metadata_2_0(metric, family_name, escaping): + """Formats the TYPE, UNIT and HELP lines, in the order OpenMetrics 2.0 recommends.""" + escaped_name = escape_metric_name(family_name, escaping) + output = [f'# TYPE {escaped_name} {metric.type}\n'] + if metric.unit: + output.append(f'# UNIT {escaped_name} {metric.unit}\n') + output.append('# HELP {} {}\n'.format( + escaped_name, _escape(metric.documentation, ALLOWUTF8, _is_legacy_labelname_rune))) + return ''.join(output) + + +def _format_simple_samples_2_0(metric, family_name, escaping): + """Formats the samples of a type that has neither start timestamps nor exemplars. + + OpenMetrics 2.0 only allows exemplars on counters and histograms, and says + to drop invalid exemplars rather than fail the exposition, so any exemplar + on these samples is left out. + """ + return ''.join(_format_sample_2_0(family_name, s, escaping) for s in metric.samples) + + +def _format_sample_2_0(family_name, sample, escaping): + """Formats a sample as ``name{labels} value [timestamp]``, named after its family as OpenMetrics 2.0 requires.""" + line = f'{_format_name_and_labels_2_0(family_name, sample.labels, escaping)} {floatToGoString(sample.value)}' + if sample.timestamp is not None: + line += f' {sample.timestamp}' + return line + '\n' + + +def _format_name_and_labels_2_0(name, labels, escaping): + """Formats ``name{labels}``, or ``{"name",labels}`` when the name has to be quoted.""" + escaped_name = escape_metric_name(name, escaping) + items = [ + '{}="{}"'.format(escape_label_name(k, escaping), _escape(v, ALLOWUTF8, _is_legacy_labelname_rune)) + for k, v in sorted(labels.items()) + ] + if escaped_name.startswith('"'): + return '{' + ','.join([escaped_name] + items) + '}' + if items: + return escaped_name + '{' + ','.join(items) + '}' + return escaped_name + + +def _format_counter_2_0(metric, family_name, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support counter metrics yet') + + +def _format_summary_2_0(metric, family_name, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support summary metrics yet') + + +def _format_histogram_2_0(metric, family_name, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support histogram metrics yet') + + +def _format_gaugehistogram_2_0(metric, family_name, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support gaugehistogram metrics yet') + + +def _format_stateset_2_0(metric, family_name, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support stateset metrics yet') + + def escape_metric_name(s: str, escaping: str = UNDERSCORES) -> str: """Escapes the metric name and puts it in quotes iff the name does not conform to the legacy Prometheus character set. diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py index 5abc0423..5cefefb4 100644 --- a/tests/openmetrics/test_exposition.py +++ b/tests/openmetrics/test_exposition.py @@ -9,11 +9,11 @@ ) from prometheus_client.core import ( BucketSpan, Exemplar, GaugeHistogramMetricFamily, GaugeMetricFamily, - HistogramMetricFamily, NativeHistogram, Timestamp, + HistogramMetricFamily, NativeHistogram, Timestamp, UnknownMetricFamily, ) from prometheus_client.openmetrics.exposition import ( - ALLOWUTF8, DOTS, escape_label_name, escape_metric_name, generate_latest, - UNDERSCORES, VALUES, + _generate_latest_2_0, ALLOWUTF8, DOTS, escape_label_name, + escape_metric_name, generate_latest, UNDERSCORES, VALUES, ) @@ -529,6 +529,172 @@ def test_native_histogram_version_comparison(self) -> None: """, generate_latest(self.registry, version="2.0.0")) + +class TestGenerateText20(unittest.TestCase): + def setUp(self): + self.registry = CollectorRegistry() + + def custom_collector(self, metric_family: Any) -> None: + class CustomCollector: + def collect(self): + return [metric_family] + + self.registry.register(CustomCollector()) + + def test_gauge(self) -> None: + g = Gauge('gg', 'A gauge', registry=self.registry) + g.set(17) + self.assertEqual(b'# TYPE gg gauge\n# HELP gg A gauge\ngg 17.0\n# EOF\n', + _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_gauge_unit_and_labels(self) -> None: + g = Gauge('gg_bytes', 'A gauge', ['a', 'b'], registry=self.registry, unit='bytes') + g.labels('c', 'd').set(17) + self.assertEqual(b"""# TYPE gg_bytes gauge +# UNIT gg_bytes bytes +# HELP gg_bytes A gauge +gg_bytes{a="c",b="d"} 17.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_gauge_timestamp(self) -> None: + g = GaugeMetricFamily('gg', 'A gauge', labels=['a']) + g.add_metric(['b'], 17, timestamp=1520879607.789) + self.custom_collector(g) + self.assertEqual(b"""# TYPE gg gauge +# HELP gg A gauge +gg{a="b"} 17.0 1520879607.789 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_gauge_utf8(self) -> None: + g = Gauge('gg.with.dots', 'A gauge', ['label.with.dots'], registry=self.registry) + g.labels('v').set(17) + self.assertEqual(b"""# TYPE "gg.with.dots" gauge +# HELP "gg.with.dots" A gauge +{"gg.with.dots","label.with.dots"="v"} 17.0 +# EOF +""", _generate_latest_2_0(self.registry, ALLOWUTF8)) + + def test_gauge_utf8_escaped_underscores(self) -> None: + g = Gauge('gg.with.dots', 'A gauge', ['label.with.dots'], registry=self.registry) + g.labels('v').set(17) + self.assertEqual(b"""# TYPE gg_with_dots gauge +# HELP gg_with_dots A gauge +gg_with_dots{label_with_dots="v"} 17.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_gauge_exemplar_dropped(self) -> None: + metric = Metric('gg', 'A gauge', 'gauge') + metric.add_sample('gg', {}, 1, None, Exemplar({'a': 'b'}, 0.5, 123)) + self.custom_collector(metric) + self.assertEqual(b'# TYPE gg gauge\n# HELP gg A gauge\ngg 1.0\n# EOF\n', + _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_info(self) -> None: + i = Info('ii', 'A info', ['a', 'b'], registry=self.registry) + i.labels('c', 'd').info({'foo': 'bar'}) + self.assertEqual(b"""# TYPE ii_info info +# HELP ii_info A info +ii_info{a="c",b="d",foo="bar"} 1.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_info_escaping(self) -> None: + i = Info('ii', 'A info', registry=self.registry) + i.info({'name': 'pretty "name"\n\\'}) + self.assertEqual(b"""# TYPE ii_info info +# HELP ii_info A info +ii_info{name="pretty \\"name\\"\\n\\\\"} 1.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_unknown(self) -> None: + u = UnknownMetricFamily('uu', 'An unknown', labels=['a']) + u.add_metric(['b'], 42.23) + self.custom_collector(u) + self.assertEqual(b"""# TYPE uu unknown +# HELP uu An unknown +uu{a="b"} 42.23 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_unknown_exemplar_dropped(self) -> None: + metric = Metric('uu', 'An unknown', 'untyped') + metric.add_sample('uu', {}, 1, None, Exemplar({'a': 'b'}, 0.5, 123)) + self.custom_collector(metric) + self.assertEqual(b'# TYPE uu unknown\n# HELP uu An unknown\nuu 1.0\n# EOF\n', + _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_unicode(self) -> None: + g = Gauge('gg', '\u4500', ['l'], registry=self.registry) + g.labels('\u4500').set(1) + self.assertEqual(b"""# TYPE gg gauge +# HELP gg \xe4\x94\x80 +gg{l="\xe4\x94\x80"} 1.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_escaping(self) -> None: + g = Gauge('gg', 'A\ngau\\ge\"', ['a'], registry=self.registry) + g.labels('\\x\n"').set(1) + self.assertEqual(b"""# TYPE gg gauge +# HELP gg A\\ngau\\\\ge\\" +gg{a="\\\\x\\n\\""} 1.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_nonnumber(self) -> None: + class MyNumber: + def __repr__(self): + return "MyNumber(123)" + + def __float__(self): + return 123.0 + + class MyCollector: + def collect(self): + metric = Metric("nonnumber", "Non number", 'untyped') + metric.add_sample("nonnumber", {}, MyNumber()) + yield metric + + self.registry.register(MyCollector()) + self.assertEqual(b'# TYPE nonnumber unknown\n# HELP nonnumber Non number\nnonnumber 123.0\n# EOF\n', + _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_timestamp(self) -> None: + 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)) + self.custom_collector(metric) + self.assertEqual(b"""# TYPE ts unknown +# HELP ts help +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_2_0(self.registry, UNDERSCORES)) + + def test_unsupported_type(self) -> None: + Summary('ss', 'A summary', registry=self.registry) + with self.assertRaises(NotImplementedError): + _generate_latest_2_0(self.registry, UNDERSCORES) + + def test_version_2_0_0_served_by_1_0_writer_until_supported(self) -> None: + 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, version="2.0.0")) + + @pytest.mark.parametrize("scenario", [ { "name": "empty string",