Repository: open-telemetry/opentelemetry-python

Issue:
Prometheus exporter should re-use the metric family
Right now, the prom exporter iterates over the flat view of the metrics, and each point creates a metric family object and adds the point to it:

https://github.com/open-telemetry/opentelemetry-python/blob/faf8868f7a3d7f64ea3214cf6f201bb95d8ec513/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py#L169-L177

I believe all points in the same metric family should be added to the same metric family object, so they appear in the same place in the prometheus text format output, without duplicating the HELP and TYPE comments:

For example, this code

```py
import time
from random import random

from prometheus_client import start_http_server

from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.sdk._metrics import MeterProvider

start_http_server(port=8123, addr="localhost")
promreader = PrometheusMetricReader()
mp = MeterProvider(metric_readers=[promreader])
meter = mp.get_meter("testmetrics")
c = meter.create_counter("foo")

for attr_val in "foo", "var", "baz":
    c.add(random() * 100, {"myattr": attr_val})

time.sleep(20)
```

produces this text format:

```
# HELP foo_total
# TYPE foo_total counter
foo_total{myattr="foo"} 76.46493794834564
# HELP foo_total
# TYPE foo_total counter
foo_total{myattr="var"} 75.13060997623913
# HELP foo_total
# TYPE foo_total counter
foo_total{myattr="baz"} 67.18691254917026
```


Discussion:

Resolving patch:
diff --git a/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py b/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py
--- a/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py
+++ b/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py
@@ -67,9 +67,15 @@
 from json import dumps
 from logging import getLogger
 from re import IGNORECASE, UNICODE, compile
-from typing import Iterable, Optional, Sequence, Tuple, Union
+from typing import Dict, Iterable, Sequence, Tuple, Union
 
-from prometheus_client import core
+from prometheus_client.core import (
+    REGISTRY,
+    CounterMetricFamily,
+    GaugeMetricFamily,
+    HistogramMetricFamily,
+)
+from prometheus_client.core import Metric as PrometheusMetric
 
 from opentelemetry.sdk._metrics.export import MetricReader
 from opentelemetry.sdk._metrics.point import Gauge, Histogram, Metric, Sum
@@ -101,7 +107,7 @@ class PrometheusMetricReader(MetricReader):
     def __init__(self, prefix: str = "") -> None:
         super().__init__()
         self._collector = _CustomCollector(prefix)
-        core.REGISTRY.register(self._collector)
+        REGISTRY.register(self._collector)
         self._collector._callback = self.collect
 
     def _receive_metrics(self, metrics: Iterable[Metric]) -> None:
@@ -110,7 +116,7 @@ def _receive_metrics(self, metrics: Iterable[Metric]) -> None:
         self._collector.add_metrics_data(metrics)
 
     def shutdown(self) -> bool:
-        core.REGISTRY.unregister(self._collector)
+        REGISTRY.unregister(self._collector)
         return True
 
 
@@ -142,18 +148,23 @@ def collect(self) -> None:
         if self._callback is not None:
             self._callback()
 
+        metric_family_id_metric_family = {}
+
         while self._metrics_to_export:
             for export_record in self._metrics_to_export.popleft():
-                prometheus_metric = self._translate_to_prometheus(
-                    export_record
+                self._translate_to_prometheus(
+                    export_record, metric_family_id_metric_family
                 )
-                if prometheus_metric is not None:
-                    yield prometheus_metric
+
+            if metric_family_id_metric_family:
+                for metric_family in metric_family_id_metric_family.values():
+                    yield metric_family
 
     def _translate_to_prometheus(
-        self, metric: Metric
-    ) -> Optional[core.Metric]:
-        prometheus_metric = None
+        self,
+        metric: Metric,
+        metric_family_id_metric_family: Dict[str, PrometheusMetric],
+    ):
         label_values = []
         label_keys = []
         for key, value in metric.attributes.items():
@@ -166,41 +177,69 @@ def _translate_to_prometheus(
         metric_name += self._sanitize(metric.name)
 
         description = metric.description or ""
+
+        metric_family_id = "|".join(
+            [metric_name, description, "%".join(label_keys), metric.unit]
+        )
+
         if isinstance(metric.point, Sum):
-            prometheus_metric = core.CounterMetricFamily(
-                name=metric_name,
-                documentation=description,
-                labels=label_keys,
-                unit=metric.unit,
+
+            metric_family_id = "|".join(
+                [metric_family_id, CounterMetricFamily.__name__]
             )
-            prometheus_metric.add_metric(
+
+            if metric_family_id not in metric_family_id_metric_family.keys():
+                metric_family_id_metric_family[
+                    metric_family_id
+                ] = CounterMetricFamily(
+                    name=metric_name,
+                    documentation=description,
+                    labels=label_keys,
+                    unit=metric.unit,
+                )
+            metric_family_id_metric_family[metric_family_id].add_metric(
                 labels=label_values, value=metric.point.value
             )
         elif isinstance(metric.point, Gauge):
-            prometheus_metric = core.GaugeMetricFamily(
-                name=metric_name,
-                documentation=description,
-                labels=label_keys,
-                unit=metric.unit,
+
+            metric_family_id = "|".join(
+                [metric_family_id, GaugeMetricFamily.__name__]
             )
-            prometheus_metric.add_metric(
+
+            if metric_family_id not in metric_family_id_metric_family.keys():
+                metric_family_id_metric_family[
+                    metric_family_id
+                ] = GaugeMetricFamily(
+                    name=metric_name,
+                    documentation=description,
+                    labels=label_keys,
+                    unit=metric.unit,
+                )
+            metric_family_id_metric_family[metric_family_id].add_metric(
                 labels=label_values, value=metric.point.value
             )
         elif isinstance(metric.point, Histogram):
-            value = metric.point.sum
-            prometheus_metric = core.HistogramMetricFamily(
-                name=metric_name,
-                documentation=description,
-                labels=label_keys,
-                unit=metric.unit,
+
+            metric_family_id = "|".join(
+                [metric_family_id, HistogramMetricFamily.__name__]
             )
-            buckets = _convert_buckets(metric)
-            prometheus_metric.add_metric(
-                labels=label_values, buckets=buckets, sum_value=value
+
+            if metric_family_id not in metric_family_id_metric_family.keys():
+                metric_family_id_metric_family[
+                    metric_family_id
+                ] = HistogramMetricFamily(
+                    name=metric_name,
+                    documentation=description,
+                    labels=label_keys,
+                    unit=metric.unit,
+                )
+            metric_family_id_metric_family[metric_family_id].add_metric(
+                labels=label_values,
+                buckets=_convert_buckets(metric),
+                sum_value=metric.point.sum,
             )
         else:
             _logger.warning("Unsupported metric type. %s", type(metric.point))
-        return prometheus_metric
 
     def _sanitize(self, key: str) -> str:
         """sanitize the given metric name or label according to Prometheus rule.


Test patch: