Skip to content
Open
1 change: 1 addition & 0 deletions .changelog/5122.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-exporter-prometheus`: add support to configure Resource attributes as metric labels
41 changes: 41 additions & 0 deletions docs/exporter/prometheus/prometheus.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -252,16 +253,21 @@ 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__(
self,
disable_target_info: bool = False,
prefix: str = "",
scope_info_enabled: bool = True,
resource_attr_filter: Callable[[str], bool] | None = None,
*,
registry: CollectorRegistry = REGISTRY,
) -> None:
Expand All @@ -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)
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand All @@ -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]] = []
Expand All @@ -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(),
):
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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)
Expand Down Expand Up @@ -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},
Expand Down
Loading