Repository: prometheus/client_python
Pull request #1040 (2024-06-18 15:59:01)

Add support for native histograms in OM parser
This PR adds an OM parser for native histograms, as a first step towards the implementation of the following proposal https://github.com/prometheus/proposals/pull/32 . The next steps (as far as this repo is concerned) would be the OM exposition and then obviously the implementation of native histograms generation/writing logic.

vesari: > Just a quick read through, but I was thinking maybe we limit the changes in the initial PR to just the parser changes? We could get that in and start iterating on it, and it will then be useful for testing the exposition changes.

I totally agree!

Patch:
diff --git a/docs/content/exporting/http/_index.md b/docs/content/exporting/http/_index.md
index 71edc7e..dc1b8f2 100644
--- a/docs/content/exporting/http/_index.md
+++ b/docs/content/exporting/http/_index.md
@@ -52,4 +52,23 @@ chain is used (see Python [ssl.SSLContext.load_default_certs()](https://docs.pyt
 from prometheus_client import start_http_server
 
 start_http_server(8000, certfile="server.crt", keyfile="server.key")
-```
\ No newline at end of file
+```
+
+# Supported HTTP methods
+
+The prometheus client will handle the following HTTP methods and resources:
+
+* `OPTIONS (any)` - returns HTTP status 200 and an 'Allow' header indicating the
+  allowed methods (OPTIONS, GET)
+* `GET (any)` - returns HTTP status 200 and the metrics data
+* `GET /favicon.ico` - returns HTTP status 200 and an empty response body. Some
+  browsers support this to display the returned icon in the browser tab.
+
+Other HTTP methods than these are rejected with HTTP status 405 "Method Not Allowed"
+and an 'Allow' header indicating the allowed methods (OPTIONS, GET).
+
+Any returned HTTP errors are also displayed in the response body after a hash
+sign and with a brief hint. Example:
+```
+# HTTP 405 Method Not Allowed: XXX; use OPTIONS or GET
+```
diff --git a/prometheus_client/core.py b/prometheus_client/core.py
index ad3a454..60f93ce 100644
--- a/prometheus_client/core.py
+++ b/prometheus_client/core.py
@@ -5,9 +5,10 @@ from .metrics_core import (
     SummaryMetricFamily, UnknownMetricFamily, UntypedMetricFamily,
 )
 from .registry import CollectorRegistry, REGISTRY
-from .samples import Exemplar, Sample, Timestamp
+from .samples import BucketSpan, Exemplar, NativeHistogram, Sample, Timestamp
 
 __all__ = (
+    'BucketSpan',
     'CollectorRegistry',
     'Counter',
     'CounterMetricFamily',
@@ -21,6 +22,7 @@ __all__ = (
     'Info',
     'InfoMetricFamily',
     'Metric',
+    'NativeHistogram',
     'REGISTRY',
     'Sample',
     'StateSetMetricFamily',
diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py
index 3a47917..fab139d 100644
--- a/prometheus_client/exposition.py
+++ b/prometheus_client/exposition.py
@@ -118,12 +118,24 @@ def make_wsgi_app(registry: CollectorRegistry = REGISTRY, disable_compression: b
         accept_header = environ.get('HTTP_ACCEPT')
         accept_encoding_header = environ.get('HTTP_ACCEPT_ENCODING')
         params = parse_qs(environ.get('QUERY_STRING', ''))
-        if environ['PATH_INFO'] == '/favicon.ico':
+        method = environ['REQUEST_METHOD']
+
+        if method == 'OPTIONS':
+            status = '200 OK'
+            headers = [('Allow', 'OPTIONS,GET')]
+            output = b''
+        elif method != 'GET':
+            status = '405 Method Not Allowed'
+            headers = [('Allow', 'OPTIONS,GET')]
+            output = '# HTTP {}: {}; use OPTIONS or GET\n'.format(status, method).encode()
+        elif environ['PATH_INFO'] == '/favicon.ico':
             # Serve empty response for browsers
             status = '200 OK'
             headers = [('', '')]
             output = b''
         else:
+            # Note: For backwards compatibility, the URI path for GET is not
+            # constrained to the documented /metrics, but any path is allowed.
             # Bake output
             status, headers, output = _bake_output(registry, accept_header, accept_encoding_header, params, disable_compression)
         # Return output
diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py
index af51211..cceaafd 100644
--- a/prometheus_client/metrics.py
+++ b/prometheus_client/metrics.py
@@ -1,5 +1,5 @@
 import os
-from threading import Lock
+from threading import RLock
 import time
 import types
 from typing import (
@@ -111,8 +111,8 @@ class MetricWrapperBase(Collector):
 
     def collect(self) -> Iterable[Metric]:
         metric = self._get_metric()
-        for suffix, labels, value, timestamp, exemplar in self._samples():
-            metric.add_sample(self._name + suffix, labels, value, timestamp, exemplar)
+        for suffix, labels, value, timestamp, exemplar, native_histogram_value in self._samples():
+            metric.add_sample(self._name + suffix, labels, value, timestamp, exemplar, native_histogram_value)
         return [metric]
 
     def __str__(self) -> str:
@@ -144,7 +144,7 @@ class MetricWrapperBase(Collector):
 
         if self._is_parent():
             # Prepare the fields needed for child metrics.
-            self._lock = Lock()
+            self._lock = RLock()
             self._metrics: Dict[Sequence[str], T] = {}
 
         if self._is_observable():
@@ -246,8 +246,8 @@ class MetricWrapperBase(Collector):
             metrics = self._metrics.copy()
         for labels, metric in metrics.items():
             series_labels = list(zip(self._labelnames, labels))
-            for suffix, sample_labels, value, timestamp, exemplar in metric._samples():
-                yield Sample(suffix, dict(series_labels + list(sample_labels.items())), value, timestamp, exemplar)
+            for suffix, sample_labels, value, timestamp, exemplar, native_histogram_value in metric._samples():
+                yield Sample(suffix, dict(series_labels + list(sample_labels.items())), value, timestamp, exemplar, native_histogram_value)
 
     def _child_samples(self) -> Iterable[Sample]:  # pragma: no cover
         raise NotImplementedError('_child_samples() must be implemented by %r' % self)
@@ -697,7 +697,7 @@ class Info(MetricWrapperBase):
 
     def _metric_init(self):
         self._labelname_set = set(self._labelnames)
-        self._lock = Lock()
+        self._lock = RLock()
         self._value = {}
 
     def info(self, val: Dict[str, str]) -> None:
@@ -759,7 +759,7 @@ class Enum(MetricWrapperBase):
 
     def _metric_init(self) -> None:
         self._value = 0
-        self._lock = Lock()
+        self._lock = RLock()
 
     def state(self, state: str) -> None:
         """Set enum metric state."""
diff --git a/prometheus_client/metrics_core.py b/prometheus_client/metrics_core.py
index 7226d92..19166e1 100644
--- a/prometheus_client/metrics_core.py
+++ b/prometheus_client/metrics_core.py
@@ -1,7 +1,7 @@
 import re
 from typing import Dict, List, Optional, Sequence, Tuple, Union
 
-from .samples import Exemplar, Sample, Timestamp
+from .samples import Exemplar, NativeHistogram, Sample, Timestamp
 
 METRIC_TYPES = (
     'counter', 'gauge', 'summary', 'histogram',
@@ -36,11 +36,11 @@ class Metric:
         self.type: str = typ
         self.samples: List[Sample] = []
 
-    def add_sample(self, name: str, labels: Dict[str, str], value: float, timestamp: Optional[Union[Timestamp, float]] = None, exemplar: Optional[Exemplar] = None) -> None:
+    def add_sample(self, name: str, labels: Dict[str, str], value: float, timestamp: Optional[Union[Timestamp, float]] = None, exemplar: Optional[Exemplar] = None, native_histogram: Optional[NativeHistogram] = None) -> None:
         """Add a sample to the metric.
 
         Internal-only, do not use."""
-        self.samples.append(Sample(name, labels, value, timestamp, exemplar))
+        self.samples.append(Sample(name, labels, value, timestamp, exemplar, native_histogram))
 
     def __eq__(self, other: object) -> bool:
         return (isinstance(other, Metric)
@@ -284,7 +284,6 @@ class HistogramMetricFamily(Metric):
                 Sample(self.name + '_sum', dict(zip(self._labelnames, labels)), sum_value, timestamp))
 
 
-
 class GaugeHistogramMetricFamily(Metric):
     """A single gauge histogram and its samples.
 
diff --git a/prometheus_client/multiprocess.py b/prometheus_client/multiprocess.py
index 7021b49..2682190 100644
--- a/prometheus_client/multiprocess.py
+++ b/prometheus_client/multiprocess.py
@@ -93,7 +93,7 @@ class MultiProcessCollector:
             buckets = defaultdict(lambda: defaultdict(float))
             samples_setdefault = samples.setdefault
             for s in metric.samples:
-                name, labels, value, timestamp, exemplar = s
+                name, labels, value, timestamp, exemplar, native_histogram_value = s
                 if metric.type == 'gauge':
                     without_pid_key = (name, tuple(l for l in labels if l[0] != 'pid'))
                     if metric._multiprocess_mode in ('min', 'livemin'):
diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py
index 26f3109..1959847 100644
--- a/prometheus_client/openmetrics/exposition.py
+++ b/prometheus_client/openmetrics/exposition.py
@@ -10,7 +10,9 @@ CONTENT_TYPE_LATEST = 'application/openmetrics-text; version=1.0.0; charset=utf-
 def _is_valid_exemplar_metric(metric, sample):
     if metric.type == 'counter' and sample.name.endswith('_total'):
         return True
-    if metric.type in ('histogram', 'gaugehistogram') and sample.name.endswith('_bucket'):
+    if metric.type in ('gaugehistogram') and sample.name.endswith('_bucket'):
+        return True
+    if metric.type in ('histogram') and sample.name.endswith('_bucket') or sample.name == metric.name:
         return True
     return False
 
diff --git a/prometheus_client/openmetrics/parser.py b/prometheus_client/openmetrics/parser.py
index 6128a0d..39a44dc 100644
--- a/prometheus_client/openmetrics/parser.py
+++ b/prometheus_client/openmetrics/parser.py
@@ -6,7 +6,7 @@ import math
 import re
 
 from ..metrics_core import Metric, METRIC_LABEL_NAME_RE
-from ..samples import Exemplar, Sample, Timestamp
+from ..samples import BucketSpan, Exemplar, NativeHistogram, Sample, Timestamp
 from ..utils import floatToGoString
 
 
@@ -364,6 +364,99 @@ def _parse_remaining_text(text):
     return val, ts, exemplar
 
 
+def _parse_nh_sample(text, suffixes):
+    labels_start = text.find("{")
+    # check if it's a native histogram with labels
+    re_nh_without_labels = re.compile(r'^[^{} ]+ {[^{}]+}$')
+    re_nh_with_labels = re.compile(r'[^{} ]+{[^{}]+} {[^{}]+}$')
+    if re_nh_with_labels.match(text):
+        nh_value_start = text.rindex("{")
+        labels_end = nh_value_start - 2
+        labelstext = text[labels_start + 1:labels_end]
+        labels = _parse_labels(labelstext)
+        name_end = labels_start
+        name = text[:name_end]
+        if name.endswith(suffixes):
+            raise ValueError("the sample name of a native histogram with labels should have no suffixes", name) 
+        nh_value = text[nh_value_start:]
+        nat_hist_value = _parse_nh_struct(nh_value)
+        return Sample(name, labels, None, None, None, nat_hist_value)
+    # check if it's a native histogram
+    if re_nh_without_labels.match(text):
+        nh_value_start = labels_start
+        nh_value = text[nh_value_start:]
+        name_end = nh_value_start - 1
+        name = text[:name_end]
+        if name.endswith(suffixes):
+            raise ValueError("the sample name of a native histogram should have no suffixes", name)
+        nat_hist_value = _parse_nh_struct(nh_value)
+        return Sample(name, None, None, None, None, nat_hist_value)      
+    else:
+        # it's not a native histogram
+        return
+
+
+def _parse_nh_struct(text):
+    pattern = r'(\w+):\s*([^,}]+)'
+    
+    re_spans = re.compile(r'(positive_spans|negative_spans):\[(\d+:\d+,\d+:\d+)\]')
+    re_deltas = re.compile(r'(positive_deltas|negative_deltas):\[(-?\d+(?:,-?\d+)*)\]')
+
+    items = dict(re.findall(pattern, text))
+    spans = dict(re_spans.findall(text))
+    deltas = dict(re_deltas.findall(text))
+
+    count_value = int(items['count'])
+    sum_value = int(items['sum'])
+    schema = int(items['schema'])
+    zero_threshold = float(items['zero_threshold'])
+    zero_count = int(items['zero_count'])
+
+    try:
+        pos_spans_text = spans['positive_spans']
+        elems = pos_spans_text.split(',')
+        arg1 = [int(x) for x in elems[0].split(':')]
+        arg2 = [int(x) for x in elems[1].split(':')]
+        pos_spans = (BucketSpan(arg1[0], arg1[1]), BucketSpan(arg2[0], arg2[1]))
+    except KeyError:
+        pos_spans = None
+       
+    try:
+        neg_spans_text = spans['negative_spans']
+        elems = neg_spans_text.split(',')
+        arg1 = [int(x) for x in elems[0].split(':')]
+        arg2 = [int(x) for x in elems[1].split(':')]
+        neg_spans = (BucketSpan(arg1[0], arg1[1]), BucketSpan(arg2[0], arg2[1]))
+    except KeyError:
+        neg_spans = None
+       
+    try:
+        pos_deltas_text = deltas['positive_deltas']
+        elems = pos_deltas_text.split(',')
+        pos_deltas = tuple([int(x) for x in elems])
+    except KeyError:
+        pos_deltas = None
+       
+    try:
+        neg_deltas_text = deltas['negative_deltas']
+        elems = neg_deltas_text.split(',')
+        neg_deltas = tuple([int(x) for x in elems])
+    except KeyError:
+        neg_deltas = None
+       
+    return NativeHistogram(
+        count_value=count_value,
+        sum_value=sum_value,
+        schema=schema,
+        zero_threshold=zero_threshold,
+        zero_count=zero_count,
+        pos_spans=pos_spans,
+        neg_spans=neg_spans,
+        pos_deltas=pos_deltas,
+        neg_deltas=neg_deltas
+    )
+        
+
 def _group_for_sample(sample, name, typ):
     if typ == 'info':
         # We can't distinguish between groups for info metrics.
@@ -406,6 +499,8 @@ def _check_histogram(samples, name):
     for s in samples:
         suffix = s.name[len(name):]
         g = _group_for_sample(s, name, 'histogram')
+        if len(suffix) == 0:
+            continue
         if g != group or s.timestamp != timestamp:
             if group is not None:
                 do_checks()
@@ -486,6 +581,8 @@ def text_fd_to_metric_families(fd):
         metric.samples = samples
         return metric
 
+    is_nh = False
+    typ = None
     for line in fd:
         if line[-1] == '\n':
             line = line[:-1]
@@ -518,7 +615,7 @@ def text_fd_to_metric_families(fd):
                 group_timestamp_samples = set()
                 samples = []
                 allowed_names = [parts[2]]
-
+            
             if parts[1] == 'HELP':
                 if documentation is not None:
                     raise ValueError("More than one HELP for metric: " + line)
@@ -537,8 +634,18 @@ def text_fd_to_metric_families(fd):
             else:
                 raise ValueError("Invalid line: " + line)
         else:
-            sample = _parse_sample(line)
-            if sample.name not in allowed_names:
+            if typ == 'histogram':
+                # set to true to account for native histograms naming exceptions/sanitizing differences
+                is_nh = True
+                sample = _parse_nh_sample(line, tuple(type_suffixes['histogram']))
+                # It's not a native histogram
+                if sample is None:
+                    is_nh = False
+                    sample = _parse_sample(line)              
+            else:
+                is_nh = False
+                sample = _parse_sample(line)
+            if sample.name not in allowed_names and not is_nh:
                 if name is not None:
                     yield build_metric(name, documentation, typ, unit, samples)
                 # Start an unknown metric.
@@ -570,26 +677,29 @@ def text_fd_to_metric_families(fd):
                          or _isUncanonicalNumber(sample.labels['quantile']))):
                 raise ValueError("Invalid quantile label: " + line)
 
-            g = tuple(sorted(_group_for_sample(sample, name, typ).items()))
-            if group is not None and g != group and g in seen_groups:
-                raise ValueError("Invalid metric grouping: " + line)
-            if group is not None and g == group:
-                if (sample.timestamp is None) != (group_timestamp is None):
-                    raise ValueError("Mix of timestamp presence within a group: " + line)
-                if group_timestamp is not None and group_timestamp > sample.timestamp and typ != 'info':
-                    raise ValueError("Timestamps went backwards within a group: " + line)
+            if not is_nh:
+                g = tuple(sorted(_group_for_sample(sample, name, typ).items()))
+                if group is not None and g != group and g in seen_groups:
+                    raise ValueError("Invalid metric grouping: " + line)
+                if group is not None and g == group:
+                    if (sample.timestamp is None) != (group_timestamp is None):
+                        raise ValueError("Mix of timestamp presence within a group: " + line)
+                    if group_timestamp is not None and group_timestamp > sample.timestamp and typ != 'info':
+                        raise ValueError("Timestamps went backwards within a group: " + line)
+                else:
+                    group_timestamp_samples = set()
+
+                series_id = (sample.name, tuple(sorted(sample.labels.items())))
+                if sample.timestamp != group_timestamp or series_id not in group_timestamp_samples:
+                    # Not a duplicate due to timestamp truncation.
+                    samples.append(sample)
+                group_timestamp_samples.add(series_id)
+
+                group = g
+                group_timestamp = sample.timestamp
+                seen_groups.add(g)
             else:
-                group_timestamp_samples = set()
-
-            series_id = (sample.name, tuple(sorted(sample.labels.items())))
-            if sample.timestamp != group_timestamp or series_id not in group_timestamp_samples:
-                # Not a duplicate due to timestamp truncation.
                 samples.append(sample)
-            group_timestamp_samples.add(series_id)
-
-            group = g
-            group_timestamp = sample.timestamp
-            seen_groups.add(g)
 
             if typ == 'stateset' and sample.value not in [0, 1]:
                 raise ValueError("Stateset samples can only have values zero and one: " + line)
@@ -606,7 +716,7 @@ def text_fd_to_metric_families(fd):
                     (typ in ['histogram', 'gaugehistogram'] and sample.name.endswith('_bucket'))
                     or (typ in ['counter'] and sample.name.endswith('_total'))):
                 raise ValueError("Invalid line only histogram/gaugehistogram buckets and counters can have exemplars: " + line)
-
+    
     if name is not None:
         yield build_metric(name, documentation, typ, unit, samples)
 
diff --git a/prometheus_client/registry.py b/prometheus_client/registry.py
index 694e4bd..4326b39 100644
--- a/prometheus_client/registry.py
+++ b/prometheus_client/registry.py
@@ -1,6 +1,6 @@
 from abc import ABC, abstractmethod
 import copy
-from threading import Lock
+from threading import RLock
 from typing import Dict, Iterable, List, Optional
 
 from .metrics_core import Metric
@@ -30,7 +30,7 @@ class CollectorRegistry(Collector):
         self._collector_to_names: Dict[Collector, List[str]] = {}
         self._names_to_collectors: Dict[str, Collector] = {}
         self._auto_describe = auto_describe
-        self._lock = Lock()
+        self._lock = RLock()
         self._target_info: Optional[Dict[str, str]] = {}
         self.set_target_info(target_info)
 
diff --git a/prometheus_client/samples.py b/prometheus_client/samples.py
index 53c4726..b57a5d4 100644
--- a/prometheus_client/samples.py
+++ b/prometheus_client/samples.py
@@ -1,4 +1,4 @@
-from typing import Dict, NamedTuple, Optional, Union
+from typing import Dict, NamedTuple, Optional, Sequence, Tuple, Union
 
 
 class Timestamp:
@@ -34,6 +34,25 @@ class Timestamp:
         return self.nsec < other.nsec if self.sec == other.sec else self.sec < other.sec
 
 
+# BucketSpan is experimental and subject to change at any time.
+class BucketSpan(NamedTuple):
+    offset: int
+    length: int
+
+
+# NativeHistogram is experimental and subject to change at any time.
+class NativeHistogram(NamedTuple):
+    count_value: float
+    sum_value: float
+    schema: int
+    zero_threshold: float
+    zero_count: float
+    pos_spans: Optional[Tuple[BucketSpan, BucketSpan]] = None
+    neg_spans: Optional[Tuple[BucketSpan, BucketSpan]] = None
+    pos_deltas: Optional[Sequence[int]] = None
+    neg_deltas: Optional[Sequence[int]] = None
+
+
 # Timestamp and exemplar are optional.
 # Value can be an int or a float.
 # Timestamp can be a float containing a unixtime in seconds,
@@ -51,3 +70,4 @@ class Sample(NamedTuple):
     value: float
     timestamp: Optional[Union[float, Timestamp]] = None
     exemplar: Optional[Exemplar] = None
+    native_histogram: Optional[NativeHistogram] = None
diff --git a/prometheus_client/values.py b/prometheus_client/values.py
index 6ff85e3..05331f8 100644
--- a/prometheus_client/values.py
+++ b/prometheus_client/values.py
@@ -1,5 +1,5 @@
 import os
-from threading import Lock
+from threading import RLock
 import warnings
 
 from .mmap_dict import mmap_key, MmapedDict
@@ -13,7 +13,7 @@ class MutexValue:
     def __init__(self, typ, metric_name, name, labelnames, labelvalues, help_text, **kwargs):
         self._value = 0.0
         self._exemplar = None
-        self._lock = Lock()
+        self._lock = RLock()
 
     def inc(self, amount):
         with self._lock:
@@ -50,7 +50,7 @@ def MultiProcessValue(process_identifier=os.getpid):
     # Use a single global lock when in multi-processing mode
     # as we presume this means there is no threading going on.
     # This avoids the need to also have mutexes in __MmapDict.
-    lock = Lock()
+    lock = RLock()
 
     class MmapedValue:
         """A float protected by a mutex backed by a per-process mmaped file."""
diff --git a/setup.py b/setup.py
index 595e595..438f643 100644
--- a/setup.py
+++ b/setup.py
@@ -8,7 +8,7 @@ with open(path.join(path.abspath(path.dirname(__file__)), 'README.md')) as f:
 
 setup(
     name="prometheus_client",
-    version="0.20.0",
+    version="0.21.0",
     author="Brian Brazil",
     author_email="brian.brazil@robustperception.io",
     description="Python client for the Prometheus monitoring system.",


Test patch:
diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py
index 937aef5..dc5e991 100644
--- a/tests/openmetrics/test_parser.py
+++ b/tests/openmetrics/test_parser.py
@@ -2,9 +2,9 @@ import math
 import unittest
 
 from prometheus_client.core import (
-    CollectorRegistry, CounterMetricFamily, Exemplar,
+    BucketSpan, CollectorRegistry, CounterMetricFamily, Exemplar,
     GaugeHistogramMetricFamily, GaugeMetricFamily, HistogramMetricFamily,
-    InfoMetricFamily, Metric, Sample, StateSetMetricFamily,
+    InfoMetricFamily, Metric, NativeHistogram, Sample, StateSetMetricFamily,
     SummaryMetricFamily, Timestamp,
 )
 from prometheus_client.openmetrics.exposition import generate_latest
@@ -175,6 +175,80 @@ a_bucket{le="+Inf"} 3 # {a="2345678901234567890123456789012345678901234567890123
                        Exemplar({"a": "2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"}, 4,
                                 Timestamp(123, 0)))
         self.assertEqual([hfm], list(families))
+    
+    def test_native_histogram(self):
+        families = text_string_to_metric_families("""# TYPE nativehistogram histogram
+# HELP nativehistogram Is a basic example of a native histogram
+nativehistogram {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
+""")
+        families = list(families)
+       
+        hfm = HistogramMetricFamily("nativehistogram", "Is a basic example of a native histogram")
+        hfm.add_sample("nativehistogram", 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.assertEqual([hfm], families)
+
+    def test_native_histogram_with_labels(self):
+        families = text_string_to_metric_families("""# TYPE hist_w_labels histogram
+# HELP hist_w_labels Is a basic example of a native histogram with labels
+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
+""")
+        families = list(families)
+       
+        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.assertEqual([hfm], families)
+
+    def test_native_histogram_with_classic_histogram(self):
+        families = text_string_to_metric_families("""# TYPE hist_w_classic histogram
+# HELP hist_w_classic Is a basic example of a native histogram coexisting with a 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
+hist_w_classic_bucket{foo="bar",le="+Inf"} 24
+hist_w_classic_count{foo="bar"} 24
+hist_w_classic_sum{foo="bar"} 100
+# EOF
+""")
+        families = list(families)
+       
+        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.assertEqual([hfm], families)
+    
+    def test_native_plus_classic_histogram_two_labelsets(self):
+        families = text_string_to_metric_families("""# TYPE hist_w_classic_two_sets histogram
+# HELP hist_w_classic_two_sets Is an example of a native histogram plus a classic histogram with two label sets
+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
+hist_w_classic_two_sets_bucket{foo="bar",le="+Inf"} 24
+hist_w_classic_two_sets_count{foo="bar"} 24
+hist_w_classic_two_sets_sum{foo="bar"} 100
+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
+hist_w_classic_two_sets_bucket{foo="baz",le="+Inf"} 24
+hist_w_classic_two_sets_count{foo="baz"} 24
+hist_w_classic_two_sets_sum{foo="baz"} 100
+# EOF
+""")
+        families = list(families)
+       
+        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.assertEqual([hfm], families)
 
     def test_simple_gaugehistogram(self):
         families = text_string_to_metric_families("""# TYPE a gaugehistogram
@@ -805,6 +879,7 @@ bar_bucket{a="c",le="+Inf"} 0.0
             ('# TYPE a histogram\na_bucket{le="+INF"} 0\n# EOF\n'),
             ('# TYPE a histogram\na_bucket{le="2"} 0\na_bucket{le="1"} 0\na_bucket{le="+Inf"} 0\n# EOF\n'),
             ('# TYPE a histogram\na_bucket{le="1"} 1\na_bucket{le="2"} 1\na_bucket{le="+Inf"} 0\n# EOF\n'),
+            ('# TYPE a histogram\na_bucket {} {}'),
             # Bad grouping or ordering.
             ('# TYPE a histogram\na_sum{a="1"} 0\na_sum{a="2"} 0\na_count{a="1"} 0\n# EOF\n'),
             ('# TYPE a histogram\na_bucket{a="1",le="1"} 0\na_bucket{a="2",le="+Inf""} '
diff --git a/tests/test_core.py b/tests/test_core.py
index 8a54a02..f80fb88 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -16,6 +16,14 @@ from prometheus_client.decorator import getargspec
 from prometheus_client.metrics import _get_use_created
 
 
+def is_locked(lock):
+    "Tries to obtain a lock, returns True on success, False on failure."
+    locked = lock.acquire(blocking=False)
+    if locked:
+        lock.release()
+    return not locked
+
+
 def assert_not_observable(fn, *args, **kwargs):
     """
     Assert that a function call falls with a ValueError exception containing
@@ -963,7 +971,7 @@ class TestCollectorRegistry(unittest.TestCase):
         m = Metric('target', 'Target metadata', 'info')
         m.samples = [Sample('target_info', {'foo': 'bar'}, 1)]
         for _ in registry.restricted_registry(['target_info', 's_sum']).collect():
-            self.assertFalse(registry._lock.locked())
+            self.assertFalse(is_locked(registry._lock))
 
 
 if __name__ == '__main__':