From fa014bf080c882135b5b057187ac861c45dcb000 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Wed, 20 May 2026 13:34:54 -0400 Subject: [PATCH 01/21] Add AzureContainerAppsKind OTel source detection --- .../otlp/attributes/source.go | 18 ++++++++++++++++ .../otlp/attributes/source/source_provider.go | 2 ++ .../otlp/attributes/source_test.go | 21 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go index 8b425ab95c43..ded5396eff68 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go @@ -16,6 +16,7 @@ package attributes import ( "go.opentelemetry.io/collector/pdata/pcommon" + conventionsv140 "go.opentelemetry.io/otel/semconv/v1.40.0" conventions "go.opentelemetry.io/otel/semconv/v1.6.1" "github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes/azure" @@ -114,6 +115,14 @@ func unsanitizedHostnameFromAttributes(attrs pcommon.Map) (string, bool) { return "", false } + // If on Azure Container Apps, we don't have a hostname + if cloudPlatform, ok := attrs.Get(string(conventions.CloudPlatformKey)); ok { + p := cloudPlatform.Str() + if p == conventionsv140.CloudPlatformAzureContainerApps.Value.AsString() || p == "azure_container_apps" { + return "", false + } + } + cloudProvider, ok := attrs.Get(string(conventions.CloudProviderKey)) switch { case ok && cloudProvider.Str() == conventions.CloudProviderAWS.Value.AsString(): @@ -157,6 +166,15 @@ func SourceFromAttrs(attrs pcommon.Map, hostFromAttributesHandler HostFromAttrib } } + if cloudPlatform, ok := attrs.Get(string(conventions.CloudPlatformKey)); ok { + p := cloudPlatform.Str() + if p == conventionsv140.CloudPlatformAzureContainerApps.Value.AsString() || p == "azure_container_apps" { + if replicaName, ok := attrs.Get(string(conventions.ServiceInstanceIDKey)); ok { + return source.Source{Kind: source.AzureContainerAppsKind, Identifier: replicaName.Str()}, true + } + } + } + if host, ok := hostnameFromAttributes(attrs); ok { if hostFromAttributesHandler != nil { hostFromAttributesHandler.OnHost(host) diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go index 9708e694b7c8..3ef635e99817 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go @@ -30,6 +30,8 @@ const ( HostnameKind Kind = "host" // AWSECSFargateKind is a serverless source on AWS ECS Fargate. AWSECSFargateKind Kind = "task_arn" + // AzureContainerAppsKind is a serverless source on Azure Container Apps. + AzureContainerAppsKind Kind = "azure_container_apps" ) // Source represents a telemetry source. diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go index 08cc80e8c066..5a496916ec2b 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/assert" "go.opentelemetry.io/collector/pdata/pcommon" + conventionsv140 "go.opentelemetry.io/otel/semconv/v1.40.0" conventions "go.opentelemetry.io/otel/semconv/v1.6.1" "github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes/azure" @@ -103,6 +104,26 @@ func TestSourceFromAttrs(t *testing.T) { ok: true, src: source.Source{Kind: source.AWSECSFargateKind, Identifier: "example-task-ARN"}, }, + { + name: "Azure Container Apps (semconv v1.35.0 or later)", + attrs: testutils.NewAttributeMap(map[string]string{ + string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), + string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), + string(conventions.ServiceInstanceIDKey): "replica-1", + }), + ok: true, + src: source.Source{Kind: source.AzureContainerAppsKind, Identifier: "replica-1"}, + }, + { + name: "Azure Container Apps (legacy)", + attrs: testutils.NewAttributeMap(map[string]string{ + string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), + string(conventions.CloudPlatformKey): "azure_container_apps", + string(conventions.ServiceInstanceIDKey): "replica-1", + }), + ok: true, + src: source.Source{Kind: source.AzureContainerAppsKind, Identifier: "replica-1"}, + }, { name: "GCP", attrs: testutils.NewAttributeMap(map[string]string{ From bd31574924294613bb2b007affb691f481323f88 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Wed, 20 May 2026 13:50:41 -0400 Subject: [PATCH 02/21] Add semconv v1.40.0 to bazel build file --- pkg/opentelemetry-mapping-go/otlp/attributes/BUILD.bazel | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/BUILD.bazel b/pkg/opentelemetry-mapping-go/otlp/attributes/BUILD.bazel index 7e145deeb2ec..f5303d185a49 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/BUILD.bazel +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/BUILD.bazel @@ -28,6 +28,7 @@ go_library( "@io_opentelemetry_go_otel//semconv/v1.12.0:v1_12_0", "@io_opentelemetry_go_otel//semconv/v1.17.0:v1_17_0", "@io_opentelemetry_go_otel//semconv/v1.27.0:v1_27_0", + "@io_opentelemetry_go_otel//semconv/v1.40.0:v1_40_0", "@io_opentelemetry_go_otel//semconv/v1.6.1:v1_6_1", "@io_opentelemetry_go_otel_metric//:metric", ], @@ -60,6 +61,7 @@ go_test( "@io_opentelemetry_go_otel//semconv/v1.12.0:v1_12_0", "@io_opentelemetry_go_otel//semconv/v1.17.0:v1_17_0", "@io_opentelemetry_go_otel//semconv/v1.27.0:v1_27_0", + "@io_opentelemetry_go_otel//semconv/v1.40.0:v1_40_0", "@io_opentelemetry_go_otel//semconv/v1.6.1:v1_6_1", "@io_opentelemetry_go_otel_sdk_metric//:metric", "@io_opentelemetry_go_otel_sdk_metric//metricdata", From 1b4d0f69d4c954f1cdc004ab21309575af9069ee Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Wed, 20 May 2026 14:24:30 -0400 Subject: [PATCH 03/21] Handle AzureContainerAppsKind in metrics and trace translators --- .../otlp/metrics/metrics_translator.go | 4 ++++ .../otlp/metrics/minimal_translator.go | 4 ++++ pkg/trace/api/otlp.go | 4 ++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index 445dae20bbb9..7d904b3147b8 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -614,6 +614,10 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } + case source.AzureContainerAppsKind: + if c, ok := consumer.(TagsConsumer); ok { + c.ConsumeTag(src.Tag()) + } } } } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go index 239e96098da3..d2a47f352a41 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go @@ -154,6 +154,10 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } + case source.AzureContainerAppsKind: + if c, ok := consumer.(TagsConsumer); ok { + c.ConsumeTag(src.Tag()) + } } } } diff --git a/pkg/trace/api/otlp.go b/pkg/trace/api/otlp.go index 73ebb19d6ba4..afb22af4ad01 100644 --- a/pkg/trace/api/otlp.go +++ b/pkg/trace/api/otlp.go @@ -344,7 +344,7 @@ func (o *OTLPReceiver) receiveResourceSpansV2(ctx context.Context, rspans ptrace appendTags(builder, tags) } else { // we couldn't obtain any container tags - if src.Kind == source.AWSECSFargateKind { + if src.Kind == source.AWSECSFargateKind || src.Kind == source.AzureContainerAppsKind { // but we have some information from the source provider that we can add appendTags(builder, src.Tag()) } @@ -528,7 +528,7 @@ func (o *OTLPReceiver) receiveResourceSpansV1(ctx context.Context, rspans ptrace appendTags(payloadTags, tags) } else { // we couldn't obtain any container tags - if src.Kind == source.AWSECSFargateKind { + if src.Kind == source.AWSECSFargateKind || src.Kind == source.AzureContainerAppsKind { // but we have some information from the source provider that we can add appendTags(payloadTags, src.Tag()) } From 79018ec9b2636dd3256ba27f32faaf3545ea8b58 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Wed, 1 Jul 2026 16:53:20 -0400 Subject: [PATCH 04/21] Check FaaSInstanceID instead of ServiceInstanceID and remove container apps from trace path --- .../otlp/attributes/source.go | 2 +- .../otlp/attributes/source_test.go | 12 ++++++------ pkg/trace/api/otlp.go | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go index ded5396eff68..df6aedf54678 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go @@ -169,7 +169,7 @@ func SourceFromAttrs(attrs pcommon.Map, hostFromAttributesHandler HostFromAttrib if cloudPlatform, ok := attrs.Get(string(conventions.CloudPlatformKey)); ok { p := cloudPlatform.Str() if p == conventionsv140.CloudPlatformAzureContainerApps.Value.AsString() || p == "azure_container_apps" { - if replicaName, ok := attrs.Get(string(conventions.ServiceInstanceIDKey)); ok { + if replicaName, ok := attrs.Get(string(conventions.FaaSInstanceKey)); ok { return source.Source{Kind: source.AzureContainerAppsKind, Identifier: replicaName.Str()}, true } } diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go index 5a496916ec2b..04050f933f42 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go @@ -107,9 +107,9 @@ func TestSourceFromAttrs(t *testing.T) { { name: "Azure Container Apps (semconv v1.35.0 or later)", attrs: testutils.NewAttributeMap(map[string]string{ - string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), - string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), - string(conventions.ServiceInstanceIDKey): "replica-1", + string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), + string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), + string(conventions.FaaSInstanceKey): "replica-1", }), ok: true, src: source.Source{Kind: source.AzureContainerAppsKind, Identifier: "replica-1"}, @@ -117,9 +117,9 @@ func TestSourceFromAttrs(t *testing.T) { { name: "Azure Container Apps (legacy)", attrs: testutils.NewAttributeMap(map[string]string{ - string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), - string(conventions.CloudPlatformKey): "azure_container_apps", - string(conventions.ServiceInstanceIDKey): "replica-1", + string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), + string(conventions.CloudPlatformKey): "azure_container_apps", + string(conventions.FaaSInstanceKey): "replica-1", }), ok: true, src: source.Source{Kind: source.AzureContainerAppsKind, Identifier: "replica-1"}, diff --git a/pkg/trace/api/otlp.go b/pkg/trace/api/otlp.go index afb22af4ad01..73ebb19d6ba4 100644 --- a/pkg/trace/api/otlp.go +++ b/pkg/trace/api/otlp.go @@ -344,7 +344,7 @@ func (o *OTLPReceiver) receiveResourceSpansV2(ctx context.Context, rspans ptrace appendTags(builder, tags) } else { // we couldn't obtain any container tags - if src.Kind == source.AWSECSFargateKind || src.Kind == source.AzureContainerAppsKind { + if src.Kind == source.AWSECSFargateKind { // but we have some information from the source provider that we can add appendTags(builder, src.Tag()) } @@ -528,7 +528,7 @@ func (o *OTLPReceiver) receiveResourceSpansV1(ctx context.Context, rspans ptrace appendTags(payloadTags, tags) } else { // we couldn't obtain any container tags - if src.Kind == source.AWSECSFargateKind || src.Kind == source.AzureContainerAppsKind { + if src.Kind == source.AWSECSFargateKind { // but we have some information from the source provider that we can add appendTags(payloadTags, src.Tag()) } From 021dec7fec4ab42a2f702359b87f3c00ad4e24d3 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Wed, 8 Jul 2026 16:49:30 -0400 Subject: [PATCH 05/21] Refactor Source.Identifier from string to struct with Primary and Dimensions --- .../datadogexporter/traces_exporter.go | 4 ++-- .../exporter/serializerexporter/exporter.go | 2 +- .../exporter/serializerexporter/serializer.go | 2 +- .../inframetadata/reporter.go | 2 +- .../otlp/attributes/attributes.go | 4 ++-- .../otlp/attributes/source.go | 8 +++---- .../otlp/attributes/source/source_provider.go | 14 ++++++++--- .../otlp/attributes/source_test.go | 24 +++++++++---------- .../otlp/logs/transform.go | 4 ++-- .../otlp/logs/translator.go | 4 ++-- .../otlp/metrics/metrics_translator.go | 4 ++-- .../otlp/metrics/metrics_translator_test.go | 2 +- .../otlp/metrics/minimal_translator.go | 2 +- pkg/trace/api/otlp.go | 10 ++++---- pkg/trace/api/otlp_test.go | 2 +- pkg/trace/transform/transform.go | 4 ++-- 16 files changed, 50 insertions(+), 42 deletions(-) diff --git a/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go b/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go index c08ba36f7837..a6b8bea123b7 100644 --- a/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go +++ b/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go @@ -88,9 +88,9 @@ func (exp *traceExporter) consumeTraces( } switch src.Kind { case source.HostnameKind: - hosts[src.Identifier] = struct{}{} + hosts[src.Identifier.Primary] = struct{}{} case source.AWSECSFargateKind: - ecsFargateArns[src.Identifier] = struct{}{} + ecsFargateArns[src.Identifier.Primary] = struct{}{} case source.InvalidKind: } } diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/exporter.go b/comp/otelcol/otlp/components/exporter/serializerexporter/exporter.go index 4ff66ceea8b9..2e79e75a356b 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/exporter.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/exporter.go @@ -151,7 +151,7 @@ func (f SourceProviderFunc) Source(ctx context.Context) (source.Source, error) { return source.Source{}, err } - return source.Source{Kind: source.HostnameKind, Identifier: hostnameIdentifier}, nil + return source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: hostnameIdentifier}}, nil } // Exporter translate OTLP metrics into the Datadog format and sends diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go b/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go index a017136b9da1..178894948d63 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go @@ -188,7 +188,7 @@ func initSerializerInternal(logger *zap.Logger, cfg *ExporterConfig, sourceProvi if err != nil { return "" } - return s.Identifier + return s.Identifier.Primary }), fx.Provide(newOrchestratorinterfaceimpl), fx.Provide(serializer.NewSerializer), diff --git a/pkg/opentelemetry-mapping-go/inframetadata/reporter.go b/pkg/opentelemetry-mapping-go/inframetadata/reporter.go index 576053f35255..f39c0b36961d 100644 --- a/pkg/opentelemetry-mapping-go/inframetadata/reporter.go +++ b/pkg/opentelemetry-mapping-go/inframetadata/reporter.go @@ -121,7 +121,7 @@ func (r *Reporter) hostname(res pcommon.Resource) (string, bool) { // The resource does not identify a host (e.g. serverless resource) return "", false } - return src.Identifier, true + return src.Identifier.Primary, true } // ConsumeResource for host metadata reporting purposes. diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go b/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go index 2105580f1cd7..b0978f58fb68 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go @@ -726,14 +726,14 @@ func GetHost(resourceAttrs pcommon.Map, fallbackHost string) string { src, srcok := SourceFromAttrs(resourceAttrs, nil) if !srcok { if v := GetOTelAttrVal(resourceAttrs, false, "_dd.hostname"); v != "" { - src = source.Source{Kind: source.HostnameKind, Identifier: v} + src = source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: v}} srcok = true } } if srcok { switch src.Kind { case source.HostnameKind: - return src.Identifier + return src.Identifier.Primary default: // We are not on a hostname (serverless), hence the hostname is empty return "" diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go index df6aedf54678..0469575f5bd2 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go @@ -162,15 +162,15 @@ type HostFromAttributesHandler interface { func SourceFromAttrs(attrs pcommon.Map, hostFromAttributesHandler HostFromAttributesHandler) (source.Source, bool) { if launchType, ok := attrs.Get(string(conventions.AWSECSLaunchtypeKey)); ok && launchType.Str() == conventions.AWSECSLaunchtypeFargate.Value.AsString() { if taskARN, ok := attrs.Get(string(conventions.AWSECSTaskARNKey)); ok { - return source.Source{Kind: source.AWSECSFargateKind, Identifier: taskARN.Str()}, true + return source.Source{Kind: source.AWSECSFargateKind, Identifier: source.Identifier{Primary: taskARN.Str()}}, true } } if cloudPlatform, ok := attrs.Get(string(conventions.CloudPlatformKey)); ok { p := cloudPlatform.Str() if p == conventionsv140.CloudPlatformAzureContainerApps.Value.AsString() || p == "azure_container_apps" { - if replicaName, ok := attrs.Get(string(conventions.FaaSInstanceKey)); ok { - return source.Source{Kind: source.AzureContainerAppsKind, Identifier: replicaName.Str()}, true + if replicaName, ok := attrs.Get("azure.container_app.instance.id"); ok { + return source.Source{Kind: source.AzureContainerAppsKind, Identifier: source.Identifier{Primary: replicaName.Str()}}, true } } } @@ -179,7 +179,7 @@ func SourceFromAttrs(attrs pcommon.Map, hostFromAttributesHandler HostFromAttrib if hostFromAttributesHandler != nil { hostFromAttributesHandler.OnHost(host) } - return source.Source{Kind: source.HostnameKind, Identifier: host}, true + return source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: host}}, true } return source.Source{}, false diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go index 3ef635e99817..0a997056b2c2 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go @@ -34,17 +34,25 @@ const ( AzureContainerAppsKind Kind = "azure_container_apps" ) +// Identifier holds the identity of a telemetry source. +// For sources that can be identified by a single value, only Primary is set. +// For sources that needs to be identified by multiple values (e.g. Azure Container Apps), Dimensions is set. +type Identifier struct { + Primary string + Dimensions map[string]string +} + // Source represents a telemetry source. type Source struct { // Kind of source (serverless v. host). Kind Kind // Identifier that uniquely determines the source. - Identifier string + Identifier Identifier } // Tag associated to a source. -func (s *Source) Tag() string { - return fmt.Sprintf("%s:%s", s.Kind, s.Identifier) +func (s Source) Tag() string { + return fmt.Sprintf("%s:%s", s.Kind, s.Identifier.Primary) } // Provider identifies a source. diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go index 04050f933f42..61b5e54c052e 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go @@ -60,7 +60,7 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostNameKey): testHostName, }), ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: testLiteralHost}, + src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testLiteralHost}}, }, { name: "custom hostname", @@ -73,7 +73,7 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostNameKey): testHostName, }), ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: testCustomName}, + src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testCustomName}}, }, { name: "container ID", @@ -89,7 +89,7 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostNameKey): testHostName, }), ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: testHostID}, + src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testHostID}}, }, { name: "ECS Fargate", @@ -102,27 +102,27 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.AWSECSLaunchtypeKey): conventions.AWSECSLaunchtypeFargate.Value.AsString(), }), ok: true, - src: source.Source{Kind: source.AWSECSFargateKind, Identifier: "example-task-ARN"}, + src: source.Source{Kind: source.AWSECSFargateKind, Identifier: source.Identifier{Primary: "example-task-ARN"}}, }, { name: "Azure Container Apps (semconv v1.35.0 or later)", attrs: testutils.NewAttributeMap(map[string]string{ string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), - string(conventions.FaaSInstanceKey): "replica-1", + "azure.container_app.instance.id": "replica-1", }), ok: true, - src: source.Source{Kind: source.AzureContainerAppsKind, Identifier: "replica-1"}, + src: source.Source{Kind: source.AzureContainerAppsKind, Identifier: source.Identifier{Primary: "replica-1"}}, }, { name: "Azure Container Apps (legacy)", attrs: testutils.NewAttributeMap(map[string]string{ string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), string(conventions.CloudPlatformKey): "azure_container_apps", - string(conventions.FaaSInstanceKey): "replica-1", + "azure.container_app.instance.id": "replica-1", }), ok: true, - src: source.Source{Kind: source.AzureContainerAppsKind, Identifier: "replica-1"}, + src: source.Source{Kind: source.AzureContainerAppsKind, Identifier: source.Identifier{Primary: "replica-1"}}, }, { name: "GCP", @@ -133,7 +133,7 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.CloudAccountIDKey): testCloudAccount, }), ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: testGCPIntegrationHostname}, + src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testGCPIntegrationHostname}}, }, { name: "GCP, no account id", @@ -151,7 +151,7 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostNameKey): testHostName, }), ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: testHostID}, + src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testHostID}}, }, { name: "host id v. hostname", @@ -160,7 +160,7 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostNameKey): testHostName, }), ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: testHostID}, + src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testHostID}}, }, { name: "no hostname", @@ -189,7 +189,7 @@ func TestLiteralHostNonString(t *testing.T) { attrs.PutInt(AttributeHost, 1000) src, ok := SourceFromAttrs(attrs, nil) assert.True(t, ok) - assert.Equal(t, source.Source{Kind: source.HostnameKind, Identifier: "1000"}, src) + assert.Equal(t, source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: "1000"}}, src) } func TestGetClusterName(t *testing.T) { diff --git a/pkg/opentelemetry-mapping-go/otlp/logs/transform.go b/pkg/opentelemetry-mapping-go/otlp/logs/transform.go index addb193c65ee..5f497cb97279 100644 --- a/pkg/opentelemetry-mapping-go/otlp/logs/transform.go +++ b/pkg/opentelemetry-mapping-go/otlp/logs/transform.go @@ -236,13 +236,13 @@ func flattenAttribute(key string, val pcommon.Value, depth int) map[string]any { func extractHostNameAndServiceName(resourceAttrs pcommon.Map, logAttrs pcommon.Map) (host string, service string) { if src, ok := attributes.SourceFromAttrs(resourceAttrs, nil); ok && src.Kind == source.HostnameKind { - host = src.Identifier + host = src.Identifier.Primary } // HACK: Check for host in log record attributes if not present in resource attributes. // This is not aligned with the specification and will be removed in the future. if host == "" { if src, ok := attributes.SourceFromAttrs(logAttrs, nil); ok && src.Kind == source.HostnameKind { - host = src.Identifier + host = src.Identifier.Primary } } if s, ok := resourceAttrs.Get(string(conventions.ServiceNameKey)); ok { diff --git a/pkg/opentelemetry-mapping-go/otlp/logs/translator.go b/pkg/opentelemetry-mapping-go/otlp/logs/translator.go index 21eece9b6fc1..dfaa913954f8 100644 --- a/pkg/opentelemetry-mapping-go/otlp/logs/translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/logs/translator.go @@ -78,7 +78,7 @@ func NewTranslatorWithHTTPClient(set component.TelemetrySettings, attributesTran func (t *Translator) hostNameAndServiceNameFromResource(ctx context.Context, res pcommon.Resource, hostFromAttributesHandler attributes.HostFromAttributesHandler) (host string, service string) { if src, ok := t.attributesTranslator.ResourceToSource(ctx, res, signalTypeSet, hostFromAttributesHandler); ok && src.Kind == source.HostnameKind { - host = src.Identifier + host = src.Identifier.Primary } if s, ok := res.Attributes().Get(string(conventions.ServiceNameKey)); ok { service = s.AsString() @@ -88,7 +88,7 @@ func (t *Translator) hostNameAndServiceNameFromResource(ctx context.Context, res func (t *Translator) hostFromAttributes(ctx context.Context, attrs pcommon.Map) string { if src, ok := t.attributesTranslator.AttributesToSource(ctx, attrs); ok && src.Kind == source.HostnameKind { - return src.Identifier + return src.Identifier.Primary } return "" } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index 7d904b3147b8..f8abaad63790 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -96,7 +96,7 @@ var _ source.Provider = (*noSourceProvider)(nil) type noSourceProvider struct{} func (*noSourceProvider) Source(context.Context) (source.Source, error) { - return source.Source{Kind: source.HostnameKind, Identifier: ""}, nil + return source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: ""}}, nil } // defaultTranslator is the default metrics translator implementation. @@ -516,7 +516,7 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, var host string if src.Kind == source.HostnameKind { - host = src.Identifier + host = src.Identifier.Primary // Don't consume the host yet, first check if we have any nonAPM metrics. } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator_test.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator_test.go index df131b7bafde..b02e329a6994 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator_test.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator_test.go @@ -97,7 +97,7 @@ type testProvider string func (t testProvider) Source(context.Context) (source.Source, error) { return source.Source{ Kind: source.HostnameKind, - Identifier: string(t), + Identifier: source.Identifier{Primary: string(t)}, }, nil } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go index d2a47f352a41..3df511d48d62 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go @@ -90,7 +90,7 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, var host string if src.Kind == source.HostnameKind { - host = src.Identifier + host = src.Identifier.Primary // Don't consume the host yet, first check if we have any nonAPM metrics. } diff --git a/pkg/trace/api/otlp.go b/pkg/trace/api/otlp.go index 73ebb19d6ba4..b1ddf351c8e1 100644 --- a/pkg/trace/api/otlp.go +++ b/pkg/trace/api/otlp.go @@ -301,7 +301,7 @@ func (o *OTLPReceiver) receiveResourceSpansV2(ctx context.Context, rspans ptrace if srcok { switch src.Kind { case source.HostnameKind: - hostname = src.Identifier + hostname = src.Identifier.Primary default: // We are not on a hostname (serverless), hence the hostname is empty hostname = "" @@ -309,7 +309,7 @@ func (o *OTLPReceiver) receiveResourceSpansV2(ctx context.Context, rspans ptrace } else { // fallback hostname hostname = o.conf.Hostname - src = source.Source{Kind: source.HostnameKind, Identifier: hostname} + src = source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: hostname}} } // Create a single accessor for all resource-level semantic lookups below, avoiding @@ -417,7 +417,7 @@ func (o *OTLPReceiver) receiveResourceSpansV1(ctx context.Context, rspans ptrace hostFromMap := func(m map[string]string, key string) { // hostFromMap sets the hostname to m[key] if it is set. if v, ok := m[key]; ok { - src = source.Source{Kind: source.HostnameKind, Identifier: v} + src = source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: v}} srcok = true } } @@ -503,7 +503,7 @@ func (o *OTLPReceiver) receiveResourceSpansV1(ctx context.Context, rspans ptrace if srcok { switch src.Kind { case source.HostnameKind: - hostname = src.Identifier + hostname = src.Identifier.Primary default: // We are not on a hostname (serverless), hence the hostname is empty hostname = "" @@ -511,7 +511,7 @@ func (o *OTLPReceiver) receiveResourceSpansV1(ctx context.Context, rspans ptrace } else { // fallback hostname hostname = o.conf.Hostname - src = source.Source{Kind: source.HostnameKind, Identifier: hostname} + src = source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: hostname}} } p.TracerPayload = &pb.TracerPayload{ Hostname: hostname, diff --git a/pkg/trace/api/otlp_test.go b/pkg/trace/api/otlp_test.go index 6b6616b057b9..4617e41ac9ce 100644 --- a/pkg/trace/api/otlp_test.go +++ b/pkg/trace/api/otlp_test.go @@ -1393,7 +1393,7 @@ func testOTLPHostname(enableReceiveResourceSpansV2 bool, t *testing.T) { }, }).Traces().ResourceSpans().At(0), http.Header{}, nil) assert.Equal(t, src.Kind, source.HostnameKind) - assert.Equal(t, src.Identifier, tt.out) + assert.Equal(t, src.Identifier.Primary, tt.out) timeout := time.After(500 * time.Millisecond) select { case <-timeout: diff --git a/pkg/trace/transform/transform.go b/pkg/trace/transform/transform.go index a75987736a14..742d854e02df 100644 --- a/pkg/trace/transform/transform.go +++ b/pkg/trace/transform/transform.go @@ -202,14 +202,14 @@ func GetOTelHostname(span ptrace.Span, res pcommon.Resource, tr *attributes.Tran src, srcok := tr.ResourceToSource(ctx, res, SignalTypeSet, nil) if !srcok { if v := GetOTelAttrValInResAndSpanAttrs(span, res, false, "_dd.hostname"); v != "" { - src = source.Source{Kind: source.HostnameKind, Identifier: v} + src = source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: v}} srcok = true } } if srcok { switch src.Kind { case source.HostnameKind: - return src.Identifier + return src.Identifier.Primary default: // We are not on a hostname (serverless), hence the hostname is empty return "" From 83198474d8c75421987d75539ed601132f61fd87 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Thu, 9 Jul 2026 09:07:35 -0700 Subject: [PATCH 06/21] Add AzureContainerAppsMappings and create Azure Container App source from resource attributes --- .../otlp/attributes/attributes.go | 49 +++++++++ .../otlp/attributes/source.go | 40 +++++++- .../otlp/attributes/source_test.go | 99 ++++++++++++++++--- 3 files changed, 174 insertions(+), 14 deletions(-) diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go b/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go index b0978f58fb68..74df9c42158b 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go @@ -16,6 +16,7 @@ package attributes import ( + "errors" "fmt" "strings" @@ -88,6 +89,16 @@ var ( string(semconv1_27.K8SPodNameKey): "pod_name", } + // AzureContainerAppsMappings is intentionally separate from ContainerMappings + // to avoid adding these broad attributes (e.g. service.name -> name) as + // tags on non-ACA workloads. + AzureContainerAppsMappings = map[string]string{ + AttributeAzureContainerAppInstanceID: "replica_name", + string(semconv1_27.ServiceNameKey): "name", + string(semconv1_27.CloudAccountIDKey): "subscription_id", + AttributeAzureResourceGroupName: "resource_group", + } + containerDDTags = (func() map[string]struct{} { m := make(map[string]struct{}, len(ContainerMappings)) for _, ddKey := range ContainerMappings { @@ -741,3 +752,41 @@ func GetHost(resourceAttrs pcommon.Map, fallbackHost string) string { } return fallbackHost } + +// azureResourceID holds the fields extracted from an Azure ARM resource ID. +type azureResourceID struct { + SubscriptionID string + ResourceGroup string + ResourceName string +} + +// parseAzureResourceID parses the cloud.resource_id string for Azure Container Apps, +// Azure App Services, and Azure Functions. +// It expects the format: /subscriptions/{sub}/resourceGroups/{rg}/providers/{provider}/{type}/{name} +func parseAzureResourceID(resourceID string) (azureResourceID, error) { + if resourceID == "" { + return azureResourceID{}, errors.New("empty resource ID") + } + + parts := strings.Split(resourceID, "/") + // Example: /subscriptions/1dd25961.../resourceGroups/rg-name/providers/Microsoft.Web/sites/site-name + // parts[0] = "" + // parts[1] = "subscriptions" + // parts[2] = "1dd25961..." + // parts[3] = "resourceGroups" + // parts[4] = "rg-name" + // parts[5] = "providers" + // parts[6] = "Microsoft.App" (or Microsoft.Web) + // parts[7] = "containerApps" (or sites) + // parts[8] = "site-name" + + if len(parts) < 9 { + return azureResourceID{}, errors.New("invalid Azure resource ID format: " + resourceID) + } + + return azureResourceID{ + SubscriptionID: parts[2], + ResourceGroup: parts[4], + ResourceName: parts[8], + }, nil +} diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go index 0469575f5bd2..13574c8577d7 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go @@ -16,6 +16,7 @@ package attributes import ( "go.opentelemetry.io/collector/pdata/pcommon" + semconv1_27 "go.opentelemetry.io/otel/semconv/v1.27.0" conventionsv140 "go.opentelemetry.io/otel/semconv/v1.40.0" conventions "go.opentelemetry.io/otel/semconv/v1.6.1" @@ -33,6 +34,15 @@ const ( // AttributeHost is a literal host tag. // We check for this to avoid double tagging. AttributeHost = "host" + // AttributeAzureContainerAppInstanceID identifies an Azure Container Apps + // replica. This is generated by the ACA resource detector and is currently + // getting added to OTel semconv. Replace with the real semconv constant once released. + AttributeAzureContainerAppInstanceID = "azure.container_app.instance.id" + // AttributeAzureResourceGroupName is OTel semconv v1.43.0 + // (azure.resource_group.name), not yet available as a Go constant. + // Replace with the real semconv constant once + // go.opentelemetry.io/otel/semconv/v1.43.0 ships. + AttributeAzureResourceGroupName = "azure.resource_group.name" ) func getClusterName(attrs pcommon.Map) (string, bool) { @@ -169,8 +179,34 @@ func SourceFromAttrs(attrs pcommon.Map, hostFromAttributesHandler HostFromAttrib if cloudPlatform, ok := attrs.Get(string(conventions.CloudPlatformKey)); ok { p := cloudPlatform.Str() if p == conventionsv140.CloudPlatformAzureContainerApps.Value.AsString() || p == "azure_container_apps" { - if replicaName, ok := attrs.Get("azure.container_app.instance.id"); ok { - return source.Source{Kind: source.AzureContainerAppsKind, Identifier: source.Identifier{Primary: replicaName.Str()}}, true + if replicaName, ok := attrs.Get(AttributeAzureContainerAppInstanceID); ok { + dims := map[string]string{} + for otelKey, ddKey := range AzureContainerAppsMappings { + if v, ok := attrs.Get(otelKey); ok && v.Str() != "" { + dims[ddKey] = v.Str() + } + } + // Fallback: derive subscription_id, resource_group, and name from cloud.resource_id + if v, ok := attrs.Get(string(semconv1_27.CloudResourceIDKey)); ok && v.Str() != "" { + if parsed, err := parseAzureResourceID(v.Str()); err == nil { + if _, ok := dims["subscription_id"]; !ok && parsed.SubscriptionID != "" { + dims["subscription_id"] = parsed.SubscriptionID + } + if _, ok := dims["resource_group"]; !ok && parsed.ResourceGroup != "" { + dims["resource_group"] = parsed.ResourceGroup + } + if _, ok := dims["name"]; !ok && parsed.ResourceName != "" { + dims["name"] = parsed.ResourceName + } + } + } + return source.Source{ + Kind: source.AzureContainerAppsKind, + Identifier: source.Identifier{ + Primary: replicaName.Str(), + Dimensions: dims, + }, + }, true } } } diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go index 61b5e54c052e..95d6a8143f41 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/assert" "go.opentelemetry.io/collector/pdata/pcommon" + semconv1_27 "go.opentelemetry.io/otel/semconv/v1.27.0" conventionsv140 "go.opentelemetry.io/otel/semconv/v1.40.0" conventions "go.opentelemetry.io/otel/semconv/v1.6.1" @@ -105,24 +106,98 @@ func TestSourceFromAttrs(t *testing.T) { src: source.Source{Kind: source.AWSECSFargateKind, Identifier: source.Identifier{Primary: "example-task-ARN"}}, }, { - name: "Azure Container Apps (semconv v1.35.0 or later)", + name: "Azure Container Apps (semconv v1.40.0 or later)", attrs: testutils.NewAttributeMap(map[string]string{ - string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), - string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), - "azure.container_app.instance.id": "replica-1", + string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), + string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), + AttributeAzureContainerAppInstanceID: "replica-1", + string(conventions.ServiceNameKey): "my-app", + string(semconv1_27.CloudAccountIDKey): "sub-123", + AttributeAzureResourceGroupName: "my-rg", }), - ok: true, - src: source.Source{Kind: source.AzureContainerAppsKind, Identifier: source.Identifier{Primary: "replica-1"}}, + ok: true, + src: source.Source{ + Kind: source.AzureContainerAppsKind, + Identifier: source.Identifier{ + Primary: "replica-1", + Dimensions: map[string]string{ + "replica_name": "replica-1", + "name": "my-app", + "subscription_id": "sub-123", + "resource_group": "my-rg", + }, + }, + }, }, { - name: "Azure Container Apps (legacy)", + name: "Azure Container Apps (legacy platform value)", attrs: testutils.NewAttributeMap(map[string]string{ - string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), - string(conventions.CloudPlatformKey): "azure_container_apps", - "azure.container_app.instance.id": "replica-1", + string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), + string(conventions.CloudPlatformKey): "azure_container_apps", + AttributeAzureContainerAppInstanceID: "replica-1", + string(conventions.ServiceNameKey): "my-app", + string(semconv1_27.CloudAccountIDKey): "sub-123", + AttributeAzureResourceGroupName: "my-rg", }), - ok: true, - src: source.Source{Kind: source.AzureContainerAppsKind, Identifier: source.Identifier{Primary: "replica-1"}}, + ok: true, + src: source.Source{ + Kind: source.AzureContainerAppsKind, + Identifier: source.Identifier{ + Primary: "replica-1", + Dimensions: map[string]string{ + "replica_name": "replica-1", + "name": "my-app", + "subscription_id": "sub-123", + "resource_group": "my-rg", + }, + }, + }, + }, + { + name: "Azure Container Apps (name, subscription_id, resource_group all from cloud.resource_id fallback)", + attrs: testutils.NewAttributeMap(map[string]string{ + string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), + string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), + AttributeAzureContainerAppInstanceID: "replica-1", + string(semconv1_27.CloudResourceIDKey): "/subscriptions/sub-123/resourceGroups/my-rg/providers/Microsoft.App/containerApps/my-app", + }), + ok: true, + src: source.Source{ + Kind: source.AzureContainerAppsKind, + Identifier: source.Identifier{ + Primary: "replica-1", + Dimensions: map[string]string{ + "replica_name": "replica-1", + "name": "my-app", + "subscription_id": "sub-123", + "resource_group": "my-rg", + }, + }, + }, + }, + { + name: "Azure Container Apps (resource_group from cloud.resource_id, name and subscription_id from primary attrs take precedence)", + attrs: testutils.NewAttributeMap(map[string]string{ + string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), + string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), + AttributeAzureContainerAppInstanceID: "replica-1", + string(conventions.ServiceNameKey): "my-app", + string(semconv1_27.CloudAccountIDKey): "sub-123", + string(semconv1_27.CloudResourceIDKey): "/subscriptions/sub-999/resourceGroups/my-rg/providers/Microsoft.App/containerApps/other-name", + }), + ok: true, + src: source.Source{ + Kind: source.AzureContainerAppsKind, + Identifier: source.Identifier{ + Primary: "replica-1", + Dimensions: map[string]string{ + "replica_name": "replica-1", + "name": "my-app", + "subscription_id": "sub-123", + "resource_group": "my-rg", + }, + }, + }, }, { name: "GCP", From 4325af071f44286a08c61e0653b1ca583b373b3e Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Thu, 9 Jul 2026 10:36:37 -0700 Subject: [PATCH 07/21] Add TagSetConsumer interface and wire Azure Container Apps into metric translators --- .../otlp/metrics/consumer.go | 9 +++++++++ .../otlp/metrics/metrics_translator.go | 12 +++++++++++- .../otlp/metrics/minimal_translator.go | 12 +++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go index 93572ac57400..f43df38e3f36 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go @@ -152,3 +152,12 @@ type TagsConsumer interface { // ConsumeTag consumes a tag ConsumeTag(tag string) } + +// TagSetConsumer is a multi-tag source consumer. +// It is an optional interface that can be implemented by a Consumer. +// Use it for sources that need multiple tags on the running metric (e.g. Azure Container Apps). +// Consumers that only implement TagsConsumer receive a fallback single-tag call instead. +type TagSetConsumer interface { + // ConsumeTagSet consumes a multi-tag source + ConsumeTagSet(key string, tags []string) +} diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index f8abaad63790..7312c112f440 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -615,7 +615,17 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, c.ConsumeTag(src.Tag()) } case source.AzureContainerAppsKind: - if c, ok := consumer.(TagsConsumer); ok { + if c, ok := consumer.(TagSetConsumer); ok { + var tags []string + for key, val := range src.Identifier.Dimensions { + tags = append(tags, key+":"+val) + } + key := src.Identifier.Dimensions["subscription_id"] + "/" + + src.Identifier.Dimensions["resource_group"] + "/" + + src.Identifier.Dimensions["name"] + "/" + + src.Identifier.Primary + c.ConsumeTagSet(key, tags) + } else if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go index 3df511d48d62..b46e69c0e65e 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go @@ -155,7 +155,17 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, c.ConsumeTag(src.Tag()) } case source.AzureContainerAppsKind: - if c, ok := consumer.(TagsConsumer); ok { + if c, ok := consumer.(TagSetConsumer); ok { + var tags []string + for key, val := range src.Identifier.Dimensions { + tags = append(tags, key+":"+val) + } + key := src.Identifier.Dimensions["subscription_id"] + "/" + + src.Identifier.Dimensions["resource_group"] + "/" + + src.Identifier.Dimensions["name"] + "/" + + src.Identifier.Primary + c.ConsumeTagSet(key, tags) + } else if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } } From 27e751a4875c723fea15f8e8a882058f64e554f7 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Fri, 10 Jul 2026 08:23:56 -0700 Subject: [PATCH 08/21] Generalize TagSetConsumer so Fargate and future workloads can use it too --- .../otlp/metrics/consumer.go | 18 ++++++++++++++---- .../otlp/metrics/metrics_translator.go | 6 ++++-- .../otlp/metrics/minimal_translator.go | 6 ++++-- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go index f43df38e3f36..4a7f0bd9a73f 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go @@ -148,6 +148,10 @@ type HostConsumer interface { // It is an optional interface that can be implemented by a Consumer. // Consumed tags are used for running metrics, and should represent // some resource running a Collector (e.g. Fargate task). +// +// Legacy: Fargate itself now goes through TagSetConsumer. This interface +// remains only for consumers that haven't migrated yet. Removing it is a +// breaking change until all known implementors migrate. type TagsConsumer interface { // ConsumeTag consumes a tag ConsumeTag(tag string) @@ -155,9 +159,15 @@ type TagsConsumer interface { // TagSetConsumer is a multi-tag source consumer. // It is an optional interface that can be implemented by a Consumer. -// Use it for sources that need multiple tags on the running metric (e.g. Azure Container Apps). -// Consumers that only implement TagsConsumer receive a fallback single-tag call instead. +// Use it for any source that needs one or more tags on its own dedicated +// running metric (e.g. Fargate, Azure Container Apps). Consumers that only +// implement TagsConsumer receive a fallback single-tag call instead. type TagSetConsumer interface { - // ConsumeTagSet consumes a multi-tag source - ConsumeTagSet(key string, tags []string) + // ConsumeTagSet consumes a multi-tag source for running metric emission. + // metricSuffix names the workload-specific metric: the resulting metric + // is "otel.datadog_exporter.metrics.running." (e.g. "fargate", + // "azurecontainerapps"). + // key serves as a unique identifier for deduplication. + // tags is the full slice of "key:value" strings to attach to the metric. + ConsumeTagSet(metricSuffix string, key string, tags []string) } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index 7312c112f440..96852e737da1 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -611,7 +611,9 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, c.ConsumeHost(host) } case source.AWSECSFargateKind: - if c, ok := consumer.(TagsConsumer); ok { + if c, ok := consumer.(TagSetConsumer); ok { + c.ConsumeTagSet("fargate", src.Tag(), []string{src.Tag()}) + } else if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } case source.AzureContainerAppsKind: @@ -624,7 +626,7 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, src.Identifier.Dimensions["resource_group"] + "/" + src.Identifier.Dimensions["name"] + "/" + src.Identifier.Primary - c.ConsumeTagSet(key, tags) + c.ConsumeTagSet("azurecontainerapps", key, tags) } else if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go index b46e69c0e65e..8c5890a27731 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go @@ -151,7 +151,9 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, c.ConsumeHost(host) } case source.AWSECSFargateKind: - if c, ok := consumer.(TagsConsumer); ok { + if c, ok := consumer.(TagSetConsumer); ok { + c.ConsumeTagSet("fargate", src.Tag(), []string{src.Tag()}) + } else if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } case source.AzureContainerAppsKind: @@ -164,7 +166,7 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, src.Identifier.Dimensions["resource_group"] + "/" + src.Identifier.Dimensions["name"] + "/" + src.Identifier.Primary - c.ConsumeTagSet(key, tags) + c.ConsumeTagSet("azurecontainerapps", key, tags) } else if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } From d67e1c1b10d04d26a7b999e1f2afc4ba62fbcf34 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Fri, 10 Jul 2026 08:39:39 -0700 Subject: [PATCH 09/21] Migrate Fargate and ACA running metrics onto generalized TagSetConsumer --- .../serializerexporter/consumer_collector.go | 55 +++++++------ .../consumer_collector_test.go | 77 ++++++++++++++++--- .../exporter/serializerexporter/factory.go | 2 +- 3 files changed, 99 insertions(+), 35 deletions(-) diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go index 98184bad2fde..06d2343b6ffc 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go @@ -8,22 +8,29 @@ package serializerexporter import ( "fmt" "strings" - - "go.opentelemetry.io/collector/component" - "go.opentelemetry.io/collector/exporter" - telemetry "github.com/DataDog/datadog-agent/comp/core/telemetry/def" "github.com/DataDog/datadog-agent/pkg/metrics" "github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes/source" "github.com/DataDog/datadog-agent/pkg/tagset" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/exporter" ) +// tagSetEntry holds a workload-specific tag set awaiting emission as a +// dedicated running metric. metricSuffix names the workload type (e.g. +// "fargate", "azurecontainerapps"); tags is the full set of "key:value" +// strings to attach. +type tagSetEntry struct { + metricSuffix string + tags []string +} + // collectorConsumer is a consumer OSS collector uses to send metrics to the DataDog. type collectorConsumer struct { *serializerConsumer - seenHosts map[string]struct{} - seenTags map[string]struct{} - buildInfo component.BuildInfo + seenHosts map[string]struct{} + seenTagSets map[string]tagSetEntry // key: composite dedup string, value: workload tag set + buildInfo component.BuildInfo // getPushTime returns a Unix time in nanoseconds, representing the time pushing metrics. // It will be overwritten in tests. getPushTime func() uint64 @@ -41,17 +48,16 @@ func (c *collectorConsumer) addRuntimeTelemetryMetric(_ string, languageTags []s series = append(series, runningMetric) } - var nonFargateTags []string - for tag := range c.seenTags { - if strings.HasPrefix(tag, string(source.AWSECSFargateKind)+":") { - series = append(series, exporterFargateMetrics(timestamp, append(buildTags, tag))) - } else { - nonFargateTags = append(nonFargateTags, tag) - } + for _, entry := range c.seenTagSets { + series = append(series, exporterWorkloadMetrics(entry.metricSuffix, timestamp, append(buildTags, entry.tags...))) } - if (len(c.seenHosts) > 0 && len(c.seenTags) == 0) || len(nonFargateTags) > 0 { - tags := append(buildTags, nonFargateTags...) - series = append(series, exporterDefaultMetrics("metrics", "", timestamp, tags)) + + // Suppress the hostless fallback emission of "metrics.running" (no Host set) + // when every signal seen was already attributed to a specific workload + // type via seenTagSets, to avoid double-counting a single workload for + // billing. + if (len(c.seenHosts) > 0 && len(c.seenTagSets) == 0) { + series = append(series, exporterDefaultMetrics("metrics", "", timestamp, buildTags)) } for _, lang := range languageTags { @@ -70,9 +76,9 @@ func (c *collectorConsumer) ConsumeHost(host string) { c.seenHosts[host] = struct{}{} } -// ConsumeTag implements the metrics.TagsConsumer interface. -func (c *collectorConsumer) ConsumeTag(tag string) { - c.seenTags[tag] = struct{}{} +// ConsumeTagSet implements the metrics.TagSetConsumer interface. +func (c *collectorConsumer) ConsumeTagSet(metricSuffix string, key string, tags []string) { + c.seenTagSets[key] = tagSetEntry{metricSuffix: metricSuffix, tags: tags} } // exporterDefaultMetrics creates built-in metrics to report that an exporter is running @@ -93,10 +99,13 @@ func exporterDefaultMetrics(exporterType string, hostname string, timestamp uint return metrics } -// exporterFargateMetrics creates a built-in metric to report that a Fargate exporter is running. -func exporterFargateMetrics(timestamp uint64, tags []string) *metrics.Serie { +// exporterWorkloadMetrics creates a built-in metric to report that a +// workload-specific exporter (e.g. Fargate, Azure Container Apps) is +// running. The resulting metric name is +// "otel.datadog_exporter.metrics.running.". +func exporterWorkloadMetrics(metricSuffix string, timestamp uint64, tags []string) *metrics.Serie { return &metrics.Serie{ - Name: "otel.datadog_exporter.metrics.running.fargate", + Name: "otel.datadog_exporter.metrics.running." + metricSuffix, Points: []metrics.Point{ { Ts: float64(timestamp), diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector_test.go b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector_test.go index 1b59374d48d4..92ff7a4138b9 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector_test.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.opentelemetry.io/collector/component" ) @@ -17,21 +18,44 @@ func newTestCollectorConsumer(buildInfo component.BuildInfo) *collectorConsumer return &collectorConsumer{ serializerConsumer: s, seenHosts: make(map[string]struct{}), - seenTags: make(map[string]struct{}), + seenTagSets: make(map[string]tagSetEntry), buildInfo: buildInfo, getPushTime: func() uint64 { return uint64(2e9) }, } } -func TestExporterFargateMetrics(t *testing.T) { - tags := []string{"version:1.0", "command:otelcontribcol"} - serie := exporterFargateMetrics(uint64(2e9), tags) +func TestExporterWorkloadMetrics(t *testing.T) { + tests := []struct { + name string + metricSuffix string + tags []string + wantName string + }{ + { + name: "fargate", + metricSuffix: "fargate", + tags: []string{"version:1.0", "command:otelcontribcol", "task_arn:arn:aws:ecs:us-east-1:123:task/cluster/abc"}, + wantName: "otel.datadog_exporter.metrics.running.fargate", + }, + { + name: "azurecontainerapps", + metricSuffix: "azurecontainerapps", + tags: []string{"version:1.0", "command:otelcontribcol", "replica_name:replica-1"}, + wantName: "otel.datadog_exporter.metrics.running.azurecontainerapps", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serie := exporterWorkloadMetrics(tt.metricSuffix, uint64(2e9), tt.tags) - assert.Equal(t, "otel.datadog_exporter.metrics.running.fargate", serie.Name) - assert.Equal(t, 1, len(serie.Points)) - assert.Equal(t, float64(2e9), serie.Points[0].Ts) - assert.Equal(t, 1.0, serie.Points[0].Value) - assert.Equal(t, "", serie.Host) + assert.Equal(t, tt.wantName, serie.Name) + assert.Equal(t, 1, len(serie.Points)) + assert.Equal(t, float64(2e9), serie.Points[0].Ts) + assert.Equal(t, 1.0, serie.Points[0].Value) + assert.Equal(t, "", serie.Host) + }) + } } func TestAddRuntimeTelemetryMetric_NoTags(t *testing.T) { @@ -64,7 +88,8 @@ func TestAddRuntimeTelemetryMetric_HostSource(t *testing.T) { func TestAddRuntimeTelemetryMetric_FargateTags(t *testing.T) { buildInfo := component.BuildInfo{Version: "1.0", Command: "otelcontribcol"} c := newTestCollectorConsumer(buildInfo) - c.ConsumeTag("task_arn:arn:aws:ecs:us-east-1:123:task/cluster/abc") + tag := "task_arn:arn:aws:ecs:us-east-1:123:task/cluster/abc" + c.ConsumeTagSet("fargate", tag, []string{tag}) c.addRuntimeTelemetryMetric("", nil) @@ -79,7 +104,8 @@ func TestAddRuntimeTelemetryMetric_HostAndFargate(t *testing.T) { buildInfo := component.BuildInfo{Version: "1.0", Command: "otelcontribcol"} c := newTestCollectorConsumer(buildInfo) c.ConsumeHost("my-hostname") - c.ConsumeTag("task_arn:arn:aws:ecs:us-east-1:123:task/cluster/abc") + tag := "task_arn:arn:aws:ecs:us-east-1:123:task/cluster/abc" + c.ConsumeTagSet("fargate", tag, []string{tag}) c.addRuntimeTelemetryMetric("", nil) @@ -91,3 +117,32 @@ func TestAddRuntimeTelemetryMetric_HostAndFargate(t *testing.T) { assert.ElementsMatch(t, metricsByName["otel.datadog_exporter.metrics.running.fargate"], []string{""}) assert.Len(t, c.series, 2) } + +func TestAzureContainerAppsMetric(t *testing.T) { + buildInfo := component.BuildInfo{} + c := newTestCollectorConsumer(buildInfo) + key := "sub-123/my-rg/my-app/replica-1" + tags := []string{ + "replica_name:replica-1", + "name:my-app", + "subscription_id:sub-123", + "resource_group:my-rg", + } + c.ConsumeTagSet("azurecontainerapps", key, tags) + // Same key — should not duplicate + c.ConsumeTagSet("azurecontainerapps", key, tags) + c.addRuntimeTelemetryMetric("", nil) + + // Exactly one series total: the ACA metric only. The hostless fallback + // emission of "otel.datadog_exporter.metrics.running" must be suppressed + // here, the same way it is for Fargate-only sources, to avoid + // double-counting a single ACA workload for billing. + require.Len(t, c.series, 1, "expected exactly one series (ACA only, no stray hostless fallback metric)") + found := c.series[0] + assert.Equal(t, "otel.datadog_exporter.metrics.running.azurecontainerapps", found.Name) + tagStrs := found.Tags.UnsafeToReadOnlySliceString() + assert.Contains(t, tagStrs, "replica_name:replica-1") + assert.Contains(t, tagStrs, "name:my-app") + assert.Contains(t, tagStrs, "subscription_id:sub-123") + assert.Contains(t, tagStrs, "resource_group:my-rg") +} diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go b/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go index 3fbc487749ad..f4da65aea0ca 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go @@ -172,7 +172,7 @@ func NewFactoryForOSSExporter(typ component.Type, statsIn chan []byte) exp.Facto return &collectorConsumer{ serializerConsumer: s, seenHosts: make(map[string]struct{}), - seenTags: make(map[string]struct{}), + seenTagSets: make(map[string]tagSetEntry), buildInfo: buildInfo, getPushTime: func() uint64 { return uint64(time.Now().Unix()) }, } From c517a39933c15fa4331611f10dfbc205ac5ad038 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Fri, 10 Jul 2026 10:38:07 -0700 Subject: [PATCH 10/21] Derive redundant TagSetConsumer key param internally --- .../serializerexporter/consumer_collector.go | 30 +++++++++++-------- .../consumer_collector_test.go | 25 ++++++++-------- .../serializerexporter/exporter_test.go | 4 +-- .../exporter/serializerexporter/factory.go | 2 +- .../otlp/attributes/azure/azure.go | 5 +++- .../otlp/attributes/source.go | 2 ++ .../otlp/metrics/consumer.go | 3 +- .../otlp/metrics/metrics_translator.go | 19 ++++++------ .../otlp/metrics/minimal_translator.go | 20 +++++++------ 9 files changed, 61 insertions(+), 49 deletions(-) diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go index 06d2343b6ffc..2c246530c9fa 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go @@ -7,29 +7,30 @@ package serializerexporter import ( "fmt" + "slices" "strings" + telemetry "github.com/DataDog/datadog-agent/comp/core/telemetry/def" "github.com/DataDog/datadog-agent/pkg/metrics" - "github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes/source" "github.com/DataDog/datadog-agent/pkg/tagset" "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/exporter" ) -// tagSetEntry holds a workload-specific tag set awaiting emission as a -// dedicated running metric. metricSuffix names the workload type (e.g. -// "fargate", "azurecontainerapps"); tags is the full set of "key:value" -// strings to attach. -type tagSetEntry struct { +// tagSetKey namespaces a ConsumeTagSet dedup key by metricSuffix, so that two +// different workload types can never collide in seenTagSets even if their +// tag content happens to coincide. key is derived from tags themselves +// (sorted and joined) so that two calls with identical tags always dedup +type tagSetKey struct { metricSuffix string - tags []string + key string } // collectorConsumer is a consumer OSS collector uses to send metrics to the DataDog. type collectorConsumer struct { *serializerConsumer seenHosts map[string]struct{} - seenTagSets map[string]tagSetEntry // key: composite dedup string, value: workload tag set + seenTagSets map[tagSetKey][]string // value: full "key:value" tag slice for the metric buildInfo component.BuildInfo // getPushTime returns a Unix time in nanoseconds, representing the time pushing metrics. // It will be overwritten in tests. @@ -48,15 +49,15 @@ func (c *collectorConsumer) addRuntimeTelemetryMetric(_ string, languageTags []s series = append(series, runningMetric) } - for _, entry := range c.seenTagSets { - series = append(series, exporterWorkloadMetrics(entry.metricSuffix, timestamp, append(buildTags, entry.tags...))) + for k, tags := range c.seenTagSets { + series = append(series, exporterWorkloadMetrics(k.metricSuffix, timestamp, append(buildTags, tags...))) } // Suppress the hostless fallback emission of "metrics.running" (no Host set) // when every signal seen was already attributed to a specific workload // type via seenTagSets, to avoid double-counting a single workload for // billing. - if (len(c.seenHosts) > 0 && len(c.seenTagSets) == 0) { + if len(c.seenHosts) > 0 && len(c.seenTagSets) == 0 { series = append(series, exporterDefaultMetrics("metrics", "", timestamp, buildTags)) } @@ -77,8 +78,11 @@ func (c *collectorConsumer) ConsumeHost(host string) { } // ConsumeTagSet implements the metrics.TagSetConsumer interface. -func (c *collectorConsumer) ConsumeTagSet(metricSuffix string, key string, tags []string) { - c.seenTagSets[key] = tagSetEntry{metricSuffix: metricSuffix, tags: tags} +func (c *collectorConsumer) ConsumeTagSet(metricSuffix string, tags []string) { + sorted := slices.Clone(tags) + slices.Sort(sorted) + key := tagSetKey{metricSuffix: metricSuffix, key: strings.Join(sorted, ",")} + c.seenTagSets[key] = tags } // exporterDefaultMetrics creates built-in metrics to report that an exporter is running diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector_test.go b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector_test.go index 92ff7a4138b9..8a5017e417de 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector_test.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector_test.go @@ -18,7 +18,7 @@ func newTestCollectorConsumer(buildInfo component.BuildInfo) *collectorConsumer return &collectorConsumer{ serializerConsumer: s, seenHosts: make(map[string]struct{}), - seenTagSets: make(map[string]tagSetEntry), + seenTagSets: make(map[tagSetKey][]string), buildInfo: buildInfo, getPushTime: func() uint64 { return uint64(2e9) }, } @@ -89,7 +89,7 @@ func TestAddRuntimeTelemetryMetric_FargateTags(t *testing.T) { buildInfo := component.BuildInfo{Version: "1.0", Command: "otelcontribcol"} c := newTestCollectorConsumer(buildInfo) tag := "task_arn:arn:aws:ecs:us-east-1:123:task/cluster/abc" - c.ConsumeTagSet("fargate", tag, []string{tag}) + c.ConsumeTagSet("fargate", []string{tag}) c.addRuntimeTelemetryMetric("", nil) @@ -105,7 +105,7 @@ func TestAddRuntimeTelemetryMetric_HostAndFargate(t *testing.T) { c := newTestCollectorConsumer(buildInfo) c.ConsumeHost("my-hostname") tag := "task_arn:arn:aws:ecs:us-east-1:123:task/cluster/abc" - c.ConsumeTagSet("fargate", tag, []string{tag}) + c.ConsumeTagSet("fargate", []string{tag}) c.addRuntimeTelemetryMetric("", nil) @@ -121,23 +121,24 @@ func TestAddRuntimeTelemetryMetric_HostAndFargate(t *testing.T) { func TestAzureContainerAppsMetric(t *testing.T) { buildInfo := component.BuildInfo{} c := newTestCollectorConsumer(buildInfo) - key := "sub-123/my-rg/my-app/replica-1" tags := []string{ "replica_name:replica-1", "name:my-app", "subscription_id:sub-123", "resource_group:my-rg", } - c.ConsumeTagSet("azurecontainerapps", key, tags) - // Same key — should not duplicate - c.ConsumeTagSet("azurecontainerapps", key, tags) + reordered := []string{ + "resource_group:my-rg", + "subscription_id:sub-123", + "name:my-app", + "replica_name:replica-1", + } + c.ConsumeTagSet("azurecontainerapps", tags) + // Same tags, different order should dedup + c.ConsumeTagSet("azurecontainerapps", reordered) c.addRuntimeTelemetryMetric("", nil) - // Exactly one series total: the ACA metric only. The hostless fallback - // emission of "otel.datadog_exporter.metrics.running" must be suppressed - // here, the same way it is for Fargate-only sources, to avoid - // double-counting a single ACA workload for billing. - require.Len(t, c.series, 1, "expected exactly one series (ACA only, no stray hostless fallback metric)") + require.Len(t, c.series, 1, "expected exactly one series (ACA only)") found := c.series[0] assert.Equal(t, "otel.datadog_exporter.metrics.running.azurecontainerapps", found.Name) tagStrs := found.Tags.UnsafeToReadOnlySliceString() diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go b/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go index 9907710191a8..744d8ca4744b 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go @@ -501,7 +501,7 @@ func TestRunningMetricForPayloadContents(t *testing.T) { return &collectorConsumer{ serializerConsumer: &serializerConsumer{}, seenHosts: make(map[string]struct{}), - seenTags: make(map[string]struct{}), + seenTagSets: make(map[tagSetKey][]string), getPushTime: func() uint64 { return 0 }, } } @@ -1185,7 +1185,7 @@ func initSyncSerializerForTest(t testing.TB, logger *zap.Logger, cfg *ExporterCo if err != nil { return "" } - return s.Identifier + return s.Identifier.Primary }), fx.Provide(newOrchestratorinterfaceimpl), fx.Provide(serializer.NewSerializer), diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go b/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go index f4da65aea0ca..78ed541650db 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go @@ -172,7 +172,7 @@ func NewFactoryForOSSExporter(typ component.Type, statsIn chan []byte) exp.Facto return &collectorConsumer{ serializerConsumer: s, seenHosts: make(map[string]struct{}), - seenTagSets: make(map[string]tagSetEntry), + seenTagSets: make(map[tagSetKey][]string), buildInfo: buildInfo, getPushTime: func() uint64 { return uint64(time.Now().Unix()) }, } diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/azure/azure.go b/pkg/opentelemetry-mapping-go/otlp/attributes/azure/azure.go index ac7ba7243f55..3a83f6cd4bdf 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/azure/azure.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/azure/azure.go @@ -22,7 +22,10 @@ import ( ) const ( - // AttributeResourceGroupName is the Azure resource group name attribute + // AttributeResourceGroupName is the Azure resource group name attribute, + // Not the same attribute as otlp/attributes.AttributeAzureResourceGroupName + // (azure.resource_group.name) — that's the newer OTel semconv v1.43.0 key, used by + // Azure Container Apps detection. See https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/49079. AttributeResourceGroupName = "azure.resourcegroup.name" ) diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go index 13574c8577d7..487f4c8e63b3 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go @@ -42,6 +42,8 @@ const ( // (azure.resource_group.name), not yet available as a Go constant. // Replace with the real semconv constant once // go.opentelemetry.io/otel/semconv/v1.43.0 ships. + // Not the same attribute as azure/azure.go's AttributeResourceGroupName + // (azure.resourcegroup.name) — this is the newer semconv key AttributeAzureResourceGroupName = "azure.resource_group.name" ) diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go index 4a7f0bd9a73f..843801efbb8b 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go @@ -167,7 +167,6 @@ type TagSetConsumer interface { // metricSuffix names the workload-specific metric: the resulting metric // is "otel.datadog_exporter.metrics.running." (e.g. "fargate", // "azurecontainerapps"). - // key serves as a unique identifier for deduplication. // tags is the full slice of "key:value" strings to attach to the metric. - ConsumeTagSet(metricSuffix string, key string, tags []string) + ConsumeTagSet(metricSuffix string, tags []string) } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index 96852e737da1..6cc5680056ed 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -612,21 +612,22 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, } case source.AWSECSFargateKind: if c, ok := consumer.(TagSetConsumer); ok { - c.ConsumeTagSet("fargate", src.Tag(), []string{src.Tag()}) + c.ConsumeTagSet("fargate", []string{src.Tag()}) } else if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } case source.AzureContainerAppsKind: if c, ok := consumer.(TagSetConsumer); ok { - var tags []string - for key, val := range src.Identifier.Dimensions { - tags = append(tags, key+":"+val) + dimKeys := make([]string, 0, len(src.Identifier.Dimensions)) + for key := range src.Identifier.Dimensions { + dimKeys = append(dimKeys, key) } - key := src.Identifier.Dimensions["subscription_id"] + "/" + - src.Identifier.Dimensions["resource_group"] + "/" + - src.Identifier.Dimensions["name"] + "/" + - src.Identifier.Primary - c.ConsumeTagSet("azurecontainerapps", key, tags) + slices.Sort(dimKeys) + tags := make([]string, 0, len(dimKeys)) + for _, key := range dimKeys { + tags = append(tags, key+":"+src.Identifier.Dimensions[key]) + } + c.ConsumeTagSet("azurecontainerapps", tags) } else if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go index 8c5890a27731..daa81358891d 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go @@ -7,6 +7,7 @@ package metrics import ( "context" + "slices" "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/pmetric" @@ -152,21 +153,22 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, } case source.AWSECSFargateKind: if c, ok := consumer.(TagSetConsumer); ok { - c.ConsumeTagSet("fargate", src.Tag(), []string{src.Tag()}) + c.ConsumeTagSet("fargate", []string{src.Tag()}) } else if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } case source.AzureContainerAppsKind: if c, ok := consumer.(TagSetConsumer); ok { - var tags []string - for key, val := range src.Identifier.Dimensions { - tags = append(tags, key+":"+val) + dimKeys := make([]string, 0, len(src.Identifier.Dimensions)) + for key := range src.Identifier.Dimensions { + dimKeys = append(dimKeys, key) } - key := src.Identifier.Dimensions["subscription_id"] + "/" + - src.Identifier.Dimensions["resource_group"] + "/" + - src.Identifier.Dimensions["name"] + "/" + - src.Identifier.Primary - c.ConsumeTagSet("azurecontainerapps", key, tags) + slices.Sort(dimKeys) + tags := make([]string, 0, len(dimKeys)) + for _, key := range dimKeys { + tags = append(tags, key+":"+src.Identifier.Dimensions[key]) + } + c.ConsumeTagSet("azurecontainerapps", tags) } else if c, ok := consumer.(TagsConsumer); ok { c.ConsumeTag(src.Tag()) } From c29ca7f0262347df9c86324609e4007fb99295c2 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Fri, 10 Jul 2026 11:25:40 -0700 Subject: [PATCH 11/21] Skip Azure Container App source emission for TagsConsumer-only consumers --- pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index 6cc5680056ed..a3072e60bd2d 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -628,8 +628,6 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, tags = append(tags, key+":"+src.Identifier.Dimensions[key]) } c.ConsumeTagSet("azurecontainerapps", tags) - } else if c, ok := consumer.(TagsConsumer); ok { - c.ConsumeTag(src.Tag()) } } } From 268be4e0017f0642cdd25d241cfde0b124f1745c Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Fri, 10 Jul 2026 11:58:11 -0700 Subject: [PATCH 12/21] Fix tag-slice aliasing and clarify comments --- .../exporter/serializerexporter/consumer_collector.go | 5 +++-- .../otlp/attributes/attributes.go | 10 +++++----- pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go index 2c246530c9fa..2ebaf1d33954 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go @@ -50,7 +50,8 @@ func (c *collectorConsumer) addRuntimeTelemetryMetric(_ string, languageTags []s } for k, tags := range c.seenTagSets { - series = append(series, exporterWorkloadMetrics(k.metricSuffix, timestamp, append(buildTags, tags...))) + allTags := append(slices.Clone(buildTags), tags...) + series = append(series, exporterWorkloadMetrics(k.metricSuffix, timestamp, allTags)) } // Suppress the hostless fallback emission of "metrics.running" (no Host set) @@ -82,7 +83,7 @@ func (c *collectorConsumer) ConsumeTagSet(metricSuffix string, tags []string) { sorted := slices.Clone(tags) slices.Sort(sorted) key := tagSetKey{metricSuffix: metricSuffix, key: strings.Join(sorted, ",")} - c.seenTagSets[key] = tags + c.seenTagSets[key] = slices.Clone(tags) } // exporterDefaultMetrics creates built-in metrics to report that an exporter is running diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go b/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go index 74df9c42158b..498d85b22985 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go @@ -760,19 +760,19 @@ type azureResourceID struct { ResourceName string } -// parseAzureResourceID parses the cloud.resource_id string for Azure Container Apps, -// Azure App Services, and Azure Functions. -// It expects the format: /subscriptions/{sub}/resourceGroups/{rg}/providers/{provider}/{type}/{name} +// parseAzureResourceID parses the cloud.resource_id string for Azure resources +// that match: /subscriptions/{sub}/resourceGroups/{rg}/providers/{provider}/{type}/{name} +// (e.g. Azure Container Apps: .../containerApps/{name}). func parseAzureResourceID(resourceID string) (azureResourceID, error) { if resourceID == "" { return azureResourceID{}, errors.New("empty resource ID") } parts := strings.Split(resourceID, "/") - // Example: /subscriptions/1dd25961.../resourceGroups/rg-name/providers/Microsoft.Web/sites/site-name + // Example: /subscriptions/11111111.../resourceGroups/rg-name/providers/Microsoft.Web/sites/site-name // parts[0] = "" // parts[1] = "subscriptions" - // parts[2] = "1dd25961..." + // parts[2] = "11111111..." // parts[3] = "resourceGroups" // parts[4] = "rg-name" // parts[5] = "providers" diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go index 843801efbb8b..858cbb3f9113 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go @@ -160,8 +160,8 @@ type TagsConsumer interface { // TagSetConsumer is a multi-tag source consumer. // It is an optional interface that can be implemented by a Consumer. // Use it for any source that needs one or more tags on its own dedicated -// running metric (e.g. Fargate, Azure Container Apps). Consumers that only -// implement TagsConsumer receive a fallback single-tag call instead. +// running metric (e.g. Fargate, Azure Container Apps). Sources that require +// multiple dimensions (e.g. Azure Container Apps) should not rely on TagsConsumer. type TagSetConsumer interface { // ConsumeTagSet consumes a multi-tag source for running metric emission. // metricSuffix names the workload-specific metric: the resulting metric From 14f5e78a5ba367f8489e1a16b32a918c82e79fdc Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Mon, 13 Jul 2026 12:27:49 -0700 Subject: [PATCH 13/21] Identify ACA source even if replica name is unset --- .../otlp/attributes/source.go | 52 ++++++++++--------- .../otlp/attributes/source/source_provider.go | 2 +- .../otlp/attributes/source_test.go | 37 +++++++++++++ .../otlp/metrics/minimal_translator.go | 2 - 4 files changed, 65 insertions(+), 28 deletions(-) diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go index 487f4c8e63b3..b97db773fd89 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go @@ -181,35 +181,37 @@ func SourceFromAttrs(attrs pcommon.Map, hostFromAttributesHandler HostFromAttrib if cloudPlatform, ok := attrs.Get(string(conventions.CloudPlatformKey)); ok { p := cloudPlatform.Str() if p == conventionsv140.CloudPlatformAzureContainerApps.Value.AsString() || p == "azure_container_apps" { - if replicaName, ok := attrs.Get(AttributeAzureContainerAppInstanceID); ok { - dims := map[string]string{} - for otelKey, ddKey := range AzureContainerAppsMappings { - if v, ok := attrs.Get(otelKey); ok && v.Str() != "" { - dims[ddKey] = v.Str() - } + dims := map[string]string{} + for otelKey, ddKey := range AzureContainerAppsMappings { + if v, ok := attrs.Get(otelKey); ok && v.Str() != "" { + dims[ddKey] = v.Str() } - // Fallback: derive subscription_id, resource_group, and name from cloud.resource_id - if v, ok := attrs.Get(string(semconv1_27.CloudResourceIDKey)); ok && v.Str() != "" { - if parsed, err := parseAzureResourceID(v.Str()); err == nil { - if _, ok := dims["subscription_id"]; !ok && parsed.SubscriptionID != "" { - dims["subscription_id"] = parsed.SubscriptionID - } - if _, ok := dims["resource_group"]; !ok && parsed.ResourceGroup != "" { - dims["resource_group"] = parsed.ResourceGroup - } - if _, ok := dims["name"]; !ok && parsed.ResourceName != "" { - dims["name"] = parsed.ResourceName - } + } + // Fallback: derive subscription_id, resource_group, and name from cloud.resource_id + if v, ok := attrs.Get(string(semconv1_27.CloudResourceIDKey)); ok && v.Str() != "" { + if parsed, err := parseAzureResourceID(v.Str()); err == nil { + if _, ok := dims["subscription_id"]; !ok && parsed.SubscriptionID != "" { + dims["subscription_id"] = parsed.SubscriptionID + } + if _, ok := dims["resource_group"]; !ok && parsed.ResourceGroup != "" { + dims["resource_group"] = parsed.ResourceGroup + } + if _, ok := dims["name"]; !ok && parsed.ResourceName != "" { + dims["name"] = parsed.ResourceName } } - return source.Source{ - Kind: source.AzureContainerAppsKind, - Identifier: source.Identifier{ - Primary: replicaName.Str(), - Dimensions: dims, - }, - }, true } + primary := dims["replica_name"] + if primary == "" { + primary = dims["name"] + } + return source.Source{ + Kind: source.AzureContainerAppsKind, + Identifier: source.Identifier{ + Primary: primary, + Dimensions: dims, + }, + }, true } } diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go index 0a997056b2c2..baf6d926ef6c 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go @@ -36,7 +36,7 @@ const ( // Identifier holds the identity of a telemetry source. // For sources that can be identified by a single value, only Primary is set. -// For sources that needs to be identified by multiple values (e.g. Azure Container Apps), Dimensions is set. +// For sources that need to be identified by multiple values (e.g. Azure Container Apps), Dimensions is set. type Identifier struct { Primary string Dimensions map[string]string diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go index 95d6a8143f41..1874d0cb0e2a 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go @@ -199,6 +199,43 @@ func TestSourceFromAttrs(t *testing.T) { }, }, }, + { + name: "Azure Container Apps (no replica name, falls back to name for Primary)", + attrs: testutils.NewAttributeMap(map[string]string{ + string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), + string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), + string(conventions.ServiceNameKey): "my-app", + string(semconv1_27.CloudAccountIDKey): "sub-123", + AttributeAzureResourceGroupName: "my-rg", + }), + ok: true, + src: source.Source{ + Kind: source.AzureContainerAppsKind, + Identifier: source.Identifier{ + Primary: "my-app", + Dimensions: map[string]string{ + "name": "my-app", + "subscription_id": "sub-123", + "resource_group": "my-rg", + }, + }, + }, + }, + { + name: "Azure Container Apps (no replica name, no name, still detected with empty Primary)", + attrs: testutils.NewAttributeMap(map[string]string{ + string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), + string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), + }), + ok: true, + src: source.Source{ + Kind: source.AzureContainerAppsKind, + Identifier: source.Identifier{ + Primary: "", + Dimensions: map[string]string{}, + }, + }, + }, { name: "GCP", attrs: testutils.NewAttributeMap(map[string]string{ diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go index daa81358891d..fb6207e7d61c 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go @@ -169,8 +169,6 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, tags = append(tags, key+":"+src.Identifier.Dimensions[key]) } c.ConsumeTagSet("azurecontainerapps", tags) - } else if c, ok := consumer.(TagsConsumer); ok { - c.ConsumeTag(src.Tag()) } } } From 21583cdc0fe783b9b74868e8d0fedc24e7259763 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Mon, 13 Jul 2026 14:16:52 -0700 Subject: [PATCH 14/21] Move conversion of Source.Identifier.Dimensions map to sorted tag slice into its own function --- .../serializerexporter/consumer_collector.go | 8 +++--- .../otlp/metrics/metrics_translator.go | 25 +++++++++++-------- .../otlp/metrics/minimal_translator.go | 12 +-------- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go index 2ebaf1d33954..a2fdd71896fb 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go @@ -19,11 +19,11 @@ import ( // tagSetKey namespaces a ConsumeTagSet dedup key by metricSuffix, so that two // different workload types can never collide in seenTagSets even if their -// tag content happens to coincide. key is derived from tags themselves +// tag content happens to coincide. sortedTags is derived from tags themselves // (sorted and joined) so that two calls with identical tags always dedup type tagSetKey struct { metricSuffix string - key string + sortedTags string } // collectorConsumer is a consumer OSS collector uses to send metrics to the DataDog. @@ -82,8 +82,8 @@ func (c *collectorConsumer) ConsumeHost(host string) { func (c *collectorConsumer) ConsumeTagSet(metricSuffix string, tags []string) { sorted := slices.Clone(tags) slices.Sort(sorted) - key := tagSetKey{metricSuffix: metricSuffix, key: strings.Join(sorted, ",")} - c.seenTagSets[key] = slices.Clone(tags) + dedupKey := tagSetKey{metricSuffix: metricSuffix, sortedTags: strings.Join(sorted, ",")} + c.seenTagSets[dedupKey] = sorted } // exporterDefaultMetrics creates built-in metrics to report that an exporter is running diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index a3072e60bd2d..9d81db75dcba 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -387,6 +387,20 @@ func getQuantileTag(quantile float64) string { return "quantile:" + formatFloat(quantile) } +// tagsFromDimensions converts an Source.Identifier.Dimensions map into a sorted "key:value" tag slice +func tagsFromDimensions(dims map[string]string) []string { + keys := make([]string, 0, len(dims)) + for k := range dims { + keys = append(keys, k) + } + slices.Sort(keys) + tags := make([]string, 0, len(keys)) + for _, k := range keys { + tags = append(tags, k+":"+dims[k]) + } + return tags +} + // resolveSource determines the source from resource attributes, falling back to the fallbackSourceProvider if no source is found. func resolveSource(ctx context.Context, attributesTranslator *attributes.Translator, res pcommon.Resource, fallbackSourceProvider source.Provider, hostFromAttributesHandler attributes.HostFromAttributesHandler) (source.Source, error) { src, hasSource := attributesTranslator.ResourceToSource(ctx, res, signalTypeSet, hostFromAttributesHandler) @@ -618,16 +632,7 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, } case source.AzureContainerAppsKind: if c, ok := consumer.(TagSetConsumer); ok { - dimKeys := make([]string, 0, len(src.Identifier.Dimensions)) - for key := range src.Identifier.Dimensions { - dimKeys = append(dimKeys, key) - } - slices.Sort(dimKeys) - tags := make([]string, 0, len(dimKeys)) - for _, key := range dimKeys { - tags = append(tags, key+":"+src.Identifier.Dimensions[key]) - } - c.ConsumeTagSet("azurecontainerapps", tags) + c.ConsumeTagSet("azurecontainerapps", tagsFromDimensions(src.Identifier.Dimensions)) } } } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go index fb6207e7d61c..8e3a51becff6 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go @@ -7,7 +7,6 @@ package metrics import ( "context" - "slices" "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/pmetric" @@ -159,16 +158,7 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, } case source.AzureContainerAppsKind: if c, ok := consumer.(TagSetConsumer); ok { - dimKeys := make([]string, 0, len(src.Identifier.Dimensions)) - for key := range src.Identifier.Dimensions { - dimKeys = append(dimKeys, key) - } - slices.Sort(dimKeys) - tags := make([]string, 0, len(dimKeys)) - for _, key := range dimKeys { - tags = append(tags, key+":"+src.Identifier.Dimensions[key]) - } - c.ConsumeTagSet("azurecontainerapps", tags) + c.ConsumeTagSet("azurecontainerapps", tagsFromDimensions(src.Identifier.Dimensions)) } } } From c4d6e4ae770484db4a7a363be9a472b47e58de3f Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Mon, 13 Jul 2026 15:21:29 -0700 Subject: [PATCH 15/21] Remove TagsConsumer interface completely in favor of TagSetConsumer --- .../exporter/serializerexporter/consumer.go | 19 +++++++++++++------ .../exporter/serializerexporter/factory.go | 2 +- .../otlp/metrics/consumer.go | 16 +--------------- .../otlp/metrics/metrics_translator.go | 2 -- .../otlp/metrics/minimal_translator.go | 2 -- 5 files changed, 15 insertions(+), 26 deletions(-) diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer.go b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer.go index 2d86f594e329..dd471a05a4c8 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "time" @@ -98,7 +99,7 @@ type serializerConsumer struct { apmReceiverAddr string ipath ingestionPath hosts map[string]struct{} - ecsFargateTags map[string]struct{} + fargateTagSets map[tagSetKey][]string } // ingestionPath specifies which ingestion path is using the serializer exporter @@ -209,8 +210,8 @@ func (c *serializerConsumer) addTelemetryMetric(agentHostname string, params exp for host := range c.hosts { coatUsageMetric.Set(1.0, buildInfo.Version, buildInfo.Command, host, "") } - for ecsFargateTag := range c.ecsFargateTags { - taskArn := strings.Split(ecsFargateTag, ":")[1] + for _, tags := range c.fargateTagSets { + taskArn := strings.Split(tags[0], ":")[1] coatUsageMetric.Set(1.0, buildInfo.Version, buildInfo.Command, "", taskArn) } case agentOTLPIngest: @@ -324,7 +325,13 @@ func (c *serializerConsumer) ConsumeHost(host string) { c.hosts[host] = struct{}{} } -// ConsumeTag implements the metrics.TagsConsumer interface. -func (c *serializerConsumer) ConsumeTag(tag string) { - c.ecsFargateTags[tag] = struct{}{} +// ConsumeTagSet implements the metrics.TagSetConsumer interface. +func (c *serializerConsumer) ConsumeTagSet(metricSuffix string, tags []string) { + if metricSuffix != "fargate" { + return + } + sorted := slices.Clone(tags) + slices.Sort(sorted) + dedupKey := tagSetKey{metricSuffix: metricSuffix, sortedTags: strings.Join(sorted, ",")} + c.fargateTagSets[dedupKey] = sorted } diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go b/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go index 78ed541650db..e25cd21a0d0d 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/factory.go @@ -121,7 +121,7 @@ func newFactoryForAgentWithType( apmReceiverAddr: apmReceiverAddr, ipath: ipath, hosts: make(map[string]struct{}), - ecsFargateTags: make(map[string]struct{}), + fargateTagSets: make(map[tagSetKey][]string), } }, options: options, diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go index 858cbb3f9113..3be40cea989f 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go @@ -144,24 +144,10 @@ type HostConsumer interface { ConsumeHost(host string) } -// TagsConsumer is a tags consumer. -// It is an optional interface that can be implemented by a Consumer. -// Consumed tags are used for running metrics, and should represent -// some resource running a Collector (e.g. Fargate task). -// -// Legacy: Fargate itself now goes through TagSetConsumer. This interface -// remains only for consumers that haven't migrated yet. Removing it is a -// breaking change until all known implementors migrate. -type TagsConsumer interface { - // ConsumeTag consumes a tag - ConsumeTag(tag string) -} - // TagSetConsumer is a multi-tag source consumer. // It is an optional interface that can be implemented by a Consumer. // Use it for any source that needs one or more tags on its own dedicated -// running metric (e.g. Fargate, Azure Container Apps). Sources that require -// multiple dimensions (e.g. Azure Container Apps) should not rely on TagsConsumer. +// running metric (e.g. Fargate, Azure Container Apps). type TagSetConsumer interface { // ConsumeTagSet consumes a multi-tag source for running metric emission. // metricSuffix names the workload-specific metric: the resulting metric diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index 9d81db75dcba..fc45d7a19f34 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -627,8 +627,6 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, case source.AWSECSFargateKind: if c, ok := consumer.(TagSetConsumer); ok { c.ConsumeTagSet("fargate", []string{src.Tag()}) - } else if c, ok := consumer.(TagsConsumer); ok { - c.ConsumeTag(src.Tag()) } case source.AzureContainerAppsKind: if c, ok := consumer.(TagSetConsumer); ok { diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go index 8e3a51becff6..d73cd0fd1bfb 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go @@ -153,8 +153,6 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, case source.AWSECSFargateKind: if c, ok := consumer.(TagSetConsumer); ok { c.ConsumeTagSet("fargate", []string{src.Tag()}) - } else if c, ok := consumer.(TagsConsumer); ok { - c.ConsumeTag(src.Tag()) } case source.AzureContainerAppsKind: if c, ok := consumer.(TagSetConsumer); ok { From b3d8ef7fd77dd4949e3040e7013744be3769b7ed Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Mon, 13 Jul 2026 16:40:46 -0700 Subject: [PATCH 16/21] Extract task_arn prefix and skip emission if not found --- .../exporter/serializerexporter/consumer.go | 8 +++++++- .../serializerexporter/consumer_collector.go | 4 ---- .../otlp/attributes/azure/azure.go | 4 ++-- .../otlp/metrics/metrics_translator.go | 13 ++++--------- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer.go b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer.go index dd471a05a4c8..efd6f8594857 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer.go @@ -24,6 +24,7 @@ import ( "github.com/DataDog/datadog-agent/comp/core/telemetry/def" "github.com/DataDog/datadog-agent/pkg/metrics" + "github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes/source" otlpmetrics "github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/metrics" pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/trace" "github.com/DataDog/datadog-agent/pkg/serializer" @@ -211,7 +212,12 @@ func (c *serializerConsumer) addTelemetryMetric(agentHostname string, params exp coatUsageMetric.Set(1.0, buildInfo.Version, buildInfo.Command, host, "") } for _, tags := range c.fargateTagSets { - taskArn := strings.Split(tags[0], ":")[1] + prefix := string(source.AWSECSFargateKind) + ":" + idx := slices.IndexFunc(tags, func(t string) bool { return strings.HasPrefix(t, prefix) }) + if idx == -1 { + continue + } + taskArn := strings.TrimPrefix(tags[idx], prefix) coatUsageMetric.Set(1.0, buildInfo.Version, buildInfo.Command, "", taskArn) } case agentOTLPIngest: diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go index a2fdd71896fb..2d1f4eaefee7 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/consumer_collector.go @@ -54,10 +54,6 @@ func (c *collectorConsumer) addRuntimeTelemetryMetric(_ string, languageTags []s series = append(series, exporterWorkloadMetrics(k.metricSuffix, timestamp, allTags)) } - // Suppress the hostless fallback emission of "metrics.running" (no Host set) - // when every signal seen was already attributed to a specific workload - // type via seenTagSets, to avoid double-counting a single workload for - // billing. if len(c.seenHosts) > 0 && len(c.seenTagSets) == 0 { series = append(series, exporterDefaultMetrics("metrics", "", timestamp, buildTags)) } diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/azure/azure.go b/pkg/opentelemetry-mapping-go/otlp/attributes/azure/azure.go index 3a83f6cd4bdf..04de01f9eab9 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/azure/azure.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/azure/azure.go @@ -24,8 +24,8 @@ import ( const ( // AttributeResourceGroupName is the Azure resource group name attribute, // Not the same attribute as otlp/attributes.AttributeAzureResourceGroupName - // (azure.resource_group.name) — that's the newer OTel semconv v1.43.0 key, used by - // Azure Container Apps detection. See https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/49079. + // (azure.resource_group.name — newer OTel semconv v1.43.0). + // See https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/49079. AttributeResourceGroupName = "azure.resourcegroup.name" ) diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index fc45d7a19f34..6607e3c23f93 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -387,16 +387,11 @@ func getQuantileTag(quantile float64) string { return "quantile:" + formatFloat(quantile) } -// tagsFromDimensions converts an Source.Identifier.Dimensions map into a sorted "key:value" tag slice +// tagsFromDimensions converts an Source.Identifier.Dimensions map into a "key:value" tag slice func tagsFromDimensions(dims map[string]string) []string { - keys := make([]string, 0, len(dims)) - for k := range dims { - keys = append(keys, k) - } - slices.Sort(keys) - tags := make([]string, 0, len(keys)) - for _, k := range keys { - tags = append(tags, k+":"+dims[k]) + tags := make([]string, 0, len(dims)) + for k, v := range dims { + tags = append(tags, k+":"+v) } return tags } From 72b5b53a2479792ac948a91cf7f8cf909d69a897 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Wed, 15 Jul 2026 11:31:49 -0700 Subject: [PATCH 17/21] Add SourceIdentifier as a new field on Source, revert Source.Identifier to string, and fix call sites --- .../datadogexporter/traces_exporter.go | 4 +- .../exporter/serializerexporter/exporter.go | 6 +- .../serializerexporter/exporter_test.go | 2 +- .../exporter/serializerexporter/serializer.go | 2 +- .../inframetadata/reporter.go | 2 +- .../otlp/attributes/attributes.go | 8 +- .../otlp/attributes/source.go | 17 +++- .../otlp/attributes/source/source_provider.go | 26 ++++-- .../otlp/attributes/source_test.go | 92 +++++++++++++------ .../otlp/logs/transform.go | 4 +- .../otlp/logs/translator.go | 4 +- .../otlp/metrics/metrics_translator.go | 8 +- .../otlp/metrics/metrics_translator_test.go | 5 +- .../otlp/metrics/minimal_translator.go | 4 +- pkg/trace/api/otlp.go | 22 ++++- pkg/trace/api/otlp_test.go | 2 +- pkg/trace/transform/transform.go | 8 +- 17 files changed, 147 insertions(+), 69 deletions(-) diff --git a/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go b/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go index a6b8bea123b7..c08ba36f7837 100644 --- a/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go +++ b/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go @@ -88,9 +88,9 @@ func (exp *traceExporter) consumeTraces( } switch src.Kind { case source.HostnameKind: - hosts[src.Identifier.Primary] = struct{}{} + hosts[src.Identifier] = struct{}{} case source.AWSECSFargateKind: - ecsFargateArns[src.Identifier.Primary] = struct{}{} + ecsFargateArns[src.Identifier] = struct{}{} case source.InvalidKind: } } diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/exporter.go b/comp/otelcol/otlp/components/exporter/serializerexporter/exporter.go index 2e79e75a356b..a04289078dda 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/exporter.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/exporter.go @@ -151,7 +151,11 @@ func (f SourceProviderFunc) Source(ctx context.Context) (source.Source, error) { return source.Source{}, err } - return source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: hostnameIdentifier}}, nil + return source.Source{ + Kind: source.HostnameKind, + Identifier: hostnameIdentifier, + SourceIdentifier: source.SourceIdentifier{Primary: hostnameIdentifier}, + }, nil } // Exporter translate OTLP metrics into the Datadog format and sends diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go b/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go index 744d8ca4744b..13d10fa6e8ff 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go @@ -1185,7 +1185,7 @@ func initSyncSerializerForTest(t testing.TB, logger *zap.Logger, cfg *ExporterCo if err != nil { return "" } - return s.Identifier.Primary + return s.Identifier }), fx.Provide(newOrchestratorinterfaceimpl), fx.Provide(serializer.NewSerializer), diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go b/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go index 178894948d63..a017136b9da1 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go @@ -188,7 +188,7 @@ func initSerializerInternal(logger *zap.Logger, cfg *ExporterConfig, sourceProvi if err != nil { return "" } - return s.Identifier.Primary + return s.Identifier }), fx.Provide(newOrchestratorinterfaceimpl), fx.Provide(serializer.NewSerializer), diff --git a/pkg/opentelemetry-mapping-go/inframetadata/reporter.go b/pkg/opentelemetry-mapping-go/inframetadata/reporter.go index f39c0b36961d..576053f35255 100644 --- a/pkg/opentelemetry-mapping-go/inframetadata/reporter.go +++ b/pkg/opentelemetry-mapping-go/inframetadata/reporter.go @@ -121,7 +121,7 @@ func (r *Reporter) hostname(res pcommon.Resource) (string, bool) { // The resource does not identify a host (e.g. serverless resource) return "", false } - return src.Identifier.Primary, true + return src.Identifier, true } // ConsumeResource for host metadata reporting purposes. diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go b/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go index 498d85b22985..cdc25fdd23bc 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go @@ -737,14 +737,18 @@ func GetHost(resourceAttrs pcommon.Map, fallbackHost string) string { src, srcok := SourceFromAttrs(resourceAttrs, nil) if !srcok { if v := GetOTelAttrVal(resourceAttrs, false, "_dd.hostname"); v != "" { - src = source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: v}} + src = source.Source{ + Kind: source.HostnameKind, + Identifier: v, + SourceIdentifier: source.SourceIdentifier{Primary: v}, + } srcok = true } } if srcok { switch src.Kind { case source.HostnameKind: - return src.Identifier.Primary + return src.Identifier default: // We are not on a hostname (serverless), hence the hostname is empty return "" diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go index b97db773fd89..1f559461bf36 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go @@ -174,7 +174,11 @@ type HostFromAttributesHandler interface { func SourceFromAttrs(attrs pcommon.Map, hostFromAttributesHandler HostFromAttributesHandler) (source.Source, bool) { if launchType, ok := attrs.Get(string(conventions.AWSECSLaunchtypeKey)); ok && launchType.Str() == conventions.AWSECSLaunchtypeFargate.Value.AsString() { if taskARN, ok := attrs.Get(string(conventions.AWSECSTaskARNKey)); ok { - return source.Source{Kind: source.AWSECSFargateKind, Identifier: source.Identifier{Primary: taskARN.Str()}}, true + return source.Source{ + Kind: source.AWSECSFargateKind, + Identifier: taskARN.Str(), + SourceIdentifier: source.SourceIdentifier{Primary: taskARN.Str()}, + }, true } } @@ -206,8 +210,9 @@ func SourceFromAttrs(attrs pcommon.Map, hostFromAttributesHandler HostFromAttrib primary = dims["name"] } return source.Source{ - Kind: source.AzureContainerAppsKind, - Identifier: source.Identifier{ + Kind: source.AzureContainerAppsKind, + Identifier: primary, + SourceIdentifier: source.SourceIdentifier{ Primary: primary, Dimensions: dims, }, @@ -219,7 +224,11 @@ func SourceFromAttrs(attrs pcommon.Map, hostFromAttributesHandler HostFromAttrib if hostFromAttributesHandler != nil { hostFromAttributesHandler.OnHost(host) } - return source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: host}}, true + return source.Source{ + Kind: source.HostnameKind, + Identifier: host, + SourceIdentifier: source.SourceIdentifier{Primary: host}, + }, true } return source.Source{}, false diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go index baf6d926ef6c..d4eb3f6095c5 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go @@ -34,25 +34,31 @@ const ( AzureContainerAppsKind Kind = "azure_container_apps" ) -// Identifier holds the identity of a telemetry source. -// For sources that can be identified by a single value, only Primary is set. -// For sources that need to be identified by multiple values (e.g. Azure Container Apps), Dimensions is set. -type Identifier struct { - Primary string - Dimensions map[string]string -} - // Source represents a telemetry source. type Source struct { // Kind of source (serverless v. host). Kind Kind // Identifier that uniquely determines the source. - Identifier Identifier + // + // Deprecated: use SourceIdentifier.Primary instead for any new call + // site. This field remains for existing callers during migration + // (tracked in datadog-agent#51116); it will be removed once all + // callers have moved to SourceIdentifier. + Identifier string + SourceIdentifier SourceIdentifier } // Tag associated to a source. func (s Source) Tag() string { - return fmt.Sprintf("%s:%s", s.Kind, s.Identifier.Primary) + return fmt.Sprintf("%s:%s", s.Kind, s.Identifier) +} + +// SourceIdentifier holds the identity of a telemetry source, generalizing the +// single-string Source.Identifier to support workloads that need more than +// one identifying attribute. +type SourceIdentifier struct { + Primary string + Dimensions map[string]string } // Provider identifies a source. diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go index 1874d0cb0e2a..f1c8fd3b91d8 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go @@ -60,8 +60,12 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostIDKey): testHostID, string(conventions.HostNameKey): testHostName, }), - ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testLiteralHost}}, + ok: true, + src: source.Source{ + Kind: source.HostnameKind, + Identifier: testLiteralHost, + SourceIdentifier: source.SourceIdentifier{Primary: testLiteralHost}, + }, }, { name: "custom hostname", @@ -73,8 +77,12 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostIDKey): testHostID, string(conventions.HostNameKey): testHostName, }), - ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testCustomName}}, + ok: true, + src: source.Source{ + Kind: source.HostnameKind, + Identifier: testCustomName, + SourceIdentifier: source.SourceIdentifier{Primary: testCustomName}, + }, }, { name: "container ID", @@ -89,8 +97,12 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostIDKey): testHostID, string(conventions.HostNameKey): testHostName, }), - ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testHostID}}, + ok: true, + src: source.Source{ + Kind: source.HostnameKind, + Identifier: testHostID, + SourceIdentifier: source.SourceIdentifier{Primary: testHostID}, + }, }, { name: "ECS Fargate", @@ -102,8 +114,12 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.AWSECSTaskRevisionKey): "example-task-revision", string(conventions.AWSECSLaunchtypeKey): conventions.AWSECSLaunchtypeFargate.Value.AsString(), }), - ok: true, - src: source.Source{Kind: source.AWSECSFargateKind, Identifier: source.Identifier{Primary: "example-task-ARN"}}, + ok: true, + src: source.Source{ + Kind: source.AWSECSFargateKind, + Identifier: "example-task-ARN", + SourceIdentifier: source.SourceIdentifier{Primary: "example-task-ARN"}, + }, }, { name: "Azure Container Apps (semconv v1.40.0 or later)", @@ -117,8 +133,9 @@ func TestSourceFromAttrs(t *testing.T) { }), ok: true, src: source.Source{ - Kind: source.AzureContainerAppsKind, - Identifier: source.Identifier{ + Kind: source.AzureContainerAppsKind, + Identifier: "replica-1", + SourceIdentifier: source.SourceIdentifier{ Primary: "replica-1", Dimensions: map[string]string{ "replica_name": "replica-1", @@ -141,8 +158,9 @@ func TestSourceFromAttrs(t *testing.T) { }), ok: true, src: source.Source{ - Kind: source.AzureContainerAppsKind, - Identifier: source.Identifier{ + Kind: source.AzureContainerAppsKind, + Identifier: "replica-1", + SourceIdentifier: source.SourceIdentifier{ Primary: "replica-1", Dimensions: map[string]string{ "replica_name": "replica-1", @@ -163,8 +181,9 @@ func TestSourceFromAttrs(t *testing.T) { }), ok: true, src: source.Source{ - Kind: source.AzureContainerAppsKind, - Identifier: source.Identifier{ + Kind: source.AzureContainerAppsKind, + Identifier: "replica-1", + SourceIdentifier: source.SourceIdentifier{ Primary: "replica-1", Dimensions: map[string]string{ "replica_name": "replica-1", @@ -187,8 +206,9 @@ func TestSourceFromAttrs(t *testing.T) { }), ok: true, src: source.Source{ - Kind: source.AzureContainerAppsKind, - Identifier: source.Identifier{ + Kind: source.AzureContainerAppsKind, + Identifier: "replica-1", + SourceIdentifier: source.SourceIdentifier{ Primary: "replica-1", Dimensions: map[string]string{ "replica_name": "replica-1", @@ -210,8 +230,9 @@ func TestSourceFromAttrs(t *testing.T) { }), ok: true, src: source.Source{ - Kind: source.AzureContainerAppsKind, - Identifier: source.Identifier{ + Kind: source.AzureContainerAppsKind, + Identifier: "my-app", + SourceIdentifier: source.SourceIdentifier{ Primary: "my-app", Dimensions: map[string]string{ "name": "my-app", @@ -229,8 +250,9 @@ func TestSourceFromAttrs(t *testing.T) { }), ok: true, src: source.Source{ - Kind: source.AzureContainerAppsKind, - Identifier: source.Identifier{ + Kind: source.AzureContainerAppsKind, + Identifier: "", + SourceIdentifier: source.SourceIdentifier{ Primary: "", Dimensions: map[string]string{}, }, @@ -244,8 +266,12 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostNameKey): testGCPHostname, string(conventions.CloudAccountIDKey): testCloudAccount, }), - ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testGCPIntegrationHostname}}, + ok: true, + src: source.Source{ + Kind: source.HostnameKind, + Identifier: testGCPIntegrationHostname, + SourceIdentifier: source.SourceIdentifier{Primary: testGCPIntegrationHostname}, + }, }, { name: "GCP, no account id", @@ -262,8 +288,12 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostIDKey): testHostID, string(conventions.HostNameKey): testHostName, }), - ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testHostID}}, + ok: true, + src: source.Source{ + Kind: source.HostnameKind, + Identifier: testHostID, + SourceIdentifier: source.SourceIdentifier{Primary: testHostID}, + }, }, { name: "host id v. hostname", @@ -271,8 +301,12 @@ func TestSourceFromAttrs(t *testing.T) { string(conventions.HostIDKey): testHostID, string(conventions.HostNameKey): testHostName, }), - ok: true, - src: source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: testHostID}}, + ok: true, + src: source.Source{ + Kind: source.HostnameKind, + Identifier: testHostID, + SourceIdentifier: source.SourceIdentifier{Primary: testHostID}, + }, }, { name: "no hostname", @@ -301,7 +335,11 @@ func TestLiteralHostNonString(t *testing.T) { attrs.PutInt(AttributeHost, 1000) src, ok := SourceFromAttrs(attrs, nil) assert.True(t, ok) - assert.Equal(t, source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: "1000"}}, src) + assert.Equal(t, source.Source{ + Kind: source.HostnameKind, + Identifier: "1000", + SourceIdentifier: source.SourceIdentifier{Primary: "1000"}, + }, src) } func TestGetClusterName(t *testing.T) { diff --git a/pkg/opentelemetry-mapping-go/otlp/logs/transform.go b/pkg/opentelemetry-mapping-go/otlp/logs/transform.go index 5f497cb97279..addb193c65ee 100644 --- a/pkg/opentelemetry-mapping-go/otlp/logs/transform.go +++ b/pkg/opentelemetry-mapping-go/otlp/logs/transform.go @@ -236,13 +236,13 @@ func flattenAttribute(key string, val pcommon.Value, depth int) map[string]any { func extractHostNameAndServiceName(resourceAttrs pcommon.Map, logAttrs pcommon.Map) (host string, service string) { if src, ok := attributes.SourceFromAttrs(resourceAttrs, nil); ok && src.Kind == source.HostnameKind { - host = src.Identifier.Primary + host = src.Identifier } // HACK: Check for host in log record attributes if not present in resource attributes. // This is not aligned with the specification and will be removed in the future. if host == "" { if src, ok := attributes.SourceFromAttrs(logAttrs, nil); ok && src.Kind == source.HostnameKind { - host = src.Identifier.Primary + host = src.Identifier } } if s, ok := resourceAttrs.Get(string(conventions.ServiceNameKey)); ok { diff --git a/pkg/opentelemetry-mapping-go/otlp/logs/translator.go b/pkg/opentelemetry-mapping-go/otlp/logs/translator.go index dfaa913954f8..21eece9b6fc1 100644 --- a/pkg/opentelemetry-mapping-go/otlp/logs/translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/logs/translator.go @@ -78,7 +78,7 @@ func NewTranslatorWithHTTPClient(set component.TelemetrySettings, attributesTran func (t *Translator) hostNameAndServiceNameFromResource(ctx context.Context, res pcommon.Resource, hostFromAttributesHandler attributes.HostFromAttributesHandler) (host string, service string) { if src, ok := t.attributesTranslator.ResourceToSource(ctx, res, signalTypeSet, hostFromAttributesHandler); ok && src.Kind == source.HostnameKind { - host = src.Identifier.Primary + host = src.Identifier } if s, ok := res.Attributes().Get(string(conventions.ServiceNameKey)); ok { service = s.AsString() @@ -88,7 +88,7 @@ func (t *Translator) hostNameAndServiceNameFromResource(ctx context.Context, res func (t *Translator) hostFromAttributes(ctx context.Context, attrs pcommon.Map) string { if src, ok := t.attributesTranslator.AttributesToSource(ctx, attrs); ok && src.Kind == source.HostnameKind { - return src.Identifier.Primary + return src.Identifier } return "" } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index 6607e3c23f93..07f9b522769b 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -96,7 +96,7 @@ var _ source.Provider = (*noSourceProvider)(nil) type noSourceProvider struct{} func (*noSourceProvider) Source(context.Context) (source.Source, error) { - return source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: ""}}, nil + return source.Source{Kind: source.HostnameKind, Identifier: "", SourceIdentifier: source.SourceIdentifier{Primary: ""}}, nil } // defaultTranslator is the default metrics translator implementation. @@ -387,7 +387,7 @@ func getQuantileTag(quantile float64) string { return "quantile:" + formatFloat(quantile) } -// tagsFromDimensions converts an Source.Identifier.Dimensions map into a "key:value" tag slice +// tagsFromDimensions converts a Source.SourceIdentifier.Dimensions map into a "key:value" tag slice func tagsFromDimensions(dims map[string]string) []string { tags := make([]string, 0, len(dims)) for k, v := range dims { @@ -525,7 +525,7 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, var host string if src.Kind == source.HostnameKind { - host = src.Identifier.Primary + host = src.Identifier // Don't consume the host yet, first check if we have any nonAPM metrics. } @@ -625,7 +625,7 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, } case source.AzureContainerAppsKind: if c, ok := consumer.(TagSetConsumer); ok { - c.ConsumeTagSet("azurecontainerapps", tagsFromDimensions(src.Identifier.Dimensions)) + c.ConsumeTagSet("azurecontainerapps", tagsFromDimensions(src.SourceIdentifier.Dimensions)) } } } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator_test.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator_test.go index b02e329a6994..b1efe3a39546 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator_test.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator_test.go @@ -96,8 +96,9 @@ type testProvider string func (t testProvider) Source(context.Context) (source.Source, error) { return source.Source{ - Kind: source.HostnameKind, - Identifier: source.Identifier{Primary: string(t)}, + Kind: source.HostnameKind, + Identifier: string(t), + SourceIdentifier: source.SourceIdentifier{Primary: string(t)}, }, nil } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go index d73cd0fd1bfb..345269d822a1 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go @@ -90,7 +90,7 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, var host string if src.Kind == source.HostnameKind { - host = src.Identifier.Primary + host = src.Identifier // Don't consume the host yet, first check if we have any nonAPM metrics. } @@ -156,7 +156,7 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, } case source.AzureContainerAppsKind: if c, ok := consumer.(TagSetConsumer); ok { - c.ConsumeTagSet("azurecontainerapps", tagsFromDimensions(src.Identifier.Dimensions)) + c.ConsumeTagSet("azurecontainerapps", tagsFromDimensions(src.SourceIdentifier.Dimensions)) } } } diff --git a/pkg/trace/api/otlp.go b/pkg/trace/api/otlp.go index b1ddf351c8e1..6fe267568b75 100644 --- a/pkg/trace/api/otlp.go +++ b/pkg/trace/api/otlp.go @@ -301,7 +301,7 @@ func (o *OTLPReceiver) receiveResourceSpansV2(ctx context.Context, rspans ptrace if srcok { switch src.Kind { case source.HostnameKind: - hostname = src.Identifier.Primary + hostname = src.Identifier default: // We are not on a hostname (serverless), hence the hostname is empty hostname = "" @@ -309,7 +309,11 @@ func (o *OTLPReceiver) receiveResourceSpansV2(ctx context.Context, rspans ptrace } else { // fallback hostname hostname = o.conf.Hostname - src = source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: hostname}} + src = source.Source{ + Kind: source.HostnameKind, + Identifier: hostname, + SourceIdentifier: source.SourceIdentifier{Primary: hostname}, + } } // Create a single accessor for all resource-level semantic lookups below, avoiding @@ -417,7 +421,11 @@ func (o *OTLPReceiver) receiveResourceSpansV1(ctx context.Context, rspans ptrace hostFromMap := func(m map[string]string, key string) { // hostFromMap sets the hostname to m[key] if it is set. if v, ok := m[key]; ok { - src = source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: v}} + src = source.Source{ + Kind: source.HostnameKind, + Identifier: v, + SourceIdentifier: source.SourceIdentifier{Primary: v}, + } srcok = true } } @@ -503,7 +511,7 @@ func (o *OTLPReceiver) receiveResourceSpansV1(ctx context.Context, rspans ptrace if srcok { switch src.Kind { case source.HostnameKind: - hostname = src.Identifier.Primary + hostname = src.Identifier default: // We are not on a hostname (serverless), hence the hostname is empty hostname = "" @@ -511,7 +519,11 @@ func (o *OTLPReceiver) receiveResourceSpansV1(ctx context.Context, rspans ptrace } else { // fallback hostname hostname = o.conf.Hostname - src = source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: hostname}} + src = source.Source{ + Kind: source.HostnameKind, + Identifier: hostname, + SourceIdentifier: source.SourceIdentifier{Primary: hostname}, + } } p.TracerPayload = &pb.TracerPayload{ Hostname: hostname, diff --git a/pkg/trace/api/otlp_test.go b/pkg/trace/api/otlp_test.go index 4617e41ac9ce..6b6616b057b9 100644 --- a/pkg/trace/api/otlp_test.go +++ b/pkg/trace/api/otlp_test.go @@ -1393,7 +1393,7 @@ func testOTLPHostname(enableReceiveResourceSpansV2 bool, t *testing.T) { }, }).Traces().ResourceSpans().At(0), http.Header{}, nil) assert.Equal(t, src.Kind, source.HostnameKind) - assert.Equal(t, src.Identifier.Primary, tt.out) + assert.Equal(t, src.Identifier, tt.out) timeout := time.After(500 * time.Millisecond) select { case <-timeout: diff --git a/pkg/trace/transform/transform.go b/pkg/trace/transform/transform.go index 742d854e02df..28694dc97d95 100644 --- a/pkg/trace/transform/transform.go +++ b/pkg/trace/transform/transform.go @@ -202,14 +202,18 @@ func GetOTelHostname(span ptrace.Span, res pcommon.Resource, tr *attributes.Tran src, srcok := tr.ResourceToSource(ctx, res, SignalTypeSet, nil) if !srcok { if v := GetOTelAttrValInResAndSpanAttrs(span, res, false, "_dd.hostname"); v != "" { - src = source.Source{Kind: source.HostnameKind, Identifier: source.Identifier{Primary: v}} + src = source.Source{ + Kind: source.HostnameKind, + Identifier: v, + SourceIdentifier: source.SourceIdentifier{Primary: v}, + } srcok = true } } if srcok { switch src.Kind { case source.HostnameKind: - return src.Identifier.Primary + return src.Identifier default: // We are not on a hostname (serverless), hence the hostname is empty return "" From 43c2b8eada752dd3bab3870abead549795087912 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Wed, 15 Jul 2026 11:42:28 -0700 Subject: [PATCH 18/21] Add nolint comments for source.Identifier callers --- .../components/exporter/serializerexporter/exporter_test.go | 2 +- .../otlp/components/exporter/serializerexporter/serializer.go | 2 +- pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go | 2 +- .../otlp/metrics/metrics_translator.go | 2 +- .../otlp/metrics/minimal_translator.go | 2 +- pkg/trace/api/otlp.go | 4 ++-- pkg/trace/api/otlp_test.go | 2 +- pkg/trace/transform/transform.go | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go b/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go index 13d10fa6e8ff..a1fb8255bd2e 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/exporter_test.go @@ -1185,7 +1185,7 @@ func initSyncSerializerForTest(t testing.TB, logger *zap.Logger, cfg *ExporterCo if err != nil { return "" } - return s.Identifier + return s.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 }), fx.Provide(newOrchestratorinterfaceimpl), fx.Provide(serializer.NewSerializer), diff --git a/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go b/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go index a017136b9da1..874311c0396b 100644 --- a/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go +++ b/comp/otelcol/otlp/components/exporter/serializerexporter/serializer.go @@ -188,7 +188,7 @@ func initSerializerInternal(logger *zap.Logger, cfg *ExporterConfig, sourceProvi if err != nil { return "" } - return s.Identifier + return s.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 }), fx.Provide(newOrchestratorinterfaceimpl), fx.Provide(serializer.NewSerializer), diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go b/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go index cdc25fdd23bc..b9ba9cd2444f 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/attributes.go @@ -748,7 +748,7 @@ func GetHost(resourceAttrs pcommon.Map, fallbackHost string) string { if srcok { switch src.Kind { case source.HostnameKind: - return src.Identifier + return src.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 default: // We are not on a hostname (serverless), hence the hostname is empty return "" diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go index 07f9b522769b..a7dd659a7cf1 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/metrics_translator.go @@ -525,7 +525,7 @@ func (t *defaultTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, var host string if src.Kind == source.HostnameKind { - host = src.Identifier + host = src.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 // Don't consume the host yet, first check if we have any nonAPM metrics. } diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go index 345269d822a1..e4c12eeb3f5d 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/minimal_translator.go @@ -90,7 +90,7 @@ func (t *minimalTranslator) MapMetrics(ctx context.Context, md pmetric.Metrics, var host string if src.Kind == source.HostnameKind { - host = src.Identifier + host = src.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 // Don't consume the host yet, first check if we have any nonAPM metrics. } diff --git a/pkg/trace/api/otlp.go b/pkg/trace/api/otlp.go index 6fe267568b75..7255c6f98611 100644 --- a/pkg/trace/api/otlp.go +++ b/pkg/trace/api/otlp.go @@ -301,7 +301,7 @@ func (o *OTLPReceiver) receiveResourceSpansV2(ctx context.Context, rspans ptrace if srcok { switch src.Kind { case source.HostnameKind: - hostname = src.Identifier + hostname = src.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 default: // We are not on a hostname (serverless), hence the hostname is empty hostname = "" @@ -511,7 +511,7 @@ func (o *OTLPReceiver) receiveResourceSpansV1(ctx context.Context, rspans ptrace if srcok { switch src.Kind { case source.HostnameKind: - hostname = src.Identifier + hostname = src.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 default: // We are not on a hostname (serverless), hence the hostname is empty hostname = "" diff --git a/pkg/trace/api/otlp_test.go b/pkg/trace/api/otlp_test.go index 6b6616b057b9..7bf38e1253f8 100644 --- a/pkg/trace/api/otlp_test.go +++ b/pkg/trace/api/otlp_test.go @@ -1393,7 +1393,7 @@ func testOTLPHostname(enableReceiveResourceSpansV2 bool, t *testing.T) { }, }).Traces().ResourceSpans().At(0), http.Header{}, nil) assert.Equal(t, src.Kind, source.HostnameKind) - assert.Equal(t, src.Identifier, tt.out) + assert.Equal(t, src.Identifier, tt.out) //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 timeout := time.After(500 * time.Millisecond) select { case <-timeout: diff --git a/pkg/trace/transform/transform.go b/pkg/trace/transform/transform.go index 28694dc97d95..b0b272e2e7c4 100644 --- a/pkg/trace/transform/transform.go +++ b/pkg/trace/transform/transform.go @@ -213,7 +213,7 @@ func GetOTelHostname(span ptrace.Span, res pcommon.Resource, tr *attributes.Tran if srcok { switch src.Kind { case source.HostnameKind: - return src.Identifier + return src.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 default: // We are not on a hostname (serverless), hence the hostname is empty return "" From 1f167fa31e1f8f228e13c6369dd15c395d83e023 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Wed, 15 Jul 2026 14:08:24 -0700 Subject: [PATCH 19/21] More nolint comments --- .../components/exporter/datadogexporter/traces_exporter.go | 4 ++-- pkg/opentelemetry-mapping-go/inframetadata/reporter.go | 2 +- pkg/opentelemetry-mapping-go/otlp/logs/transform.go | 4 ++-- pkg/opentelemetry-mapping-go/otlp/logs/translator.go | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go b/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go index c08ba36f7837..0226ef1d2b28 100644 --- a/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go +++ b/comp/otelcol/otlp/components/exporter/datadogexporter/traces_exporter.go @@ -88,9 +88,9 @@ func (exp *traceExporter) consumeTraces( } switch src.Kind { case source.HostnameKind: - hosts[src.Identifier] = struct{}{} + hosts[src.Identifier] = struct{}{} //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 case source.AWSECSFargateKind: - ecsFargateArns[src.Identifier] = struct{}{} + ecsFargateArns[src.Identifier] = struct{}{} //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 case source.InvalidKind: } } diff --git a/pkg/opentelemetry-mapping-go/inframetadata/reporter.go b/pkg/opentelemetry-mapping-go/inframetadata/reporter.go index 576053f35255..750de6964516 100644 --- a/pkg/opentelemetry-mapping-go/inframetadata/reporter.go +++ b/pkg/opentelemetry-mapping-go/inframetadata/reporter.go @@ -121,7 +121,7 @@ func (r *Reporter) hostname(res pcommon.Resource) (string, bool) { // The resource does not identify a host (e.g. serverless resource) return "", false } - return src.Identifier, true + return src.Identifier, true //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 } // ConsumeResource for host metadata reporting purposes. diff --git a/pkg/opentelemetry-mapping-go/otlp/logs/transform.go b/pkg/opentelemetry-mapping-go/otlp/logs/transform.go index addb193c65ee..c68195931d01 100644 --- a/pkg/opentelemetry-mapping-go/otlp/logs/transform.go +++ b/pkg/opentelemetry-mapping-go/otlp/logs/transform.go @@ -236,13 +236,13 @@ func flattenAttribute(key string, val pcommon.Value, depth int) map[string]any { func extractHostNameAndServiceName(resourceAttrs pcommon.Map, logAttrs pcommon.Map) (host string, service string) { if src, ok := attributes.SourceFromAttrs(resourceAttrs, nil); ok && src.Kind == source.HostnameKind { - host = src.Identifier + host = src.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 } // HACK: Check for host in log record attributes if not present in resource attributes. // This is not aligned with the specification and will be removed in the future. if host == "" { if src, ok := attributes.SourceFromAttrs(logAttrs, nil); ok && src.Kind == source.HostnameKind { - host = src.Identifier + host = src.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 } } if s, ok := resourceAttrs.Get(string(conventions.ServiceNameKey)); ok { diff --git a/pkg/opentelemetry-mapping-go/otlp/logs/translator.go b/pkg/opentelemetry-mapping-go/otlp/logs/translator.go index 21eece9b6fc1..3b2586b77e9c 100644 --- a/pkg/opentelemetry-mapping-go/otlp/logs/translator.go +++ b/pkg/opentelemetry-mapping-go/otlp/logs/translator.go @@ -78,7 +78,7 @@ func NewTranslatorWithHTTPClient(set component.TelemetrySettings, attributesTran func (t *Translator) hostNameAndServiceNameFromResource(ctx context.Context, res pcommon.Resource, hostFromAttributesHandler attributes.HostFromAttributesHandler) (host string, service string) { if src, ok := t.attributesTranslator.ResourceToSource(ctx, res, signalTypeSet, hostFromAttributesHandler); ok && src.Kind == source.HostnameKind { - host = src.Identifier + host = src.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 } if s, ok := res.Attributes().Get(string(conventions.ServiceNameKey)); ok { service = s.AsString() @@ -88,7 +88,7 @@ func (t *Translator) hostNameAndServiceNameFromResource(ctx context.Context, res func (t *Translator) hostFromAttributes(ctx context.Context, attrs pcommon.Map) string { if src, ok := t.attributesTranslator.AttributesToSource(ctx, attrs); ok && src.Kind == source.HostnameKind { - return src.Identifier + return src.Identifier //nolint:staticcheck // SA1019: intentional during Step 1 of the Source.Identifier migration (datadog-agent#51116); this call site migrates to SourceIdentifier.Primary in Step 2 } return "" } From 4aa33b03c7b7ea4c453a4ba1a46c8fb15351de21 Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Thu, 16 Jul 2026 10:35:17 -0700 Subject: [PATCH 20/21] Fall back to SourceIdentifier.Primary when Identifier is empty in Source.Tag() --- .../otlp/attributes/source/source_provider.go | 6 +++++- .../otlp/metrics/consumer.go | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go index d4eb3f6095c5..543a4c4f6b9d 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source/source_provider.go @@ -50,7 +50,11 @@ type Source struct { // Tag associated to a source. func (s Source) Tag() string { - return fmt.Sprintf("%s:%s", s.Kind, s.Identifier) + identifier := s.Identifier + if identifier == "" { + identifier = s.SourceIdentifier.Primary + } + return fmt.Sprintf("%s:%s", s.Kind, identifier) } // SourceIdentifier holds the identity of a telemetry source, generalizing the diff --git a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go index 3be40cea989f..98c56449cda6 100644 --- a/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go +++ b/pkg/opentelemetry-mapping-go/otlp/metrics/consumer.go @@ -144,6 +144,18 @@ type HostConsumer interface { ConsumeHost(host string) } +// TagsConsumer is a tags consumer. +// It is an optional interface that can be implemented by a Consumer. +// Consumed tags are used for running metrics, and should represent +// some resource running a Collector (e.g. Fargate task). +// +// Deprecated: use TagSetConsumer instead. This interface remains for +// consumers that haven't migrated yet; translators no longer call it. +type TagsConsumer interface { + // ConsumeTag consumes a tag + ConsumeTag(tag string) +} + // TagSetConsumer is a multi-tag source consumer. // It is an optional interface that can be implemented by a Consumer. // Use it for any source that needs one or more tags on its own dedicated From dfa67386cde16a8791c4b1238e1b8d764d04be6e Mon Sep 17 00:00:00 2001 From: Kathie Huang Date: Thu, 23 Jul 2026 13:53:31 -0400 Subject: [PATCH 21/21] Submit metric only if all identifying attributes are present --- .../otlp/attributes/source.go | 26 +++++++++++-------- .../otlp/attributes/source_test.go | 14 +++------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go index 1f559461bf36..1d643d232bfc 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source.go @@ -205,18 +205,22 @@ func SourceFromAttrs(attrs pcommon.Map, hostFromAttributesHandler HostFromAttrib } } } - primary := dims["replica_name"] - if primary == "" { - primary = dims["name"] + if dims["name"] != "" && dims["resource_group"] != "" && dims["subscription_id"] != "" { + // The actual unique identifier for an Azure Container App is its subscription id, + // resource group, and name combination + primary := dims["replica_name"] + if primary == "" { + primary = dims["name"] + } + return source.Source{ + Kind: source.AzureContainerAppsKind, + Identifier: primary, + SourceIdentifier: source.SourceIdentifier{ + Primary: primary, + Dimensions: dims, + }, + }, true } - return source.Source{ - Kind: source.AzureContainerAppsKind, - Identifier: primary, - SourceIdentifier: source.SourceIdentifier{ - Primary: primary, - Dimensions: dims, - }, - }, true } } diff --git a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go index f1c8fd3b91d8..dca4e7ace87f 100644 --- a/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go +++ b/pkg/opentelemetry-mapping-go/otlp/attributes/source_test.go @@ -243,20 +243,14 @@ func TestSourceFromAttrs(t *testing.T) { }, }, { - name: "Azure Container Apps (no replica name, no name, still detected with empty Primary)", + name: "Azure Container Apps (missing identifying attributes, falls through unidentified)", attrs: testutils.NewAttributeMap(map[string]string{ string(conventions.CloudProviderKey): conventions.CloudProviderAzure.Value.AsString(), string(conventions.CloudPlatformKey): conventionsv140.CloudPlatformAzureContainerApps.Value.AsString(), + AttributeAzureResourceGroupName: "my-rg", }), - ok: true, - src: source.Source{ - Kind: source.AzureContainerAppsKind, - Identifier: "", - SourceIdentifier: source.SourceIdentifier{ - Primary: "", - Dimensions: map[string]string{}, - }, - }, + ok: false, + src: source.Source{}, }, { name: "GCP",