From e7eb505c41a5583d4c06c7706a23ad7a5be518fc Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 16 Jun 2026 22:23:21 -0400 Subject: [PATCH 1/3] 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/3] 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/3] 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