Skip to content

Commit d763e4d

Browse files
committed
[EXPORTER] Prometheus exporter: merge metric families across scopes and drop conflicting metric types
Update PrometheusExporterUtils::TranslateToPrometheus to conform to the Metric Metadata section of the OpenTelemetry Prometheus and OpenMetrics compatibility specification: - Merge metrics across scopes (or unit/suffix-stripped instruments) that map to the same Prometheus metric name and MetricType into a single MetricFamily, retaining the first HELP description and logging a warning on description mismatch. - Drop metrics that map to an existing Prometheus metric name with a conflicting MetricType and log a warning. Part of #1841
1 parent e854d29 commit d763e4d

3 files changed

Lines changed: 118 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ Increment the:
1515

1616
## [Unreleased]
1717

18+
* [EXPORTER] Prometheus exporter: merge metric families across scopes and drop
19+
conflicting metric types
20+
[#4736](https://github.com/open-telemetry/opentelemetry-cpp/pull/4736)
21+
1822
## [1.30.0] 2026-10-22
1923

2024
This release contains:

‎exporters/prometheus/src/exporter_utils.cc‎

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -138,24 +138,55 @@ std::vector<prometheus_client::MetricFamily> PrometheusExporterUtils::TranslateT
138138
&output);
139139
}
140140

