Repository: open-telemetry/opentelemetry-python

Issue:
Prometheus exporter emits target_info for Resource
From the spec for [handling resource in prometheus exporters](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/compatibility/prometheus_and_openmetrics.md#resource-attributes-1):

> In SDK Prometheus (pull) exporters, resource attributes SHOULD be converted to a single [target_info metric](https://github.com/OpenObservability/OpenMetrics/blob/main/specification/OpenMetrics.md#supporting-target-metadata-in-both-push-based-and-pull-based-systems); otherwise, they MUST be dropped, and MUST NOT be attached as labels to other metric families. The target_info metric MUST be an info-typed metric whose labels MUST include the resource attributes, and MUST NOT include any other labels. There MUST be at most one target_info metric exposed on an SDK Prometheus endpoint.


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
@@ -74,6 +74,7 @@
     CounterMetricFamily,
     GaugeMetricFamily,
     HistogramMetricFamily,
+    InfoMetricFamily,
 )
 from prometheus_client.core import Metric as PrometheusMetric
 
@@ -97,6 +98,9 @@
 
 _logger = getLogger(__name__)
 
+_TARGET_INFO_NAME = "target"
+_TARGET_INFO_DESCRIPTION = "Target metadata"
+
 
 def _convert_buckets(
     bucket_counts: Sequence[int], explicit_bounds: Sequence[float]
@@ -116,8 +120,7 @@ def _convert_buckets(
 class PrometheusMetricReader(MetricReader):
     """Prometheus metric exporter for OpenTelemetry."""
 
-    def __init__(self) -> None:
-
+    def __init__(self, disable_target_info: bool = False) -> None:
         super().__init__(
             preferred_temporality={
                 Counter: AggregationTemporality.CUMULATIVE,
@@ -128,7 +131,7 @@ def __init__(self) -> None:
                 ObservableGauge: AggregationTemporality.CUMULATIVE,
             }
         )
-        self._collector = _CustomCollector()
+        self._collector = _CustomCollector(disable_target_info)
         REGISTRY.register(self._collector)
         self._collector._callback = self.collect
 
@@ -153,12 +156,14 @@ class _CustomCollector:
     https://github.com/prometheus/client_python#custom-collectors
     """
 
-    def __init__(self):
+    def __init__(self, disable_target_info: bool = False):
         self._callback = None
         self._metrics_datas = deque()
         self._non_letters_digits_underscore_re = compile(
             r"[^\w]", UNICODE | IGNORECASE
         )
+        self._disable_target_info = disable_target_info
+        self._target_info = None
 
     def add_metrics_data(self, metrics_data: MetricsData) -> None:
         """Add metrics to Prometheus data"""
@@ -175,6 +180,20 @@ def collect(self) -> None:
 
         metric_family_id_metric_family = {}
 
+        if len(self._metrics_datas):
+            if not self._disable_target_info:
+                if self._target_info is None:
+                    attributes = {}
+                    for res in self._metrics_datas[0].resource_metrics:
+                        attributes = {**attributes, **res.resource.attributes}
+
+                    self._target_info = self._create_info_metric(
+                        _TARGET_INFO_NAME, _TARGET_INFO_DESCRIPTION, attributes
+                    )
+                metric_family_id_metric_family[
+                    _TARGET_INFO_NAME
+                ] = self._target_info
+
         while self._metrics_datas:
             self._translate_to_prometheus(
                 self._metrics_datas.popleft(), metric_family_id_metric_family
@@ -327,3 +346,11 @@ def _check_value(self, value: Union[int, float, str, Sequence]) -> str:
         if not isinstance(value, str):
             return dumps(value, default=str)
         return str(value)
+
+    def _create_info_metric(
+        self, name: str, description: str, attributes: Dict[str, str]
+    ) -> InfoMetricFamily:
+        """Create an Info Metric Family with list of attributes"""
+        info = InfoMetricFamily(name, description, labels=attributes)
+        info.add_metric(labels=list(attributes.keys()), value=attributes)
+        return info


Test patch:
diff --git a/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py b/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py
--- a/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py
+++ b/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py
@@ -17,7 +17,11 @@
 from unittest.mock import Mock, patch
 
 from prometheus_client import generate_latest
-from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily
+from prometheus_client.core import (
+    CounterMetricFamily,
+    GaugeMetricFamily,
+    InfoMetricFamily,
+)
 
 from opentelemetry.exporter.prometheus import (
     PrometheusMetricReader,
@@ -33,6 +37,7 @@
     ResourceMetrics,
     ScopeMetrics,
 )
+from opentelemetry.sdk.resources import Resource
 from opentelemetry.test.metrictestutil import (
     _generate_gauge,
     _generate_sum,
@@ -101,7 +106,7 @@ def test_histogram_to_prometheus(self):
             ]
         )
 
-        collector = _CustomCollector()
+        collector = _CustomCollector(disable_target_info=True)
         collector.add_metrics_data(metrics_data)
         result_bytes = generate_latest(collector)
         result = result_bytes.decode("utf-8")
@@ -146,7 +151,7 @@ def test_sum_to_prometheus(self):
             ]
         )
 
-        collector = _CustomCollector()
+        collector = _CustomCollector(disable_target_info=True)
         collector.add_metrics_data(metrics_data)
 
         for prometheus_metric in collector.collect():
@@ -189,7 +194,7 @@ def test_gauge_to_prometheus(self):
             ]
         )
 
-        collector = _CustomCollector()
+        collector = _CustomCollector(disable_target_info=True)
         collector.add_metrics_data(metrics_data)
 
         for prometheus_metric in collector.collect():
@@ -251,7 +256,7 @@ def test_list_labels(self):
                 )
             ]
         )
-        collector = _CustomCollector()
+        collector = _CustomCollector(disable_target_info=True)
         collector.add_metrics_data(metrics_data)
 
         for prometheus_metric in collector.collect():
@@ -293,3 +298,46 @@ def test_multiple_collection_calls(self):
         result_2 = list(metric_reader._collector.collect())
         self.assertEqual(result_0, result_1)
         self.assertEqual(result_1, result_2)
+
+    def test_target_info_enabled_by_default(self):
+        metric_reader = PrometheusMetricReader()
+        provider = MeterProvider(
+            metric_readers=[metric_reader],
+            resource=Resource({"os": "Unix", "histo": 1}),
+        )
+        meter = provider.get_meter("getting-started", "0.1.2")
+        counter = meter.create_counter("counter")
+        counter.add(1)
+        result = list(metric_reader._collector.collect())
+
+        for prometheus_metric in result[:0]:
+            self.assertEqual(type(prometheus_metric), InfoMetricFamily)
+            self.assertEqual(prometheus_metric.name, "target")
+            self.assertEqual(
+                prometheus_metric.documentation, "Target metadata"
+            )
+            self.assertTrue(len(prometheus_metric.samples) == 1)
+            self.assertEqual(prometheus_metric.samples[0].value, 1)
+            self.assertTrue(len(prometheus_metric.samples[0].labels) == 2)
+            self.assertEqual(prometheus_metric.samples[0].labels["os"], "Unix")
+            self.assertEqual(prometheus_metric.samples[0].labels["histo"], "1")
+
+    def test_target_info_disabled(self):
+        metric_reader = PrometheusMetricReader(disable_target_info=True)
+        provider = MeterProvider(
+            metric_readers=[metric_reader],
+            resource=Resource({"os": "Unix", "histo": 1}),
+        )
+        meter = provider.get_meter("getting-started", "0.1.2")
+        counter = meter.create_counter("counter")
+        counter.add(1)
+        result = list(metric_reader._collector.collect())
+
+        for prometheus_metric in result:
+            self.assertNotEqual(type(prometheus_metric), InfoMetricFamily)
+            self.assertNotEqual(prometheus_metric.name, "target")
+            self.assertNotEqual(
+                prometheus_metric.documentation, "Target metadata"
+            )
+            self.assertNotIn("os", prometheus_metric.samples[0].labels)
+            self.assertNotIn("histo", prometheus_metric.samples[0].labels)