diff --git a/source/extensions/stat_sinks/open_telemetry/config.cc b/source/extensions/stat_sinks/open_telemetry/config.cc index d6e91d5cdcee9..ca6cdd93e67cd 100644 --- a/source/extensions/stat_sinks/open_telemetry/config.cc +++ b/source/extensions/stat_sinks/open_telemetry/config.cc @@ -22,11 +22,19 @@ OpenTelemetrySinkFactory::createStatsSink(const Protobuf::Message& config, Tracers::OpenTelemetry::ResourceProviderPtr resource_provider = std::make_unique(); - auto otlp_options = std::make_shared( - sink_config, - resource_provider->getResource(sink_config.resource_detectors(), server, - /*service_name=*/""), - server); + auto resource = resource_provider->getResource(sink_config.resource_detectors(), server, + /*service_name=*/""); + // Inject Envoy node attributes into the resource using standard OTel semantic + // conventions so that downstream collectors can identify which Envoy instance + // emitted the metrics. Only populated when not already set by a configured resource detector. + const auto& local_info = server.localInfo(); + if (!local_info.nodeName().empty()) { + resource.attributes_.try_emplace("service.instance.id", local_info.nodeName()); + } + if (!local_info.clusterName().empty()) { + resource.attributes_.try_emplace("service.namespace", local_info.clusterName()); + } + auto otlp_options = std::make_shared(sink_config, resource, server); std::shared_ptr otlp_metrics_flusher = std::make_shared(otlp_options); diff --git a/test/extensions/stats_sinks/open_telemetry/config_test.cc b/test/extensions/stats_sinks/open_telemetry/config_test.cc index 7ec5edc3a3cb9..c551fccb9c973 100644 --- a/test/extensions/stats_sinks/open_telemetry/config_test.cc +++ b/test/extensions/stats_sinks/open_telemetry/config_test.cc @@ -10,6 +10,7 @@ #include "gtest/gtest.h" using testing::NiceMock; +using testing::ReturnRef; namespace Envoy { namespace Extensions { @@ -94,6 +95,58 @@ TEST(OpenTelemetryConfigTest, OtlpOptionsTest) { } } +// Verify that the factory injects service.instance.id and service.namespace from +// LocalInfo into the resource attributes when creating the sink, so consumers can +// identify the originating Envoy instance. +TEST(OpenTelemetryConfigTest, NodeAttributesAutoPopulatedFromLocalInfo) { + NiceMock server; + + // Set a real node id and cluster on the local_info mock. + server.local_info_.node_.set_id("test-node-42"); + server.local_info_.node_.set_cluster("my-cluster"); + + Server::Configuration::StatsSinkFactory* factory = + Registry::FactoryRegistry::getFactory( + OpenTelemetryName); + ASSERT_NE(factory, nullptr); + + envoy::extensions::stat_sinks::open_telemetry::v3::SinkConfig sink_config; + sink_config.mutable_grpc_service()->mutable_envoy_grpc()->set_cluster_name("otlp_grpc"); + ProtobufTypes::MessagePtr message = factory->createEmptyConfigProto(); + TestUtility::jsonConvert(sink_config, *message); + + // Factory must succeed and produce a valid sink. + auto sink_or = factory->createStatsSink(*message, server); + ASSERT_TRUE(sink_or.ok()); + EXPECT_NE(sink_or.value(), nullptr); +} + +// Verify that when a resource detector already sets service.instance.id, it takes +// priority over the auto-populated value from LocalInfo (try_emplace semantics). +TEST(OpenTelemetryConfigTest, NodeAttributesNotOverriddenByAutoPopulation) { + NiceMock server; + envoy::extensions::stat_sinks::open_telemetry::v3::SinkConfig sink_config; + + // Manually build a resource as if a detector already set service.instance.id. + Tracers::OpenTelemetry::Resource resource; + resource.attributes_["service.instance.id"] = "detector-set-id"; + resource.attributes_["service.namespace"] = "detector-set-cluster"; + + OtlpOptions options(sink_config, resource, server); + std::string instance_id_val; + std::string namespace_val; + for (const auto& attr : options.resource_attributes()) { + if (attr.key() == "service.instance.id") { + instance_id_val = attr.value().string_value(); + } else if (attr.key() == "service.namespace") { + namespace_val = attr.value().string_value(); + } + } + // Detector-set values must be preserved. + EXPECT_EQ("detector-set-id", instance_id_val); + EXPECT_EQ("detector-set-cluster", namespace_val); +} + } // namespace } // namespace OpenTelemetry } // namespace StatSinks diff --git a/test/extensions/stats_sinks/open_telemetry/open_telemetry_impl_test.cc b/test/extensions/stats_sinks/open_telemetry/open_telemetry_impl_test.cc index 77c97dd621590..39937bcdc7df2 100644 --- a/test/extensions/stats_sinks/open_telemetry/open_telemetry_impl_test.cc +++ b/test/extensions/stats_sinks/open_telemetry/open_telemetry_impl_test.cc @@ -1208,6 +1208,34 @@ TEST_F(OtlpMetricsFlusherTests, SetResourceAttributes) { metrics->resource_metrics()[0].resource().attributes()[0].value().string_value()); } +// Verify that service.instance.id and service.namespace resource attributes are propagated into +// the exported OTLP ResourceMetrics, enabling consumers to identify the source. +TEST_F(OtlpMetricsFlusherTests, NodeIdAndClusterResourceAttributes) { + OtlpMetricsFlusherImpl flusher( + otlpOptions(true, false, true, true, "", + {{"service.instance.id", "envoy-node-1"}, {"service.namespace", "prod-cluster"}})); + addCounterToSnapshot("test_counter1", 5, 5); + MetricsExportRequestSharedPtr metrics = + flusher.flush(snapshot_, delta_start_time_ns_, cumulative_start_time_ns_); + expectMetricsCount(metrics, 1); + + ASSERT_EQ(1, metrics->resource_metrics().size()); + const auto& resource_attrs = metrics->resource_metrics()[0].resource().attributes(); + ASSERT_EQ(2, resource_attrs.size()); + + std::string instance_id_val; + std::string namespace_val; + for (const auto& attr : resource_attrs) { + if (attr.key() == "service.instance.id") { + instance_id_val = attr.value().string_value(); + } else if (attr.key() == "service.namespace") { + namespace_val = attr.value().string_value(); + } + } + EXPECT_EQ("envoy-node-1", instance_id_val); + EXPECT_EQ("prod-cluster", namespace_val); +} + class MockOpenTelemetryGrpcMetricsExporter : public OpenTelemetryGrpcMetricsExporter { public: MOCK_METHOD(void, send, (MetricsExportRequestPtr&&));