141+
std::unordered_map<std::string, std::size_t> family_index_by_name;
142+
141143
for (const auto &instrumentation_info : data.scope_metric_data_)
142144
{
143145
for (const auto &metric_data : instrumentation_info.metric_data_)
144146
{
145-
prometheus_client::MetricFamily metric_family;
146-
metric_family.help = metric_data.instrument_descriptor.description_;
147-
auto front = metric_data.point_data_attr_.front();
148-
auto kind = getAggregationType(front.point_data);
149-
bool is_monotonic = true;
147+
auto front = metric_data.point_data_attr_.front();
148+
auto kind = getAggregationType(front.point_data);
149+
bool is_monotonic = true;
150150
if (kind == sdk::metrics::AggregationType::kSum)
151151
{
152152
is_monotonic = nostd::get<sdk::metrics::SumPointData>(front.point_data).is_monotonic_;
153153
}
154154
const prometheus_client::MetricType type = TranslateType(kind, is_monotonic);
155-
metric_family.name = MapToPrometheusName(metric_data.instrument_descriptor.name_,
156-
metric_data.instrument_descriptor.unit_, type,
157-
without_units, without_type_suffix);
158-
metric_family.type = type;
155+
const std::string name = MapToPrometheusName(metric_data.instrument_descriptor.name_,
156+
metric_data.instrument_descriptor.unit_, type,
157+
without_units, without_type_suffix);
158+
prometheus_client::MetricFamily *metric_family_ptr = nullptr;
159+
auto existing_it = family_index_by_name.find(name);
160+
if (existing_it != family_index_by_name.end())
161+
{
162+
auto &existing_family = output[existing_it->second];
163+
if (existing_family.type != type)
164+
{
165+
OTEL_INTERNAL_LOG_WARN(
166+
"[Prometheus Exporter] TranslateToPrometheus - "
167+
"Instrument type conflict for metric '"
168+
<< name << "'. Dropping conflicting metric.");
169+
continue;
170+
}
171+
if (existing_family.help != metric_data.instrument_descriptor.description_)
172+
{
173+
OTEL_INTERNAL_LOG_WARN(
174+
"[Prometheus Exporter] TranslateToPrometheus - "
175+
"Instrument description conflict for metric '"
176+
<< name << "': '" << metric_data.instrument_descriptor.description_
177+
<< "', using existing description: '" << existing_family.help << "'.");
178+
}
179+
metric_family_ptr = &existing_family;
180+
}
181+
else
182+
{
183+
family_index_by_name.emplace(name, output.size());
184+
output.emplace_back();
185+
metric_family_ptr = &output.back();
186+
metric_family_ptr->name = name;
187+
metric_family_ptr->help = metric_data.instrument_descriptor.description_;
188+
metric_family_ptr->type = type;
189+
}
159190
const opentelemetry::sdk::instrumentationscope::InstrumentationScope *scope =
160191
without_otel_scope ? nullptr : instrumentation_info.scope_;
161192

@@ -177,7 +208,7 @@ std::vector<prometheus_client::MetricFamily> PrometheusExporterUtils::TranslateT
177208
sum = static_cast<double>(nostd::get<int64_t>(histogram_point_data.sum_));
178209
}
179210
SetData(std::vector<double>{sum, static_cast<double>(histogram_point_data.count_)},
180-
boundaries, counts, point_data_attr.attributes, scope, &metric_family,
211+
boundaries, counts, point_data_attr.attributes, scope, metric_family_ptr,
181212
data.resource_);
182213
}
183214
else if (type == prometheus_client::MetricType::Gauge)
@@ -188,15 +219,15 @@ std::vector<prometheus_client::MetricFamily> PrometheusExporterUtils::TranslateT
188219
auto last_value_point_data =
189220
nostd::get<sdk::metrics::LastValuePointData>(point_data_attr.point_data);
190221
std::vector<metric_sdk::ValueType> values{last_value_point_data.value_};
191-
SetData(values, point_data_attr.attributes, scope, type, &metric_family,
222+
SetData(values, point_data_attr.attributes, scope, type, metric_family_ptr,
192223
data.resource_);
193224
}
194225
else if (nostd::holds_alternative<sdk::metrics::SumPointData>(point_data_attr.point_data))
195226
{
196227
auto sum_point_data =
197228
nostd::get<sdk::metrics::SumPointData>(point_data_attr.point_data);
198229
std::vector<metric_sdk::ValueType> values{sum_point_data.value_};
199-
SetData(values, point_data_attr.attributes, scope, type, &metric_family,
230+
SetData(values, point_data_attr.attributes, scope, type, metric_family_ptr,
200231
data.resource_);
201232
}
202233
else
@@ -213,7 +244,7 @@ std::vector<prometheus_client::MetricFamily> PrometheusExporterUtils::TranslateT
213244
auto sum_point_data =
214245
nostd::get<sdk::metrics::SumPointData>(point_data_attr.point_data);
215246
std::vector<metric_sdk::ValueType> values{sum_point_data.value_};
216-
SetData(values, point_data_attr.attributes, scope, type, &metric_family,
247+
SetData(values, point_data_attr.attributes, scope, type, metric_family_ptr,
217248
data.resource_);
218249
}
219250
else
@@ -224,7 +255,6 @@ std::vector<prometheus_client::MetricFamily> PrometheusExporterUtils::TranslateT
224255
}
225256
}
226257
}
227-
output.emplace_back(metric_family);
228258
}
229259
}
230260
return output;

‎exporters/prometheus/test/exporter_utils_test.cc‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include "opentelemetry/nostd/unique_ptr.h"
1818
#include "opentelemetry/sdk/instrumentationscope/instrumentation_scope.h"
1919
#include "opentelemetry/sdk/metrics/data/metric_data.h"
20+
#include "opentelemetry/sdk/metrics/data/point_data.h"
2021
#include "opentelemetry/sdk/metrics/export/metric_producer.h"
2122
#include "opentelemetry/sdk/metrics/instruments.h"
2223
#include "opentelemetry/sdk/resource/resource.h"
@@ -547,4 +548,73 @@ TEST_F(AttributeCollisionTest, DropsInvertedKeys)
547548
{"otel_scope_version", "1.2.0"}});
548549
}
549550

