Skip to content

Commit 3097daa

Browse files
herin049xrmx
andauthored
opentelemetry-exporter-prometheus: add support to configure Resource attributes as metric labels (#5122)
* add resource attr filter * update unit tests * add changelog fragment * switch from 'resource_attr_filter' to 'resource_attribute_filter' * fix formatting * Apply suggestion from @xrmx --------- Co-authored-by: Riccardo Magliocchetti <riccardo.magliocchetti@gmail.com>
1 parent aa8e7db commit 3097daa

4 files changed

Lines changed: 239 additions & 9 deletions

File tree

.changelog/5122.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-exporter-prometheus`: add support to configure Resource attributes as metric labels

docs/exporter/prometheus/prometheus.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,47 @@ To omit instrumentation scope labels from exported metrics, set
7979

8080
reader = PrometheusMetricReader(scope_info_enabled=False)
8181

82+
Resource labels
83+
---------------
84+
85+
By default, resource attributes are exported on the ``target_info`` metric. To
86+
also add selected resource attributes as Prometheus labels on every exported
87+
metric, pass a ``resource_attribute_filter`` callback to ``PrometheusMetricReader``.
88+
The callback receives the original resource attribute key and returns ``True``
89+
for attributes that should be copied to metric labels::
90+
91+
from prometheus_client import start_http_server
92+
93+
from opentelemetry import metrics
94+
from opentelemetry.exporter.prometheus import PrometheusMetricReader
95+
from opentelemetry.sdk.metrics import MeterProvider
96+
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
97+
98+
resource = Resource.create(
99+
attributes={
100+
SERVICE_NAME: "checkout-service",
101+
"service.namespace": "shop",
102+
"deployment.environment": "production",
103+
}
104+
)
105+
106+
start_http_server(port=9464, addr="localhost")
107+
included_resource_attrs = {SERVICE_NAME, "service.namespace"}
108+
reader = PrometheusMetricReader(
109+
resource_attribute_filter=lambda key: key in included_resource_attrs
110+
)
111+
provider = MeterProvider(resource=resource, metric_readers=[reader])
112+
metrics.set_meter_provider(provider)
113+
114+
meter = metrics.get_meter(__name__)
115+
counter = meter.create_counter("orders")
116+
counter.add(1)
117+
118+
The exported metric includes ``service_name="checkout-service"`` and
119+
``service_namespace="shop"`` labels. Resource attribute keys are sanitized to
120+
valid Prometheus label names, and metric attributes with the same sanitized name
121+
take precedence over copied resource attributes.
122+
82123
Configuration
83124
-------------
84125

exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
MetricsData,
101101
Sum,
102102
)
103+
from opentelemetry.sdk.resources import Resource
103104
from opentelemetry.sdk.util.instrumentation import InstrumentationScope
104105
from opentelemetry.semconv._incubating.attributes.otel_attributes import (
105106
OtelComponentTypeValues,
@@ -242,9 +243,13 @@ class PrometheusMetricReader(MetricReader):
242243
243244
Args:
244245
disable_target_info: Whether to disable the ``target_info`` metric.
246+
prefix: Prefix added to exported Prometheus metric names.
245247
scope_info_enabled: Whether to include instrumentation scope labels on
246248
exported metrics. Scope labels are exported by default.
247-
prefix: Prefix added to exported Prometheus metric names.
249+
resource_attribute_filter: Optional callback to select resource attributes
250+
that are copied as labels on exported metrics. The callback receives
251+
the original resource attribute key. Selected keys are sanitized to
252+
valid Prometheus label names.
248253
"""
249254

250255
def __init__(
@@ -253,6 +258,7 @@ def __init__(
253258
prefix: str = "",
254259
scope_info_enabled: bool = True,
255260
*,
261+
resource_attribute_filter: Callable[[str], bool] | None = None,
256262
registry: CollectorRegistry = REGISTRY,
257263
) -> None:
258264
super().__init__(
@@ -270,6 +276,7 @@ def __init__(
270276
disable_target_info=disable_target_info,
271277
prefix=prefix,
272278
scope_info_enabled=scope_info_enabled,
279+
resource_attribute_filter=resource_attribute_filter,
273280
)
274281
self._registry = registry
275282
self._registry.register(self._collector)
@@ -302,13 +309,15 @@ def __init__(
302309
disable_target_info: bool = False,
303310
prefix: str = "",
304311
scope_info_enabled: bool = True,
312+
resource_attribute_filter: Callable[[str], bool] | None = None,
305313
):
306314
self._callback = None
307315
self._metrics_datas: deque[MetricsData] = deque()
308316
self._disable_target_info = disable_target_info
309-
self._scope_info_enabled = scope_info_enabled
310317
self._target_info = None
311318
self._prefix = prefix
319+
self._scope_info_enabled = scope_info_enabled
320+
self._resource_attribute_filter = resource_attribute_filter
312321

313322
def add_metrics_data(self, metrics_data: MetricsData) -> None:
314323
"""Add metrics to Prometheus data"""
@@ -349,25 +358,28 @@ def _translate_to_prometheus(
349358
metric_family_id_metric_family: dict[str, PrometheusMetric],
350359
):
351360
for rm in metrics_data.resource_metrics:
361+
resource_attrs = self._build_resource_attrs(rm.resource)
352362
for sm in rm.scope_metrics:
353363
scope_attrs = self._build_scope_attrs(sm.scope)
354364
for metric in sm.metrics:
355365
self._translate_metric(
356-
metric,
357-
scope_attrs,
358-
metric_family_id_metric_family,
366+
metric=metric,
367+
scope_attrs=scope_attrs,
368+
resource_attrs=resource_attrs,
369+
metric_family_id_metric_family=metric_family_id_metric_family,
359370
)
360371

361372
def _translate_metric(
362373
self,
363374
metric: Metric,
364-
scope_attrs: dict[str, Any],
375+
scope_attrs: dict[str, AttributeValue],
376+
resource_attrs: dict[str, AttributeValue],
365377
metric_family_id_metric_family: dict[str, PrometheusMetric],
366378
) -> None:
367379
metric_name = self._resolve_metric_name(metric.name)
368380
description = metric.description or ""
369381
unit = map_unit(metric.unit or "")
370-
label_keys, label_rows, values = self._collect_data_points(metric.data, scope_attrs)
382+
label_keys, label_rows, values = self._collect_data_points(metric.data, scope_attrs, resource_attrs)
371383
per_metric_family_id = "|".join((metric_name, description, unit))
372384

373385
convert_sum_to_gauge = _should_convert_sum_to_gauge(metric)
@@ -420,6 +432,11 @@ def _build_scope_attrs(self, scope: InstrumentationScope) -> dict[str, Attribute
420432
attrs[_OTEL_SCOPE_SCHEMA_URL_LABEL] = scope.schema_url or ""
421433
return attrs
422434

435+
def _build_resource_attrs(self, resource: Resource) -> dict[str, AttributeValue]:
436+
if not self._resource_attribute_filter:
437+
return {}
438+
return {key: value for key, value in resource.attributes.items() if self._resource_attribute_filter(key)}
439+
423440
def _resolve_metric_name(self, name: str) -> str:
424441
if self._prefix:
425442
name = self._prefix + "_" + name
@@ -429,6 +446,7 @@ def _collect_data_points(
429446
self,
430447
metric_data: DataT,
431448
scope_attrs: dict[str, AttributeValue],
449+
resource_attrs: dict[str, AttributeValue],
432450
) -> tuple[list[str], list[list[str]], list[float | dict[str, Any]]]:
433451
keys: set[str] = set()
434452
rows: list[dict[str, str]] = []
@@ -437,6 +455,7 @@ def _collect_data_points(
437455
for point in metric_data.data_points:
438456
labels: dict[str, str] = {}
439457
for key, value in chain(
458+
resource_attrs.items(),
440459
scope_attrs.items(),
441460
point.attributes.items(),
442461
):

exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright The OpenTelemetry Authors
22
# SPDX-License-Identifier: Apache-2.0
3-
3+
# pylint: disable=too-many-lines
4+
from collections.abc import Callable
45
from textwrap import dedent
56
from unittest import TestCase
67
from unittest.mock import Mock, patch
@@ -69,11 +70,13 @@ def verify_text_format(
6970
prefix: str = "",
7071
scope: InstrumentationScope | None = None,
7172
scope_info_enabled: bool = False,
73+
resource: Resource | None = None,
74+
resource_attr_filter: Callable[[str], bool] | None = None,
7275
) -> None:
7376
metrics_data = MetricsData(
7477
resource_metrics=[
7578
ResourceMetrics(
76-
resource=Mock(),
79+
resource=resource or Mock(),
7780
scope_metrics=[
7881
ScopeMetrics(
7982
scope=scope or Mock(),
@@ -90,6 +93,7 @@ def verify_text_format(
9093
disable_target_info=True,
9194
scope_info_enabled=scope_info_enabled,
9295
prefix=prefix,
96+
resource_attribute_filter=resource_attr_filter,
9397
)
9498
collector.add_metrics_data(metrics_data)
9599
result_bytes = generate_latest(collector)
@@ -827,6 +831,171 @@ def test_scope_attributes_labels(self):
827831
self.assertEqual(labels[_OTEL_SCOPE_VERSION_LABEL], "1.0")
828832
self.assertEqual(labels[_OTEL_SCOPE_SCHEMA_URL_LABEL], "schema_url")
829833

834+
def test_resource_attr_filter_default(self):
835+
metric = _generate_gauge(
836+
"test_gauge",
837+
42,
838+
attributes={"env": "prod"},
839+
description="testdesc",
840+
unit="",
841+
)
842+
metrics_data = MetricsData(
843+
resource_metrics=[
844+
ResourceMetrics(
845+
resource=Resource({"service.name": "my-service"}),
846+
scope_metrics=[
847+
ScopeMetrics(
848+
scope=Mock(),
849+
metrics=[metric],
850+
schema_url="schema_url",
851+
)
852+
],
853+
schema_url="schema_url",
854+
)
855+
]
856+
)
857+
collector = _CustomCollector(disable_target_info=True, scope_info_enabled=False)
858+
collector.add_metrics_data(metrics_data)
859+
860+
for prometheus_metric in collector.collect():
861+
labels = prometheus_metric.samples[0].labels
862+
self.assertNotIn("service_name", labels)
863+
self.assertEqual(labels["env"], "prod")
864+
865+
def test_resource_attr_filter_labels(self):
866+
metric = _generate_gauge(
867+
"test_gauge",
868+
42,
869+
attributes={"env": "prod"},
870+
description="testdesc",
871+
unit="",
872+
)
873+
metrics_data = MetricsData(
874+
resource_metrics=[
875+
ResourceMetrics(
876+
resource=Resource({"service.name": "my-service", "host.name": "myhost"}),
877+
scope_metrics=[
878+
ScopeMetrics(
879+
scope=Mock(),
880+
metrics=[metric],
881+
schema_url="schema_url",
882+
)
883+
],
884+
schema_url="schema_url",
885+
)
886+
]
887+
)
888+
collector = _CustomCollector(
889+
disable_target_info=True,
890+
scope_info_enabled=False,
891+
resource_attribute_filter=lambda key: key == "service.name",
892+
)
893+
collector.add_metrics_data(metrics_data)
894+
895+
for prometheus_metric in collector.collect():
896+
labels = prometheus_metric.samples[0].labels
897+
self.assertEqual(labels["service_name"], "my-service")
898+
self.assertEqual(labels["env"], "prod")
899+
self.assertNotIn("host_name", labels)
900+
901+
def test_resource_attr_filter_receives_raw_keys(self):
902+
metric = _generate_gauge(
903+
"test_gauge",
904+
42,
905+
attributes={},
906+
description="testdesc",
907+
unit="",
908+
)
909+
metrics_data = MetricsData(
910+
resource_metrics=[
911+
ResourceMetrics(
912+
resource=Resource({"service.name": "my-service", "hostname": "myhost"}),
913+
scope_metrics=[
914+
ScopeMetrics(
915+
scope=Mock(),
916+
metrics=[metric],
917+
schema_url="schema_url",
918+
)
919+
],
920+
schema_url="schema_url",
921+
)
922+
]
923+
)
924+
collector = _CustomCollector(
925+
disable_target_info=True,
926+
scope_info_enabled=False,
927+
resource_attribute_filter=lambda key: "." in key,
928+
)
929+
collector.add_metrics_data(metrics_data)
930+
931+
for prometheus_metric in collector.collect():
932+
labels = prometheus_metric.samples[0].labels
933+
self.assertEqual(labels["service_name"], "my-service")
934+
self.assertNotIn("hostname", labels)
935+
936+
def test_resource_attr_filter_text_format(self):
937+
self.verify_text_format(
938+
_generate_gauge(
939+
"test_gauge",
940+
42,
941+
attributes={"env": "prod"},
942+
description="testdesc",
943+
unit="",
944+
),
945+
dedent(
946+
"""\
947+
# HELP test_gauge testdesc
948+
# TYPE test_gauge gauge
949+
test_gauge{env="prod",service_name="my-service"} 42.0
950+
"""
951+
),
952+
resource=Resource({"service.name": "my-service"}),
953+
resource_attr_filter=lambda key: True,
954+
)
955+
956+
def test_resource_attr_filter_with_scope_labels_text_format(self):
957+
scope = InstrumentationScope(name="library.test", version="1.0")
958+
self.verify_text_format(
959+
_generate_gauge(
960+
"test_gauge",
961+
42,
962+
attributes={},
963+
description="testdesc",
964+
unit="",
965+
),
966+
dedent(
967+
"""\
968+
# HELP test_gauge testdesc
969+
# TYPE test_gauge gauge
970+
test_gauge{otel_scope_name="library.test",otel_scope_schema_url="",otel_scope_version="1.0",service_name="my-service"} 42.0
971+
"""
972+
),
973+
scope=scope,
974+
scope_info_enabled=True,
975+
resource=Resource({"service.name": "my-service"}),
976+
resource_attr_filter=lambda key: True,
977+
)
978+
979+
def test_resource_attr_filter_point_attr_precedence_text_format(self):
980+
self.verify_text_format(
981+
_generate_gauge(
982+
"test_gauge",
983+
42,
984+
attributes={"env": "point-env"},
985+
description="testdesc",
986+
unit="",
987+
),
988+
dedent(
989+
"""\
990+
# HELP test_gauge testdesc
991+
# TYPE test_gauge gauge
992+
test_gauge{env="point-env"} 42.0
993+
"""
994+
),
995+
resource=Resource({"env": "resource-env"}),
996+
resource_attr_filter=lambda key: True,
997+
)
998+
830999
def test_multiple_data_points_with_different_label_sets(self):
8311000
hist_point_1 = HistogramDataPoint(
8321001
attributes={"http_target": "/foobar", "net_host_port": 8080},

0 commit comments

Comments
 (0)