Skip to content

Commit 86ab04f

Browse files
committed
[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. - Fix SetMetricBasic early return so metrics with empty point attributes and null resource still receive scope labels. Part of #1841
1 parent e854d29 commit 86ab04f

4 files changed

Lines changed: 164 additions & 10 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: export instrumentation scope schema URL and
19+
attributes as labels
20+
[#4735](https://github.com/open-telemetry/opentelemetry-cpp/pull/4735)
21+
1822
## [1.30.0] 2026-10-22
1923

2024
This release contains:

‎exporters/prometheus/include/opentelemetry/exporters/prometheus/exporter_utils.h‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ class PrometheusExporterUtils
2828
*
2929
* @param records a collection of metrics in OpenTelemetry
3030
* @param populate_target_info whether to populate target_info
31-
* @param without_otel_scope whether to populate otel_scope_name and otel_scope_version
32-
* attributes
31+
* @param without_otel_scope whether to populate otel_scope_name, otel_scope_version,
32+
* otel_scope_schema_url, and otel_scope_ attributes
3333
* @param without_units exporter configuration controlling whether to append unit suffix in
3434
* the exported metrics.
3535
* @param without_type_suffix exporter configuration controlling whether to append type suffix in

‎exporters/prometheus/src/exporter_utils.cc‎

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ namespace metrics
4141
namespace
4242
{
4343

44-
static constexpr const char *kScopeNameKey = "otel_scope_name";
45-
static constexpr const char *kScopeVersionKey = "otel_scope_version";
44+
static constexpr const char *kScopeNameKey = "otel_scope_name";
45+
static constexpr const char *kScopeVersionKey = "otel_scope_version";
46+
static constexpr const char *kScopeSchemaUrlKey = "otel_scope_schema_url";
47+
static constexpr const char *kScopeLabelPrefix = "otel_scope_";
4648

4749
/**
4850
* Sanitize the given metric name by replacing invalid characters with _,
@@ -671,7 +673,7 @@ void PrometheusExporterUtils::SetMetricBasic(
671673
const opentelemetry::sdk::instrumentationscope::InstrumentationScope *scope,
672674
const opentelemetry::sdk::resource::Resource *resource)
673675
{
674-
if (labels.empty() && nullptr == resource)
676+
if (labels.empty() && nullptr == scope && nullptr == resource)
675677
{
676678
return;
677679
}
@@ -680,7 +682,12 @@ void PrometheusExporterUtils::SetMetricBasic(
680682
// Note that attribute keys are sorted, but sanitized keys can be out-of-order.
681683
// We could sort the sanitized keys again, but this seems too expensive to do
682684
// in this hot code path. Instead, we ignore out-of-order keys and emit a warning.
683-
metric.label.reserve(labels.size() + 2);
685+
std::size_t reserve_labels = labels.size();
686+
if (scope != nullptr)
687+
{
688+
reserve_labels += 3 + scope->GetAttributes().size();
689+
}
690+
metric.label.reserve(reserve_labels);
684691
std::string previous_key;
685692
for (auto const &label : labels)
686693
{
@@ -708,19 +715,68 @@ void PrometheusExporterUtils::SetMetricBasic(
708715
{
709716
return;
710717
}
711-
auto scope_name = scope->GetName();
718+
const auto &scope_name = scope->GetName();
712719
if (!scope_name.empty())
713720
{
714721
metric.label.emplace_back();
715722
metric.label.back().name = kScopeNameKey;
716-
metric.label.back().value = std::move(scope_name);
723+
metric.label.back().value = scope_name;
717724
}
718-
auto scope_version = scope->GetVersion();
725+
const auto &scope_version = scope->GetVersion();
719726
if (!scope_version.empty())
720727
{
721728
metric.label.emplace_back();
722729
metric.label.back().name = kScopeVersionKey;
723-
metric.label.back().value = std::move(scope_version);
730+
metric.label.back().value = scope_version;
731+
}
732+
const auto &scope_schema_url = scope->GetSchemaURL();
733+
if (!scope_schema_url.empty())
734+
{
735+
metric.label.emplace_back();
736+
metric.label.back().name = kScopeSchemaUrlKey;
737+
metric.label.back().value = scope_schema_url;
738+
}
739+
const auto &scope_attributes = scope->GetAttributes();
740+
if (!scope_attributes.empty())
741+
{
742+
struct ScopeAttrEntry
743+
{
744+
std::string sanitized_key;
745+
const std::string *original_key;
746+
const opentelemetry::sdk::common::OwnedAttributeValue *value;
747+
};
748+
std::vector<ScopeAttrEntry> sorted_scope_attrs;
749+
sorted_scope_attrs.reserve(scope_attributes.size());
750+
for (const auto &attr : scope_attributes)
751+
{
752+
std::string sanitized = SanitizeLabel(std::string(kScopeLabelPrefix) + attr.first);
753+
if (sanitized == kScopeNameKey || sanitized == kScopeVersionKey ||
754+
sanitized == kScopeSchemaUrlKey)
755+
{
756+
continue;
757+
}
758+
sorted_scope_attrs.push_back({std::move(sanitized), &attr.first, &attr.second});
759+
}
760+
std::sort(sorted_scope_attrs.begin(), sorted_scope_attrs.end(),
761+
[](const ScopeAttrEntry &a, const ScopeAttrEntry &b) {
762+
if (a.sanitized_key != b.sanitized_key)
763+
{
764+
return a.sanitized_key < b.sanitized_key;
765+
}
766+
return *a.original_key < *b.original_key;
767+
});
768+
for (std::size_t i = 0; i < sorted_scope_attrs.size(); ++i)
769+
{
770+
if (i > 0 && sorted_scope_attrs[i].sanitized_key == metric.label.back().name)
771+
{
772+
metric.label.back().value += ";" + AttributeValueToString(*sorted_scope_attrs[i].value);
773+
}
774+
else
775+
{
776+
metric.label.push_back({std::move(sorted_scope_attrs[i].sanitized_key),
777+
AttributeValueToString(*sorted_scope_attrs[i].value)});
778+
}
779+
}
724780
}
725781
}
726782

‎exporters/prometheus/test/exporter_utils_test.cc‎

Lines changed: 94 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,97 @@ TEST_F(AttributeCollisionTest, DropsInvertedKeys)
547548
{"otel_scope_version", "1.2.0"}});
548549
}
549550

551+
TEST(PrometheusExporterUtils, InstrumentationScopeLabelsAndAttributes)
552+
{
553+
auto scope = InstrumentationScope::Create("library_name", "1.2.0",
554+
"https://opentelemetry.io/schemas/1.21.0",
555+
{{"scope.attr1", "val1"}, {"scope.attr2", 42}});
556+
557+
metric_sdk::SumPointData sum_point_data{};
558+
sum_point_data.value_ = 10.0;
559+
metric_sdk::MetricData metric_data{
560+
metric_sdk::InstrumentDescriptor{"requests", "description", "1",
561+
metric_sdk::InstrumentType::kCounter,
562+
metric_sdk::InstrumentValueType::kDouble},
563+
metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{},
564+
opentelemetry::common::SystemTimestamp{},
565+
std::vector<metric_sdk::PointDataAttributes>{{{}, sum_point_data}}};
566+
567+
metric_sdk::ResourceMetrics data{
568+
nullptr, std::vector<metric_sdk::ScopeMetrics>{
569+
{scope.get(), std::vector<metric_sdk::MetricData>{metric_data}}}};
570+
571+
auto translated = PrometheusExporterUtils::TranslateToPrometheus(data, false, false);
572+
ASSERT_EQ(translated.size(), 1U);
573+
ASSERT_EQ(translated[0].metric.size(), 1U);
574+
575+
const auto &labels = translated[0].metric[0].label;
576+
std::vector<prometheus::ClientMetric::Label> expected = {
577+
{"otel_scope_name", "library_name"},
578+
{"otel_scope_version", "1.2.0"},
579+
{"otel_scope_schema_url", "https://opentelemetry.io/schemas/1.21.0"},
580+
{"otel_scope_scope_attr1", "val1"},
581+
{"otel_scope_scope_attr2", "42"}};
582+
ASSERT_EQ(labels.size(), expected.size());
583+
for (std::size_t i = 0; i < expected.size(); ++i)
584+
{
585+
EXPECT_EQ(labels[i].name, expected[i].name);
586+
EXPECT_EQ(labels[i].value, expected[i].value);
587+
}
588+
589+
auto translated_without_scope = PrometheusExporterUtils::TranslateToPrometheus(data, false, true);
590+
ASSERT_EQ(translated_without_scope.size(), 1U);
591+
ASSERT_EQ(translated_without_scope[0].metric.size(), 1U);
592+
EXPECT_TRUE(translated_without_scope[0].metric[0].label.empty());
593+
}
594+
595+
TEST(PrometheusExporterUtils, InstrumentationScopeAttributeCollisionsAndReservedConflicts)
596+
{
597+
auto scope = InstrumentationScope::Create("library_name", "1.2.0",
598+
"https://opentelemetry.io/schemas/1.21.0",
599+
{{"name", "ignored_name"},
600+
{"_name", "ignored_leading_underscore_name"},
601+
{"version", "ignored_version"},
602+
{"schema_url", "ignored_schema_url"},
603+
{"schema.url", "ignored_schema_dot_url"},
604+
{"foo.a", "value1"},
605+
{"foo_a", "value2"},
606+
{"foo.b", "value3"},
607+
{"foo__a", "value4"}});
608+
609+
metric_sdk::SumPointData sum_point_data{};
610+
sum_point_data.value_ = 5.0;
611+
metric_sdk::MetricData metric_data{
612+
metric_sdk::InstrumentDescriptor{"requests", "description", "1",
613+
metric_sdk::InstrumentType::kCounter,
614+
metric_sdk::InstrumentValueType::kDouble},
615+
metric_sdk::AggregationTemporality::kCumulative, opentelemetry::common::SystemTimestamp{},
616+
opentelemetry::common::SystemTimestamp{},
617+
std::vector<metric_sdk::PointDataAttributes>{{{{"k", "v"}}, sum_point_data}}};
618+
619+
Resource resource = Resource::Create(ResourceAttributes{});
620+
metric_sdk::ResourceMetrics data{
621+
&resource, std::vector<metric_sdk::ScopeMetrics>{
622+
{scope.get(), std::vector<metric_sdk::MetricData>{metric_data}}}};
623+
624+
auto translated = PrometheusExporterUtils::TranslateToPrometheus(data, false, false);
625+
ASSERT_EQ(translated.size(), 1U);
626+
ASSERT_EQ(translated[0].metric.size(), 1U);
627+
628+
const auto &labels = translated[0].metric[0].label;
629+
std::vector<prometheus::ClientMetric::Label> expected = {
630+
{"k", "v"},
631+
{"otel_scope_name", "library_name"},
632+
{"otel_scope_version", "1.2.0"},
633+
{"otel_scope_schema_url", "https://opentelemetry.io/schemas/1.21.0"},
634+
{"otel_scope_foo_a", "value1;value4;value2"},
635+
{"otel_scope_foo_b", "value3"}};
636+
ASSERT_EQ(labels.size(), expected.size());
637+
for (std::size_t i = 0; i < expected.size(); ++i)
638+
{
639+
EXPECT_EQ(labels[i].name, expected[i].name);
640+
EXPECT_EQ(labels[i].value, expected[i].value);
641+
}
642+
}
643+
550644
OPENTELEMETRY_END_NAMESPACE

0 commit comments

Comments
 (0)