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..524c60ecbd 100644 --- a/exporters/prometheus/src/exporter_utils.cc +++ b/exporters/prometheus/src/exporter_utils.cc @@ -44,8 +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 *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 _, @@ -686,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) @@ -704,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()) { @@ -772,7 +775,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 { @@ -791,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}); } @@ -800,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}); } @@ -820,37 +828,42 @@ void PrometheusExporterUtils::SetMetricBasic( return; } - auto add_or_merge_scope_label = [&metric, &colliding_scope_entries]( + auto merge_colliding_label = [&metric, &colliding_scope_entries](std::size_t target_index, + const std::string &original_key, + std::string value) { + colliding_scope_entries.push_back({target_index, original_key, std::move(value)}); + std::vector matched; + for (const auto &candidate : colliding_scope_entries) + { + if (candidate.label_index == target_index) + { + matched.push_back(&candidate); + } + } + std::stable_sort(matched.begin(), matched.end(), + [](const LabelCollisionEntry *a, const LabelCollisionEntry *b) { + return a->original_key < b->original_key; + }); + std::string merged; + for (std::size_t j = 0; j < matched.size(); ++j) + { + if (j > 0) + { + merged.push_back(';'); + } + merged.append(matched[j]->value); + } + metric.label[target_index].value = std::move(merged); + }; + + auto add_or_merge_scope_label = [&metric, &colliding_scope_entries, &merge_colliding_label]( const std::string &sanitized_key, const std::string &original_key, const std::string &value) { for (const auto &entry : colliding_scope_entries) { if (metric.label[entry.label_index].name == sanitized_key) { - const std::size_t target_index = entry.label_index; - colliding_scope_entries.push_back({target_index, original_key, value}); - std::vector matched; - for (const auto &candidate : colliding_scope_entries) - { - if (candidate.label_index == target_index) - { - matched.push_back(&candidate); - } - } - std::stable_sort(matched.begin(), matched.end(), - [](const LabelCollisionEntry *a, const LabelCollisionEntry *b) { - return a->original_key < b->original_key; - }); - std::string merged; - for (std::size_t j = 0; j < matched.size(); ++j) - { - if (j > 0) - { - merged.push_back(';'); - } - merged.append(matched[j]->value); - } - metric.label[target_index].value = std::move(merged); + merge_colliding_label(entry.label_index, original_key, value); return; } } @@ -867,6 +880,63 @@ void PrometheusExporterUtils::SetMetricBasic( { add_or_merge_scope_label(kScopeVersionKey, kScopeVersionKey, scope_version); } + const auto &scope_schema_url = scope->GetSchemaURL(); + if (!scope_schema_url.empty()) + { + add_or_merge_scope_label(kScopeSchemaUrlKey, kScopeSchemaUrlKey, scope_schema_url); + } + const auto &scope_attributes = scope->GetAttributes(); + if (!scope_attributes.empty()) + { + const std::size_t scope_attr_label_start = metric.label.size(); + std::vector scope_attr_original_keys; + if (scope_attributes.size() > 1U) + { + scope_attr_original_keys.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; + } + bool merged = false; + for (const auto &entry : colliding_scope_entries) + { + if (metric.label[entry.label_index].name == sanitized) + { + merge_colliding_label(entry.label_index, attr.first, AttributeValueToString(attr.second)); + merged = true; + break; + } + } + if (merged) + { + continue; + } + for (std::size_t i = scope_attr_label_start; i < metric.label.size(); ++i) + { + if (metric.label[i].name == sanitized) + { + colliding_scope_entries.push_back( + {i, *scope_attr_original_keys[i - scope_attr_label_start], metric.label[i].value}); + merge_colliding_label(i, attr.first, AttributeValueToString(attr.second)); + merged = true; + break; + } + } + if (!merged) + { + if (scope_attributes.size() > 1U) + { + scope_attr_original_keys.push_back(&attr.first); + } + metric.label.push_back({std::move(sanitized), AttributeValueToString(attr.second)}); + } + } + } } std::string PrometheusExporterUtils::AttributeValueToString( diff --git a/exporters/prometheus/test/exporter_utils_test.cc b/exporters/prometheus/test/exporter_utils_test.cc index 415ca9e34f..4430a15673 100644 --- a/exporters/prometheus/test/exporter_utils_test.cc +++ b/exporters/prometheus/test/exporter_utils_test.cc @@ -638,4 +638,200 @@ 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 (const auto &expected_label : expected) + { + bool found = false; + for (const auto &label : labels) + { + if (label.name == expected_label.name) + { + EXPECT_EQ(label.value, expected_label.value); + found = true; + } + } + EXPECT_TRUE(found); + } + + 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 (const auto &expected_label : expected) + { + bool found = false; + for (const auto &label : labels) + { + if (label.name == expected_label.name) + { + EXPECT_EQ(label.value, expected_label.value); + found = true; + } + } + EXPECT_TRUE(found); + } +} + +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); + } +} + +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