diff --git a/CHANGELOG.md b/CHANGELOG.md index 765eb43998..161939f4b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ Increment the: and empty point guards [#4738](https://github.com/open-telemetry/opentelemetry-cpp/pull/4738) +* [EXPORTER] Prometheus exporter: merge metric families across scopes and drop + conflicting metric types + [#4736](https://github.com/open-telemetry/opentelemetry-cpp/pull/4736) + ## [1.30.0] 2026-10-22 This release contains: diff --git a/exporters/prometheus/src/exporter_utils.cc b/exporters/prometheus/src/exporter_utils.cc index 9377a6ce66..b61e612890 100644 --- a/exporters/prometheus/src/exporter_utils.cc +++ b/exporters/prometheus/src/exporter_utils.cc @@ -226,6 +226,12 @@ std::vector PrometheusExporterUtils::TranslateT &output); } + std::unordered_map family_index_by_name; + for (std::size_t i = 0; i < output.size(); ++i) + { + family_index_by_name.emplace(output[i].name, i); + } + for (const auto &instrumentation_info : data.scope_metric_data_) { for (const auto &metric_data : instrumentation_info.metric_data_) @@ -234,20 +240,57 @@ std::vector PrometheusExporterUtils::TranslateT { continue; } - prometheus_client::MetricFamily metric_family; - metric_family.help = metric_data.instrument_descriptor.description_; - auto front = metric_data.point_data_attr_.front(); - auto kind = getAggregationType(front.point_data); - bool is_monotonic = true; + auto front = metric_data.point_data_attr_.front(); + auto kind = getAggregationType(front.point_data); + bool is_monotonic = true; if (kind == sdk::metrics::AggregationType::kSum) { is_monotonic = nostd::get(front.point_data).is_monotonic_; } const prometheus_client::MetricType type = TranslateType(kind, is_monotonic); - metric_family.name = MapToPrometheusName(metric_data.instrument_descriptor.name_, - metric_data.instrument_descriptor.unit_, type, - without_units, without_type_suffix); - metric_family.type = type; + if (type == prometheus_client::MetricType::Untyped) + { + OTEL_INTERNAL_LOG_WARN( + "[Prometheus Exporter] TranslateToPrometheus - " + "Unsupported aggregation type for metric '" + << metric_data.instrument_descriptor.name_ << "'. Dropping metric."); + continue; + } + const std::string name = MapToPrometheusName(metric_data.instrument_descriptor.name_, + metric_data.instrument_descriptor.unit_, type, + without_units, without_type_suffix); + prometheus_client::MetricFamily *metric_family_ptr = nullptr; + auto existing_it = family_index_by_name.find(name); + if (existing_it != family_index_by_name.end()) + { + auto &existing_family = output[existing_it->second]; + if (existing_family.type != type) + { + OTEL_INTERNAL_LOG_WARN( + "[Prometheus Exporter] TranslateToPrometheus - " + "Instrument type conflict for metric '" + << name << "'. Dropping conflicting metric."); + continue; + } + if (existing_family.help != metric_data.instrument_descriptor.description_) + { + OTEL_INTERNAL_LOG_WARN( + "[Prometheus Exporter] TranslateToPrometheus - " + "Instrument description conflict for metric '" + << name << "': '" << metric_data.instrument_descriptor.description_ + << "', using existing description: '" << existing_family.help << "'."); + } + metric_family_ptr = &existing_family; + } + else + { + family_index_by_name.emplace(name, output.size()); + output.emplace_back(); + metric_family_ptr = &output.back(); + metric_family_ptr->name = name; + metric_family_ptr->help = metric_data.instrument_descriptor.description_; + metric_family_ptr->type = type; + } const opentelemetry::sdk::instrumentationscope::InstrumentationScope *scope = without_otel_scope ? nullptr : instrumentation_info.scope_; @@ -269,7 +312,7 @@ std::vector PrometheusExporterUtils::TranslateT sum = static_cast(nostd::get(histogram_point_data.sum_)); } SetData(std::vector{sum, static_cast(histogram_point_data.count_)}, - boundaries, counts, point_data_attr.attributes, scope, &metric_family, + boundaries, counts, point_data_attr.attributes, scope, metric_family_ptr, data.resource_); } else if (type == prometheus_client::MetricType::Gauge) @@ -280,7 +323,7 @@ std::vector PrometheusExporterUtils::TranslateT auto last_value_point_data = nostd::get(point_data_attr.point_data); std::vector values{last_value_point_data.value_}; - SetData(values, point_data_attr.attributes, scope, type, &metric_family, + SetData(values, point_data_attr.attributes, scope, type, metric_family_ptr, data.resource_); } else if (nostd::holds_alternative(point_data_attr.point_data)) @@ -288,7 +331,7 @@ std::vector PrometheusExporterUtils::TranslateT auto sum_point_data = nostd::get(point_data_attr.point_data); std::vector values{sum_point_data.value_}; - SetData(values, point_data_attr.attributes, scope, type, &metric_family, + SetData(values, point_data_attr.attributes, scope, type, metric_family_ptr, data.resource_); } else @@ -305,7 +348,7 @@ std::vector PrometheusExporterUtils::TranslateT auto sum_point_data = nostd::get(point_data_attr.point_data); std::vector values{sum_point_data.value_}; - SetData(values, point_data_attr.attributes, scope, type, &metric_family, + SetData(values, point_data_attr.attributes, scope, type, metric_family_ptr, data.resource_); } else @@ -316,7 +359,6 @@ std::vector PrometheusExporterUtils::TranslateT } } } - output.emplace_back(metric_family); } } return output; diff --git a/exporters/prometheus/test/exporter_utils_test.cc b/exporters/prometheus/test/exporter_utils_test.cc index 415ca9e34f..1e8a94cbbd 100644 --- a/exporters/prometheus/test/exporter_utils_test.cc +++ b/exporters/prometheus/test/exporter_utils_test.cc @@ -638,4 +638,178 @@ TEST(PrometheusExporterUtils, CrossSourceScopeCollisionsAndArrayAttributes) } } +TEST(PrometheusExporterUtils, MultipleScopesMetricFamilyCollisions) +{ + Resource resource = Resource::Create(ResourceAttributes{}); + auto scope1 = InstrumentationScope::Create("scope_1", "1.0.0"); + auto scope2 = InstrumentationScope::Create("scope_2", "2.0.0"); + auto scope3 = InstrumentationScope::Create("scope_3", "3.0.0"); + + metric_sdk::SumPointData counter_point_1{}; + counter_point_1.value_ = 10.0; + counter_point_1.is_monotonic_ = true; + + metric_sdk::SumPointData counter_point_2{}; + counter_point_2.value_ = 20.0; + counter_point_2.is_monotonic_ = true; + + metric_sdk::LastValuePointData gauge_point_3{}; + gauge_point_3.value_ = 30.0; + gauge_point_3.is_lastvalue_valid_ = true; + + // Scope 1 & Scope 2 emit the same metric name & type, but different descriptions. + metric_sdk::MetricData metric_scope_1{ + metric_sdk::InstrumentDescriptor{"http.requests", "first description", "s", + metric_sdk::InstrumentType::kCounter, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{{{{"env", "prod"}}, counter_point_1}}}; + + metric_sdk::MetricData metric_scope_2{ + metric_sdk::InstrumentDescriptor{"http.requests", "conflicting description", "ms", + metric_sdk::InstrumentType::kCounter, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{{{{"env", "dev"}}, counter_point_2}}}; + + // Scope 3 emits a conflicting type (Gauge) that maps to the same Prometheus name when + // without_units=true and without_type_suffix=true. + metric_sdk::MetricData metric_scope_3_conflict_type{ + metric_sdk::InstrumentDescriptor{"http_requests", "gauge description", "1", + metric_sdk::InstrumentType::kObservableGauge, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{{{{"env", "stage"}}, gauge_point_3}}}; + + metric_sdk::ResourceMetrics data{ + &resource, + std::vector{ + {scope1.get(), std::vector{metric_scope_1}}, + {scope2.get(), std::vector{metric_scope_2}}, + {scope3.get(), std::vector{metric_scope_3_conflict_type}}}}; + + // With without_units=true and without_type_suffix=true, all three map to "http_requests". + // Scope 1 and Scope 2 (both Counter) should be merged into a single MetricFamily keeping + // "first description", while Scope 3 (Gauge) should be dropped due to type conflict. + auto translated = PrometheusExporterUtils::TranslateToPrometheus( + data, /*populate_target_info=*/false, /*without_otel_scope=*/false, + /*without_units=*/true, /*without_type_suffix=*/true); + + ASSERT_EQ(translated.size(), 1U); + EXPECT_EQ(translated[0].name, "http_requests"); + EXPECT_EQ(translated[0].help, "first description"); + EXPECT_EQ(translated[0].type, prometheus_client::MetricType::Counter); + ASSERT_EQ(translated[0].metric.size(), 2U); + EXPECT_DOUBLE_EQ(translated[0].metric[0].counter.value, 10.0); + EXPECT_DOUBLE_EQ(translated[0].metric[1].counter.value, 20.0); +} + +TEST(PrometheusExporterUtils, TargetInfoMetricFamilyCollision) +{ + Resource resource = Resource::Create(ResourceAttributes{{"service.name", "test_service"}}); + auto scope = InstrumentationScope::Create("scope_1", "1.0.0"); + + metric_sdk::LastValuePointData gauge_point{}; + gauge_point.value_ = 42.0; + gauge_point.is_lastvalue_valid_ = true; + + metric_sdk::SumPointData counter_point{}; + counter_point.value_ = 7.0; + counter_point.is_monotonic_ = true; + + // A unitless gauge named "target" maps to family name "target", which collides with the + // MetricFamily{"target", MetricType::Info} emitted by SetTarget when populate_target_info=true. + metric_sdk::MetricData colliding_target_gauge{ + metric_sdk::InstrumentDescriptor{"target", "conflicting gauge description", "", + metric_sdk::InstrumentType::kObservableGauge, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{{{{"env", "prod"}}, gauge_point}}}; + + metric_sdk::MetricData counter_metric{ + metric_sdk::InstrumentDescriptor{"http.requests", "requests description", "1", + metric_sdk::InstrumentType::kCounter, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{{{{"env", "prod"}}, counter_point}}}; + + metric_sdk::ResourceMetrics data{ + &resource, std::vector{ + {scope.get(), std::vector{colliding_target_gauge, + counter_metric}}}}; + + auto translated = PrometheusExporterUtils::TranslateToPrometheus( + data, /*populate_target_info=*/true, /*without_otel_scope=*/false, + /*without_units=*/false, /*without_type_suffix=*/false); + + ASSERT_EQ(translated.size(), 2U); + EXPECT_EQ(translated[0].name, "target"); + EXPECT_EQ(translated[0].help, "Target metadata"); + EXPECT_EQ(translated[0].type, prometheus_client::MetricType::Info); + ASSERT_EQ(translated[0].metric.size(), 1U); + + EXPECT_EQ(translated[1].name, "http_requests_total"); + EXPECT_EQ(translated[1].type, prometheus_client::MetricType::Counter); + ASSERT_EQ(translated[1].metric.size(), 1U); + EXPECT_DOUBLE_EQ(translated[1].metric[0].counter.value, 7.0); +} + +TEST(PrometheusExporterUtils, UnsupportedAggregationDoesNotReserveMetricFamilyName) +{ + Resource resource = Resource::Create(ResourceAttributes{}); + auto scope1 = InstrumentationScope::Create("scope_1", "1.0.0"); + auto scope2 = InstrumentationScope::Create("scope_2", "2.0.0"); + + metric_sdk::Base2ExponentialHistogramPointData exp_histogram_point{}; + exp_histogram_point.count_ = 5; + exp_histogram_point.sum_ = 15.0; + + metric_sdk::HistogramPointData histogram_point{}; + histogram_point.boundaries_ = {10.0, 20.0}; + histogram_point.counts_ = {1, 2, 1}; + histogram_point.count_ = 4; + histogram_point.sum_ = 45.0; + + // An unsupported Base2ExponentialHistogram aggregation should be skipped without reserving + // the metric family name as Untyped, allowing a subsequent supported Histogram with the same + // name to be exported. + metric_sdk::MetricData unsupported_exp_histogram{ + metric_sdk::InstrumentDescriptor{"http.server.duration", "exp histogram description", "s", + metric_sdk::InstrumentType::kHistogram, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{{{{"env", "prod"}}, exp_histogram_point}}}; + + metric_sdk::MetricData supported_histogram{ + metric_sdk::InstrumentDescriptor{"http.server.duration", "histogram description", "s", + metric_sdk::InstrumentType::kHistogram, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{{{{"env", "prod"}}, histogram_point}}}; + + metric_sdk::ResourceMetrics data{ + &resource, std::vector{ + {scope1.get(), std::vector{unsupported_exp_histogram}}, + {scope2.get(), std::vector{supported_histogram}}}}; + + auto translated = PrometheusExporterUtils::TranslateToPrometheus( + data, /*populate_target_info=*/false, /*without_otel_scope=*/false, + /*without_units=*/false, /*without_type_suffix=*/false); + + ASSERT_EQ(translated.size(), 1U); + EXPECT_EQ(translated[0].name, "http_server_duration_seconds"); + EXPECT_EQ(translated[0].help, "histogram description"); + EXPECT_EQ(translated[0].type, prometheus_client::MetricType::Histogram); + ASSERT_EQ(translated[0].metric.size(), 1U); + EXPECT_EQ(translated[0].metric[0].histogram.sample_count, 4U); + EXPECT_DOUBLE_EQ(translated[0].metric[0].histogram.sample_sum, 45.0); +} + OPENTELEMETRY_END_NAMESPACE