From 11644fbcfa00e6539c535822309ccd2c7e243737 Mon Sep 17 00:00:00 2001 From: David Ashpole Date: Sun, 25 Oct 2026 15:12:09 +0000 Subject: [PATCH 1/3] [EXPORTER] Prometheus exporter: export instrumentation scope schema URL and attributes as labels Update PrometheusExporterUtils::SetMetricBasic to conform to the OpenTelemetry Prometheus and OpenMetrics compatibility specification: - Export otel_scope_schema_url when InstrumentationScope::GetSchemaURL() is non-empty and without_otel_scope is false. - Export InstrumentationScope attributes as labels with the otel_scope_ prefix, dropping scope attributes that conflict with otel_scope_name, otel_scope_version, or otel_scope_schema_url after sanitization, and concatenating values with ';' in lexicographical order of original keys when sanitized keys collide. Part of #1841 --- CHANGELOG.md | 4 + .../exporters/prometheus/exporter_utils.h | 4 +- exporters/prometheus/src/exporter_utils.cc | 63 ++++++++++++- .../prometheus/test/exporter_utils_test.cc | 94 +++++++++++++++++++ 4 files changed, 159 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0bde603a7..4b7670eb17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,10 @@ Increment the: schedule delay. [#4741](https://github.com/open-telemetry/opentelemetry-cpp/pull/4741) +* [EXPORTER] Prometheus exporter: export instrumentation scope schema URL and + attributes as labels + [#4735](https://github.com/open-telemetry/opentelemetry-cpp/pull/4735) + ## [1.30.0] 2026-10-22 This release contains: diff --git a/exporters/prometheus/include/opentelemetry/exporters/prometheus/exporter_utils.h b/exporters/prometheus/include/opentelemetry/exporters/prometheus/exporter_utils.h index 4e40947ea8..0da2239777 100644 --- a/exporters/prometheus/include/opentelemetry/exporters/prometheus/exporter_utils.h +++ b/exporters/prometheus/include/opentelemetry/exporters/prometheus/exporter_utils.h @@ -28,8 +28,8 @@ class PrometheusExporterUtils * * @param records a collection of metrics in OpenTelemetry * @param populate_target_info whether to populate target_info - * @param without_otel_scope whether to populate otel_scope_name and otel_scope_version - * attributes + * @param without_otel_scope whether to populate otel_scope_name, otel_scope_version, + * otel_scope_schema_url, and otel_scope_ attributes * @param without_units exporter configuration controlling whether to append unit suffix in * the exported metrics. * @param without_type_suffix exporter configuration controlling whether to append type suffix in diff --git a/exporters/prometheus/src/exporter_utils.cc b/exporters/prometheus/src/exporter_utils.cc index 9377a6ce66..234057e936 100644 --- a/exporters/prometheus/src/exporter_utils.cc +++ b/exporters/prometheus/src/exporter_utils.cc @@ -44,8 +44,10 @@ namespace metrics namespace { -static constexpr const char *kScopeNameKey = "otel_scope_name"; -static constexpr const char *kScopeVersionKey = "otel_scope_version"; +static constexpr const char *kScopeNameKey = "otel_scope_name"; +static constexpr const char *kScopeVersionKey = "otel_scope_version"; +static constexpr const char *kScopeSchemaUrlKey = "otel_scope_schema_url"; +static constexpr const char *kScopeLabelPrefix = "otel_scope_"; /** * Sanitize the given metric name by replacing invalid characters with _, @@ -772,7 +774,12 @@ void PrometheusExporterUtils::SetMetricBasic( // Note that attribute keys are sorted, but sanitized keys can be out-of-order. // We could sort the sanitized keys again, but this seems too expensive to do // in this hot code path. Instead, we ignore out-of-order keys and emit a warning. - metric.label.reserve(labels.size() + 2); + std::size_t reserve_labels = labels.size(); + if (scope != nullptr) + { + reserve_labels += 3 + scope->GetAttributes().size(); + } + metric.label.reserve(reserve_labels); struct LabelCollisionEntry { @@ -819,7 +826,6 @@ void PrometheusExporterUtils::SetMetricBasic( { return; } - auto add_or_merge_scope_label = [&metric, &colliding_scope_entries]( const std::string &sanitized_key, const std::string &original_key, const std::string &value) { @@ -867,6 +873,55 @@ void PrometheusExporterUtils::SetMetricBasic( { add_or_merge_scope_label(kScopeVersionKey, kScopeVersionKey, scope_version); } + const auto &scope_schema_url = scope->GetSchemaURL(); + if (!scope_schema_url.empty()) + { + metric.label.emplace_back(); + metric.label.back().name = kScopeSchemaUrlKey; + metric.label.back().value = scope_schema_url; + } + const auto &scope_attributes = scope->GetAttributes(); + if (!scope_attributes.empty()) + { + struct ScopeAttrEntry + { + std::string sanitized_key; + const std::string *original_key; + const opentelemetry::sdk::common::OwnedAttributeValue *value; + }; + std::vector sorted_scope_attrs; + sorted_scope_attrs.reserve(scope_attributes.size()); + for (const auto &attr : scope_attributes) + { + std::string sanitized = SanitizeLabel(std::string(kScopeLabelPrefix) + attr.first); + if (sanitized == kScopeNameKey || sanitized == kScopeVersionKey || + sanitized == kScopeSchemaUrlKey) + { + continue; + } + sorted_scope_attrs.push_back({std::move(sanitized), &attr.first, &attr.second}); + } + std::sort(sorted_scope_attrs.begin(), sorted_scope_attrs.end(), + [](const ScopeAttrEntry &a, const ScopeAttrEntry &b) { + if (a.sanitized_key != b.sanitized_key) + { + return a.sanitized_key < b.sanitized_key; + } + return *a.original_key < *b.original_key; + }); + for (std::size_t i = 0; i < sorted_scope_attrs.size(); ++i) + { + if (i > 0 && sorted_scope_attrs[i].sanitized_key == metric.label.back().name) + { + metric.label.back().value += ";" + AttributeValueToString(*sorted_scope_attrs[i].value); + } + else + { + metric.label.push_back({std::move(sorted_scope_attrs[i].sanitized_key), + AttributeValueToString(*sorted_scope_attrs[i].value)}); + } + } + } } std::string PrometheusExporterUtils::AttributeValueToString( diff --git a/exporters/prometheus/test/exporter_utils_test.cc b/exporters/prometheus/test/exporter_utils_test.cc index 415ca9e34f..b336ff9c5c 100644 --- a/exporters/prometheus/test/exporter_utils_test.cc +++ b/exporters/prometheus/test/exporter_utils_test.cc @@ -638,4 +638,98 @@ TEST(PrometheusExporterUtils, CrossSourceScopeCollisionsAndArrayAttributes) } } +TEST(PrometheusExporterUtils, InstrumentationScopeLabelsAndAttributes) +{ + auto scope = InstrumentationScope::Create("library_name", "1.2.0", + "https://opentelemetry.io/schemas/1.21.0", + {{"scope.attr1", "val1"}, {"scope.attr2", 42}}); + + metric_sdk::SumPointData sum_point_data{}; + sum_point_data.value_ = 10.0; + metric_sdk::MetricData metric_data{ + metric_sdk::InstrumentDescriptor{"requests", "description", "1", + metric_sdk::InstrumentType::kCounter, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{{{}, sum_point_data}}}; + + Resource resource = Resource::Create(ResourceAttributes{}); + metric_sdk::ResourceMetrics data{ + &resource, std::vector{ + {scope.get(), std::vector{metric_data}}}}; + + auto translated = PrometheusExporterUtils::TranslateToPrometheus(data, false, false); + ASSERT_EQ(translated.size(), 1U); + ASSERT_EQ(translated[0].metric.size(), 1U); + + const auto &labels = translated[0].metric[0].label; + std::vector expected = { + {"otel_scope_name", "library_name"}, + {"otel_scope_version", "1.2.0"}, + {"otel_scope_schema_url", "https://opentelemetry.io/schemas/1.21.0"}, + {"otel_scope_scope_attr1", "val1"}, + {"otel_scope_scope_attr2", "42"}}; + ASSERT_EQ(labels.size(), expected.size()); + for (std::size_t i = 0; i < expected.size(); ++i) + { + EXPECT_EQ(labels[i].name, expected[i].name); + EXPECT_EQ(labels[i].value, expected[i].value); + } + + auto translated_without_scope = PrometheusExporterUtils::TranslateToPrometheus(data, false, true); + ASSERT_EQ(translated_without_scope.size(), 1U); + ASSERT_EQ(translated_without_scope[0].metric.size(), 1U); + EXPECT_TRUE(translated_without_scope[0].metric[0].label.empty()); +} + +TEST(PrometheusExporterUtils, InstrumentationScopeAttributeCollisionsAndReservedConflicts) +{ + auto scope = InstrumentationScope::Create("library_name", "1.2.0", + "https://opentelemetry.io/schemas/1.21.0", + {{"name", "ignored_name"}, + {"_name", "ignored_leading_underscore_name"}, + {"version", "ignored_version"}, + {"schema_url", "ignored_schema_url"}, + {"schema.url", "ignored_schema_dot_url"}, + {"foo.a", "value1"}, + {"foo_a", "value2"}, + {"foo.b", "value3"}, + {"foo__a", "value4"}}); + + metric_sdk::SumPointData sum_point_data{}; + sum_point_data.value_ = 5.0; + metric_sdk::MetricData metric_data{ + metric_sdk::InstrumentDescriptor{"requests", "description", "1", + metric_sdk::InstrumentType::kCounter, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{{{{"k", "v"}}, sum_point_data}}}; + + Resource resource = Resource::Create(ResourceAttributes{}); + metric_sdk::ResourceMetrics data{ + &resource, std::vector{ + {scope.get(), std::vector{metric_data}}}}; + + auto translated = PrometheusExporterUtils::TranslateToPrometheus(data, false, false); + ASSERT_EQ(translated.size(), 1U); + ASSERT_EQ(translated[0].metric.size(), 1U); + + const auto &labels = translated[0].metric[0].label; + std::vector expected = { + {"k", "v"}, + {"otel_scope_name", "library_name"}, + {"otel_scope_version", "1.2.0"}, + {"otel_scope_schema_url", "https://opentelemetry.io/schemas/1.21.0"}, + {"otel_scope_foo_a", "value1;value4;value2"}, + {"otel_scope_foo_b", "value3"}}; + ASSERT_EQ(labels.size(), expected.size()); + for (std::size_t i = 0; i < expected.size(); ++i) + { + EXPECT_EQ(labels[i].name, expected[i].name); + EXPECT_EQ(labels[i].value, expected[i].value); + } +} + OPENTELEMETRY_END_NAMESPACE From e7fcd08e545384b2db4ff7c506f712637d70ccd2 Mon Sep 17 00:00:00 2001 From: David Ashpole Date: Tue, 27 Oct 2026 15:25:41 +0000 Subject: [PATCH 2/3] Address review comment: handle point attribute collisions with scope schema URL and attributes --- exporters/prometheus/src/exporter_utils.cc | 43 +++++++++--------- .../prometheus/test/exporter_utils_test.cc | 45 +++++++++++++++++++ 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/exporters/prometheus/src/exporter_utils.cc b/exporters/prometheus/src/exporter_utils.cc index 234057e936..baa64767d0 100644 --- a/exporters/prometheus/src/exporter_utils.cc +++ b/exporters/prometheus/src/exporter_utils.cc @@ -44,10 +44,11 @@ namespace metrics namespace { -static constexpr const char *kScopeNameKey = "otel_scope_name"; -static constexpr const char *kScopeVersionKey = "otel_scope_version"; -static constexpr const char *kScopeSchemaUrlKey = "otel_scope_schema_url"; -static constexpr const char *kScopeLabelPrefix = "otel_scope_"; +static constexpr const char *kScopeNameKey = "otel_scope_name"; +static constexpr const char *kScopeVersionKey = "otel_scope_version"; +static constexpr const char *kScopeSchemaUrlKey = "otel_scope_schema_url"; +static constexpr const char *kScopeLabelPrefix = "otel_scope_"; +static constexpr std::size_t kScopeLabelPrefixLen = sizeof("otel_scope_") - 1; /** * Sanitize the given metric name by replacing invalid characters with _, @@ -798,7 +799,7 @@ void PrometheusExporterUtils::SetMetricBasic( { previous_key = sanitized; std::string val = AttributeValueToString(label.second); - if (scope != nullptr && (sanitized == kScopeNameKey || sanitized == kScopeVersionKey)) + if (scope != nullptr && sanitized.compare(0, kScopeLabelPrefixLen, kScopeLabelPrefix) == 0) { colliding_scope_entries.push_back({metric.label.size(), label.first, val}); } @@ -807,7 +808,7 @@ void PrometheusExporterUtils::SetMetricBasic( else if (comparison == 0) // key collision after sanitation { std::string val = AttributeValueToString(label.second); - if (scope != nullptr && (sanitized == kScopeNameKey || sanitized == kScopeVersionKey)) + if (scope != nullptr && sanitized.compare(0, kScopeLabelPrefixLen, kScopeLabelPrefix) == 0) { colliding_scope_entries.push_back({metric.label.size() - 1, label.first, val}); } @@ -826,7 +827,9 @@ void PrometheusExporterUtils::SetMetricBasic( { return; } - auto add_or_merge_scope_label = [&metric, &colliding_scope_entries]( + + const std::size_t point_label_count = metric.label.size(); + auto add_or_merge_scope_label = [&metric, &colliding_scope_entries, point_label_count]( const std::string &sanitized_key, const std::string &original_key, const std::string &value) { for (const auto &entry : colliding_scope_entries) @@ -860,7 +863,14 @@ void PrometheusExporterUtils::SetMetricBasic( return; } } - metric.label.push_back({sanitized_key, value}); + if (metric.label.size() > point_label_count && metric.label.back().name == sanitized_key) + { + metric.label.back().value += ";" + value; + } + else + { + metric.label.push_back({sanitized_key, value}); + } }; const auto &scope_name = scope->GetName(); @@ -876,9 +886,7 @@ void PrometheusExporterUtils::SetMetricBasic( const auto &scope_schema_url = scope->GetSchemaURL(); if (!scope_schema_url.empty()) { - metric.label.emplace_back(); - metric.label.back().name = kScopeSchemaUrlKey; - metric.label.back().value = scope_schema_url; + add_or_merge_scope_label(kScopeSchemaUrlKey, kScopeSchemaUrlKey, scope_schema_url); } const auto &scope_attributes = scope->GetAttributes(); if (!scope_attributes.empty()) @@ -909,17 +917,10 @@ void PrometheusExporterUtils::SetMetricBasic( } return *a.original_key < *b.original_key; }); - for (std::size_t i = 0; i < sorted_scope_attrs.size(); ++i) + for (const auto &scope_attr : sorted_scope_attrs) { - if (i > 0 && sorted_scope_attrs[i].sanitized_key == metric.label.back().name) - { - metric.label.back().value += ";" + AttributeValueToString(*sorted_scope_attrs[i].value); - } - else - { - metric.label.push_back({std::move(sorted_scope_attrs[i].sanitized_key), - AttributeValueToString(*sorted_scope_attrs[i].value)}); - } + add_or_merge_scope_label(scope_attr.sanitized_key, *scope_attr.original_key, + AttributeValueToString(*scope_attr.value)); } } } diff --git a/exporters/prometheus/test/exporter_utils_test.cc b/exporters/prometheus/test/exporter_utils_test.cc index b336ff9c5c..836d14a070 100644 --- a/exporters/prometheus/test/exporter_utils_test.cc +++ b/exporters/prometheus/test/exporter_utils_test.cc @@ -732,4 +732,49 @@ TEST(PrometheusExporterUtils, InstrumentationScopeAttributeCollisionsAndReserved } } +TEST(PrometheusExporterUtils, InstrumentationScopePointAttributeCollisions) +{ + auto scope = InstrumentationScope::Create("library_name", "1.2.0", + "https://opentelemetry.io/schemas/1.21.0", + {{"foo", "scope_foo"}, {"z", "scope_z"}}); + + metric_sdk::SumPointData sum_point_data{}; + sum_point_data.value_ = 5.0; + metric_sdk::MetricData metric_data{ + metric_sdk::InstrumentDescriptor{"requests", "description", "1", + metric_sdk::InstrumentType::kCounter, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{ + {{{"otel_scope_foo", "point_foo"}, + {"otel_scope_schema_url", "https://example.com/point_schema"}, + {"otel_scope_z", "point_z"}}, + sum_point_data}}}; + + Resource resource = Resource::Create(ResourceAttributes{}); + metric_sdk::ResourceMetrics data{ + &resource, std::vector{ + {scope.get(), std::vector{metric_data}}}}; + + auto translated = PrometheusExporterUtils::TranslateToPrometheus(data, false, false); + ASSERT_EQ(translated.size(), 1U); + ASSERT_EQ(translated[0].metric.size(), 1U); + + const auto &labels = translated[0].metric[0].label; + std::vector expected = { + {"otel_scope_foo", "scope_foo;point_foo"}, + {"otel_scope_schema_url", + "https://example.com/point_schema;https://opentelemetry.io/schemas/1.21.0"}, + {"otel_scope_z", "point_z;scope_z"}, + {"otel_scope_name", "library_name"}, + {"otel_scope_version", "1.2.0"}}; + ASSERT_EQ(labels.size(), expected.size()); + for (std::size_t i = 0; i < expected.size(); ++i) + { + EXPECT_EQ(labels[i].name, expected[i].name); + EXPECT_EQ(labels[i].value, expected[i].value); + } +} + OPENTELEMETRY_END_NAMESPACE From eb32fc8fcc833ce7478b4517c30d04c5329969e0 Mon Sep 17 00:00:00 2001 From: David Ashpole Date: Wed, 28 Oct 2026 16:25:19 +0000 Subject: [PATCH 3/3] Address review comment: exclude scope labels from target_info --- exporters/prometheus/src/exporter_utils.cc | 4 +- .../prometheus/test/exporter_utils_test.cc | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/exporters/prometheus/src/exporter_utils.cc b/exporters/prometheus/src/exporter_utils.cc index baa64767d0..1c21f73626 100644 --- a/exporters/prometheus/src/exporter_utils.cc +++ b/exporters/prometheus/src/exporter_utils.cc @@ -689,7 +689,7 @@ prometheus_client::MetricType PrometheusExporterUtils::TranslateType( void PrometheusExporterUtils::SetTarget( const sdk::metrics::ResourceMetrics &data, - const opentelemetry::sdk::instrumentationscope::InstrumentationScope *scope, + const opentelemetry::sdk::instrumentationscope::InstrumentationScope * /* scope */, std::vector<::prometheus::MetricFamily> *output) { if (output == nullptr || data.resource_ == nullptr) @@ -707,7 +707,7 @@ void PrometheusExporterUtils::SetTarget( metric.info.value = 1.0; metric_sdk::PointAttributes empty_attributes; - SetMetricBasic(metric, empty_attributes, scope, data.resource_); + SetMetricBasic(metric, empty_attributes, nullptr, data.resource_); for (auto &label : data.resource_->GetAttributes()) { diff --git a/exporters/prometheus/test/exporter_utils_test.cc b/exporters/prometheus/test/exporter_utils_test.cc index 836d14a070..ff4645d6fc 100644 --- a/exporters/prometheus/test/exporter_utils_test.cc +++ b/exporters/prometheus/test/exporter_utils_test.cc @@ -777,4 +777,45 @@ TEST(PrometheusExporterUtils, InstrumentationScopePointAttributeCollisions) } } +TEST(PrometheusExporterUtils, TargetInfoExcludesInstrumentationScopeLabels) +{ + auto scope = InstrumentationScope::Create( + "library_name", "1.2.0", "https://opentelemetry.io/schemas/1.21.0", {{"foo", "scope_foo"}}); + + metric_sdk::SumPointData sum_point_data{}; + sum_point_data.value_ = 5.0; + metric_sdk::MetricData metric_data{ + metric_sdk::InstrumentDescriptor{"requests", "description", "1", + metric_sdk::InstrumentType::kCounter, + metric_sdk::InstrumentValueType::kDouble}, + metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{}, + opentelemetry::common::SystemTimestamp{}, + std::vector{{{}, sum_point_data}}}; + + Resource resource = Resource::Create( + ResourceAttributes{{"service.name", "test_service"}, {"otel_scope_foo", "resource_foo"}}); + metric_sdk::ResourceMetrics data{ + &resource, std::vector{ + {scope.get(), std::vector{metric_data}}}}; + + auto translated = PrometheusExporterUtils::TranslateToPrometheus(data, true, false); + ASSERT_EQ(translated.size(), 2U); + ASSERT_EQ(translated[0].name, "target"); + ASSERT_EQ(translated[0].metric.size(), 1U); + + std::size_t otel_scope_foo_count = 0; + for (const auto &label : translated[0].metric[0].label) + { + EXPECT_NE(label.name, "otel_scope_name"); + EXPECT_NE(label.name, "otel_scope_version"); + EXPECT_NE(label.name, "otel_scope_schema_url"); + if (label.name == "otel_scope_foo") + { + EXPECT_EQ(label.value, "resource_foo"); + ++otel_scope_foo_count; + } + } + EXPECT_EQ(otel_scope_foo_count, 1U); +} + OPENTELEMETRY_END_NAMESPACE