diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index 84600605..a1c930b2 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -30,7 +30,7 @@ def generate_latest(registry): escape_metric_name(mname), _escape(metric.documentation))) output.append(f'# TYPE {escape_metric_name(mname)} {metric.type}\n') if metric.unit: - output.append(f'# UNIT {escape_metric_name(mname)} {metric.unit}\n') + output.append(f'# UNIT {escape_metric_name(mname)} {metric.unit}\n') for s in metric.samples: if not _is_valid_legacy_metric_name(s.name): labelstr = escape_metric_name(s.name) @@ -38,16 +38,16 @@ def generate_latest(registry): labelstr += ', ' else: labelstr = '' - + if s.labels: - items = sorted(s.labels.items()) + items = s.labels.items() labelstr += ','.join( ['{}="{}"'.format( escape_label_name(k), _escape(v)) - for k, v in items]) + for k, v in items]) if labelstr: labelstr = "{" + labelstr + "}" - + if s.exemplar: if not _is_valid_exemplar_metric(metric, s): raise ValueError(f"Metric {metric.name} has exemplars, but is not a histogram bucket or counter") @@ -67,24 +67,87 @@ def generate_latest(registry): floatToGoString(s.exemplar.value), ) else: - exemplarstr = '' + exemplarstr = '' + timestamp = '' if s.timestamp is not None: timestamp = f' {s.timestamp}' + + native_histogram = '' + positive_spans = '' + positive_deltas = '' + negative_spans = '' + negative_deltas = '' + pos = False + neg = False + + if s.native_histogram: + # Initialize basic nh template + nh_sample_template = '{{count:{},sum:{},schema:{},zero_threshold:{},zero_count:{}' + + args = [ + s.native_histogram.count_value, + s.native_histogram.sum_value, + s.native_histogram.schema, + s.native_histogram.zero_threshold, + s.native_histogram.zero_count, + ] + + # Signal presence for pos/neg spans/deltas + pos = False + neg = False + + # If there are pos spans, append them to the template and args + if s.native_histogram.pos_spans: + positive_spans = ','.join([f'{ps[0]}:{ps[1]}' for ps in s.native_histogram.pos_spans]) + positive_deltas = ','.join(f'{pd}' for pd in s.native_histogram.pos_deltas) + nh_sample_template += ',positive_spans:[{}]' + args.append(positive_spans) + pos = True + + # If there are neg spans exist, append them to the template and args + if s.native_histogram.neg_spans: + negative_spans = ','.join([f'{ns[0]}:{ns[1]}' for ns in s.native_histogram.neg_spans]) + negative_deltas = ','.join(str(nd) for nd in s.native_histogram.neg_deltas) + nh_sample_template += ',negative_spans:[{}]' + args.append(negative_spans) + neg = True + + # Append pos deltas if pos spans were added + if pos: + nh_sample_template += ',positive_deltas:[{}]' + args.append(positive_deltas) + + # Append neg deltas if neg spans were added + if neg: + nh_sample_template += ',negative_deltas:[{}]' + args.append(negative_deltas) + + # Add closing brace + nh_sample_template += '}}' + + # Format the template with the args + native_histogram = nh_sample_template.format(*args) + + value = '' + if s.value is not None or not s.native_histogram: + value = floatToGoString(s.value) if _is_valid_legacy_metric_name(s.name): - output.append('{}{} {}{}{}\n'.format( + output.append('{}{} {}{}{}{}\n'.format( s.name, labelstr, - floatToGoString(s.value), + value, timestamp, exemplarstr, + native_histogram )) else: - output.append('{} {}{}{}\n'.format( + output.append('{} {}{}{}{}\n'.format( labelstr, - floatToGoString(s.value), + value, timestamp, exemplarstr, + native_histogram )) except Exception as exception: exception.args = (exception.args or ('',)) + (metric,) @@ -115,3 +178,6 @@ def escape_label_name(s: str) -> str: def _escape(s: str) -> str: """Performs backslash escaping on backslash, newline, and double-quote characters.""" return s.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"') + + + diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py index 124e55e9..0f421405 100644 --- a/tests/openmetrics/test_exposition.py +++ b/tests/openmetrics/test_exposition.py @@ -5,7 +5,8 @@ CollectorRegistry, Counter, Enum, Gauge, Histogram, Info, Metric, Summary, ) from prometheus_client.core import ( - Exemplar, GaugeHistogramMetricFamily, Timestamp, + BucketSpan, Exemplar, GaugeHistogramMetricFamily, HistogramMetricFamily, + NativeHistogram, Timestamp, ) from prometheus_client.openmetrics.exposition import generate_latest @@ -94,6 +95,125 @@ def test_histogram(self): # EOF """, generate_latest(self.registry)) + + def test_native_histogram(self): + hfm = HistogramMetricFamily("nh", "nh") + hfm.add_sample("nh", None, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + self.custom_collector(hfm) + self.assertEqual(b"""# HELP nh nh +# TYPE nh histogram +nh {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]} +# EOF +""", generate_latest(self.registry)) + + + def test_nh_no_observation(self): + hfm = HistogramMetricFamily("nhnoobs", "nhnoobs") + hfm.add_sample("nhnoobs", None, None, None, None, NativeHistogram(0, 0, 3, 2.938735877055719e-39, 0)) + self.custom_collector(hfm) + self.assertEqual(b"""# HELP nhnoobs nhnoobs +# TYPE nhnoobs histogram +nhnoobs {count:0,sum:0,schema:3,zero_threshold:2.938735877055719e-39,zero_count:0} +# EOF +""", generate_latest(self.registry)) + + + def test_nh_longer_spans(self): + hfm = HistogramMetricFamily("nhsp", "Is a basic example of a native histogram with three spans") + hfm.add_sample("nhsp", None, None, None, None, NativeHistogram(4, 6, 3, 2.938735877055719e-39, 1, (BucketSpan(0, 1), BucketSpan(7, 1), BucketSpan(4, 1)), None, (1, 0, 0), None)) + self.custom_collector(hfm) + self.assertEqual(b"""# HELP nhsp Is a basic example of a native histogram with three spans +# TYPE nhsp histogram +nhsp {count:4,sum:6,schema:3,zero_threshold:2.938735877055719e-39,zero_count:1,positive_spans:[0:1,7:1,4:1],positive_deltas:[1,0,0]} +# EOF +""", generate_latest(self.registry)) + + def test_native_histogram_utf8(self): + hfm = HistogramMetricFamily("native{histogram", "Is a basic example of a native histogram") + hfm.add_sample("native{histogram", None, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + self.custom_collector(hfm) + self.assertEqual(b"""# HELP "native{histogram" Is a basic example of a native histogram +# TYPE "native{histogram" histogram +{"native{histogram"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]} +# EOF +""", generate_latest(self.registry)) + + def test_native_histogram_utf8_stress(self): + hfm = HistogramMetricFamily("native{histogram", "Is a basic example of a native histogram") + hfm.add_sample("native{histogram", {'xx{} # {}': ' EOF # {}}}'}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + self.custom_collector(hfm) + self.assertEqual(b"""# HELP "native{histogram" Is a basic example of a native histogram +# TYPE "native{histogram" histogram +{"native{histogram", "xx{} # {}"=" EOF # {}}}"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]} +# EOF +""", generate_latest(self.registry)) + + def test_native_histogram_with_labels(self): + hfm = HistogramMetricFamily("hist_w_labels", "Is a basic example of a native histogram with labels") + hfm.add_sample("hist_w_labels", {"foo": "bar", "baz": "qux"}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + self.custom_collector(hfm) + self.assertEqual(b"""# HELP hist_w_labels Is a basic example of a native histogram with labels +# TYPE hist_w_labels histogram +hist_w_labels{foo="bar",baz="qux"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]} +# EOF +""", generate_latest(self.registry)) + + def test_native_histogram_with_labels_utf8(self): + hfm = HistogramMetricFamily("hist.w.labels", "Is a basic example of a native histogram with labels") + hfm.add_sample("hist.w.labels", {"foo": "bar", "baz": "qux"}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + self.custom_collector(hfm) + self.assertEqual(b"""# HELP "hist.w.labels" Is a basic example of a native histogram with labels +# TYPE "hist.w.labels" histogram +{"hist.w.labels", foo="bar",baz="qux"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]} +# EOF +""", generate_latest(self.registry)) + + def test_native_histogram_with_classic_histogram(self): + hfm = HistogramMetricFamily("hist_w_classic", "Is a basic example of a native histogram coexisting with a classic histogram") + hfm.add_sample("hist_w_classic", {"foo": "bar"}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + hfm.add_sample("hist_w_classic_bucket", {"foo": "bar", "le": "0.001"}, 4.0, None, None, None) + hfm.add_sample("hist_w_classic_bucket", {"foo": "bar", "le": "+Inf"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_count", {"foo": "bar"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_sum", {"foo": "bar"}, 100.0, None, None, None) + self.custom_collector(hfm) + self.assertEqual(b"""# HELP hist_w_classic Is a basic example of a native histogram coexisting with a classic histogram +# TYPE hist_w_classic histogram +hist_w_classic{foo="bar"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]} +hist_w_classic_bucket{foo="bar",le="0.001"} 4.0 +hist_w_classic_bucket{foo="bar",le="+Inf"} 24.0 +hist_w_classic_count{foo="bar"} 24.0 +hist_w_classic_sum{foo="bar"} 100.0 +# EOF +""", generate_latest(self.registry)) + + def test_native_plus_classic_histogram_two_labelsets(self): + hfm = HistogramMetricFamily("hist_w_classic_two_sets", "Is an example of a native histogram plus a classic histogram with two label sets") + hfm.add_sample("hist_w_classic_two_sets", {"foo": "bar"}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "bar", "le": "0.001"}, 4.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "bar", "le": "+Inf"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_count", {"foo": "bar"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_sum", {"foo": "bar"}, 100.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets", {"foo": "baz"}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "baz", "le": "0.001"}, 4.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "baz", "le": "+Inf"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_count", {"foo": "baz"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_sum", {"foo": "baz"}, 100.0, None, None, None) + self.custom_collector(hfm) + self.assertEqual(b"""# HELP hist_w_classic_two_sets Is an example of a native histogram plus a classic histogram with two label sets +# TYPE hist_w_classic_two_sets histogram +hist_w_classic_two_sets{foo="bar"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]} +hist_w_classic_two_sets_bucket{foo="bar",le="0.001"} 4.0 +hist_w_classic_two_sets_bucket{foo="bar",le="+Inf"} 24.0 +hist_w_classic_two_sets_count{foo="bar"} 24.0 +hist_w_classic_two_sets_sum{foo="bar"} 100.0 +hist_w_classic_two_sets{foo="baz"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]} +hist_w_classic_two_sets_bucket{foo="baz",le="0.001"} 4.0 +hist_w_classic_two_sets_bucket{foo="baz",le="+Inf"} 24.0 +hist_w_classic_two_sets_count{foo="baz"} 24.0 +hist_w_classic_two_sets_sum{foo="baz"} 100.0 +# EOF +""", generate_latest(self.registry)) + def test_histogram_negative_buckets(self): s = Histogram('hh', 'A histogram', buckets=[-1, -0.5, 0, 0.5, 1], registry=self.registry) s.observe(-0.5)