551+
TEST(PrometheusExporterUtils, MultipleScopesMetricFamilyCollisions)
552+
{
553+
Resource resource = Resource::Create(ResourceAttributes{});
554+
auto scope1 = InstrumentationScope::Create("scope_1", "1.0.0");
555+
auto scope2 = InstrumentationScope::Create("scope_2", "2.0.0");
556+
auto scope3 = InstrumentationScope::Create("scope_3", "3.0.0");
557+
558+
metric_sdk::SumPointData counter_point_1{};
559+
counter_point_1.value_ = 10.0;
560+
counter_point_1.is_monotonic_ = true;
561+
562+
metric_sdk::SumPointData counter_point_2{};
563+
counter_point_2.value_ = 20.0;
564+
counter_point_2.is_monotonic_ = true;
565+
566+
metric_sdk::LastValuePointData gauge_point_3{};
567+
gauge_point_3.value_ = 30.0;
568+
gauge_point_3.is_lastvalue_valid_ = true;
569+
570+
// Scope 1 & Scope 2 emit the same metric name & type, but different descriptions.
571+
metric_sdk::MetricData metric_scope_1{
572+
metric_sdk::InstrumentDescriptor{"http.requests", "first description", "s",
573+
metric_sdk::InstrumentType::kCounter,
574+
metric_sdk::InstrumentValueType::kDouble},
575+
metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{},
576+
opentelemetry::common::SystemTimestamp{},
577+
std::vector<metric_sdk::PointDataAttributes>{{{{"env", "prod"}}, counter_point_1}}};
578+
579+
metric_sdk::MetricData metric_scope_2{
580+
metric_sdk::InstrumentDescriptor{"http.requests", "conflicting description", "ms",
581+
metric_sdk::InstrumentType::kCounter,
582+
metric_sdk::InstrumentValueType::kDouble},
583+
metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{},
584+
opentelemetry::common::SystemTimestamp{},
585+
std::vector<metric_sdk::PointDataAttributes>{{{{"env", "dev"}}, counter_point_2}}};
586+
587+
// Scope 3 emits a conflicting type (Gauge) that maps to the same Prometheus name when
588+
// without_units=true and without_type_suffix=true.
589+
metric_sdk::MetricData metric_scope_3_conflict_type{
590+
metric_sdk::InstrumentDescriptor{"http_requests", "gauge description", "1",
591+
metric_sdk::InstrumentType::kObservableGauge,
592+
metric_sdk::InstrumentValueType::kDouble},
593+
metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{},
594+
opentelemetry::common::SystemTimestamp{},
595+
std::vector<metric_sdk::PointDataAttributes>{{{{"env", "stage"}}, gauge_point_3}}};
596+
597+
metric_sdk::ResourceMetrics data{
598+
&resource,
599+
std::vector<metric_sdk::ScopeMetrics>{
600+
{scope1.get(), std::vector<metric_sdk::MetricData>{metric_scope_1}},
601+
{scope2.get(), std::vector<metric_sdk::MetricData>{metric_scope_2}},
602+
{scope3.get(), std::vector<metric_sdk::MetricData>{metric_scope_3_conflict_type}}}};
603+
604+
// With without_units=true and without_type_suffix=true, all three map to "http_requests".
605+
// Scope 1 and Scope 2 (both Counter) should be merged into a single MetricFamily keeping
606+
// "first description", while Scope 3 (Gauge) should be dropped due to type conflict.
607+
auto translated = PrometheusExporterUtils::TranslateToPrometheus(
608+
data, /*populate_target_info=*/false, /*without_otel_scope=*/false,
609+
/*without_units=*/true, /*without_type_suffix=*/true);
610+
611+
ASSERT_EQ(translated.size(), 1U);
612+
EXPECT_EQ(translated[0].name, "http_requests");
613+
EXPECT_EQ(translated[0].help, "first description");
614+
EXPECT_EQ(translated[0].type, prometheus_client::MetricType::Counter);
615+
ASSERT_EQ(translated[0].metric.size(), 2U);
616+
EXPECT_DOUBLE_EQ(translated[0].metric[0].counter.value, 10.0);
617+
EXPECT_DOUBLE_EQ(translated[0].metric[1].counter.value, 20.0);
618+
}
619+
550620
OPENTELEMETRY_END_NAMESPACE

0 commit comments

Comments
 (0)