From e7eb505c41a5583d4c06c7706a23ad7a5be518fc Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 16 Jun 2026 22:23:21 -0400 Subject: [PATCH 1/6] add resource attr filter --- docs/exporter/prometheus/prometheus.rst | 41 +++++++++++++++++++ .../exporter/prometheus/__init__.py | 39 ++++++++++++++---- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/docs/exporter/prometheus/prometheus.rst b/docs/exporter/prometheus/prometheus.rst index ab8ff7c615c..03819d67091 100644 --- a/docs/exporter/prometheus/prometheus.rst +++ b/docs/exporter/prometheus/prometheus.rst @@ -79,6 +79,47 @@ To omit instrumentation scope labels from exported metrics, set reader = PrometheusMetricReader(scope_info_enabled=False) +Resource labels +------------------- + +By default, resource attributes are exported on the ``target_info`` metric. To +also add selected resource attributes as Prometheus labels on every exported +metric, pass a ``resource_attr_filter`` callback to ``PrometheusMetricReader``. +The callback receives the original resource attribute key and returns ``True`` +for attributes that should be copied to metric labels:: + + from prometheus_client import start_http_server + + from opentelemetry import metrics + from opentelemetry.exporter.prometheus import PrometheusMetricReader + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.resources import SERVICE_NAME, Resource + + resource = Resource.create( + attributes={ + SERVICE_NAME: "checkout-service", + "service.namespace": "shop", + "deployment.environment": "production", + } + ) + + start_http_server(port=9464, addr="localhost") + included_resource_attrs = {SERVICE_NAME, "service.namespace"} + reader = PrometheusMetricReader( + resource_attr_filter=lambda key: key in included_resource_attrs + ) + provider = MeterProvider(resource=resource, metric_readers=[reader]) + metrics.set_meter_provider(provider) + + meter = metrics.get_meter(__name__) + counter = meter.create_counter("orders") + counter.add(1) + +The exported metric includes ``service_name="checkout-service"`` and +``service_namespace="shop"`` labels. Resource attribute keys are sanitized to +valid Prometheus label names, and metric attributes with the same sanitized name +take precedence over copied resource attributes. + Configuration ------------- diff --git a/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py b/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py index 12a6755337f..04a1b18c7c0 100644 --- a/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py +++ b/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py @@ -100,6 +100,7 @@ MetricsData, Sum, ) +from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.util.instrumentation import InstrumentationScope from opentelemetry.semconv._incubating.attributes.otel_attributes import ( OtelComponentTypeValues, @@ -252,9 +253,13 @@ class PrometheusMetricReader(MetricReader): Args: disable_target_info: Whether to disable the ``target_info`` metric. + prefix: Prefix added to exported Prometheus metric names. scope_info_enabled: Whether to include instrumentation scope labels on exported metrics. Scope labels are exported by default. - prefix: Prefix added to exported Prometheus metric names. + resource_attr_filter: Optional callback to select resource attributes + that are copied as labels on exported metrics. The callback receives + the original resource attribute key. Selected keys are sanitized to + valid Prometheus label names. """ def __init__( @@ -262,6 +267,7 @@ def __init__( disable_target_info: bool = False, prefix: str = "", scope_info_enabled: bool = True, + resource_attr_filter: Callable[[str], bool] | None = None, *, registry: CollectorRegistry = REGISTRY, ) -> None: @@ -280,6 +286,7 @@ def __init__( disable_target_info=disable_target_info, prefix=prefix, scope_info_enabled=scope_info_enabled, + resource_attr_filter=resource_attr_filter, ) self._registry = registry self._registry.register(self._collector) @@ -312,13 +319,15 @@ def __init__( disable_target_info: bool = False, prefix: str = "", scope_info_enabled: bool = True, + resource_attr_filter: Callable[[str], bool] | None = None, ): self._callback = None self._metrics_datas: deque[MetricsData] = deque() self._disable_target_info = disable_target_info - self._scope_info_enabled = scope_info_enabled self._target_info = None self._prefix = prefix + self._scope_info_enabled = scope_info_enabled + self._resource_attr_filter = resource_attr_filter def add_metrics_data(self, metrics_data: MetricsData) -> None: """Add metrics to Prometheus data""" @@ -363,26 +372,29 @@ def _translate_to_prometheus( metric_family_id_metric_family: dict[str, PrometheusMetric], ): for rm in metrics_data.resource_metrics: + resource_attrs = self._build_resource_attrs(rm.resource) for sm in rm.scope_metrics: scope_attrs = self._build_scope_attrs(sm.scope) for metric in sm.metrics: self._translate_metric( - metric, - scope_attrs, - metric_family_id_metric_family, + metric=metric, + scope_attrs=scope_attrs, + resource_attrs=resource_attrs, + metric_family_id_metric_family=metric_family_id_metric_family, ) def _translate_metric( self, metric: Metric, - scope_attrs: dict[str, Any], + scope_attrs: dict[str, AttributeValue], + resource_attrs: dict[str, AttributeValue], metric_family_id_metric_family: dict[str, PrometheusMetric], ) -> None: metric_name = self._resolve_metric_name(metric.name) description = metric.description or "" unit = map_unit(metric.unit or "") label_keys, label_rows, values = self._collect_data_points( - metric.data, scope_attrs + metric.data, scope_attrs, resource_attrs ) per_metric_family_id = "|".join((metric_name, description, unit)) @@ -438,6 +450,17 @@ def _build_scope_attrs( attrs[_OTEL_SCOPE_SCHEMA_URL_LABEL] = scope.schema_url or "" return attrs + def _build_resource_attrs( + self, resource: Resource + ) -> dict[str, AttributeValue]: + if not self._resource_attr_filter: + return {} + return { + key: value + for key, value in resource.attributes.items() + if self._resource_attr_filter(key) + } + def _resolve_metric_name(self, name: str) -> str: if self._prefix: name = self._prefix + "_" + name @@ -447,6 +470,7 @@ def _collect_data_points( self, metric_data: DataT, scope_attrs: dict[str, AttributeValue], + resource_attrs: dict[str, AttributeValue], ) -> tuple[list[str], list[list[str]], list[float | dict[str, Any]]]: keys: set[str] = set() rows: list[dict[str, str]] = [] @@ -455,6 +479,7 @@ def _collect_data_points( for point in metric_data.data_points: labels: dict[str, str] = {} for key, value in chain( + resource_attrs.items(), scope_attrs.items(), point.attributes.items(), ): From 0b6b63af26dd683d61f104023df4991dfae5b2ef Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 16 Jun 2026 22:49:56 -0400 Subject: [PATCH 2/6] update unit tests --- .../tests/test_prometheus_exporter.py | 179 +++++++++++++++++- 1 file changed, 177 insertions(+), 2 deletions(-) diff --git a/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py b/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py index 24a1ab284e5..86e873778b6 100644 --- a/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py +++ b/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py @@ -1,6 +1,7 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 - +# pylint: disable=too-many-lines +from collections.abc import Callable from textwrap import dedent from unittest import TestCase from unittest.mock import Mock, patch @@ -69,11 +70,13 @@ def verify_text_format( prefix: str = "", scope: InstrumentationScope | None = None, scope_info_enabled: bool = False, + resource: Resource | None = None, + resource_attr_filter: Callable[[str], bool] | None = None, ) -> None: metrics_data = MetricsData( resource_metrics=[ ResourceMetrics( - resource=Mock(), + resource=resource or Mock(), scope_metrics=[ ScopeMetrics( scope=scope or Mock(), @@ -90,6 +93,7 @@ def verify_text_format( disable_target_info=True, scope_info_enabled=scope_info_enabled, prefix=prefix, + resource_attr_filter=resource_attr_filter, ) collector.add_metrics_data(metrics_data) result_bytes = generate_latest(collector) @@ -867,6 +871,177 @@ def test_scope_attributes_labels(self): labels[_OTEL_SCOPE_SCHEMA_URL_LABEL], "schema_url" ) + def test_resource_attr_filter_default(self): + metric = _generate_gauge( + "test_gauge", + 42, + attributes={"env": "prod"}, + description="testdesc", + unit="", + ) + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource({"service.name": "my-service"}), + scope_metrics=[ + ScopeMetrics( + scope=Mock(), + metrics=[metric], + schema_url="schema_url", + ) + ], + schema_url="schema_url", + ) + ] + ) + collector = _CustomCollector( + disable_target_info=True, scope_info_enabled=False + ) + collector.add_metrics_data(metrics_data) + + for prometheus_metric in collector.collect(): + labels = prometheus_metric.samples[0].labels + self.assertNotIn("service_name", labels) + self.assertEqual(labels["env"], "prod") + + def test_resource_attr_filter_labels(self): + metric = _generate_gauge( + "test_gauge", + 42, + attributes={"env": "prod"}, + description="testdesc", + unit="", + ) + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource( + {"service.name": "my-service", "host.name": "myhost"} + ), + scope_metrics=[ + ScopeMetrics( + scope=Mock(), + metrics=[metric], + schema_url="schema_url", + ) + ], + schema_url="schema_url", + ) + ] + ) + collector = _CustomCollector( + disable_target_info=True, + scope_info_enabled=False, + resource_attr_filter=lambda key: key == "service.name", + ) + collector.add_metrics_data(metrics_data) + + for prometheus_metric in collector.collect(): + labels = prometheus_metric.samples[0].labels + self.assertEqual(labels["service_name"], "my-service") + self.assertEqual(labels["env"], "prod") + self.assertNotIn("host_name", labels) + + def test_resource_attr_filter_receives_raw_keys(self): + metric = _generate_gauge( + "test_gauge", + 42, + attributes={}, + description="testdesc", + unit="", + ) + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource( + {"service.name": "my-service", "hostname": "myhost"} + ), + scope_metrics=[ + ScopeMetrics( + scope=Mock(), + metrics=[metric], + schema_url="schema_url", + ) + ], + schema_url="schema_url", + ) + ] + ) + collector = _CustomCollector( + disable_target_info=True, + scope_info_enabled=False, + resource_attr_filter=lambda key: "." in key, + ) + collector.add_metrics_data(metrics_data) + + for prometheus_metric in collector.collect(): + labels = prometheus_metric.samples[0].labels + self.assertEqual(labels["service_name"], "my-service") + self.assertNotIn("hostname", labels) + + def test_resource_attr_filter_text_format(self): + self.verify_text_format( + _generate_gauge( + "test_gauge", + 42, + attributes={"env": "prod"}, + description="testdesc", + unit="", + ), + dedent( + """\ + # HELP test_gauge testdesc + # TYPE test_gauge gauge + test_gauge{env="prod",service_name="my-service"} 42.0 + """ + ), + resource=Resource({"service.name": "my-service"}), + resource_attr_filter=lambda key: True, + ) + + def test_resource_attr_filter_with_scope_labels_text_format(self): + scope = InstrumentationScope(name="library.test", version="1.0") + self.verify_text_format( + _generate_gauge( + "test_gauge", + 42, + attributes={}, + description="testdesc", + unit="", + ), + dedent( + """\ + # HELP test_gauge testdesc + # TYPE test_gauge gauge + test_gauge{otel_scope_name="library.test",otel_scope_schema_url="",otel_scope_version="1.0",service_name="my-service"} 42.0 + """ + ), + scope=scope, + scope_info_enabled=True, + resource=Resource({"service.name": "my-service"}), + resource_attr_filter=lambda key: True, + ) + + def test_resource_attr_filter_point_attr_precedence_text_format(self): + self.verify_text_format( + _generate_gauge( + "test_gauge", + 42, + attributes={"env": "point-env"}, + description="testdesc", + unit="", + ), + dedent( + """\ + # HELP test_gauge testdesc + # TYPE test_gauge gauge + test_gauge{env="point-env"} 42.0 + """ + ), + resource=Resource({"env": "resource-env"}), + resource_attr_filter=lambda key: True, + ) + def test_multiple_data_points_with_different_label_sets(self): hist_point_1 = HistogramDataPoint( attributes={"http_target": "/foobar", "net_host_port": 8080}, From 8ecb28de58762e5b104add89dc709e32ccfe6fcd Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 16 Jun 2026 22:53:10 -0400 Subject: [PATCH 3/6] add changelog fragment --- .changelog/5122.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5122.added diff --git a/.changelog/5122.added b/.changelog/5122.added new file mode 100644 index 00000000000..5cbfaef1226 --- /dev/null +++ b/.changelog/5122.added @@ -0,0 +1 @@ +`opentelemetry-exporter-prometheus`: add support to configure Resource attributes as metric labels From 250779a3a9ba0cb7efb9832d0bd31b5b26bc04ab Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Thu, 30 Jul 2026 21:06:04 -0400 Subject: [PATCH 4/6] switch from 'resource_attr_filter' to 'resource_attribute_filter' --- docs/exporter/prometheus/prometheus.rst | 4 ++-- .../opentelemetry/exporter/prometheus/__init__.py | 14 +++++++------- .../tests/test_prometheus_exporter.py | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/exporter/prometheus/prometheus.rst b/docs/exporter/prometheus/prometheus.rst index 03819d67091..5dfab272d4d 100644 --- a/docs/exporter/prometheus/prometheus.rst +++ b/docs/exporter/prometheus/prometheus.rst @@ -84,7 +84,7 @@ Resource labels By default, resource attributes are exported on the ``target_info`` metric. To also add selected resource attributes as Prometheus labels on every exported -metric, pass a ``resource_attr_filter`` callback to ``PrometheusMetricReader``. +metric, pass a ``resource_attribute_filter`` callback to ``PrometheusMetricReader``. The callback receives the original resource attribute key and returns ``True`` for attributes that should be copied to metric labels:: @@ -106,7 +106,7 @@ for attributes that should be copied to metric labels:: start_http_server(port=9464, addr="localhost") included_resource_attrs = {SERVICE_NAME, "service.namespace"} reader = PrometheusMetricReader( - resource_attr_filter=lambda key: key in included_resource_attrs + resource_attribute_filter=lambda key: key in included_resource_attrs ) provider = MeterProvider(resource=resource, metric_readers=[reader]) metrics.set_meter_provider(provider) diff --git a/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py b/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py index 04a1b18c7c0..86d5b6ad187 100644 --- a/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py +++ b/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py @@ -256,7 +256,7 @@ class PrometheusMetricReader(MetricReader): prefix: Prefix added to exported Prometheus metric names. scope_info_enabled: Whether to include instrumentation scope labels on exported metrics. Scope labels are exported by default. - resource_attr_filter: Optional callback to select resource attributes + resource_attribute_filter: Optional callback to select resource attributes that are copied as labels on exported metrics. The callback receives the original resource attribute key. Selected keys are sanitized to valid Prometheus label names. @@ -267,8 +267,8 @@ def __init__( disable_target_info: bool = False, prefix: str = "", scope_info_enabled: bool = True, - resource_attr_filter: Callable[[str], bool] | None = None, *, + resource_attribute_filter: Callable[[str], bool] | None = None, registry: CollectorRegistry = REGISTRY, ) -> None: super().__init__( @@ -286,7 +286,7 @@ def __init__( disable_target_info=disable_target_info, prefix=prefix, scope_info_enabled=scope_info_enabled, - resource_attr_filter=resource_attr_filter, + resource_attribute_filter=resource_attribute_filter, ) self._registry = registry self._registry.register(self._collector) @@ -319,7 +319,7 @@ def __init__( disable_target_info: bool = False, prefix: str = "", scope_info_enabled: bool = True, - resource_attr_filter: Callable[[str], bool] | None = None, + resource_attribute_filter: Callable[[str], bool] | None = None, ): self._callback = None self._metrics_datas: deque[MetricsData] = deque() @@ -327,7 +327,7 @@ def __init__( self._target_info = None self._prefix = prefix self._scope_info_enabled = scope_info_enabled - self._resource_attr_filter = resource_attr_filter + self._resource_attribute_filter = resource_attribute_filter def add_metrics_data(self, metrics_data: MetricsData) -> None: """Add metrics to Prometheus data""" @@ -453,12 +453,12 @@ def _build_scope_attrs( def _build_resource_attrs( self, resource: Resource ) -> dict[str, AttributeValue]: - if not self._resource_attr_filter: + if not self._resource_attribute_filter: return {} return { key: value for key, value in resource.attributes.items() - if self._resource_attr_filter(key) + if self._resource_attribute_filter(key) } def _resolve_metric_name(self, name: str) -> str: diff --git a/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py b/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py index 86e873778b6..9b4887d1f69 100644 --- a/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py +++ b/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py @@ -93,7 +93,7 @@ def verify_text_format( disable_target_info=True, scope_info_enabled=scope_info_enabled, prefix=prefix, - resource_attr_filter=resource_attr_filter, + resource_attribute_filter=resource_attr_filter, ) collector.add_metrics_data(metrics_data) result_bytes = generate_latest(collector) @@ -932,7 +932,7 @@ def test_resource_attr_filter_labels(self): collector = _CustomCollector( disable_target_info=True, scope_info_enabled=False, - resource_attr_filter=lambda key: key == "service.name", + resource_attribute_filter=lambda key: key == "service.name", ) collector.add_metrics_data(metrics_data) @@ -970,7 +970,7 @@ def test_resource_attr_filter_receives_raw_keys(self): collector = _CustomCollector( disable_target_info=True, scope_info_enabled=False, - resource_attr_filter=lambda key: "." in key, + resource_attribute_filter=lambda key: "." in key, ) collector.add_metrics_data(metrics_data) From f1d6cf75a8d45752430c8bb6b719eef4e6dbf4f0 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Mon, 10 Aug 2026 19:58:09 -0400 Subject: [PATCH 5/6] fix formatting --- .../opentelemetry/exporter/prometheus/__init__.py | 10 ++-------- .../tests/test_prometheus_exporter.py | 12 +++--------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py b/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py index c9dc8105eb8..5c129db8611 100644 --- a/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py +++ b/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py @@ -432,16 +432,10 @@ def _build_scope_attrs(self, scope: InstrumentationScope) -> dict[str, Attribute attrs[_OTEL_SCOPE_SCHEMA_URL_LABEL] = scope.schema_url or "" return attrs - def _build_resource_attrs( - self, resource: Resource - ) -> dict[str, AttributeValue]: + def _build_resource_attrs(self, resource: Resource) -> dict[str, AttributeValue]: if not self._resource_attribute_filter: return {} - return { - key: value - for key, value in resource.attributes.items() - if self._resource_attribute_filter(key) - } + return {key: value for key, value in resource.attributes.items() if self._resource_attribute_filter(key)} def _resolve_metric_name(self, name: str) -> str: if self._prefix: diff --git a/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py b/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py index 95966434a44..894b3631341 100644 --- a/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py +++ b/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py @@ -854,9 +854,7 @@ def test_resource_attr_filter_default(self): ) ] ) - collector = _CustomCollector( - disable_target_info=True, scope_info_enabled=False - ) + collector = _CustomCollector(disable_target_info=True, scope_info_enabled=False) collector.add_metrics_data(metrics_data) for prometheus_metric in collector.collect(): @@ -875,9 +873,7 @@ def test_resource_attr_filter_labels(self): metrics_data = MetricsData( resource_metrics=[ ResourceMetrics( - resource=Resource( - {"service.name": "my-service", "host.name": "myhost"} - ), + resource=Resource({"service.name": "my-service", "host.name": "myhost"}), scope_metrics=[ ScopeMetrics( scope=Mock(), @@ -913,9 +909,7 @@ def test_resource_attr_filter_receives_raw_keys(self): metrics_data = MetricsData( resource_metrics=[ ResourceMetrics( - resource=Resource( - {"service.name": "my-service", "hostname": "myhost"} - ), + resource=Resource({"service.name": "my-service", "hostname": "myhost"}), scope_metrics=[ ScopeMetrics( scope=Mock(), From cab93fe56001faf92f832d52b9da18a112b3bb75 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Mon, 17 Aug 2026 09:36:24 +0200 Subject: [PATCH 6/6] Apply suggestion from @xrmx --- docs/exporter/prometheus/prometheus.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/exporter/prometheus/prometheus.rst b/docs/exporter/prometheus/prometheus.rst index 5dfab272d4d..42465c0624f 100644 --- a/docs/exporter/prometheus/prometheus.rst +++ b/docs/exporter/prometheus/prometheus.rst @@ -80,7 +80,7 @@ To omit instrumentation scope labels from exported metrics, set reader = PrometheusMetricReader(scope_info_enabled=False) Resource labels -------------------- +--------------- By default, resource attributes are exported on the ``target_info`` metric. To also add selected resource attributes as Prometheus labels on every exported