From 65b7e8c3ff322ead9a8fa4c9df6cf1382eab006d Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Mon, 30 Mar 2026 16:30:09 -0400 Subject: [PATCH 01/15] feat: align ECS OTLP metric fallback with apm-data for raw metric dimensions --- .../internal/ecs/ecs_translation.go | 38 +++ .../internal/ecs/ecs_translation_test.go | 101 ++++++++ processor/elasticapmprocessor/processor.go | 57 +++++ .../elasticapmprocessor/processor_test.go | 229 ++++++++++++++++++ .../ecs/elastic_hostname/metrics_output.yaml | 3 + .../metrics_false_ecs_output.yaml | 3 + .../metrics_true_ecs_output.yaml | 3 + 7 files changed, 434 insertions(+) diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go index 7a5ab8952..431799453 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go @@ -48,6 +48,44 @@ func TranslateLogRecordAttributes(attributes pcommon.Map) { translateAttributes(attributes, isSupportedLogRecordAttribute) } +// TranslateMetricDataPointAttributes applies the apm-data OTLP metric fallback +// for raw metric datapoint attributes in ECS mode. Existing labels.* / +// numeric_labels.* keys are sanitized in place, metric-specific special cases +// are preserved, and everything else is moved to labels.* / numeric_labels.*. +// +// The collector preserves data_stream.type in addition to the apm-data +// data_stream dataset/namespace handling, since datapoint-level routing depends +// on the full data_stream triple before export. +func TranslateMetricDataPointAttributes(attributes pcommon.Map) { + attributes.Range(func(k string, v pcommon.Value) bool { + if sanitize.IsLabelAttribute(k) { + sanitized := sanitize.HandleLabelAttributeKey(k) + if sanitized != k { + v.CopyTo(attributes.PutEmpty(sanitized)) + attributes.Remove(k) + } + return true + } + switch k { + case elasticattr.DataStreamDataset, + elasticattr.DataStreamNamespace, + elasticattr.DataStreamType, + elasticattr.EventDataset, + "event.module", + "system.process.cmdline", + "system.process.cpu.start_time", + "system.filesystem.mount_point", + "system.process.state", + string(semconv.UserNameKey): + return true + default: + setLabelAttributeValue(attributes, sanitize.HandleAttributeKey(k), v) + attributes.Remove(k) + } + return true + }) +} + func translateAttributes(attributes pcommon.Map, isSupported func(string) bool) { attributes.Range(func(k string, v pcommon.Value) bool { if sanitize.IsLabelAttribute(k) { diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go index 2e2a013d9..632824008 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go @@ -296,6 +296,107 @@ func TestTranslateLogRecordAttributes(t *testing.T) { } } +func TestTranslateMetricDataPointAttributes(t *testing.T) { + tests := []struct { + name string + setAttrs func(pcommon.Map) + want map[string]any + wantAbsent []string + }{ + { + name: "raw otlp metric dimensions moved to labels", + setAttrs: func(attrs pcommon.Map) { + attrs.PutStr("http.request.method", "GET") + attrs.PutStr("http.route", "/api/users") + attrs.PutInt("http.response.status_code", 200) + attrs.PutStr("host", "server-01") + attrs.PutStr("state", "used") + }, + want: map[string]any{ + "labels.http_request_method": "GET", + "labels.http_route": "/api/users", + "numeric_labels.http_response_status_code": float64(200), + "labels.host": "server-01", + "labels.state": "used", + }, + wantAbsent: []string{ + "http.request.method", + "http.route", + "http.response.status_code", + "host", + "state", + }, + }, + { + name: "existing metric label keys are sanitized in place", + setAttrs: func(attrs pcommon.Map) { + attrs.PutStr("labels.http.request.method", "GET") + attrs.PutDouble("numeric_labels.http.response.status_code", 200) + }, + want: map[string]any{ + "labels.http_request_method": "GET", + "numeric_labels.http_response_status_code": 200.0, + }, + wantAbsent: []string{ + "labels.http.request.method", + "numeric_labels.http.response.status_code", + }, + }, + { + name: "metric special cases and routing attrs are preserved", + setAttrs: func(attrs pcommon.Map) { + attrs.PutStr(elasticattr.DataStreamDataset, "apm.internal") + attrs.PutStr(elasticattr.DataStreamNamespace, "default") + attrs.PutStr(elasticattr.DataStreamType, "metrics") + attrs.PutStr("host", "server-01") + attrs.PutStr("state", "used") + attrs.PutStr("system.process.cmdline", "/usr/bin/java") + attrs.PutStr("event.module", "system") + attrs.PutStr("user.name", "appuser") + }, + want: map[string]any{ + elasticattr.DataStreamDataset: "apm.internal", + elasticattr.DataStreamNamespace: "default", + elasticattr.DataStreamType: "metrics", + "labels.host": "server-01", + "labels.state": "used", + "system.process.cmdline": "/usr/bin/java", + "event.module": "system", + "user.name": "appuser", + }, + wantAbsent: []string{"host", "state"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + attrs := pcommon.NewMap() + tc.setAttrs(attrs) + + TranslateMetricDataPointAttributes(attrs) + + for key, want := range tc.want { + got, ok := attrs.Get(key) + if !assert.True(t, ok, "expected %q to be present", key) { + continue + } + switch want := want.(type) { + case string: + assert.Equal(t, want, got.Str()) + case float64: + assert.InDelta(t, want, got.Double(), 1e-9) + default: + t.Fatalf("unsupported want type %T", want) + } + } + for _, key := range tc.wantAbsent { + _, ok := attrs.Get(key) + assert.False(t, ok, "expected %q to be absent", key) + } + }) + } +} + // TestSetLabelAttributeValue verifies that setLabelAttributeValue stores // supported value types under the correct labels.* / numeric_labels.* prefix // and rejects unsupported types (Map, Bytes, Empty). This matches diff --git a/processor/elasticapmprocessor/processor.go b/processor/elasticapmprocessor/processor.go index 1ef932fd2..015bf15ab 100644 --- a/processor/elasticapmprocessor/processor.go +++ b/processor/elasticapmprocessor/processor.go @@ -213,9 +213,11 @@ func newMetricProcessor(cfg *Config, next consumer.Metrics, logger *zap.Logger) ecsEnricherConfig.Resource.HostOSType.Enabled = true ecsEnricherConfig.Resource.ServiceName.Enabled = true ecsEnricherConfig.Resource.DefaultDeploymentEnvironment.Enabled = true + ecsEnricherConfig.Resource.DefaultServiceLanguage.Enabled = true intakeECSEnricherConfig := ecsEnricherConfig intakeECSEnricherConfig.Resource.HostOSType.Enabled = false + intakeECSEnricherConfig.Resource.DefaultServiceLanguage.Enabled = false return &MetricProcessor{ next: next, @@ -237,12 +239,15 @@ func (p *MetricProcessor) ConsumeMetrics(ctx context.Context, md pmetric.Metrics if ecsMode { enricher = p.ecsEnricher resourceMetrics := md.ResourceMetrics() + // ECS metric batches are assumed to be homogeneous by origin. We select + // the enricher from the first resource metric and apply it to the whole batch. if resourceMetrics.Len() > 0 && isIntakeECS(resourceMetrics.At(0).Resource()) { enricher = p.intakeECSEnricher } for i := 0; i < resourceMetrics.Len(); i++ { resourceMetric := resourceMetrics.At(i) resource := resourceMetric.Resource() + resourceIsIntake := isIntakeECS(resource) ecs.TranslateResourceMetadata(resource) ecs.ApplyResourceConventions(resource) routing.EncodeDataStream(resource, routing.DataStreamTypeMetrics, p.cfg.ServiceNameInDataStreamDataset) @@ -258,6 +263,9 @@ func (p *MetricProcessor) ConsumeMetrics(ctx context.Context, md pmetric.Metrics // Route internal metrics to appropriate data streams if needed. routeMetricsToDataStream(resourceMetric.ScopeMetrics(), hasServiceName) + if !resourceIsIntake && hasServiceName { + translateRawOTLPMetricDataPoints(resourceMetric.ScopeMetrics()) + } } } // When skipEnrichment is true, only enrich when mapping mode is ecs @@ -328,6 +336,55 @@ func routeMetricsToDataStream(scopeMetrics pmetric.ScopeMetricsSlice, hasService } } +func translateRawOTLPMetricDataPoints(scopeMetrics pmetric.ScopeMetricsSlice) { + for j := 0; j < scopeMetrics.Len(); j++ { + metrics := scopeMetrics.At(j).Metrics() + for k := 0; k < metrics.Len(); k++ { + metric := metrics.At(k) + switch metric.Type() { + case pmetric.MetricTypeGauge: + dataPoints := metric.Gauge().DataPoints() + for l := 0; l < dataPoints.Len(); l++ { + translateRawOTLPMetricDataPointAttributes(dataPoints.At(l).Attributes()) + } + case pmetric.MetricTypeSum: + dataPoints := metric.Sum().DataPoints() + for l := 0; l < dataPoints.Len(); l++ { + translateRawOTLPMetricDataPointAttributes(dataPoints.At(l).Attributes()) + } + case pmetric.MetricTypeHistogram: + dataPoints := metric.Histogram().DataPoints() + for l := 0; l < dataPoints.Len(); l++ { + translateRawOTLPMetricDataPointAttributes(dataPoints.At(l).Attributes()) + } + case pmetric.MetricTypeExponentialHistogram: + dataPoints := metric.ExponentialHistogram().DataPoints() + for l := 0; l < dataPoints.Len(); l++ { + translateRawOTLPMetricDataPointAttributes(dataPoints.At(l).Attributes()) + } + case pmetric.MetricTypeSummary: + dataPoints := metric.Summary().DataPoints() + for l := 0; l < dataPoints.Len(); l++ { + translateRawOTLPMetricDataPointAttributes(dataPoints.At(l).Attributes()) + } + } + } + } +} + +func translateRawOTLPMetricDataPointAttributes(attributes pcommon.Map) { + if _, ok := attributes.Get("metricset.name"); ok { + return + } + if _, ok := attributes.Get("metricset.interval"); ok { + return + } + if _, ok := attributes.Get("elasticsearch.mapping.hints"); ok { + return + } + ecs.TranslateMetricDataPointAttributes(attributes) +} + func (p *LogProcessor) ConsumeLogs(ctx context.Context, ld plog.Logs) error { enricher := p.enricher ecsMode := isECS(ctx) diff --git a/processor/elasticapmprocessor/processor_test.go b/processor/elasticapmprocessor/processor_test.go index f3ec0efe3..c8e043224 100644 --- a/processor/elasticapmprocessor/processor_test.go +++ b/processor/elasticapmprocessor/processor_test.go @@ -498,6 +498,235 @@ func TestConsumeLogs_ECSAssumesHomogeneousBatchOrigin(t *testing.T) { assert.False(t, ok) } +func TestConsumeMetrics_ECSOTLPFallbacks(t *testing.T) { + ctx := client.NewContext(context.Background(), client.Info{ + Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), + }) + + factory := NewFactory() + settings := processortest.NewNopSettings(metadata.Type) + next := &consumertest.MetricsSink{} + cfg := NewDefaultConfig().(*Config) + + mp, err := factory.CreateMetrics(ctx, settings, cfg, next) + require.NoError(t, err) + + metrics := pmetric.NewMetrics() + resourceMetric := metrics.ResourceMetrics().AppendEmpty() + resource := resourceMetric.Resource() + resource.Attributes().PutStr("service.name", "test-service") + resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "opentelemetry") + + scopeMetrics := resourceMetric.ScopeMetrics().AppendEmpty() + + httpMetric := scopeMetrics.Metrics().AppendEmpty() + httpMetric.SetName("http.requests.total") + httpDP := httpMetric.SetEmptySum().DataPoints().AppendEmpty() + httpDP.SetIntValue(1) + httpAttrs := httpDP.Attributes() + httpAttrs.PutStr("http.request.method", "GET") + httpAttrs.PutStr("http.route", "/api/users") + httpAttrs.PutInt("http.response.status_code", 200) + + memoryMetric := scopeMetrics.Metrics().AppendEmpty() + memoryMetric.SetName("system.memory.usage") + memoryDP := memoryMetric.SetEmptyGauge().DataPoints().AppendEmpty() + memoryDP.SetDoubleValue(2048.5) + memoryAttrs := memoryDP.Attributes() + memoryAttrs.PutStr("host", "server-01") + memoryAttrs.PutStr("state", "used") + + require.NoError(t, mp.ConsumeMetrics(ctx, metrics)) + actual := next.AllMetrics()[0] + + require.Equal(t, 1, actual.ResourceMetrics().Len()) + actualResource := actual.ResourceMetrics().At(0).Resource().Attributes() + lang, ok := actualResource.Get(string(semconv.TelemetrySDKLanguageKey)) + require.True(t, ok) + assert.Equal(t, "unknown", lang.Str()) + + agentName, ok := actualResource.Get("agent.name") + require.True(t, ok) + assert.Equal(t, "opentelemetry", agentName.Str()) + + actualMetrics := actual.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics() + actualHTTPAttrs := actualMetrics.At(0).Sum().DataPoints().At(0).Attributes() + value, ok := actualHTTPAttrs.Get("labels.http_request_method") + require.True(t, ok) + assert.Equal(t, "GET", value.Str()) + value, ok = actualHTTPAttrs.Get("labels.http_route") + require.True(t, ok) + assert.Equal(t, "/api/users", value.Str()) + value, ok = actualHTTPAttrs.Get("numeric_labels.http_response_status_code") + require.True(t, ok) + assert.InDelta(t, 200, value.Double(), 1e-9) + _, ok = actualHTTPAttrs.Get("http.request.method") + assert.False(t, ok) + _, ok = actualHTTPAttrs.Get("http.route") + assert.False(t, ok) + _, ok = actualHTTPAttrs.Get("http.response.status_code") + assert.False(t, ok) + + actualMemoryAttrs := actualMetrics.At(1).Gauge().DataPoints().At(0).Attributes() + value, ok = actualMemoryAttrs.Get("labels.host") + require.True(t, ok) + assert.Equal(t, "server-01", value.Str()) + value, ok = actualMemoryAttrs.Get("labels.state") + require.True(t, ok) + assert.Equal(t, "used", value.Str()) + _, ok = actualMemoryAttrs.Get("host") + assert.False(t, ok) + _, ok = actualMemoryAttrs.Get("state") + assert.False(t, ok) +} + +func TestConsumeMetrics_ECSIntakeSkipsOTLPFallbacks(t *testing.T) { + ctx := client.NewContext(context.Background(), client.Info{ + Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), + }) + + factory := NewFactory() + settings := processortest.NewNopSettings(metadata.Type) + next := &consumertest.MetricsSink{} + cfg := NewDefaultConfig().(*Config) + + mp, err := factory.CreateMetrics(ctx, settings, cfg, next) + require.NoError(t, err) + + metrics := pmetric.NewMetrics() + resourceMetric := metrics.ResourceMetrics().AppendEmpty() + resource := resourceMetric.Resource() + resource.Attributes().PutStr("service.name", "test-service") + resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "ElasticAPM") + + scopeMetrics := resourceMetric.ScopeMetrics().AppendEmpty() + metric := scopeMetrics.Metrics().AppendEmpty() + metric.SetName("http.requests.total") + dp := metric.SetEmptySum().DataPoints().AppendEmpty() + dp.SetIntValue(1) + attrs := dp.Attributes() + attrs.PutStr("http.request.method", "GET") + attrs.PutStr("http.route", "/api/users") + attrs.PutInt("http.response.status_code", 200) + attrs.PutStr("host", "server-01") + attrs.PutStr("state", "used") + + require.NoError(t, mp.ConsumeMetrics(ctx, metrics)) + actual := next.AllMetrics()[0] + + actualResource := actual.ResourceMetrics().At(0).Resource().Attributes() + _, ok := actualResource.Get(string(semconv.TelemetrySDKLanguageKey)) + assert.False(t, ok) + + actualAttrs := actual.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().At(0).Sum().DataPoints().At(0).Attributes() + value, ok := actualAttrs.Get("http.request.method") + require.True(t, ok) + assert.Equal(t, "GET", value.Str()) + value, ok = actualAttrs.Get("http.route") + require.True(t, ok) + assert.Equal(t, "/api/users", value.Str()) + value, ok = actualAttrs.Get("http.response.status_code") + require.True(t, ok) + assert.EqualValues(t, 200, value.Int()) + value, ok = actualAttrs.Get("host") + require.True(t, ok) + assert.Equal(t, "server-01", value.Str()) + value, ok = actualAttrs.Get("state") + require.True(t, ok) + assert.Equal(t, "used", value.Str()) + _, ok = actualAttrs.Get("labels.http_request_method") + assert.False(t, ok) + _, ok = actualAttrs.Get("labels.http_route") + assert.False(t, ok) + _, ok = actualAttrs.Get("numeric_labels.http_response_status_code") + assert.False(t, ok) + _, ok = actualAttrs.Get("labels.host") + assert.False(t, ok) + _, ok = actualAttrs.Get("labels.state") + assert.False(t, ok) +} + +func TestTranslateRawOTLPMetricDataPointAttributesSkipsAggregatedMetrics(t *testing.T) { + tests := []struct { + name string + markerKey string + markerVal func(pcommon.Map) + }{ + { + name: "metricset name present", + markerKey: "metricset.name", + markerVal: func(attrs pcommon.Map) { attrs.PutStr("metricset.name", "service_summary") }, + }, + { + name: "metricset interval present", + markerKey: "metricset.interval", + markerVal: func(attrs pcommon.Map) { attrs.PutStr("metricset.interval", "1m") }, + }, + { + name: "mapping hints present", + markerKey: "elasticsearch.mapping.hints", + markerVal: func(attrs pcommon.Map) { + hints := attrs.PutEmptySlice("elasticsearch.mapping.hints") + hints.AppendEmpty().SetStr("_doc_count") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + attrs := pcommon.NewMap() + tc.markerVal(attrs) + attrs.PutStr("host", "server-01") + attrs.PutStr("state", "used") + + translateRawOTLPMetricDataPointAttributes(attrs) + + value, ok := attrs.Get("host") + require.True(t, ok) + assert.Equal(t, "server-01", value.Str()) + value, ok = attrs.Get("state") + require.True(t, ok) + assert.Equal(t, "used", value.Str()) + _, ok = attrs.Get("labels.host") + assert.False(t, ok) + _, ok = attrs.Get("labels.state") + assert.False(t, ok) + }) + } +} + +func TestTranslateRawOTLPMetricDataPointAttributesPreservesRoutingAttrs(t *testing.T) { + attrs := pcommon.NewMap() + attrs.PutStr("data_stream.dataset", "apm.internal") + attrs.PutStr("data_stream.namespace", "default") + attrs.PutStr("data_stream.type", "metrics") + attrs.PutStr("host", "server-01") + attrs.PutStr("state", "used") + + translateRawOTLPMetricDataPointAttributes(attrs) + + value, ok := attrs.Get("data_stream.dataset") + require.True(t, ok) + assert.Equal(t, "apm.internal", value.Str()) + value, ok = attrs.Get("data_stream.namespace") + require.True(t, ok) + assert.Equal(t, "default", value.Str()) + value, ok = attrs.Get("data_stream.type") + require.True(t, ok) + assert.Equal(t, "metrics", value.Str()) + + value, ok = attrs.Get("labels.host") + require.True(t, ok) + assert.Equal(t, "server-01", value.Str()) + value, ok = attrs.Get("labels.state") + require.True(t, ok) + assert.Equal(t, "used", value.Str()) + _, ok = attrs.Get("host") + assert.False(t, ok) + _, ok = attrs.Get("state") + assert.False(t, ok) +} + // TestSkipEnrichmentMetrics tests that metrics are only enriched when skipEnrichment is false or when mapping mode is ecs func TestSkipEnrichmentMetrics(t *testing.T) { testCases := []struct { diff --git a/processor/elasticapmprocessor/testdata/ecs/elastic_hostname/metrics_output.yaml b/processor/elasticapmprocessor/testdata/ecs/elastic_hostname/metrics_output.yaml index 06e8046f9..6c0e34a44 100644 --- a/processor/elasticapmprocessor/testdata/ecs/elastic_hostname/metrics_output.yaml +++ b/processor/elasticapmprocessor/testdata/ecs/elastic_hostname/metrics_output.yaml @@ -34,6 +34,9 @@ resourceMetrics: - key: service.name value: stringValue: test-service + - key: telemetry.sdk.language + value: + stringValue: unknown scopeMetrics: - metrics: - gauge: diff --git a/processor/elasticapmprocessor/testdata/skip_enrichment/metrics_false_ecs_output.yaml b/processor/elasticapmprocessor/testdata/skip_enrichment/metrics_false_ecs_output.yaml index a4137d4de..c2ecbe491 100644 --- a/processor/elasticapmprocessor/testdata/skip_enrichment/metrics_false_ecs_output.yaml +++ b/processor/elasticapmprocessor/testdata/skip_enrichment/metrics_false_ecs_output.yaml @@ -25,6 +25,9 @@ resourceMetrics: - key: service.name value: stringValue: test-service + - key: telemetry.sdk.language + value: + stringValue: unknown scopeMetrics: - metrics: - gauge: diff --git a/processor/elasticapmprocessor/testdata/skip_enrichment/metrics_true_ecs_output.yaml b/processor/elasticapmprocessor/testdata/skip_enrichment/metrics_true_ecs_output.yaml index a4137d4de..c2ecbe491 100644 --- a/processor/elasticapmprocessor/testdata/skip_enrichment/metrics_true_ecs_output.yaml +++ b/processor/elasticapmprocessor/testdata/skip_enrichment/metrics_true_ecs_output.yaml @@ -25,6 +25,9 @@ resourceMetrics: - key: service.name value: stringValue: test-service + - key: telemetry.sdk.language + value: + stringValue: unknown scopeMetrics: - metrics: - gauge: From 8162569c521ff518adbd67add5d97b0f9dc3b9d6 Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Mon, 30 Mar 2026 17:01:21 -0400 Subject: [PATCH 02/15] feat: refactor metric encrichment --- .../internal/ecs/ecs_translation.go | 57 ++++---- .../internal/enrichments/config/config.go | 5 +- .../internal/enrichments/enricher.go | 4 + .../internal/enrichments/metric.go | 72 +++++++++++ .../internal/enrichments/metric_test.go | 121 +++++++++++++++++ .../internal/enrichments/resource.go | 2 +- processor/elasticapmprocessor/processor.go | 55 +------- .../elasticapmprocessor/processor_test.go | 122 ++++++++---------- 8 files changed, 281 insertions(+), 157 deletions(-) diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go index 431799453..f7c90ce6b 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go @@ -52,38 +52,8 @@ func TranslateLogRecordAttributes(attributes pcommon.Map) { // for raw metric datapoint attributes in ECS mode. Existing labels.* / // numeric_labels.* keys are sanitized in place, metric-specific special cases // are preserved, and everything else is moved to labels.* / numeric_labels.*. -// -// The collector preserves data_stream.type in addition to the apm-data -// data_stream dataset/namespace handling, since datapoint-level routing depends -// on the full data_stream triple before export. func TranslateMetricDataPointAttributes(attributes pcommon.Map) { - attributes.Range(func(k string, v pcommon.Value) bool { - if sanitize.IsLabelAttribute(k) { - sanitized := sanitize.HandleLabelAttributeKey(k) - if sanitized != k { - v.CopyTo(attributes.PutEmpty(sanitized)) - attributes.Remove(k) - } - return true - } - switch k { - case elasticattr.DataStreamDataset, - elasticattr.DataStreamNamespace, - elasticattr.DataStreamType, - elasticattr.EventDataset, - "event.module", - "system.process.cmdline", - "system.process.cpu.start_time", - "system.filesystem.mount_point", - "system.process.state", - string(semconv.UserNameKey): - return true - default: - setLabelAttributeValue(attributes, sanitize.HandleAttributeKey(k), v) - attributes.Remove(k) - } - return true - }) + translateAttributes(attributes, isSupportedMetricDataPointAttribute) } func translateAttributes(attributes pcommon.Map, isSupported func(string) bool) { @@ -192,6 +162,31 @@ func isSupportedLogRecordAttribute(attr string) bool { return false } +// isSupportedMetricDataPointAttribute mirrors the apm-data OTLP metric +// datapoint handling where a small set of fields are preserved as first-class +// values and the rest fall back to labels.* / numeric_labels.*. +// +// The collector preserves data_stream.type in addition to the apm-data +// data_stream dataset/namespace handling, since datapoint-level routing depends +// on the full data_stream triple before export. +func isSupportedMetricDataPointAttribute(attr string) bool { + switch attr { + case elasticattr.DataStreamDataset, + elasticattr.DataStreamNamespace, + elasticattr.DataStreamType, + elasticattr.EventDataset, + "event.module", + "system.process.cmdline", + "system.process.cpu.start_time", + "system.filesystem.mount_point", + "system.process.state", + string(semconv.UserNameKey): + return true + } + + return false +} + // isSupportedResourceAttribute returns true if the resource attribute is // supported by ECS and can be mapped directly. // Supported fields can include OTEL SemConv attributes or ECS specific attributes. diff --git a/processor/elasticapmprocessor/internal/enrichments/config/config.go b/processor/elasticapmprocessor/internal/enrichments/config/config.go index bade313d0..2cb998c45 100644 --- a/processor/elasticapmprocessor/internal/enrichments/config/config.go +++ b/processor/elasticapmprocessor/internal/enrichments/config/config.go @@ -118,8 +118,9 @@ type SpanEventConfig struct { // ElasticMetricConfig configures the enrichment attributes for metrics type ElasticMetricConfig struct { - ProcessorEvent AttributeConfig `mapstructure:"processor_event"` - MetricsetName AttributeConfig `mapstructure:"metricset_name"` + ProcessorEvent AttributeConfig `mapstructure:"processor_event"` + MetricsetName AttributeConfig `mapstructure:"metricset_name"` + TranslateUnsupportedAttributes AttributeConfig `mapstructure:"translate_unsupported_attributes"` } // ElasticLogConfig configures the enrichment attributes for logs diff --git a/processor/elasticapmprocessor/internal/enrichments/enricher.go b/processor/elasticapmprocessor/internal/enrichments/enricher.go index 2e625ab06..00a47a7bf 100644 --- a/processor/elasticapmprocessor/internal/enrichments/enricher.go +++ b/processor/elasticapmprocessor/internal/enrichments/enricher.go @@ -102,6 +102,10 @@ func (e *Enricher) EnrichMetrics(pl pmetric.Metrics) { for j := 0; j < scopeMetics.Len(); j++ { scopeMetric := scopeMetics.At(j) EnrichScope(scopeMetric.Scope(), e.Config) + metrics := scopeMetric.Metrics() + for k := 0; k < metrics.Len(); k++ { + EnrichMetricDataPoints(metrics.At(k), e.Config) + } } } } diff --git a/processor/elasticapmprocessor/internal/enrichments/metric.go b/processor/elasticapmprocessor/internal/enrichments/metric.go index d0f6e1f92..b816e4746 100644 --- a/processor/elasticapmprocessor/internal/enrichments/metric.go +++ b/processor/elasticapmprocessor/internal/enrichments/metric.go @@ -18,9 +18,11 @@ package enrichments // import "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/enrichments" import ( + "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/pmetric" "github.com/elastic/opentelemetry-collector-components/internal/elasticattr" + "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/ecs" "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/enrichments/attribute" "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/enrichments/config" ) @@ -38,3 +40,73 @@ func EnrichMetric(metric pmetric.ResourceMetrics, cfg config.Config) { attribute.PutStr(resAttrs, elasticattr.MetricsetName, metricSetNameApp) } } + +func EnrichMetricDataPoints(metric pmetric.Metric, cfg config.Config) { + switch metric.Type() { + case pmetric.MetricTypeGauge: + dataPoints := metric.Gauge().DataPoints() + for i := 0; i < dataPoints.Len(); i++ { + enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), cfg) + } + case pmetric.MetricTypeSum: + dataPoints := metric.Sum().DataPoints() + for i := 0; i < dataPoints.Len(); i++ { + enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), cfg) + } + case pmetric.MetricTypeHistogram: + dataPoints := metric.Histogram().DataPoints() + for i := 0; i < dataPoints.Len(); i++ { + enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), cfg) + } + case pmetric.MetricTypeExponentialHistogram: + dataPoints := metric.ExponentialHistogram().DataPoints() + for i := 0; i < dataPoints.Len(); i++ { + enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), cfg) + } + case pmetric.MetricTypeSummary: + dataPoints := metric.Summary().DataPoints() + for i := 0; i < dataPoints.Len(); i++ { + enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), cfg) + } + } +} + +// enrichMetricDataPointAttributes applies the raw OTLP metric fallback during +// enrichment, analogous to how EnrichLog delegates log-record fallback to +// EnrichLog. This happens later than apm-data's OTLP-to-APM conversion, so we +// must explicitly avoid relabeling attrs that identify aggregated metrics or +// influence exporter behavior. +func enrichMetricDataPointAttributes(attributes pcommon.Map, cfg config.Config) { + if !cfg.Metric.TranslateUnsupportedAttributes.Enabled { + return + } + if isAggregatedMetricDataPointAttributes(attributes) { + return + } + ecs.TranslateMetricDataPointAttributes(attributes) +} + +// isAggregatedMetricDataPointAttributes preserves aggregated-metric identity and +// exporter-only metadata before the raw OTLP fallback runs. +// +// In the collector we mutate OTel datapoints in place, so we must keep these attrs +// out of label fallback here. +// `elasticsearch.mapping.hints` is collector/exporter-specific metadata and has +// no apm-data equivalent, but it also needs to passthrough untouched. +func isAggregatedMetricDataPointAttributes(attributes pcommon.Map) bool { + if _, ok := attributes.Get("metricset.name"); ok { + return true + } + if _, ok := attributes.Get("metricset.interval"); ok { + return true + } + if isESMappingHint(attributes) { + return true + } + return false +} + +func isESMappingHint(attributes pcommon.Map) bool { + _, ok := attributes.Get("elasticsearch.mapping.hints") + return ok +} diff --git a/processor/elasticapmprocessor/internal/enrichments/metric_test.go b/processor/elasticapmprocessor/internal/enrichments/metric_test.go index 50e21b310..9285b8109 100644 --- a/processor/elasticapmprocessor/internal/enrichments/metric_test.go +++ b/processor/elasticapmprocessor/internal/enrichments/metric_test.go @@ -21,6 +21,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/pmetric" "github.com/elastic/opentelemetry-collector-components/internal/elasticattr" @@ -132,3 +134,122 @@ func TestEnrichMetric(t *testing.T) { }) } } + +func TestEnrichMetrics_TranslateUnsupportedAttributes(t *testing.T) { + cfg := config.Enabled() + cfg.Metric.TranslateUnsupportedAttributes.Enabled = true + + metrics := pmetric.NewMetrics() + resourceMetrics := metrics.ResourceMetrics().AppendEmpty() + scopeMetrics := resourceMetrics.ScopeMetrics().AppendEmpty() + metric := scopeMetrics.Metrics().AppendEmpty() + metric.SetName("test.metric") + dp := metric.SetEmptyGauge().DataPoints().AppendEmpty() + dp.SetDoubleValue(1.0) + attrs := dp.Attributes() + attrs.PutStr("data_stream.dataset", "apm.internal") + attrs.PutStr("data_stream.namespace", "default") + attrs.PutStr("data_stream.type", "metrics") + attrs.PutStr("host", "server-01") + attrs.PutStr("state", "used") + attrs.PutStr("event.module", "system") + attrs.PutStr("system.process.cmdline", "/usr/bin/java") + + enricher := NewEnricher(cfg) + enricher.EnrichMetrics(metrics) + + actualAttrs := metrics.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().At(0).Gauge().DataPoints().At(0).Attributes() + + value, ok := actualAttrs.Get("data_stream.dataset") + require.True(t, ok) + assert.Equal(t, "apm.internal", value.Str()) + value, ok = actualAttrs.Get("data_stream.namespace") + require.True(t, ok) + assert.Equal(t, "default", value.Str()) + value, ok = actualAttrs.Get("data_stream.type") + require.True(t, ok) + assert.Equal(t, "metrics", value.Str()) + value, ok = actualAttrs.Get("labels.host") + require.True(t, ok) + assert.Equal(t, "server-01", value.Str()) + value, ok = actualAttrs.Get("labels.state") + require.True(t, ok) + assert.Equal(t, "used", value.Str()) + value, ok = actualAttrs.Get("event.module") + require.True(t, ok) + assert.Equal(t, "system", value.Str()) + value, ok = actualAttrs.Get("system.process.cmdline") + require.True(t, ok) + assert.Equal(t, "/usr/bin/java", value.Str()) + _, ok = actualAttrs.Get("host") + assert.False(t, ok) + _, ok = actualAttrs.Get("state") + assert.False(t, ok) +} + +func TestEnrichMetricDataPoints_SkipsAggregatedMetricAttributes(t *testing.T) { + cfg := config.Enabled() + cfg.Metric.TranslateUnsupportedAttributes.Enabled = true + + metric := pmetric.NewMetric() + metric.SetName("service_summary") + dp := metric.SetEmptyGauge().DataPoints().AppendEmpty() + dp.SetDoubleValue(1.0) + attrs := dp.Attributes() + attrs.PutStr("metricset.name", "service_summary") + attrs.PutStr("host", "server-01") + attrs.PutStr("state", "used") + + EnrichMetricDataPoints(metric, cfg) + + actualAttrs := metric.Gauge().DataPoints().At(0).Attributes() + value, ok := actualAttrs.Get("host") + require.True(t, ok) + assert.Equal(t, "server-01", value.Str()) + value, ok = actualAttrs.Get("state") + require.True(t, ok) + assert.Equal(t, "used", value.Str()) + _, ok = actualAttrs.Get("labels.host") + assert.False(t, ok) + _, ok = actualAttrs.Get("labels.state") + assert.False(t, ok) +} + +func TestEnrichMetricDataPoints_SkipsMetricsWithMappingHints(t *testing.T) { + cfg := config.Enabled() + cfg.Metric.TranslateUnsupportedAttributes.Enabled = true + + metric := pmetric.NewMetric() + metric.SetName("transaction.duration.histogram") + dp := metric.SetEmptyGauge().DataPoints().AppendEmpty() + dp.SetDoubleValue(1.0) + attrs := dp.Attributes() + hints := attrs.PutEmptySlice("elasticsearch.mapping.hints") + hints.AppendEmpty().SetStr("_doc_count") + attrs.PutStr("host", "server-01") + + EnrichMetricDataPoints(metric, cfg) + + actualAttrs := metric.Gauge().DataPoints().At(0).Attributes() + value, ok := actualAttrs.Get("host") + require.True(t, ok) + assert.Equal(t, "server-01", value.Str()) + _, ok = actualAttrs.Get("labels.host") + assert.False(t, ok) +} + +func TestEnrichMetricDataPointAttributes_NoOpWhenDisabled(t *testing.T) { + cfg := config.Enabled() + cfg.Metric.TranslateUnsupportedAttributes.Enabled = false + + attrs := pcommon.NewMap() + attrs.PutStr("host", "server-01") + + enrichMetricDataPointAttributes(attrs, cfg) + + value, ok := attrs.Get("host") + require.True(t, ok) + assert.Equal(t, "server-01", value.Str()) + _, ok = attrs.Get("labels.host") + assert.False(t, ok) +} diff --git a/processor/elasticapmprocessor/internal/enrichments/resource.go b/processor/elasticapmprocessor/internal/enrichments/resource.go index 504f10f9d..49bbd4f1a 100644 --- a/processor/elasticapmprocessor/internal/enrichments/resource.go +++ b/processor/elasticapmprocessor/internal/enrichments/resource.go @@ -174,7 +174,7 @@ func (s *resourceEnrichmentContext) setServiceLanguage(resource pcommon.Resource // setDefaultServiceLanguage ensures telemetry.sdk.language is populated after // agent.name/version have already been derived. This preserves the // apm-data-compatible service.language.name alias without changing agent naming. -// The log processor enables this only for OTLP ECS log batches. +// The ECS log and metric processors enable this only for non-intake OTLP batches. func (s *resourceEnrichmentContext) setDefaultServiceLanguage(resource pcommon.Resource) { if s.telemetrySDKLanguage != "" { return diff --git a/processor/elasticapmprocessor/processor.go b/processor/elasticapmprocessor/processor.go index 015bf15ab..c91ad95a2 100644 --- a/processor/elasticapmprocessor/processor.go +++ b/processor/elasticapmprocessor/processor.go @@ -214,10 +214,12 @@ func newMetricProcessor(cfg *Config, next consumer.Metrics, logger *zap.Logger) ecsEnricherConfig.Resource.ServiceName.Enabled = true ecsEnricherConfig.Resource.DefaultDeploymentEnvironment.Enabled = true ecsEnricherConfig.Resource.DefaultServiceLanguage.Enabled = true + ecsEnricherConfig.Metric.TranslateUnsupportedAttributes.Enabled = true intakeECSEnricherConfig := ecsEnricherConfig intakeECSEnricherConfig.Resource.HostOSType.Enabled = false intakeECSEnricherConfig.Resource.DefaultServiceLanguage.Enabled = false + intakeECSEnricherConfig.Metric.TranslateUnsupportedAttributes.Enabled = false return &MetricProcessor{ next: next, @@ -247,7 +249,6 @@ func (p *MetricProcessor) ConsumeMetrics(ctx context.Context, md pmetric.Metrics for i := 0; i < resourceMetrics.Len(); i++ { resourceMetric := resourceMetrics.At(i) resource := resourceMetric.Resource() - resourceIsIntake := isIntakeECS(resource) ecs.TranslateResourceMetadata(resource) ecs.ApplyResourceConventions(resource) routing.EncodeDataStream(resource, routing.DataStreamTypeMetrics, p.cfg.ServiceNameInDataStreamDataset) @@ -263,9 +264,6 @@ func (p *MetricProcessor) ConsumeMetrics(ctx context.Context, md pmetric.Metrics // Route internal metrics to appropriate data streams if needed. routeMetricsToDataStream(resourceMetric.ScopeMetrics(), hasServiceName) - if !resourceIsIntake && hasServiceName { - translateRawOTLPMetricDataPoints(resourceMetric.ScopeMetrics()) - } } } // When skipEnrichment is true, only enrich when mapping mode is ecs @@ -336,55 +334,6 @@ func routeMetricsToDataStream(scopeMetrics pmetric.ScopeMetricsSlice, hasService } } -func translateRawOTLPMetricDataPoints(scopeMetrics pmetric.ScopeMetricsSlice) { - for j := 0; j < scopeMetrics.Len(); j++ { - metrics := scopeMetrics.At(j).Metrics() - for k := 0; k < metrics.Len(); k++ { - metric := metrics.At(k) - switch metric.Type() { - case pmetric.MetricTypeGauge: - dataPoints := metric.Gauge().DataPoints() - for l := 0; l < dataPoints.Len(); l++ { - translateRawOTLPMetricDataPointAttributes(dataPoints.At(l).Attributes()) - } - case pmetric.MetricTypeSum: - dataPoints := metric.Sum().DataPoints() - for l := 0; l < dataPoints.Len(); l++ { - translateRawOTLPMetricDataPointAttributes(dataPoints.At(l).Attributes()) - } - case pmetric.MetricTypeHistogram: - dataPoints := metric.Histogram().DataPoints() - for l := 0; l < dataPoints.Len(); l++ { - translateRawOTLPMetricDataPointAttributes(dataPoints.At(l).Attributes()) - } - case pmetric.MetricTypeExponentialHistogram: - dataPoints := metric.ExponentialHistogram().DataPoints() - for l := 0; l < dataPoints.Len(); l++ { - translateRawOTLPMetricDataPointAttributes(dataPoints.At(l).Attributes()) - } - case pmetric.MetricTypeSummary: - dataPoints := metric.Summary().DataPoints() - for l := 0; l < dataPoints.Len(); l++ { - translateRawOTLPMetricDataPointAttributes(dataPoints.At(l).Attributes()) - } - } - } - } -} - -func translateRawOTLPMetricDataPointAttributes(attributes pcommon.Map) { - if _, ok := attributes.Get("metricset.name"); ok { - return - } - if _, ok := attributes.Get("metricset.interval"); ok { - return - } - if _, ok := attributes.Get("elasticsearch.mapping.hints"); ok { - return - } - ecs.TranslateMetricDataPointAttributes(attributes) -} - func (p *LogProcessor) ConsumeLogs(ctx context.Context, ld plog.Logs) error { enricher := p.enricher ecsMode := isECS(ctx) diff --git a/processor/elasticapmprocessor/processor_test.go b/processor/elasticapmprocessor/processor_test.go index c8e043224..77041f4c6 100644 --- a/processor/elasticapmprocessor/processor_test.go +++ b/processor/elasticapmprocessor/processor_test.go @@ -646,84 +646,66 @@ func TestConsumeMetrics_ECSIntakeSkipsOTLPFallbacks(t *testing.T) { assert.False(t, ok) } -func TestTranslateRawOTLPMetricDataPointAttributesSkipsAggregatedMetrics(t *testing.T) { - tests := []struct { - name string - markerKey string - markerVal func(pcommon.Map) - }{ - { - name: "metricset name present", - markerKey: "metricset.name", - markerVal: func(attrs pcommon.Map) { attrs.PutStr("metricset.name", "service_summary") }, - }, - { - name: "metricset interval present", - markerKey: "metricset.interval", - markerVal: func(attrs pcommon.Map) { attrs.PutStr("metricset.interval", "1m") }, - }, - { - name: "mapping hints present", - markerKey: "elasticsearch.mapping.hints", - markerVal: func(attrs pcommon.Map) { - hints := attrs.PutEmptySlice("elasticsearch.mapping.hints") - hints.AppendEmpty().SetStr("_doc_count") - }, - }, - } +func TestConsumeMetrics_ECSAssumesHomogeneousBatchOrigin(t *testing.T) { + ctx := client.NewContext(context.Background(), client.Info{ + Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), + }) - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - attrs := pcommon.NewMap() - tc.markerVal(attrs) - attrs.PutStr("host", "server-01") - attrs.PutStr("state", "used") - - translateRawOTLPMetricDataPointAttributes(attrs) - - value, ok := attrs.Get("host") - require.True(t, ok) - assert.Equal(t, "server-01", value.Str()) - value, ok = attrs.Get("state") - require.True(t, ok) - assert.Equal(t, "used", value.Str()) - _, ok = attrs.Get("labels.host") - assert.False(t, ok) - _, ok = attrs.Get("labels.state") - assert.False(t, ok) - }) - } -} + factory := NewFactory() + settings := processortest.NewNopSettings(metadata.Type) + next := &consumertest.MetricsSink{} + cfg := NewDefaultConfig().(*Config) -func TestTranslateRawOTLPMetricDataPointAttributesPreservesRoutingAttrs(t *testing.T) { - attrs := pcommon.NewMap() - attrs.PutStr("data_stream.dataset", "apm.internal") - attrs.PutStr("data_stream.namespace", "default") - attrs.PutStr("data_stream.type", "metrics") - attrs.PutStr("host", "server-01") - attrs.PutStr("state", "used") + mp, err := factory.CreateMetrics(ctx, settings, cfg, next) + require.NoError(t, err) - translateRawOTLPMetricDataPointAttributes(attrs) + metrics := pmetric.NewMetrics() - value, ok := attrs.Get("data_stream.dataset") - require.True(t, ok) - assert.Equal(t, "apm.internal", value.Str()) - value, ok = attrs.Get("data_stream.namespace") - require.True(t, ok) - assert.Equal(t, "default", value.Str()) - value, ok = attrs.Get("data_stream.type") - require.True(t, ok) - assert.Equal(t, "metrics", value.Str()) + intakeResourceMetric := metrics.ResourceMetrics().AppendEmpty() + intakeResource := intakeResourceMetric.Resource() + intakeResource.Attributes().PutStr("service.name", "intake-service") + intakeResource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "ElasticAPM") + intakeMetric := intakeResourceMetric.ScopeMetrics().AppendEmpty().Metrics().AppendEmpty() + intakeMetric.SetName("intake.metric") + intakeMetric.SetEmptyGauge().DataPoints().AppendEmpty().SetDoubleValue(1.0) + + otlpResourceMetric := metrics.ResourceMetrics().AppendEmpty() + otlpResource := otlpResourceMetric.Resource() + otlpResource.Attributes().PutStr("service.name", "otlp-service") + otlpResource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "opentelemetry") + otlpMetric := otlpResourceMetric.ScopeMetrics().AppendEmpty().Metrics().AppendEmpty() + otlpMetric.SetName("http.requests.total") + otlpDP := otlpMetric.SetEmptySum().DataPoints().AppendEmpty() + otlpDP.SetIntValue(1) + otlpAttrs := otlpDP.Attributes() + otlpAttrs.PutStr("http.request.method", "GET") + otlpAttrs.PutStr("host", "server-01") + + require.NoError(t, mp.ConsumeMetrics(ctx, metrics)) + actual := next.AllMetrics()[0] + + require.Equal(t, 2, actual.ResourceMetrics().Len()) + + // Mixed-origin batches are not supported. The first resource metric determines + // which ECS metric enricher is used for the whole batch. + intakeAttrs := actual.ResourceMetrics().At(0).Resource().Attributes() + _, ok := intakeAttrs.Get(string(semconv.TelemetrySDKLanguageKey)) + assert.False(t, ok) - value, ok = attrs.Get("labels.host") + otlpAttrsAfter := actual.ResourceMetrics().At(1).Resource().Attributes() + _, ok = otlpAttrsAfter.Get(string(semconv.TelemetrySDKLanguageKey)) + assert.False(t, ok) + + actualDPAttrs := actual.ResourceMetrics().At(1).ScopeMetrics().At(0).Metrics().At(0).Sum().DataPoints().At(0).Attributes() + value, ok := actualDPAttrs.Get("http.request.method") require.True(t, ok) - assert.Equal(t, "server-01", value.Str()) - value, ok = attrs.Get("labels.state") + assert.Equal(t, "GET", value.Str()) + value, ok = actualDPAttrs.Get("host") require.True(t, ok) - assert.Equal(t, "used", value.Str()) - _, ok = attrs.Get("host") + assert.Equal(t, "server-01", value.Str()) + _, ok = actualDPAttrs.Get("labels.http_request_method") assert.False(t, ok) - _, ok = attrs.Get("state") + _, ok = actualDPAttrs.Get("labels.host") assert.False(t, ok) } From 069c70ea3ae0f01badec87d69cdcc72282397bee Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Mon, 30 Mar 2026 18:09:24 -0400 Subject: [PATCH 03/15] feat: refactor to handle truncation --- .../internal/ecs/ecs_translation.go | 161 +++++++++++------- .../internal/ecs/ecs_translation_test.go | 142 ++++++++++++++- .../internal/enrichments/metric.go | 2 +- .../internal/enrichments/metric_test.go | 53 ++++++ 4 files changed, 298 insertions(+), 60 deletions(-) diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go index f7c90ce6b..4c83f5294 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go @@ -32,12 +32,20 @@ const ( ecsAttrOpenCensusExporterVersion = "opencensus.exporterversion" ) +type attrAction uint8 + +const ( + attrActionFallback attrAction = iota + attrActionPreserve + attrActionTruncateAndPreserve +) + // TranslateResourceMetadata normalizes resource attributes. // Sanitizes existing labels and numeric_labels keys. // Moves unsupported attributes to labels with a "labels." prefix (key sanitized), // and leaves supported ECS attributes unchanged. func TranslateResourceMetadata(resource pcommon.Resource) { - translateAttributes(resource.Attributes(), isSupportedResourceAttribute) + translateAttributes(resource.Attributes(), classifyResourceAttribute) } // TranslateLogRecordAttributes applies the apm-data OTLP fallback behaviour for @@ -45,7 +53,7 @@ func TranslateResourceMetadata(resource pcommon.Resource) { // unsupported attributes are moved to labels.* / numeric_labels.* with a // sanitized key. func TranslateLogRecordAttributes(attributes pcommon.Map) { - translateAttributes(attributes, isSupportedLogRecordAttribute) + translateAttributes(attributes, classifyLogRecordAttribute) } // TranslateMetricDataPointAttributes applies the apm-data OTLP metric fallback @@ -53,10 +61,10 @@ func TranslateLogRecordAttributes(attributes pcommon.Map) { // numeric_labels.* keys are sanitized in place, metric-specific special cases // are preserved, and everything else is moved to labels.* / numeric_labels.*. func TranslateMetricDataPointAttributes(attributes pcommon.Map) { - translateAttributes(attributes, isSupportedMetricDataPointAttribute) + translateAttributes(attributes, classifyMetricDataPointAttribute) } -func translateAttributes(attributes pcommon.Map, isSupported func(string) bool) { +func translateAttributes(attributes pcommon.Map, classify func(string, pcommon.Value) attrAction) { attributes.Range(func(k string, v pcommon.Value) bool { if sanitize.IsLabelAttribute(k) { sanitized := sanitize.HandleLabelAttributeKey(k) @@ -64,16 +72,36 @@ func translateAttributes(attributes pcommon.Map, isSupported func(string) bool) v.CopyTo(attributes.PutEmpty(sanitized)) attributes.Remove(k) } - } else if !isSupported(k) { + return true + } + + switch classify(k, v) { + case attrActionPreserve: + return true + case attrActionTruncateAndPreserve: + truncated := sanitize.Truncate(v.Str()) + if truncated != v.Str() { + attributes.PutStr(k, truncated) + } + return true + default: // Attributes not supported by ECS are moved to labels with a // labels./numeric_labels. prefix depending on their value type. setLabelAttributeValue(attributes, sanitize.HandleAttributeKey(k), v) attributes.Remove(k) + return true } - return true }) } +// shouldPreserveAndTruncateIfString determines if the value should be preserved, optionally truncating strings before preserving. +func shouldPreserveAndTruncateIfString(value pcommon.Value) attrAction { + if value.Type() == pcommon.ValueTypeStr { + return attrActionTruncateAndPreserve + } + return attrActionPreserve +} + // setLabelAttributeValue maps a value into labels.* / numeric_labels.*. // Elasticsearch label mappings only support flat scalar values and // homogeneous arrays thereof; Map, Bytes, and empty types cannot be @@ -135,13 +163,19 @@ func setLabelAttributeValue(attributes pcommon.Map, key string, value pcommon.Va } } -// isSupportedLogRecordAttribute is based on the OTLP log-record attribute switch +// classifyLogRecordAttribute is based on the OTLP log-record attribute switch // in apm-data/input/otlp/logs.go, which preserves exception.*, event.name, // event.domain, session.id, network.connection.type, and data_stream.* as // first-class fields and sends everything else through setLabel(replaceDots(k), ...). +// +// Unlike resource metadata and some metric special cases, apm-data does not +// truncate these preserved log-record fields. Truncation for log attributes only +// happens on the fallback label path via setLabel, which is mirrored here by +// setLabelAttributeValue. +// // This allowlist also keeps processor-added fields like processor.event, // error.id, and data_stream.type so they survive the collector-side translation pass. -func isSupportedLogRecordAttribute(attr string) bool { +func classifyLogRecordAttribute(attr string, _ pcommon.Value) attrAction { switch attr { case string(semconv26.ExceptionEscapedKey), string(semconv.ExceptionMessageKey), @@ -156,50 +190,58 @@ func isSupportedLogRecordAttribute(attr string) bool { "event.name", elasticattr.ProcessorEvent, elasticattr.SessionID: - return true + return attrActionPreserve } - return false + return attrActionFallback } -// isSupportedMetricDataPointAttribute mirrors the apm-data OTLP metric +// classifyMetricDataPointAttribute mirrors the apm-data OTLP metric // datapoint handling where a small set of fields are preserved as first-class // values and the rest fall back to labels.* / numeric_labels.*. // // The collector preserves data_stream.type in addition to the apm-data // data_stream dataset/namespace handling, since datapoint-level routing depends // on the full data_stream triple before export. -func isSupportedMetricDataPointAttribute(attr string) bool { +func classifyMetricDataPointAttribute(attr string, value pcommon.Value) attrAction { switch attr { case elasticattr.DataStreamDataset, elasticattr.DataStreamNamespace, elasticattr.DataStreamType, elasticattr.EventDataset, "event.module", - "system.process.cmdline", "system.process.cpu.start_time", + "system.process.state": + return attrActionPreserve + case "system.process.cmdline", "system.filesystem.mount_point", - "system.process.state", string(semconv.UserNameKey): - return true + return shouldPreserveAndTruncateIfString(value) } - return false + return attrActionFallback } -// isSupportedResourceAttribute returns true if the resource attribute is -// supported by ECS and can be mapped directly. -// Supported fields can include OTEL SemConv attributes or ECS specific attributes. +// classifyResourceAttribute returns the action required for a supported ECS +// resource attribute. Supported fields can include OTEL SemConv attributes or +// ECS specific attributes. +// // Fields are based on those found in the below areas: // 1. apm-data: https://github.com/elastic/apm-data/blob/main/input/otlp/metadata.go // 2. elasticapmintake receiver: https://github.com/elastic/opentelemetry-collector-components/tree/main/receiver/elasticapmintakereceiver/internal/mappers -func isSupportedResourceAttribute(attr string) bool { +// +// Where apm-data truncates a preserved resource string when populating the APM +// event, we mirror that here with attrActionTruncateAndPreserve so resource +// translation stays single-pass. +func classifyResourceAttribute(attr string, value pcommon.Value) attrAction { switch attr { // service.* - case string(semconv.ServiceNameKey), - string(semconv.ServiceVersionKey), - string(semconv.ServiceInstanceIDKey), - string(semconv.ServiceNamespaceKey), + case string(semconv.ServiceNameKey): + return attrActionPreserve + case string(semconv.ServiceVersionKey), + string(semconv.ServiceInstanceIDKey): + return shouldPreserveAndTruncateIfString(value) + case string(semconv.ServiceNamespaceKey), elasticattr.ServiceFrameworkName, elasticattr.ServiceFrameworkVersion, elasticattr.ServiceOriginID, @@ -207,27 +249,29 @@ func isSupportedResourceAttribute(attr string) bool { elasticattr.ServiceOriginVersion, elasticattr.ServiceTargetName, elasticattr.ServiceTargetType: - return true + return attrActionPreserve // deployment.* case string(semconv26.DeploymentEnvironmentKey), string(semconv.DeploymentEnvironmentNameKey): - return true + return shouldPreserveAndTruncateIfString(value) // telemetry.sdk.* case string(semconv.TelemetrySDKNameKey), string(semconv.TelemetrySDKVersionKey), - string(semconv.TelemetrySDKLanguageKey), - string(semconv.TelemetryDistroNameKey), + string(semconv.TelemetrySDKLanguageKey): + return shouldPreserveAndTruncateIfString(value) + case string(semconv.TelemetryDistroNameKey), string(semconv.TelemetryDistroVersionKey): - return true + return attrActionPreserve // cloud.* case string(semconv.CloudProviderKey), string(semconv.CloudAccountIDKey), string(semconv.CloudRegionKey), string(semconv.CloudAvailabilityZoneKey), - string(semconv.CloudPlatformKey), - elasticattr.CloudOriginAccountID, + string(semconv.CloudPlatformKey): + return shouldPreserveAndTruncateIfString(value) + case elasticattr.CloudOriginAccountID, elasticattr.CloudOriginProvider, elasticattr.CloudOriginRegion, elasticattr.CloudOriginServiceName, @@ -237,74 +281,77 @@ func isSupportedResourceAttribute(attr string) bool { elasticattr.CloudMachineType, elasticattr.CloudProjectID, elasticattr.CloudProjectName: - return true + return attrActionPreserve // container.* case string(semconv.ContainerNameKey), string(semconv.ContainerIDKey), string(semconv.ContainerImageNameKey), elasticattr.ContainerImageTag, - string(semconv.ContainerImageTagsKey), string(semconv.ContainerRuntimeKey): - return true + return shouldPreserveAndTruncateIfString(value) + case string(semconv.ContainerImageTagsKey): + return attrActionPreserve // k8s.* case string(semconv.K8SNamespaceNameKey), string(semconv.K8SNodeNameKey), string(semconv.K8SPodNameKey), string(semconv.K8SPodUIDKey): - return true + return shouldPreserveAndTruncateIfString(value) // host.* case string(semconv.HostNameKey), - elasticattr.HostHostName, // legacy hostname key for backwards compatibility string(semconv.HostIDKey), string(semconv.HostTypeKey), - string(semconv.HostArchKey), + string(semconv.HostArchKey): + return shouldPreserveAndTruncateIfString(value) + case elasticattr.HostHostName, string(semconv.HostIPKey), elasticattr.HostOSType: - return true + return attrActionPreserve // process.* - case string(semconv.ProcessPIDKey), - string(semconv.ProcessParentPIDKey), - string(semconv.ProcessExecutableNameKey), - string(semconv.ProcessCommandLineKey), + case string(semconv.ProcessCommandLineKey), string(semconv.ProcessExecutablePathKey), string(semconv.ProcessRuntimeNameKey), string(semconv.ProcessRuntimeVersionKey), string(semconv.ProcessOwnerKey): - return true + return shouldPreserveAndTruncateIfString(value) + case string(semconv.ProcessPIDKey), + string(semconv.ProcessParentPIDKey), + string(semconv.ProcessExecutableNameKey): + return attrActionPreserve // os.* case string(semconv.OSTypeKey), string(semconv.OSDescriptionKey), string(semconv.OSNameKey), string(semconv.OSVersionKey): - return true + return shouldPreserveAndTruncateIfString(value) // device.* case string(semconv.DeviceIDKey), string(semconv.DeviceModelIdentifierKey), string(semconv.DeviceModelNameKey), elasticattr.DeviceManufacturer: - return true + return shouldPreserveAndTruncateIfString(value) // data_stream.* case elasticattr.DataStreamDataset, elasticattr.DataStreamNamespace: - return true + return attrActionPreserve // user.* case string(semconv.UserIDKey), string(semconv.UserEmailKey), string(semconv.UserNameKey), elasticattr.UserDomain: - return true + return attrActionPreserve // user_agent.* case string(semconv.UserAgentOriginalKey): - return true + return attrActionPreserve // network.* case string(semconv.NetworkConnectionTypeKey), @@ -313,22 +360,22 @@ func isSupportedResourceAttribute(attr string) bool { string(semconv.NetworkCarrierMccKey), string(semconv.NetworkCarrierMncKey), string(semconv.NetworkCarrierIccKey): - return true + return attrActionPreserve // client.* case string(semconv.ClientAddressKey), string(semconv.ClientPortKey): - return true + return attrActionPreserve // source.* case string(semconv.SourceAddressKey), string(semconv.SourcePortKey), elasticattr.SourceNATIP: - return true + return attrActionPreserve // destination.* case elasticattr.DestinationIP: - return true + return attrActionPreserve // faas.* case string(semconv.FaaSInstanceKey), @@ -338,25 +385,25 @@ func isSupportedResourceAttribute(attr string) bool { string(semconv.FaaSColdstartKey), elasticattr.FaaSTriggerRequestID, elasticattr.FaaSExecution: - return true + return attrActionPreserve // Legacy OpenCensus attributes case ecsAttrOpenCensusExporterVersion: - return true + return attrActionPreserve // APM Agent enrichment case elasticattr.AgentName, elasticattr.AgentVersion, elasticattr.AgentEphemeralID, elasticattr.AgentActivationMethod: - return true + return attrActionPreserve // Metrics case elasticattr.MetricsetName: - return true + return attrActionPreserve } - return false + return attrActionFallback } func ApplyResourceConventions(resource pcommon.Resource) { diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go index 632824008..94404bb6c 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/elastic/opentelemetry-collector-components/internal/elasticattr" + "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/sanitize" "github.com/stretchr/testify/assert" "go.opentelemetry.io/collector/pdata/pcommon" semconv "go.opentelemetry.io/otel/semconv/v1.25.0" @@ -33,6 +34,7 @@ func TestTranslateResourceMetadata(t *testing.T) { inputKey string inputVal string wantKey string + wantVal string wantAbsent string // if non-empty, assert this attribute key is removed after translation (e.g. sanitized key) }{ { @@ -123,12 +125,96 @@ func TestTranslateResourceMetadata(t *testing.T) { inputVal: "dotnet", wantKey: string(semconv.TelemetrySDKLanguageKey), }, + { + name: "supported service version truncated", + inputKey: string(semconv.ServiceVersionKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.ServiceVersionKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, + { + name: "supported service instance id truncated", + inputKey: string(semconv.ServiceInstanceIDKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.ServiceInstanceIDKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, + { + name: "supported deployment environment truncated", + inputKey: string(semconv.DeploymentEnvironmentKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.DeploymentEnvironmentKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, + { + name: "supported telemetry sdk name truncated", + inputKey: string(semconv.TelemetrySDKNameKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.TelemetrySDKNameKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, { name: "supported telemetry sdk version", inputKey: string(semconv.TelemetrySDKVersionKey), inputVal: "8.0.0", wantKey: string(semconv.TelemetrySDKVersionKey), }, + { + name: "supported cloud provider truncated", + inputKey: string(semconv.CloudProviderKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.CloudProviderKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, + { + name: "supported container name truncated", + inputKey: string(semconv.ContainerNameKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.ContainerNameKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, + { + name: "supported kubernetes namespace truncated", + inputKey: string(semconv.K8SNamespaceNameKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.K8SNamespaceNameKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, + { + name: "supported host name truncated", + inputKey: string(semconv.HostNameKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.HostNameKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, + { + name: "supported process command line truncated", + inputKey: string(semconv.ProcessCommandLineKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.ProcessCommandLineKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, + { + name: "supported process owner truncated", + inputKey: string(semconv.ProcessOwnerKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.ProcessOwnerKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, + { + name: "supported os description truncated", + inputKey: string(semconv.OSDescriptionKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.OSDescriptionKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, + { + name: "supported device model name truncated", + inputKey: string(semconv.DeviceModelNameKey), + inputVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + wantKey: string(semconv.DeviceModelNameKey), + wantVal: strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + }, { name: "supported process runtime name", inputKey: string(semconv.ProcessRuntimeNameKey), @@ -182,8 +268,12 @@ func TestTranslateResourceMetadata(t *testing.T) { if !ok { t.Fatalf("expected attribute %q to be present. all attrs %v", tc.wantKey, attrs.AsRaw()) } - if v.AsString() != tc.inputVal { - t.Errorf("attribute %q value = %q, want %q", tc.wantKey, v.AsString(), tc.inputVal) + wantVal := tc.inputVal + if tc.wantVal != "" { + wantVal = tc.wantVal + } + if v.AsString() != wantVal { + t.Errorf("attribute %q value = %q, want %q", tc.wantKey, v.AsString(), wantVal) } if tc.wantAbsent != "" { if _, ok := attrs.Get(tc.wantAbsent); ok { @@ -230,6 +320,34 @@ func TestTranslateLogRecordAttributes(t *testing.T) { elasticattr.DataStreamNamespace: "default", }, }, + { + name: "supported semantic fields are not truncated", + setAttrs: func(attrs pcommon.Map) { + longValue := strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1) + attrs.PutStr(string(semconv.ExceptionMessageKey), longValue) + attrs.PutStr(string(semconv.ExceptionStacktraceKey), longValue) + attrs.PutStr(string(semconv.ExceptionTypeKey), longValue) + attrs.PutStr("event.name", longValue) + attrs.PutStr("event.domain", longValue) + attrs.PutStr("session.id", longValue) + attrs.PutStr(string(semconv.NetworkConnectionTypeKey), longValue) + attrs.PutStr(elasticattr.DataStreamDataset, longValue) + attrs.PutStr(elasticattr.DataStreamNamespace, longValue) + attrs.PutStr(elasticattr.DataStreamType, longValue) + }, + want: map[string]any{ + string(semconv.ExceptionMessageKey): strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + string(semconv.ExceptionStacktraceKey): strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + string(semconv.ExceptionTypeKey): strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + "event.name": strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + "event.domain": strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + "session.id": strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + string(semconv.NetworkConnectionTypeKey): strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + elasticattr.DataStreamDataset: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + elasticattr.DataStreamNamespace: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + elasticattr.DataStreamType: strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + }, + }, { name: "unsupported attributes moved to labels", setAttrs: func(attrs pcommon.Map) { @@ -351,6 +469,7 @@ func TestTranslateMetricDataPointAttributes(t *testing.T) { attrs.PutStr("host", "server-01") attrs.PutStr("state", "used") attrs.PutStr("system.process.cmdline", "/usr/bin/java") + attrs.PutStr("system.filesystem.mount_point", "/mnt/data") attrs.PutStr("event.module", "system") attrs.PutStr("user.name", "appuser") }, @@ -361,11 +480,30 @@ func TestTranslateMetricDataPointAttributes(t *testing.T) { "labels.host": "server-01", "labels.state": "used", "system.process.cmdline": "/usr/bin/java", + "system.filesystem.mount_point": "/mnt/data", "event.module": "system", "user.name": "appuser", }, wantAbsent: []string{"host", "state"}, }, + { + name: "metric special cases requiring truncation are truncated in place", + setAttrs: func(attrs pcommon.Map) { + longValue := strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1) + attrs.PutStr("system.process.cmdline", longValue) + attrs.PutStr("system.filesystem.mount_point", longValue) + attrs.PutStr("user.name", longValue) + attrs.PutStr("event.module", longValue) + attrs.PutStr("system.process.state", longValue) + }, + want: map[string]any{ + "system.process.cmdline": strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + "system.filesystem.mount_point": strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + "user.name": strings.Repeat("a", int(sanitize.StandardKeyWordLength)), + "event.module": strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + "system.process.state": strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1), + }, + }, } for _, tc := range tests { diff --git a/processor/elasticapmprocessor/internal/enrichments/metric.go b/processor/elasticapmprocessor/internal/enrichments/metric.go index b816e4746..9d447b29c 100644 --- a/processor/elasticapmprocessor/internal/enrichments/metric.go +++ b/processor/elasticapmprocessor/internal/enrichments/metric.go @@ -94,7 +94,7 @@ func enrichMetricDataPointAttributes(attributes pcommon.Map, cfg config.Config) // `elasticsearch.mapping.hints` is collector/exporter-specific metadata and has // no apm-data equivalent, but it also needs to passthrough untouched. func isAggregatedMetricDataPointAttributes(attributes pcommon.Map) bool { - if _, ok := attributes.Get("metricset.name"); ok { + if _, ok := attributes.Get(elasticattr.MetricsetName); ok { return true } if _, ok := attributes.Get("metricset.interval"); ok { diff --git a/processor/elasticapmprocessor/internal/enrichments/metric_test.go b/processor/elasticapmprocessor/internal/enrichments/metric_test.go index 9285b8109..e27a4291f 100644 --- a/processor/elasticapmprocessor/internal/enrichments/metric_test.go +++ b/processor/elasticapmprocessor/internal/enrichments/metric_test.go @@ -18,6 +18,7 @@ package enrichments import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -27,6 +28,7 @@ import ( "github.com/elastic/opentelemetry-collector-components/internal/elasticattr" "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/enrichments/config" + "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/sanitize" ) func TestEnrichMetric(t *testing.T) { @@ -154,6 +156,8 @@ func TestEnrichMetrics_TranslateUnsupportedAttributes(t *testing.T) { attrs.PutStr("state", "used") attrs.PutStr("event.module", "system") attrs.PutStr("system.process.cmdline", "/usr/bin/java") + attrs.PutStr("system.filesystem.mount_point", "/mnt/data") + attrs.PutStr("user.name", "appuser") enricher := NewEnricher(cfg) enricher.EnrichMetrics(metrics) @@ -181,12 +185,61 @@ func TestEnrichMetrics_TranslateUnsupportedAttributes(t *testing.T) { value, ok = actualAttrs.Get("system.process.cmdline") require.True(t, ok) assert.Equal(t, "/usr/bin/java", value.Str()) + value, ok = actualAttrs.Get("system.filesystem.mount_point") + require.True(t, ok) + assert.Equal(t, "/mnt/data", value.Str()) + value, ok = actualAttrs.Get("user.name") + require.True(t, ok) + assert.Equal(t, "appuser", value.Str()) _, ok = actualAttrs.Get("host") assert.False(t, ok) _, ok = actualAttrs.Get("state") assert.False(t, ok) } +func TestEnrichMetrics_TruncatesPreservedMetricSpecialCaseAttributes(t *testing.T) { + cfg := config.Enabled() + cfg.Metric.TranslateUnsupportedAttributes.Enabled = true + + metrics := pmetric.NewMetrics() + resourceMetrics := metrics.ResourceMetrics().AppendEmpty() + scopeMetrics := resourceMetrics.ScopeMetrics().AppendEmpty() + metric := scopeMetrics.Metrics().AppendEmpty() + metric.SetName("test.metric") + dp := metric.SetEmptyGauge().DataPoints().AppendEmpty() + dp.SetDoubleValue(1.0) + attrs := dp.Attributes() + longValue := strings.Repeat("a", int(sanitize.StandardKeyWordLength)+1) + attrs.PutStr("system.process.cmdline", longValue) + attrs.PutStr("system.filesystem.mount_point", longValue) + attrs.PutStr("user.name", longValue) + attrs.PutStr("event.module", longValue) + attrs.PutStr("system.process.state", longValue) + + NewEnricher(cfg).EnrichMetrics(metrics) + + actualAttrs := metrics.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().At(0).Gauge().DataPoints().At(0).Attributes() + expected := strings.Repeat("a", int(sanitize.StandardKeyWordLength)) + + value, ok := actualAttrs.Get("system.process.cmdline") + require.True(t, ok) + assert.Equal(t, expected, value.Str()) + value, ok = actualAttrs.Get("system.filesystem.mount_point") + require.True(t, ok) + assert.Equal(t, expected, value.Str()) + value, ok = actualAttrs.Get("user.name") + require.True(t, ok) + assert.Equal(t, expected, value.Str()) + + // These preserved attrs are intentionally not truncated in apm-data. + value, ok = actualAttrs.Get("event.module") + require.True(t, ok) + assert.Equal(t, longValue, value.Str()) + value, ok = actualAttrs.Get("system.process.state") + require.True(t, ok) + assert.Equal(t, longValue, value.Str()) +} + func TestEnrichMetricDataPoints_SkipsAggregatedMetricAttributes(t *testing.T) { cfg := config.Enabled() cfg.Metric.TranslateUnsupportedAttributes.Enabled = true From b73f726702d63901b0a520719f7d825e48dbabb1 Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Mon, 30 Mar 2026 18:19:21 -0400 Subject: [PATCH 04/15] feat: simplify translation functions --- .../internal/ecs/ecs_translation.go | 455 +++++++----------- 1 file changed, 176 insertions(+), 279 deletions(-) diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go index 4c83f5294..9b85eadd6 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go @@ -32,20 +32,124 @@ const ( ecsAttrOpenCensusExporterVersion = "opencensus.exporterversion" ) -type attrAction uint8 - -const ( - attrActionFallback attrAction = iota - attrActionPreserve - attrActionTruncateAndPreserve -) - // TranslateResourceMetadata normalizes resource attributes. // Sanitizes existing labels and numeric_labels keys. // Moves unsupported attributes to labels with a "labels." prefix (key sanitized), // and leaves supported ECS attributes unchanged. func TranslateResourceMetadata(resource pcommon.Resource) { - translateAttributes(resource.Attributes(), classifyResourceAttribute) + attributes := resource.Attributes() + attributes.Range(func(k string, v pcommon.Value) bool { + if sanitizeExistingLabelAttribute(attributes, k, v) { + return true + } + + switch k { + case string(semconv.ServiceNameKey), + string(semconv.ServiceNamespaceKey), + elasticattr.ServiceFrameworkName, + elasticattr.ServiceFrameworkVersion, + elasticattr.ServiceOriginID, + elasticattr.ServiceOriginName, + elasticattr.ServiceOriginVersion, + elasticattr.ServiceTargetName, + elasticattr.ServiceTargetType, + string(semconv.TelemetryDistroNameKey), + string(semconv.TelemetryDistroVersionKey), + elasticattr.CloudOriginAccountID, + elasticattr.CloudOriginProvider, + elasticattr.CloudOriginRegion, + elasticattr.CloudOriginServiceName, + elasticattr.CloudAccountName, + elasticattr.CloudInstanceID, + elasticattr.CloudInstanceName, + elasticattr.CloudMachineType, + elasticattr.CloudProjectID, + elasticattr.CloudProjectName, + string(semconv.ContainerImageTagsKey), + elasticattr.HostHostName, + string(semconv.HostIPKey), + elasticattr.HostOSType, + elasticattr.DataStreamDataset, + elasticattr.DataStreamNamespace, + string(semconv.UserIDKey), + string(semconv.UserEmailKey), + string(semconv.UserNameKey), + elasticattr.UserDomain, + string(semconv.UserAgentOriginalKey), + string(semconv.NetworkConnectionTypeKey), + string(semconv.NetworkConnectionSubtypeKey), + string(semconv.NetworkCarrierNameKey), + string(semconv.NetworkCarrierMccKey), + string(semconv.NetworkCarrierMncKey), + string(semconv.NetworkCarrierIccKey), + string(semconv.ClientAddressKey), + string(semconv.ClientPortKey), + string(semconv.SourceAddressKey), + string(semconv.SourcePortKey), + elasticattr.SourceNATIP, + elasticattr.DestinationIP, + string(semconv.FaaSInstanceKey), + string(semconv.FaaSNameKey), + string(semconv.FaaSVersionKey), + string(semconv.FaaSTriggerKey), + string(semconv.FaaSColdstartKey), + elasticattr.FaaSTriggerRequestID, + elasticattr.FaaSExecution, + ecsAttrOpenCensusExporterVersion, + elasticattr.AgentName, + elasticattr.AgentVersion, + elasticattr.AgentEphemeralID, + elasticattr.AgentActivationMethod, + elasticattr.MetricsetName, + string(semconv.ProcessPIDKey), + string(semconv.ProcessParentPIDKey), + string(semconv.ProcessExecutableNameKey): + return true + case string(semconv.ServiceVersionKey), + string(semconv.ServiceInstanceIDKey), + string(semconv26.DeploymentEnvironmentKey), + string(semconv.DeploymentEnvironmentNameKey), + string(semconv.TelemetrySDKNameKey), + string(semconv.TelemetrySDKVersionKey), + string(semconv.TelemetrySDKLanguageKey), + string(semconv.CloudProviderKey), + string(semconv.CloudAccountIDKey), + string(semconv.CloudRegionKey), + string(semconv.CloudAvailabilityZoneKey), + string(semconv.CloudPlatformKey), + string(semconv.ContainerNameKey), + string(semconv.ContainerIDKey), + string(semconv.ContainerImageNameKey), + elasticattr.ContainerImageTag, + string(semconv.ContainerRuntimeKey), + string(semconv.K8SNamespaceNameKey), + string(semconv.K8SNodeNameKey), + string(semconv.K8SPodNameKey), + string(semconv.K8SPodUIDKey), + string(semconv.HostNameKey), + string(semconv.HostIDKey), + string(semconv.HostTypeKey), + string(semconv.HostArchKey), + string(semconv.ProcessCommandLineKey), + string(semconv.ProcessExecutablePathKey), + string(semconv.ProcessRuntimeNameKey), + string(semconv.ProcessRuntimeVersionKey), + string(semconv.ProcessOwnerKey), + string(semconv.OSTypeKey), + string(semconv.OSDescriptionKey), + string(semconv.OSNameKey), + string(semconv.OSVersionKey), + string(semconv.DeviceIDKey), + string(semconv.DeviceModelIdentifierKey), + string(semconv.DeviceModelNameKey), + elasticattr.DeviceManufacturer: + truncatePreservedStringAttribute(attributes, k, v) + return true + default: + fallbackToLabelAttribute(attributes, k, v) + return true + } + }) } // TranslateLogRecordAttributes applies the apm-data OTLP fallback behaviour for @@ -53,7 +157,31 @@ func TranslateResourceMetadata(resource pcommon.Resource) { // unsupported attributes are moved to labels.* / numeric_labels.* with a // sanitized key. func TranslateLogRecordAttributes(attributes pcommon.Map) { - translateAttributes(attributes, classifyLogRecordAttribute) + attributes.Range(func(k string, v pcommon.Value) bool { + if sanitizeExistingLabelAttribute(attributes, k, v) { + return true + } + + switch k { + case string(semconv26.ExceptionEscapedKey), + string(semconv.ExceptionMessageKey), + string(semconv.ExceptionStacktraceKey), + string(semconv.ExceptionTypeKey), + string(semconv.NetworkConnectionTypeKey), + elasticattr.DataStreamDataset, + elasticattr.DataStreamNamespace, + elasticattr.DataStreamType, + elasticattr.ErrorID, + "event.domain", + "event.name", + elasticattr.ProcessorEvent, + elasticattr.SessionID: + return true + default: + fallbackToLabelAttribute(attributes, k, v) + return true + } + }) } // TranslateMetricDataPointAttributes applies the apm-data OTLP metric fallback @@ -61,45 +189,57 @@ func TranslateLogRecordAttributes(attributes pcommon.Map) { // numeric_labels.* keys are sanitized in place, metric-specific special cases // are preserved, and everything else is moved to labels.* / numeric_labels.*. func TranslateMetricDataPointAttributes(attributes pcommon.Map) { - translateAttributes(attributes, classifyMetricDataPointAttribute) -} - -func translateAttributes(attributes pcommon.Map, classify func(string, pcommon.Value) attrAction) { attributes.Range(func(k string, v pcommon.Value) bool { - if sanitize.IsLabelAttribute(k) { - sanitized := sanitize.HandleLabelAttributeKey(k) - if sanitized != k { - v.CopyTo(attributes.PutEmpty(sanitized)) - attributes.Remove(k) - } + if sanitizeExistingLabelAttribute(attributes, k, v) { return true } - switch classify(k, v) { - case attrActionPreserve: + switch k { + case elasticattr.DataStreamDataset, + elasticattr.DataStreamNamespace, + elasticattr.DataStreamType, + elasticattr.EventDataset, + "event.module", + "system.process.cpu.start_time", + "system.process.state": return true - case attrActionTruncateAndPreserve: - truncated := sanitize.Truncate(v.Str()) - if truncated != v.Str() { - attributes.PutStr(k, truncated) - } + case "system.process.cmdline", + "system.filesystem.mount_point", + string(semconv.UserNameKey): + truncatePreservedStringAttribute(attributes, k, v) return true default: - // Attributes not supported by ECS are moved to labels with a - // labels./numeric_labels. prefix depending on their value type. - setLabelAttributeValue(attributes, sanitize.HandleAttributeKey(k), v) - attributes.Remove(k) + fallbackToLabelAttribute(attributes, k, v) return true } }) } -// shouldPreserveAndTruncateIfString determines if the value should be preserved, optionally truncating strings before preserving. -func shouldPreserveAndTruncateIfString(value pcommon.Value) attrAction { - if value.Type() == pcommon.ValueTypeStr { - return attrActionTruncateAndPreserve +func sanitizeExistingLabelAttribute(attributes pcommon.Map, key string, value pcommon.Value) bool { + if !sanitize.IsLabelAttribute(key) { + return false } - return attrActionPreserve + sanitized := sanitize.HandleLabelAttributeKey(key) + if sanitized != key { + value.CopyTo(attributes.PutEmpty(sanitized)) + attributes.Remove(key) + } + return true +} + +func truncatePreservedStringAttribute(attributes pcommon.Map, key string, value pcommon.Value) { + if value.Type() != pcommon.ValueTypeStr { + return + } + truncated := sanitize.Truncate(value.Str()) + if truncated != value.Str() { + attributes.PutStr(key, truncated) + } +} + +func fallbackToLabelAttribute(attributes pcommon.Map, key string, value pcommon.Value) { + setLabelAttributeValue(attributes, sanitize.HandleAttributeKey(key), value) + attributes.Remove(key) } // setLabelAttributeValue maps a value into labels.* / numeric_labels.*. @@ -163,249 +303,6 @@ func setLabelAttributeValue(attributes pcommon.Map, key string, value pcommon.Va } } -// classifyLogRecordAttribute is based on the OTLP log-record attribute switch -// in apm-data/input/otlp/logs.go, which preserves exception.*, event.name, -// event.domain, session.id, network.connection.type, and data_stream.* as -// first-class fields and sends everything else through setLabel(replaceDots(k), ...). -// -// Unlike resource metadata and some metric special cases, apm-data does not -// truncate these preserved log-record fields. Truncation for log attributes only -// happens on the fallback label path via setLabel, which is mirrored here by -// setLabelAttributeValue. -// -// This allowlist also keeps processor-added fields like processor.event, -// error.id, and data_stream.type so they survive the collector-side translation pass. -func classifyLogRecordAttribute(attr string, _ pcommon.Value) attrAction { - switch attr { - case string(semconv26.ExceptionEscapedKey), - string(semconv.ExceptionMessageKey), - string(semconv.ExceptionStacktraceKey), - string(semconv.ExceptionTypeKey), - string(semconv.NetworkConnectionTypeKey), - elasticattr.DataStreamDataset, - elasticattr.DataStreamNamespace, - elasticattr.DataStreamType, - elasticattr.ErrorID, - "event.domain", - "event.name", - elasticattr.ProcessorEvent, - elasticattr.SessionID: - return attrActionPreserve - } - - return attrActionFallback -} - -// classifyMetricDataPointAttribute mirrors the apm-data OTLP metric -// datapoint handling where a small set of fields are preserved as first-class -// values and the rest fall back to labels.* / numeric_labels.*. -// -// The collector preserves data_stream.type in addition to the apm-data -// data_stream dataset/namespace handling, since datapoint-level routing depends -// on the full data_stream triple before export. -func classifyMetricDataPointAttribute(attr string, value pcommon.Value) attrAction { - switch attr { - case elasticattr.DataStreamDataset, - elasticattr.DataStreamNamespace, - elasticattr.DataStreamType, - elasticattr.EventDataset, - "event.module", - "system.process.cpu.start_time", - "system.process.state": - return attrActionPreserve - case "system.process.cmdline", - "system.filesystem.mount_point", - string(semconv.UserNameKey): - return shouldPreserveAndTruncateIfString(value) - } - - return attrActionFallback -} - -// classifyResourceAttribute returns the action required for a supported ECS -// resource attribute. Supported fields can include OTEL SemConv attributes or -// ECS specific attributes. -// -// Fields are based on those found in the below areas: -// 1. apm-data: https://github.com/elastic/apm-data/blob/main/input/otlp/metadata.go -// 2. elasticapmintake receiver: https://github.com/elastic/opentelemetry-collector-components/tree/main/receiver/elasticapmintakereceiver/internal/mappers -// -// Where apm-data truncates a preserved resource string when populating the APM -// event, we mirror that here with attrActionTruncateAndPreserve so resource -// translation stays single-pass. -func classifyResourceAttribute(attr string, value pcommon.Value) attrAction { - switch attr { - // service.* - case string(semconv.ServiceNameKey): - return attrActionPreserve - case string(semconv.ServiceVersionKey), - string(semconv.ServiceInstanceIDKey): - return shouldPreserveAndTruncateIfString(value) - case string(semconv.ServiceNamespaceKey), - elasticattr.ServiceFrameworkName, - elasticattr.ServiceFrameworkVersion, - elasticattr.ServiceOriginID, - elasticattr.ServiceOriginName, - elasticattr.ServiceOriginVersion, - elasticattr.ServiceTargetName, - elasticattr.ServiceTargetType: - return attrActionPreserve - - // deployment.* - case string(semconv26.DeploymentEnvironmentKey), string(semconv.DeploymentEnvironmentNameKey): - return shouldPreserveAndTruncateIfString(value) - - // telemetry.sdk.* - case string(semconv.TelemetrySDKNameKey), - string(semconv.TelemetrySDKVersionKey), - string(semconv.TelemetrySDKLanguageKey): - return shouldPreserveAndTruncateIfString(value) - case string(semconv.TelemetryDistroNameKey), - string(semconv.TelemetryDistroVersionKey): - return attrActionPreserve - - // cloud.* - case string(semconv.CloudProviderKey), - string(semconv.CloudAccountIDKey), - string(semconv.CloudRegionKey), - string(semconv.CloudAvailabilityZoneKey), - string(semconv.CloudPlatformKey): - return shouldPreserveAndTruncateIfString(value) - case elasticattr.CloudOriginAccountID, - elasticattr.CloudOriginProvider, - elasticattr.CloudOriginRegion, - elasticattr.CloudOriginServiceName, - elasticattr.CloudAccountName, - elasticattr.CloudInstanceID, - elasticattr.CloudInstanceName, - elasticattr.CloudMachineType, - elasticattr.CloudProjectID, - elasticattr.CloudProjectName: - return attrActionPreserve - - // container.* - case string(semconv.ContainerNameKey), - string(semconv.ContainerIDKey), - string(semconv.ContainerImageNameKey), - elasticattr.ContainerImageTag, - string(semconv.ContainerRuntimeKey): - return shouldPreserveAndTruncateIfString(value) - case string(semconv.ContainerImageTagsKey): - return attrActionPreserve - - // k8s.* - case string(semconv.K8SNamespaceNameKey), - string(semconv.K8SNodeNameKey), - string(semconv.K8SPodNameKey), - string(semconv.K8SPodUIDKey): - return shouldPreserveAndTruncateIfString(value) - - // host.* - case string(semconv.HostNameKey), - string(semconv.HostIDKey), - string(semconv.HostTypeKey), - string(semconv.HostArchKey): - return shouldPreserveAndTruncateIfString(value) - case elasticattr.HostHostName, - string(semconv.HostIPKey), - elasticattr.HostOSType: - return attrActionPreserve - - // process.* - case string(semconv.ProcessCommandLineKey), - string(semconv.ProcessExecutablePathKey), - string(semconv.ProcessRuntimeNameKey), - string(semconv.ProcessRuntimeVersionKey), - string(semconv.ProcessOwnerKey): - return shouldPreserveAndTruncateIfString(value) - case string(semconv.ProcessPIDKey), - string(semconv.ProcessParentPIDKey), - string(semconv.ProcessExecutableNameKey): - return attrActionPreserve - - // os.* - case string(semconv.OSTypeKey), - string(semconv.OSDescriptionKey), - string(semconv.OSNameKey), - string(semconv.OSVersionKey): - return shouldPreserveAndTruncateIfString(value) - - // device.* - case string(semconv.DeviceIDKey), - string(semconv.DeviceModelIdentifierKey), - string(semconv.DeviceModelNameKey), - elasticattr.DeviceManufacturer: - return shouldPreserveAndTruncateIfString(value) - - // data_stream.* - case elasticattr.DataStreamDataset, - elasticattr.DataStreamNamespace: - return attrActionPreserve - - // user.* - case string(semconv.UserIDKey), - string(semconv.UserEmailKey), - string(semconv.UserNameKey), - elasticattr.UserDomain: - return attrActionPreserve - - // user_agent.* - case string(semconv.UserAgentOriginalKey): - return attrActionPreserve - - // network.* - case string(semconv.NetworkConnectionTypeKey), - string(semconv.NetworkConnectionSubtypeKey), - string(semconv.NetworkCarrierNameKey), - string(semconv.NetworkCarrierMccKey), - string(semconv.NetworkCarrierMncKey), - string(semconv.NetworkCarrierIccKey): - return attrActionPreserve - - // client.* - case string(semconv.ClientAddressKey), - string(semconv.ClientPortKey): - return attrActionPreserve - - // source.* - case string(semconv.SourceAddressKey), - string(semconv.SourcePortKey), - elasticattr.SourceNATIP: - return attrActionPreserve - - // destination.* - case elasticattr.DestinationIP: - return attrActionPreserve - - // faas.* - case string(semconv.FaaSInstanceKey), - string(semconv.FaaSNameKey), - string(semconv.FaaSVersionKey), - string(semconv.FaaSTriggerKey), - string(semconv.FaaSColdstartKey), - elasticattr.FaaSTriggerRequestID, - elasticattr.FaaSExecution: - return attrActionPreserve - - // Legacy OpenCensus attributes - case ecsAttrOpenCensusExporterVersion: - return attrActionPreserve - - // APM Agent enrichment - case elasticattr.AgentName, - elasticattr.AgentVersion, - elasticattr.AgentEphemeralID, - elasticattr.AgentActivationMethod: - return attrActionPreserve - - // Metrics - case elasticattr.MetricsetName: - return attrActionPreserve - } - - return attrActionFallback -} - func ApplyResourceConventions(resource pcommon.Resource) { setHostnameFromKubernetes(resource) } From 416e86816e83be7226253d357e98d839df4a77e8 Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Wed, 1 Apr 2026 18:42:27 -0400 Subject: [PATCH 05/15] fix: service.framework.* discrepancies --- .../internal/enrichments/enricher.go | 3 +- .../internal/enrichments/metric.go | 32 +++++++++++----- .../internal/enrichments/metric_test.go | 37 +++++++++++++++++-- .../elasticapmprocessor/processor_test.go | 35 +++++++++++++++++- 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/processor/elasticapmprocessor/internal/enrichments/enricher.go b/processor/elasticapmprocessor/internal/enrichments/enricher.go index 00a47a7bf..493eddde9 100644 --- a/processor/elasticapmprocessor/internal/enrichments/enricher.go +++ b/processor/elasticapmprocessor/internal/enrichments/enricher.go @@ -102,9 +102,10 @@ func (e *Enricher) EnrichMetrics(pl pmetric.Metrics) { for j := 0; j < scopeMetics.Len(); j++ { scopeMetric := scopeMetics.At(j) EnrichScope(scopeMetric.Scope(), e.Config) + scopeAttrs := scopeMetric.Scope().Attributes() metrics := scopeMetric.Metrics() for k := 0; k < metrics.Len(); k++ { - EnrichMetricDataPoints(metrics.At(k), e.Config) + EnrichMetricDataPoints(metrics.At(k), scopeAttrs, e.Config) } } } diff --git a/processor/elasticapmprocessor/internal/enrichments/metric.go b/processor/elasticapmprocessor/internal/enrichments/metric.go index 9d447b29c..785cfd161 100644 --- a/processor/elasticapmprocessor/internal/enrichments/metric.go +++ b/processor/elasticapmprocessor/internal/enrichments/metric.go @@ -41,32 +41,32 @@ func EnrichMetric(metric pmetric.ResourceMetrics, cfg config.Config) { } } -func EnrichMetricDataPoints(metric pmetric.Metric, cfg config.Config) { +func EnrichMetricDataPoints(metric pmetric.Metric, scopeAttrs pcommon.Map, cfg config.Config) { switch metric.Type() { case pmetric.MetricTypeGauge: dataPoints := metric.Gauge().DataPoints() for i := 0; i < dataPoints.Len(); i++ { - enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), cfg) + enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), scopeAttrs, cfg) } case pmetric.MetricTypeSum: dataPoints := metric.Sum().DataPoints() for i := 0; i < dataPoints.Len(); i++ { - enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), cfg) + enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), scopeAttrs, cfg) } case pmetric.MetricTypeHistogram: dataPoints := metric.Histogram().DataPoints() for i := 0; i < dataPoints.Len(); i++ { - enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), cfg) + enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), scopeAttrs, cfg) } case pmetric.MetricTypeExponentialHistogram: dataPoints := metric.ExponentialHistogram().DataPoints() for i := 0; i < dataPoints.Len(); i++ { - enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), cfg) + enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), scopeAttrs, cfg) } case pmetric.MetricTypeSummary: dataPoints := metric.Summary().DataPoints() for i := 0; i < dataPoints.Len(); i++ { - enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), cfg) + enrichMetricDataPointAttributes(dataPoints.At(i).Attributes(), scopeAttrs, cfg) } } } @@ -76,14 +76,26 @@ func EnrichMetricDataPoints(metric pmetric.Metric, cfg config.Config) { // EnrichLog. This happens later than apm-data's OTLP-to-APM conversion, so we // must explicitly avoid relabeling attrs that identify aggregated metrics or // influence exporter behavior. -func enrichMetricDataPointAttributes(attributes pcommon.Map, cfg config.Config) { - if !cfg.Metric.TranslateUnsupportedAttributes.Enabled { +func enrichMetricDataPointAttributes(attributes pcommon.Map, scopeAttrs pcommon.Map, cfg config.Config) { + if isAggregatedMetricDataPointAttributes(attributes) { return } - if isAggregatedMetricDataPointAttributes(attributes) { + if cfg.Metric.TranslateUnsupportedAttributes.Enabled { + ecs.TranslateMetricDataPointAttributes(attributes) + projectMetricFrameworkAttributes(attributes, scopeAttrs, cfg) + } +} + +func projectMetricFrameworkAttributes(attributes pcommon.Map, scopeAttrs pcommon.Map, cfg config.Config) { + if !cfg.Scope.ServiceFrameworkName.Enabled { return } - ecs.TranslateMetricDataPointAttributes(attributes) + if value, ok := scopeAttrs.Get(elasticattr.ServiceFrameworkName); ok { + attribute.PutStr(attributes, elasticattr.ServiceFrameworkName, value.Str()) + } + if value, ok := scopeAttrs.Get(elasticattr.ServiceFrameworkVersion); ok { + attribute.PutStr(attributes, elasticattr.ServiceFrameworkVersion, value.Str()) + } } // isAggregatedMetricDataPointAttributes preserves aggregated-metric identity and diff --git a/processor/elasticapmprocessor/internal/enrichments/metric_test.go b/processor/elasticapmprocessor/internal/enrichments/metric_test.go index e27a4291f..fc7f8c95c 100644 --- a/processor/elasticapmprocessor/internal/enrichments/metric_test.go +++ b/processor/elasticapmprocessor/internal/enrichments/metric_test.go @@ -144,6 +144,8 @@ func TestEnrichMetrics_TranslateUnsupportedAttributes(t *testing.T) { metrics := pmetric.NewMetrics() resourceMetrics := metrics.ResourceMetrics().AppendEmpty() scopeMetrics := resourceMetrics.ScopeMetrics().AppendEmpty() + scopeMetrics.Scope().SetName("metrics-instrumentation") + scopeMetrics.Scope().SetVersion("1.0.0") metric := scopeMetrics.Metrics().AppendEmpty() metric.SetName("test.metric") dp := metric.SetEmptyGauge().DataPoints().AppendEmpty() @@ -191,6 +193,12 @@ func TestEnrichMetrics_TranslateUnsupportedAttributes(t *testing.T) { value, ok = actualAttrs.Get("user.name") require.True(t, ok) assert.Equal(t, "appuser", value.Str()) + value, ok = actualAttrs.Get(elasticattr.ServiceFrameworkName) + require.True(t, ok) + assert.Equal(t, "metrics-instrumentation", value.Str()) + value, ok = actualAttrs.Get(elasticattr.ServiceFrameworkVersion) + require.True(t, ok) + assert.Equal(t, "1.0.0", value.Str()) _, ok = actualAttrs.Get("host") assert.False(t, ok) _, ok = actualAttrs.Get("state") @@ -253,7 +261,12 @@ func TestEnrichMetricDataPoints_SkipsAggregatedMetricAttributes(t *testing.T) { attrs.PutStr("host", "server-01") attrs.PutStr("state", "used") - EnrichMetricDataPoints(metric, cfg) + scope := pcommon.NewInstrumentationScope() + scope.SetName("github.com/open-telemetry/opentelemetry-collector-contrib/connector/signaltometricsconnector") + scope.SetVersion("1.0.0") + scopeAttrs := scope.Attributes() + + EnrichMetricDataPoints(metric, scopeAttrs, cfg) actualAttrs := metric.Gauge().DataPoints().At(0).Attributes() value, ok := actualAttrs.Get("host") @@ -266,6 +279,10 @@ func TestEnrichMetricDataPoints_SkipsAggregatedMetricAttributes(t *testing.T) { assert.False(t, ok) _, ok = actualAttrs.Get("labels.state") assert.False(t, ok) + _, ok = actualAttrs.Get(elasticattr.ServiceFrameworkName) + assert.False(t, ok) + _, ok = actualAttrs.Get(elasticattr.ServiceFrameworkVersion) + assert.False(t, ok) } func TestEnrichMetricDataPoints_SkipsMetricsWithMappingHints(t *testing.T) { @@ -281,7 +298,12 @@ func TestEnrichMetricDataPoints_SkipsMetricsWithMappingHints(t *testing.T) { hints.AppendEmpty().SetStr("_doc_count") attrs.PutStr("host", "server-01") - EnrichMetricDataPoints(metric, cfg) + scope := pcommon.NewInstrumentationScope() + scope.SetName("github.com/open-telemetry/opentelemetry-collector-contrib/connector/signaltometricsconnector") + scope.SetVersion("1.0.0") + scopeAttrs := scope.Attributes() + + EnrichMetricDataPoints(metric, scopeAttrs, cfg) actualAttrs := metric.Gauge().DataPoints().At(0).Attributes() value, ok := actualAttrs.Get("host") @@ -289,6 +311,8 @@ func TestEnrichMetricDataPoints_SkipsMetricsWithMappingHints(t *testing.T) { assert.Equal(t, "server-01", value.Str()) _, ok = actualAttrs.Get("labels.host") assert.False(t, ok) + _, ok = actualAttrs.Get(elasticattr.ServiceFrameworkName) + assert.False(t, ok) } func TestEnrichMetricDataPointAttributes_NoOpWhenDisabled(t *testing.T) { @@ -298,11 +322,18 @@ func TestEnrichMetricDataPointAttributes_NoOpWhenDisabled(t *testing.T) { attrs := pcommon.NewMap() attrs.PutStr("host", "server-01") - enrichMetricDataPointAttributes(attrs, cfg) + scope := pcommon.NewInstrumentationScope() + scope.SetName("metrics-instrumentation") + scope.SetVersion("1.0.0") + scopeAttrs := scope.Attributes() + + enrichMetricDataPointAttributes(attrs, scopeAttrs, cfg) value, ok := attrs.Get("host") require.True(t, ok) assert.Equal(t, "server-01", value.Str()) _, ok = attrs.Get("labels.host") assert.False(t, ok) + _, ok = attrs.Get(elasticattr.ServiceFrameworkName) + assert.False(t, ok) } diff --git a/processor/elasticapmprocessor/processor_test.go b/processor/elasticapmprocessor/processor_test.go index 77041f4c6..9d42c4f3d 100644 --- a/processor/elasticapmprocessor/processor_test.go +++ b/processor/elasticapmprocessor/processor_test.go @@ -39,6 +39,7 @@ import ( "go.opentelemetry.io/collector/processor/processortest" semconv "go.opentelemetry.io/otel/semconv/v1.27.0" + "github.com/elastic/opentelemetry-collector-components/internal/elasticattr" "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/metadata" ) @@ -518,6 +519,8 @@ func TestConsumeMetrics_ECSOTLPFallbacks(t *testing.T) { resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "opentelemetry") scopeMetrics := resourceMetric.ScopeMetrics().AppendEmpty() + scopeMetrics.Scope().SetName("metrics-instrumentation") + scopeMetrics.Scope().SetVersion("1.0.0") httpMetric := scopeMetrics.Metrics().AppendEmpty() httpMetric.SetName("http.requests.total") @@ -560,6 +563,12 @@ func TestConsumeMetrics_ECSOTLPFallbacks(t *testing.T) { value, ok = actualHTTPAttrs.Get("numeric_labels.http_response_status_code") require.True(t, ok) assert.InDelta(t, 200, value.Double(), 1e-9) + value, ok = actualHTTPAttrs.Get(elasticattr.ServiceFrameworkName) + require.True(t, ok) + assert.Equal(t, "metrics-instrumentation", value.Str()) + value, ok = actualHTTPAttrs.Get(elasticattr.ServiceFrameworkVersion) + require.True(t, ok) + assert.Equal(t, "1.0.0", value.Str()) _, ok = actualHTTPAttrs.Get("http.request.method") assert.False(t, ok) _, ok = actualHTTPAttrs.Get("http.route") @@ -574,6 +583,12 @@ func TestConsumeMetrics_ECSOTLPFallbacks(t *testing.T) { value, ok = actualMemoryAttrs.Get("labels.state") require.True(t, ok) assert.Equal(t, "used", value.Str()) + value, ok = actualMemoryAttrs.Get(elasticattr.ServiceFrameworkName) + require.True(t, ok) + assert.Equal(t, "metrics-instrumentation", value.Str()) + value, ok = actualMemoryAttrs.Get(elasticattr.ServiceFrameworkVersion) + require.True(t, ok) + assert.Equal(t, "1.0.0", value.Str()) _, ok = actualMemoryAttrs.Get("host") assert.False(t, ok) _, ok = actualMemoryAttrs.Get("state") @@ -600,6 +615,8 @@ func TestConsumeMetrics_ECSIntakeSkipsOTLPFallbacks(t *testing.T) { resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "ElasticAPM") scopeMetrics := resourceMetric.ScopeMetrics().AppendEmpty() + scopeMetrics.Scope().SetName("metrics-instrumentation") + scopeMetrics.Scope().SetVersion("1.0.0") metric := scopeMetrics.Metrics().AppendEmpty() metric.SetName("http.requests.total") dp := metric.SetEmptySum().DataPoints().AppendEmpty() @@ -644,6 +661,10 @@ func TestConsumeMetrics_ECSIntakeSkipsOTLPFallbacks(t *testing.T) { assert.False(t, ok) _, ok = actualAttrs.Get("labels.state") assert.False(t, ok) + _, ok = actualAttrs.Get(elasticattr.ServiceFrameworkName) + assert.False(t, ok) + _, ok = actualAttrs.Get(elasticattr.ServiceFrameworkVersion) + assert.False(t, ok) } func TestConsumeMetrics_ECSAssumesHomogeneousBatchOrigin(t *testing.T) { @@ -665,7 +686,10 @@ func TestConsumeMetrics_ECSAssumesHomogeneousBatchOrigin(t *testing.T) { intakeResource := intakeResourceMetric.Resource() intakeResource.Attributes().PutStr("service.name", "intake-service") intakeResource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "ElasticAPM") - intakeMetric := intakeResourceMetric.ScopeMetrics().AppendEmpty().Metrics().AppendEmpty() + intakeScopeMetrics := intakeResourceMetric.ScopeMetrics().AppendEmpty() + intakeScopeMetrics.Scope().SetName("metrics-instrumentation") + intakeScopeMetrics.Scope().SetVersion("1.0.0") + intakeMetric := intakeScopeMetrics.Metrics().AppendEmpty() intakeMetric.SetName("intake.metric") intakeMetric.SetEmptyGauge().DataPoints().AppendEmpty().SetDoubleValue(1.0) @@ -673,7 +697,10 @@ func TestConsumeMetrics_ECSAssumesHomogeneousBatchOrigin(t *testing.T) { otlpResource := otlpResourceMetric.Resource() otlpResource.Attributes().PutStr("service.name", "otlp-service") otlpResource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "opentelemetry") - otlpMetric := otlpResourceMetric.ScopeMetrics().AppendEmpty().Metrics().AppendEmpty() + otlpScopeMetrics := otlpResourceMetric.ScopeMetrics().AppendEmpty() + otlpScopeMetrics.Scope().SetName("metrics-instrumentation") + otlpScopeMetrics.Scope().SetVersion("1.0.0") + otlpMetric := otlpScopeMetrics.Metrics().AppendEmpty() otlpMetric.SetName("http.requests.total") otlpDP := otlpMetric.SetEmptySum().DataPoints().AppendEmpty() otlpDP.SetIntValue(1) @@ -707,6 +734,10 @@ func TestConsumeMetrics_ECSAssumesHomogeneousBatchOrigin(t *testing.T) { assert.False(t, ok) _, ok = actualDPAttrs.Get("labels.host") assert.False(t, ok) + _, ok = actualDPAttrs.Get(elasticattr.ServiceFrameworkName) + assert.False(t, ok) + _, ok = actualDPAttrs.Get(elasticattr.ServiceFrameworkVersion) + assert.False(t, ok) } // TestSkipEnrichmentMetrics tests that metrics are only enriched when skipEnrichment is false or when mapping mode is ecs From eeb1c8cd56370fa11bbae97b316d83e5e0e3c34c Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Thu, 2 Apr 2026 11:11:45 -0400 Subject: [PATCH 06/15] chore: format case comparison order alphabetically --- .../internal/ecs/ecs_translation.go | 158 +++++++++--------- 1 file changed, 79 insertions(+), 79 deletions(-) diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go index 8a6197571..9b29caa41 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go @@ -44,106 +44,106 @@ func TranslateResourceMetadata(resource pcommon.Resource) { } switch k { - case string(semconv.ServiceNameKey), - string(semconv.ServiceNamespaceKey), - elasticattr.ServiceFrameworkName, - elasticattr.ServiceFrameworkVersion, - elasticattr.ServiceOriginID, - elasticattr.ServiceOriginName, - elasticattr.ServiceOriginVersion, - elasticattr.ServiceTargetName, - elasticattr.ServiceTargetType, - string(semconv.TelemetryDistroNameKey), - string(semconv.TelemetryDistroVersionKey), - elasticattr.CloudOriginAccountID, - elasticattr.CloudOriginProvider, - elasticattr.CloudOriginRegion, - elasticattr.CloudOriginServiceName, + case elasticattr.AgentActivationMethod, + elasticattr.AgentEphemeralID, + elasticattr.AgentName, + elasticattr.AgentVersion, elasticattr.CloudAccountName, elasticattr.CloudInstanceID, elasticattr.CloudInstanceName, elasticattr.CloudMachineType, + elasticattr.CloudOriginAccountID, + elasticattr.CloudOriginProvider, + elasticattr.CloudOriginRegion, + elasticattr.CloudOriginServiceName, elasticattr.CloudProjectID, elasticattr.CloudProjectName, - string(semconv.ContainerImageTagsKey), - elasticattr.HostHostName, - string(semconv.HostIPKey), - elasticattr.HostOSType, elasticattr.DataStreamDataset, elasticattr.DataStreamNamespace, elasticattr.DataStreamType, - string(semconv.UserIDKey), - string(semconv.UserEmailKey), - string(semconv.UserNameKey), + elasticattr.DestinationIP, + elasticattr.FaaSExecution, + elasticattr.FaaSTriggerRequestID, + elasticattr.HostHostName, + elasticattr.HostOSType, + elasticattr.MetricsetName, + elasticattr.ServiceFrameworkName, + elasticattr.ServiceFrameworkVersion, + elasticattr.ServiceOriginID, + elasticattr.ServiceOriginName, + elasticattr.ServiceOriginVersion, + elasticattr.ServiceTargetName, + elasticattr.ServiceTargetType, + elasticattr.SourceNATIP, elasticattr.UserDomain, - string(semconv.UserAgentOriginalKey), - string(semconv.NetworkConnectionTypeKey), - string(semconv.NetworkConnectionSubtypeKey), - string(semconv.NetworkCarrierNameKey), - string(semconv.NetworkCarrierMccKey), - string(semconv.NetworkCarrierMncKey), - string(semconv.NetworkCarrierIccKey), string(semconv.ClientAddressKey), string(semconv.ClientPortKey), - string(semconv.SourceAddressKey), - string(semconv.SourcePortKey), - elasticattr.SourceNATIP, - elasticattr.DestinationIP, + string(semconv.ContainerImageTagsKey), + string(semconv.FaaSColdstartKey), string(semconv.FaaSInstanceKey), string(semconv.FaaSNameKey), - string(semconv.FaaSVersionKey), string(semconv.FaaSTriggerKey), - string(semconv.FaaSColdstartKey), - elasticattr.FaaSTriggerRequestID, - elasticattr.FaaSExecution, - ecsAttrOpenCensusExporterVersion, - elasticattr.AgentName, - elasticattr.AgentVersion, - elasticattr.AgentEphemeralID, - elasticattr.AgentActivationMethod, - elasticattr.MetricsetName, - string(semconv.ProcessPIDKey), + string(semconv.FaaSVersionKey), + string(semconv.HostIPKey), + string(semconv.NetworkCarrierIccKey), + string(semconv.NetworkCarrierMccKey), + string(semconv.NetworkCarrierMncKey), + string(semconv.NetworkCarrierNameKey), + string(semconv.NetworkConnectionSubtypeKey), + string(semconv.NetworkConnectionTypeKey), + string(semconv.ProcessExecutableNameKey), string(semconv.ProcessParentPIDKey), - string(semconv.ProcessExecutableNameKey): + string(semconv.ProcessPIDKey), + string(semconv.ServiceNameKey), + string(semconv.ServiceNamespaceKey), + string(semconv.SourceAddressKey), + string(semconv.SourcePortKey), + string(semconv.TelemetryDistroNameKey), + string(semconv.TelemetryDistroVersionKey), + string(semconv.UserAgentOriginalKey), + string(semconv.UserEmailKey), + string(semconv.UserIDKey), + string(semconv.UserNameKey), + ecsAttrOpenCensusExporterVersion: return true - case string(semconv.ServiceVersionKey), - string(semconv.ServiceInstanceIDKey), - string(semconv26.DeploymentEnvironmentKey), - string(semconv.DeploymentEnvironmentNameKey), - string(semconv.TelemetrySDKNameKey), - string(semconv.TelemetrySDKVersionKey), - string(semconv.TelemetrySDKLanguageKey), - string(semconv.CloudProviderKey), + case elasticattr.ContainerImageTag, + elasticattr.DeviceManufacturer, string(semconv.CloudAccountIDKey), - string(semconv.CloudRegionKey), string(semconv.CloudAvailabilityZoneKey), string(semconv.CloudPlatformKey), - string(semconv.ContainerNameKey), + string(semconv.CloudProviderKey), + string(semconv.CloudRegionKey), string(semconv.ContainerIDKey), string(semconv.ContainerImageNameKey), - elasticattr.ContainerImageTag, + string(semconv.ContainerNameKey), string(semconv.ContainerRuntimeKey), + string(semconv26.DeploymentEnvironmentKey), + string(semconv.DeploymentEnvironmentNameKey), + string(semconv.DeviceIDKey), + string(semconv.DeviceModelIdentifierKey), + string(semconv.DeviceModelNameKey), + string(semconv.HostArchKey), + string(semconv.HostIDKey), + string(semconv.HostNameKey), + string(semconv.HostTypeKey), string(semconv.K8SNamespaceNameKey), string(semconv.K8SNodeNameKey), string(semconv.K8SPodNameKey), string(semconv.K8SPodUIDKey), - string(semconv.HostNameKey), - string(semconv.HostIDKey), - string(semconv.HostTypeKey), - string(semconv.HostArchKey), + string(semconv.OSDescriptionKey), + string(semconv.OSNameKey), + string(semconv.OSTypeKey), + string(semconv.OSVersionKey), string(semconv.ProcessCommandLineKey), string(semconv.ProcessExecutablePathKey), + string(semconv.ProcessOwnerKey), string(semconv.ProcessRuntimeNameKey), string(semconv.ProcessRuntimeVersionKey), - string(semconv.ProcessOwnerKey), - string(semconv.OSTypeKey), - string(semconv.OSDescriptionKey), - string(semconv.OSNameKey), - string(semconv.OSVersionKey), - string(semconv.DeviceIDKey), - string(semconv.DeviceModelIdentifierKey), - string(semconv.DeviceModelNameKey), - elasticattr.DeviceManufacturer: + string(semconv.ServiceInstanceIDKey), + string(semconv.ServiceVersionKey), + string(semconv.TelemetrySDKLanguageKey), + string(semconv.TelemetrySDKNameKey), + string(semconv.TelemetrySDKVersionKey): truncatePreservedStringAttribute(attributes, k, v) return true default: @@ -164,19 +164,19 @@ func TranslateLogRecordAttributes(attributes pcommon.Map) { } switch k { - case string(semconv26.ExceptionEscapedKey), + case elasticattr.DataStreamDataset, + elasticattr.DataStreamNamespace, + elasticattr.DataStreamType, + elasticattr.ErrorID, + elasticattr.ProcessorEvent, + elasticattr.SessionID, + string(semconv26.ExceptionEscapedKey), string(semconv.ExceptionMessageKey), string(semconv.ExceptionStacktraceKey), string(semconv.ExceptionTypeKey), string(semconv.NetworkConnectionTypeKey), - elasticattr.DataStreamDataset, - elasticattr.DataStreamNamespace, - elasticattr.DataStreamType, - elasticattr.ErrorID, "event.domain", - "event.name", - elasticattr.ProcessorEvent, - elasticattr.SessionID: + "event.name": return true default: fallbackToLabelAttribute(attributes, k, v) @@ -204,9 +204,9 @@ func TranslateMetricDataPointAttributes(attributes pcommon.Map) { "system.process.cpu.start_time", "system.process.state": return true - case "system.process.cmdline", + case string(semconv.UserNameKey), "system.filesystem.mount_point", - string(semconv.UserNameKey): + "system.process.cmdline": truncatePreservedStringAttribute(attributes, k, v) return true default: From 44d9c704c2830165fe88f7b64e3adcf9c463307a Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Fri, 10 Apr 2026 12:54:14 -0400 Subject: [PATCH 07/15] feat: translate unsupported ECS OTLP span attributes --- .../internal/ecs/ecs_translation.go | 82 ++++++ .../internal/ecs/ecs_translation_test.go | 116 ++++++++ .../internal/enrichments/config/config.go | 27 +- .../internal/enrichments/enricher.go | 26 +- .../internal/enrichments/span.go | 5 + processor/elasticapmprocessor/processor.go | 21 +- .../elasticapmprocessor/processor_test.go | 255 ++++++++++++++++++ 7 files changed, 502 insertions(+), 30 deletions(-) diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go index 9b29caa41..3207499d2 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go @@ -185,6 +185,88 @@ func TranslateLogRecordAttributes(attributes pcommon.Map) { }) } +// TranslateSpanAttributes applies the apm-data OTLP span fallback behaviour for +// ECS mode spans. The preserved attributes and fallback-to-label cases are based +// on the OTLP span translation in apm-data's input/otlp/traces.go: +// https://github.com/elastic/apm-data/blob/7da222dcc0320f9c812c5d72f65f830c838aae11/input/otlp/traces.go +// +// Attributes required by span enrichment or exporter-side ECS conversions are +// preserved, while unsupported attributes are moved to labels.* / +// numeric_labels.* with a sanitized key. +func TranslateSpanAttributes(attributes pcommon.Map) { + attributes.Range(func(k string, v pcommon.Value) bool { + if sanitizeExistingLabelAttribute(attributes, k, v) { + return true + } + + switch k { + case elasticattr.DataStreamDataset, + elasticattr.DataStreamNamespace, + elasticattr.DataStreamType, + elasticattr.TransactionType, + "code.stacktrace", + "db.name", + "db.namespace", + "db.query.text", + "db.statement", + "db.system", + "db.system.name", + "db.user", + "gen_ai.provider.name", + "gen_ai.system", + "http.flavor", + "http.method", + "http.request.method", + "http.response.body.size", + "http.response.status_code", + "http.scheme", + "http.status_code", + "http.target", + "http.url", + "http.user_agent", + "messaging.destination.name", + "messaging.destination.temporary", + "messaging.operation", + "messaging.operation.name", + "messaging.system", + "net.host.name", + "net.peer.name", + "net.peer.port", + "network.carrier.icc", + "network.carrier.mcc", + "network.carrier.mnc", + "network.carrier.name", + "network.connection.subtype", + "network.connection.type", + "peer.service", + "rpc.grpc.status_code", + "rpc.method", + "rpc.response.status_code", + "rpc.service", + "rpc.system", + "rpc.system.name", + "server.address", + "server.port", + "service.peer.name", + "session.id", + "type", + "url.domain", + "url.full", + "url.path", + "url.port", + "url.query", + "url.scheme", + "user_agent.name", + "user_agent.original", + "user_agent.version": + return true + default: + fallbackToLabelAttribute(attributes, k, v) + return true + } + }) +} + // TranslateMetricDataPointAttributes applies the apm-data OTLP metric fallback // for raw metric datapoint attributes in ECS mode. Existing labels.* / // numeric_labels.* keys are sanitized in place, metric-specific special cases diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go index 94404bb6c..8d7610a95 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go @@ -414,6 +414,122 @@ func TestTranslateLogRecordAttributes(t *testing.T) { } } +func TestTranslateSpanAttributes(t *testing.T) { + tests := []struct { + name string + setAttrs func(pcommon.Map) + want map[string]any + wantAbsent []string + }{ + { + name: "supported span attrs preserved", + setAttrs: func(attrs pcommon.Map) { + attrs.PutStr("http.request.method", "GET") + attrs.PutInt("http.response.status_code", 200) + attrs.PutStr("http.url", "https://api.example.com/users") + attrs.PutStr("db.query.text", "SELECT * FROM users") + attrs.PutStr("session.id", "session-123") + attrs.PutStr(string(semconv.NetworkConnectionTypeKey), "wifi") + attrs.PutStr(elasticattr.DataStreamDataset, "apm") + }, + want: map[string]any{ + "http.request.method": "GET", + "http.response.status_code": int64(200), + "http.url": "https://api.example.com/users", + "db.query.text": "SELECT * FROM users", + "session.id": "session-123", + string(semconv.NetworkConnectionTypeKey): "wifi", + elasticattr.DataStreamDataset: "apm", + }, + }, + { + name: "unsupported span attrs moved to labels", + setAttrs: func(attrs pcommon.Map) { + attrs.PutStr("http.route", "/users") + attrs.PutInt("http.response_content_length", 1024) + attrs.PutStr("error.message", "Database connection failed") + attrs.PutStr("error.type", "DBError") + attrs.PutStr("exception.message", "Database connection failed") + attrs.PutStr("exception.type", "DBError") + attrs.PutStr("exception.stacktrace", "stacktrace") + attrs.PutBool("custom.flag", true) + }, + want: map[string]any{ + "labels.http_route": "/users", + "numeric_labels.http_response_content_length": float64(1024), + "labels.error_message": "Database connection failed", + "labels.error_type": "DBError", + "labels.exception_message": "Database connection failed", + "labels.exception_type": "DBError", + "labels.exception_stacktrace": "stacktrace", + "labels.custom_flag": "true", + }, + wantAbsent: []string{ + "http.route", + "http.response_content_length", + "error.message", + "error.type", + "exception.message", + "exception.type", + "exception.stacktrace", + "custom.flag", + }, + }, + { + name: "existing span label keys are sanitized", + setAttrs: func(attrs pcommon.Map) { + attrs.PutStr("labels.http.route", "/users") + attrs.PutDouble("numeric_labels.http.response_content_length", 1024) + }, + want: map[string]any{ + "labels.http_route": "/users", + "numeric_labels.http_response_content_length": 1024.0, + }, + wantAbsent: []string{ + "labels.http.route", + "numeric_labels.http.response_content_length", + }, + }, + { + name: "unsupported span map attr dropped", + setAttrs: func(attrs pcommon.Map) { + attrs.PutEmptyMap("http.request") + }, + wantAbsent: []string{"http.request"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + attrs := pcommon.NewMap() + tc.setAttrs(attrs) + + TranslateSpanAttributes(attrs) + + for key, want := range tc.want { + got, ok := attrs.Get(key) + if !assert.True(t, ok, "expected %q to be present", key) { + continue + } + switch want := want.(type) { + case string: + assert.Equal(t, want, got.Str()) + case int64: + assert.Equal(t, want, got.Int()) + case float64: + assert.InDelta(t, want, got.Double(), 1e-9) + default: + t.Fatalf("unsupported want type %T", want) + } + } + for _, key := range tc.wantAbsent { + _, ok := attrs.Get(key) + assert.False(t, ok, "expected %q to be absent", key) + } + }) + } +} + func TestTranslateMetricDataPointAttributes(t *testing.T) { tests := []struct { name string diff --git a/processor/elasticapmprocessor/internal/enrichments/config/config.go b/processor/elasticapmprocessor/internal/enrichments/config/config.go index 2cb998c45..d8ecb816a 100644 --- a/processor/elasticapmprocessor/internal/enrichments/config/config.go +++ b/processor/elasticapmprocessor/internal/enrichments/config/config.go @@ -87,19 +87,20 @@ type ElasticSpanConfig struct { // TimestampUs is a temporary attribute to enable higher // resolution timestamps in Elasticsearch. For more details see: // https://github.com/elastic/opentelemetry-dev/issues/374. - TimestampUs AttributeConfig `mapstructure:"timestamp_us"` - ProcessorEvent AttributeConfig `mapstructure:"processor_event"` - RepresentativeCount AttributeConfig `mapstructure:"representative_count"` - Action AttributeConfig `mapstructure:"action"` - TypeSubtype AttributeConfig `mapstructure:"type_subtype"` - DurationUs AttributeConfig `mapstructure:"duration_us"` - EventOutcome AttributeConfig `mapstructure:"event_outcome"` - ServiceTarget AttributeConfig `mapstructure:"service_target"` - DestinationService AttributeConfig `mapstructure:"destination_service"` - InferredSpans AttributeConfig `mapstructure:"inferred_spans"` - UserAgent AttributeConfig `mapstructure:"user_agent"` - RemoveMessaging AttributeConfig `mapstructure:"remove_messaging"` - MessageQueueName AttributeConfig `mapstructure:"message_queue_name"` + TimestampUs AttributeConfig `mapstructure:"timestamp_us"` + ProcessorEvent AttributeConfig `mapstructure:"processor_event"` + RepresentativeCount AttributeConfig `mapstructure:"representative_count"` + Action AttributeConfig `mapstructure:"action"` + TypeSubtype AttributeConfig `mapstructure:"type_subtype"` + DurationUs AttributeConfig `mapstructure:"duration_us"` + EventOutcome AttributeConfig `mapstructure:"event_outcome"` + ServiceTarget AttributeConfig `mapstructure:"service_target"` + DestinationService AttributeConfig `mapstructure:"destination_service"` + InferredSpans AttributeConfig `mapstructure:"inferred_spans"` + UserAgent AttributeConfig `mapstructure:"user_agent"` + RemoveMessaging AttributeConfig `mapstructure:"remove_messaging"` + MessageQueueName AttributeConfig `mapstructure:"message_queue_name"` + TranslateUnsupportedAttributes AttributeConfig `mapstructure:"translate_unsupported_attributes"` } // SpanEventConfig configures enrichment attributes for the span events. diff --git a/processor/elasticapmprocessor/internal/enrichments/enricher.go b/processor/elasticapmprocessor/internal/enrichments/enricher.go index 493eddde9..8b5bdf9fb 100644 --- a/processor/elasticapmprocessor/internal/enrichments/enricher.go +++ b/processor/elasticapmprocessor/internal/enrichments/enricher.go @@ -52,16 +52,22 @@ type Enricher struct { func (e *Enricher) EnrichTraces(pt ptrace.Traces) { resSpans := pt.ResourceSpans() for i := 0; i < resSpans.Len(); i++ { - resSpan := resSpans.At(i) - EnrichResource(resSpan.Resource(), e.Config.Resource) - scopeSpans := resSpan.ScopeSpans() - for j := 0; j < scopeSpans.Len(); j++ { - scopeSpan := scopeSpans.At(j) - EnrichScope(scopeSpan.Scope(), e.Config) - spans := scopeSpan.Spans() - for k := 0; k < spans.Len(); k++ { - EnrichSpan(spans.At(k), e.Config, e.userAgentParser) - } + e.EnrichResourceSpans(resSpans.At(i)) + } +} + +// EnrichResourceSpans enriches a single ResourceSpans with the current enricher +// configuration. This is used when ECS batches need per-resource enrichment +// policies based on the origin of each ResourceSpans. +func (e *Enricher) EnrichResourceSpans(resSpan ptrace.ResourceSpans) { + EnrichResource(resSpan.Resource(), e.Config.Resource) + scopeSpans := resSpan.ScopeSpans() + for j := 0; j < scopeSpans.Len(); j++ { + scopeSpan := scopeSpans.At(j) + EnrichScope(scopeSpan.Scope(), e.Config) + spans := scopeSpan.Spans() + for k := 0; k < spans.Len(); k++ { + EnrichSpan(spans.At(k), e.Config, e.userAgentParser) } } } diff --git a/processor/elasticapmprocessor/internal/enrichments/span.go b/processor/elasticapmprocessor/internal/enrichments/span.go index 24c17a9d7..5b50c5a36 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span.go +++ b/processor/elasticapmprocessor/internal/enrichments/span.go @@ -40,6 +40,7 @@ import ( "google.golang.org/grpc/codes" "github.com/elastic/opentelemetry-collector-components/internal/elasticattr" + "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/ecs" "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/enrichments/attribute" "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/enrichments/config" ) @@ -122,6 +123,10 @@ func (s *spanEnrichmentContext) Enrich( cfg config.Config, userAgentParser *uaparser.Parser, ) { + if cfg.Span.TranslateUnsupportedAttributes.Enabled { + ecs.TranslateSpanAttributes(span.Attributes()) + } + // Extract top level span information. s.spanStatusCode = span.Status().Code() diff --git a/processor/elasticapmprocessor/processor.go b/processor/elasticapmprocessor/processor.go index 7f3dd2e6b..c1797f3f3 100644 --- a/processor/elasticapmprocessor/processor.go +++ b/processor/elasticapmprocessor/processor.go @@ -62,6 +62,7 @@ func NewTraceProcessor(cfg *Config, next consumer.Traces, logger *zap.Logger) *T ecsEnricherConfig.Resource.ServiceName.Enabled = true ecsEnricherConfig.Resource.DefaultDeploymentEnvironment.Enabled = true ecsEnricherConfig.Resource.DefaultServiceLanguage.Enabled = true + ecsEnricherConfig.Span.TranslateUnsupportedAttributes.Enabled = true intakeECSEnricherConfig := ecsEnricherConfig // The intake receiver already sets transaction.root; skip re-deriving it @@ -72,6 +73,7 @@ func NewTraceProcessor(cfg *Config, next consumer.Traces, logger *zap.Logger) *T intakeECSEnricherConfig.Resource.HostOSType.Enabled = false // disable default service language to avoid adding it on apm events intakeECSEnricherConfig.Resource.DefaultServiceLanguage.Enabled = false + intakeECSEnricherConfig.Span.TranslateUnsupportedAttributes.Enabled = false return &TraceProcessor{ next: next, @@ -88,16 +90,12 @@ func (p *TraceProcessor) Capabilities() consumer.Capabilities { } func (p *TraceProcessor) ConsumeTraces(ctx context.Context, td ptrace.Traces) error { - enricher := p.enricher if isECS(ctx) { - enricher = p.ecsEnricher resourceSpans := td.ResourceSpans() - if resourceSpans.Len() > 0 && isIntakeECS(resourceSpans.At(0).Resource()) { - enricher = p.intakeECSEnricher - } for i := 0; i < resourceSpans.Len(); i++ { resourceSpan := resourceSpans.At(i) resource := resourceSpan.Resource() + enricher := p.ecsTraceEnricher(resource) ecs.TranslateResourceMetadata(resource) ecs.ApplyResourceConventions(resource) // Traces signal never need to be routed to service-specific datasets @@ -122,14 +120,23 @@ func (p *TraceProcessor) ConsumeTraces(ctx context.Context, td ptrace.Traces) er } } } + + enricher.EnrichResourceSpans(resourceSpan) } + return p.next.ConsumeTraces(ctx, td) } - enricher.EnrichTraces(td) - + p.enricher.EnrichTraces(td) return p.next.ConsumeTraces(ctx, td) } +func (p *TraceProcessor) ecsTraceEnricher(resource pcommon.Resource) *enrichments.Enricher { + if isIntakeECS(resource) { + return p.intakeECSEnricher + } + return p.ecsEnricher +} + func isECS(ctx context.Context) bool { clientCtx := client.FromContext(ctx) mappingMode := getMetadataValue(clientCtx) diff --git a/processor/elasticapmprocessor/processor_test.go b/processor/elasticapmprocessor/processor_test.go index 9a5f4b9a3..47626f806 100644 --- a/processor/elasticapmprocessor/processor_test.go +++ b/processor/elasticapmprocessor/processor_test.go @@ -35,6 +35,7 @@ import ( "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/plog" "go.opentelemetry.io/collector/pdata/pmetric" + "go.opentelemetry.io/collector/pdata/ptrace" "go.opentelemetry.io/collector/processor" "go.opentelemetry.io/collector/processor/processortest" semconv "go.opentelemetry.io/otel/semconv/v1.27.0" @@ -302,6 +303,260 @@ func testMetrics(t *testing.T, ctx context.Context, factory processor.Factory, s assert.NoError(t, pmetrictest.CompareMetrics(expectedMetrics, actual, pmetrictest.IgnoreMetricsOrder(), pmetrictest.IgnoreResourceMetricsOrder(), pmetrictest.IgnoreTimestamp())) } +func appendTraceResourceSpan(traces ptrace.Traces, serviceName, sdkName, osType, spanName string) { + resourceSpan := traces.ResourceSpans().AppendEmpty() + resource := resourceSpan.Resource() + resource.Attributes().PutStr(string(semconv.ServiceNameKey), serviceName) + resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), sdkName) + if osType != "" { + resource.Attributes().PutStr(string(semconv.OSTypeKey), osType) + } + + scopeSpans := resourceSpan.ScopeSpans().AppendEmpty() + span := scopeSpans.Spans().AppendEmpty() + span.SetName(spanName) + span.SetKind(ptrace.SpanKindServer) + span.SetStartTimestamp(pcommon.Timestamp(1_000_000_000)) + span.SetEndTimestamp(pcommon.Timestamp(2_000_000_000)) + span.Status().SetCode(ptrace.StatusCodeOk) +} + +func TestConsumeTraces_ECSIntakeSkipsOTLPFallbacks(t *testing.T) { + ctx := client.NewContext(context.Background(), client.Info{ + Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), + }) + + factory := NewFactory() + settings := processortest.NewNopSettings(metadata.Type) + next := &consumertest.TracesSink{} + cfg := NewDefaultConfig().(*Config) + + tp, err := factory.CreateTraces(ctx, settings, cfg, next) + require.NoError(t, err) + + traces := ptrace.NewTraces() + appendTraceResourceSpan(traces, "intake-service", "ElasticAPM", "linux", "intake-span") + + require.NoError(t, tp.ConsumeTraces(ctx, traces)) + actual := next.AllTraces()[0] + + require.Equal(t, 1, actual.ResourceSpans().Len()) + actualResource := actual.ResourceSpans().At(0).Resource().Attributes() + _, ok := actualResource.Get(string(semconv.TelemetrySDKLanguageKey)) + assert.False(t, ok) + _, ok = actualResource.Get(elasticattr.HostOSType) + assert.False(t, ok) + + actualSpanAttrs := actual.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + _, ok = actualSpanAttrs.Get(elasticattr.TransactionResult) + assert.False(t, ok) +} + +func TestConsumeTraces_ECSMixedOriginUsesPerResourceEnricher(t *testing.T) { + ctx := client.NewContext(context.Background(), client.Info{ + Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), + }) + + factory := NewFactory() + settings := processortest.NewNopSettings(metadata.Type) + next := &consumertest.TracesSink{} + cfg := NewDefaultConfig().(*Config) + + tp, err := factory.CreateTraces(ctx, settings, cfg, next) + require.NoError(t, err) + + traces := ptrace.NewTraces() + appendTraceResourceSpan(traces, "intake-service", "ElasticAPM", "linux", "intake-span") + appendTraceResourceSpan(traces, "otlp-service", "opentelemetry", "linux", "otlp-span") + + require.NoError(t, tp.ConsumeTraces(ctx, traces)) + actual := next.AllTraces()[0] + + require.Equal(t, 2, actual.ResourceSpans().Len()) + + intakeResourceAttrs := actual.ResourceSpans().At(0).Resource().Attributes() + _, ok := intakeResourceAttrs.Get(string(semconv.TelemetrySDKLanguageKey)) + assert.False(t, ok) + _, ok = intakeResourceAttrs.Get(elasticattr.HostOSType) + assert.False(t, ok) + + intakeSpanAttrs := actual.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + _, ok = intakeSpanAttrs.Get(elasticattr.TransactionResult) + assert.False(t, ok) + + otlpResourceAttrs := actual.ResourceSpans().At(1).Resource().Attributes() + value, ok := otlpResourceAttrs.Get(string(semconv.TelemetrySDKLanguageKey)) + require.True(t, ok) + assert.Equal(t, "unknown", value.Str()) + value, ok = otlpResourceAttrs.Get(elasticattr.HostOSType) + require.True(t, ok) + assert.Equal(t, "linux", value.Str()) + + otlpSpanAttrs := actual.ResourceSpans().At(1).ScopeSpans().At(0).Spans().At(0).Attributes() + value, ok = otlpSpanAttrs.Get(elasticattr.TransactionResult) + require.True(t, ok) + assert.Equal(t, "Success", value.Str()) +} + +func TestConsumeTraces_ECSOTLPFallbacks(t *testing.T) { + ctx := client.NewContext(context.Background(), client.Info{ + Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), + }) + + factory := NewFactory() + settings := processortest.NewNopSettings(metadata.Type) + next := &consumertest.TracesSink{} + cfg := NewDefaultConfig().(*Config) + + tp, err := factory.CreateTraces(ctx, settings, cfg, next) + require.NoError(t, err) + + traces := ptrace.NewTraces() + resourceSpan := traces.ResourceSpans().AppendEmpty() + resource := resourceSpan.Resource() + resource.Attributes().PutStr("service.name", "otlp-service") + resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "opentelemetry") + + scopeSpans := resourceSpan.ScopeSpans().AppendEmpty() + span := scopeSpans.Spans().AppendEmpty() + span.SetName("otlp-span") + span.SetKind(ptrace.SpanKindClient) + span.SetParentSpanID(pcommon.SpanID{1, 2, 3, 4, 5, 6, 7, 8}) + span.SetStartTimestamp(pcommon.Timestamp(1_000_000_000)) + span.SetEndTimestamp(pcommon.Timestamp(2_000_000_000)) + span.Status().SetCode(ptrace.StatusCodeError) + + attrs := span.Attributes() + attrs.PutStr("http.request.method", "GET") + attrs.PutInt("http.response.status_code", 404) + attrs.PutStr("http.url", "https://api.example.com/users") + attrs.PutStr("http.route", "/users") + attrs.PutInt("http.response_content_length", 1024) + attrs.PutStr("error.message", "Database connection failed") + attrs.PutStr("error.type", "DBError") + attrs.PutStr("exception.message", "Database connection failed") + attrs.PutStr("exception.type", "DBError") + attrs.PutStr("exception.stacktrace", "stacktrace") + + require.NoError(t, tp.ConsumeTraces(ctx, traces)) + actual := next.AllTraces()[0] + + actualResource := actual.ResourceSpans().At(0).Resource().Attributes() + lang, ok := actualResource.Get(string(semconv.TelemetrySDKLanguageKey)) + require.True(t, ok) + assert.Equal(t, "unknown", lang.Str()) + + actualSpanAttrs := actual.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + value, ok := actualSpanAttrs.Get("labels.http_route") + require.True(t, ok) + assert.Equal(t, "/users", value.Str()) + value, ok = actualSpanAttrs.Get("numeric_labels.http_response_content_length") + require.True(t, ok) + assert.InDelta(t, 1024, value.Double(), 1e-9) + value, ok = actualSpanAttrs.Get("labels.error_message") + require.True(t, ok) + assert.Equal(t, "Database connection failed", value.Str()) + value, ok = actualSpanAttrs.Get("labels.error_type") + require.True(t, ok) + assert.Equal(t, "DBError", value.Str()) + value, ok = actualSpanAttrs.Get("labels.exception_message") + require.True(t, ok) + assert.Equal(t, "Database connection failed", value.Str()) + value, ok = actualSpanAttrs.Get("labels.exception_type") + require.True(t, ok) + assert.Equal(t, "DBError", value.Str()) + value, ok = actualSpanAttrs.Get("labels.exception_stacktrace") + require.True(t, ok) + assert.Equal(t, "stacktrace", value.Str()) + + value, ok = actualSpanAttrs.Get("http.request.method") + require.True(t, ok) + assert.Equal(t, "GET", value.Str()) + value, ok = actualSpanAttrs.Get("http.response.status_code") + require.True(t, ok) + assert.EqualValues(t, 404, value.Int()) + value, ok = actualSpanAttrs.Get("http.url") + require.True(t, ok) + assert.Equal(t, "https://api.example.com/users", value.Str()) + + _, ok = actualSpanAttrs.Get("http.route") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("http.response_content_length") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("error.message") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("error.type") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("exception.message") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("exception.type") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("exception.stacktrace") + assert.False(t, ok) +} + +func TestConsumeTraces_ECSIntakeSkipsOTLPSpanFallbackTranslation(t *testing.T) { + ctx := client.NewContext(context.Background(), client.Info{ + Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), + }) + + factory := NewFactory() + settings := processortest.NewNopSettings(metadata.Type) + next := &consumertest.TracesSink{} + cfg := NewDefaultConfig().(*Config) + + tp, err := factory.CreateTraces(ctx, settings, cfg, next) + require.NoError(t, err) + + traces := ptrace.NewTraces() + resourceSpan := traces.ResourceSpans().AppendEmpty() + resource := resourceSpan.Resource() + resource.Attributes().PutStr("service.name", "intake-service") + resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "ElasticAPM") + + scopeSpans := resourceSpan.ScopeSpans().AppendEmpty() + span := scopeSpans.Spans().AppendEmpty() + span.SetName("intake-span") + span.SetKind(ptrace.SpanKindClient) + span.SetParentSpanID(pcommon.SpanID{1, 2, 3, 4, 5, 6, 7, 8}) + span.SetStartTimestamp(pcommon.Timestamp(1_000_000_000)) + span.SetEndTimestamp(pcommon.Timestamp(2_000_000_000)) + span.Status().SetCode(ptrace.StatusCodeError) + + attrs := span.Attributes() + attrs.PutStr("http.request.method", "GET") + attrs.PutStr("http.route", "/users") + attrs.PutInt("http.response_content_length", 1024) + attrs.PutStr("error.message", "Database connection failed") + attrs.PutStr("exception.message", "Database connection failed") + + require.NoError(t, tp.ConsumeTraces(ctx, traces)) + actual := next.AllTraces()[0] + + actualSpanAttrs := actual.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + value, ok := actualSpanAttrs.Get("http.request.method") + require.True(t, ok) + assert.Equal(t, "GET", value.Str()) + value, ok = actualSpanAttrs.Get("http.route") + require.True(t, ok) + assert.Equal(t, "/users", value.Str()) + value, ok = actualSpanAttrs.Get("error.message") + require.True(t, ok) + assert.Equal(t, "Database connection failed", value.Str()) + value, ok = actualSpanAttrs.Get("exception.message") + require.True(t, ok) + assert.Equal(t, "Database connection failed", value.Str()) + + _, ok = actualSpanAttrs.Get("labels.http_route") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("numeric_labels.http_response_content_length") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("labels.error_message") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("labels.exception_message") + assert.False(t, ok) +} + // TestSkipEnrichmentLogs tests that logs are only enriched when skipEnrichment is false or when mapping mode is ecs func TestSkipEnrichmentLogs(t *testing.T) { testCases := []struct { From a9cdaa06d34cb6f50e5f26ad7ed33ea3d2a44665 Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Fri, 10 Apr 2026 13:53:27 -0400 Subject: [PATCH 08/15] feat: align ECS OTLP HTTP span destination fields --- .../internal/enrichments/span.go | 141 +++++++++++++++++- .../internal/enrichments/span_test.go | 71 ++++++++- .../elasticapmprocessor/processor_test.go | 22 ++- .../ecs/elastic_hostname/spans_output.yaml | 2 +- .../testdata/elastic_span_http/output.yaml | 17 ++- 5 files changed, 246 insertions(+), 7 deletions(-) diff --git a/processor/elasticapmprocessor/internal/enrichments/span.go b/processor/elasticapmprocessor/internal/enrichments/span.go index 5b50c5a36..cc18738f2 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span.go +++ b/processor/elasticapmprocessor/internal/enrichments/span.go @@ -79,6 +79,7 @@ type spanEnrichmentContext struct { peerService string serverAddress string + httpTarget string urlScheme string urlDomain string urlPath string @@ -172,11 +173,13 @@ func (s *spanEnrichmentContext) Enrich( s.httpStatusCode = v.Int() case string(semconv25.HTTPMethodKey), string(semconv25.HTTPRequestMethodKey), - string(semconv25.HTTPTargetKey), string(semconv25.HTTPSchemeKey), string(semconv25.HTTPFlavorKey), string(semconv25.NetHostNameKey): s.isHTTP = true + case string(semconv25.HTTPTargetKey): + s.isHTTP = true + s.httpTarget = v.Str() case string(semconv25.URLFullKey), string(semconv25.HTTPURLKey): s.isHTTP = true @@ -241,6 +244,9 @@ func (s *spanEnrichmentContext) Enrich( s.normalizeAttributes(userAgentParser) s.isTransaction = isElasticTransaction(span) s.enrich(span, cfg) + if cfg.Span.TranslateUnsupportedAttributes.Enabled && s.isHTTP { + s.removeHTTPSourceAttributes(span) + } spanEvents := span.Events() for i := 0; i < spanEvents.Len(); i++ { @@ -394,6 +400,9 @@ func (s *spanEnrichmentContext) normalizeAttributes(userAgentPraser *uaparser.Pa if s.rpcSystem == "" && s.grpcStatus != "" { s.rpcSystem = "grpc" } + if s.urlFull == nil { + s.urlFull = s.buildURLFromComponents() + } if s.userAgentOriginal != "" && userAgentPraser != nil { ua := userAgentPraser.ParseUserAgent(s.userAgentOriginal) s.inferredUserAgentName = ua.Family @@ -582,7 +591,9 @@ func (s *spanEnrichmentContext) setServiceTarget(span ptrace.Span) { } case s.isHTTP: targetType = "http" - if resource := getHostPort( + if details, ok := s.httpDestinationDetails(); ok && details.serviceTargetName != "" { + targetName = details.serviceTargetName + } else if resource := getHostPort( s.urlFull, s.urlDomain, s.urlPort, s.serverAddress, s.serverPort, // fallback ); resource != "" { @@ -619,6 +630,19 @@ func (s *spanEnrichmentContext) setDestinationService(span ptrace.Span) { destnResource += "/" + s.messagingDestinationName } case s.isRPC, s.isHTTP: + if s.isHTTP { + if details, ok := s.httpDestinationDetails(); ok { + attribute.PutNonEmptyStr(span.Attributes(), "destination.address", details.destinationAddress) + if details.destinationPort > 0 { + attribute.PutInt(span.Attributes(), "destination.port", details.destinationPort) + } + attribute.PutNonEmptyStr(span.Attributes(), "url.original", details.urlOriginal) + attribute.PutNonEmptyStr(span.Attributes(), elasticattr.SpanDestinationServiceName, details.spanDestinationServiceName) + attribute.PutStr(span.Attributes(), elasticattr.SpanDestinationServiceType, "external") + destnResource = details.spanDestinationServiceResource + break + } + } if destnResource == "" { if res := getHostPort( s.urlFull, s.urlDomain, s.urlPort, @@ -637,6 +661,109 @@ func (s *spanEnrichmentContext) setDestinationService(span ptrace.Span) { } } +type httpDestinationDetails struct { + urlOriginal string + destinationAddress string + destinationPort int64 + serviceTargetName string + spanDestinationServiceName string + spanDestinationServiceResource string +} + +// httpDestinationDetails derives the HTTP destination/service target fields that +// apm-data computes inline while translating OTLP spans in input/otlp/traces.go: +// https://github.com/elastic/apm-data/blob/7da222dcc0320f9c812c5d72f65f830c838aae11/input/otlp/traces.go#L848-L1034 +func (s *spanEnrichmentContext) httpDestinationDetails() (httpDestinationDetails, bool) { + if s.urlFull == nil { + return httpDestinationDetails{}, false + } + + details := httpDestinationDetails{ + urlOriginal: s.urlFull.String(), + destinationAddress: s.urlFull.Hostname(), + } + if details.destinationAddress == "" { + return httpDestinationDetails{}, false + } + + if portString := s.urlFull.Port(); portString != "" { + port, err := strconv.ParseInt(portString, 10, 64) + if err == nil { + details.destinationPort = port + } + } else { + details.destinationPort = int64(schemeDefaultPort(s.urlFull.Scheme)) + } + + urlForName := url.URL{ + Scheme: s.urlFull.Scheme, + Host: s.urlFull.Host, + } + resource := s.urlFull.Host + defaultPort := int64(schemeDefaultPort(urlForName.Scheme)) + if defaultPort > 0 && details.destinationPort == defaultPort { + if s.urlFull.Port() != "" { + urlForName.Host = details.destinationAddress + } else { + resource = fmt.Sprintf("%s:%d", resource, details.destinationPort) + } + } + + details.serviceTargetName = resource + details.spanDestinationServiceName = urlForName.String() + details.spanDestinationServiceResource = resource + return details, true +} + +func (s *spanEnrichmentContext) buildURLFromComponents() *url.URL { + target := s.httpTarget + if target == "" { + if s.urlPath == "" { + return nil + } + target = s.urlPath + if s.urlQuery != "" { + target += "?" + s.urlQuery + } + } + + u, err := url.Parse(target) + if err != nil { + return nil + } + if u.Scheme == "" { + u.Scheme = s.urlScheme + } + if u.Host == "" { + host := s.urlDomain + port := s.urlPort + if host == "" { + host = s.serverAddress + port = s.serverPort + } + if host != "" { + if port > 0 { + u.Host = net.JoinHostPort(host, strconv.FormatInt(port, 10)) + } else { + u.Host = host + } + } + } + return u +} + +func (s *spanEnrichmentContext) removeHTTPSourceAttributes(span ptrace.Span) { + attributes := span.Attributes() + attributes.Remove(string(semconv25.HTTPURLKey)) + attributes.Remove(string(semconv25.HTTPTargetKey)) + attributes.Remove(string(semconv25.URLFullKey)) + attributes.Remove(string(semconv25.URLSchemeKey)) + attributes.Remove(string(semconv25.URLDomainKey)) + attributes.Remove(string(semconv25.URLPortKey)) + attributes.Remove(string(semconv25.URLPathKey)) + attributes.Remove(string(semconv25.URLQueryKey)) +} + func (s *spanEnrichmentContext) setInferredSpans(span ptrace.Span) { if _, exists := span.Attributes().Get(elasticattr.ChildIDs); exists { return @@ -869,6 +996,16 @@ func getHostPort( return "" } +func schemeDefaultPort(scheme string) int { + switch scheme { + case "http": + return 80 + case "https": + return 443 + } + return 0 +} + var standardStatusCodeResults = [...]string{ "HTTP 1xx", "HTTP 2xx", diff --git a/processor/elasticapmprocessor/internal/enrichments/span_test.go b/processor/elasticapmprocessor/internal/enrichments/span_test.go index c3a50c7af..6cec6fa60 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span_test.go +++ b/processor/elasticapmprocessor/internal/enrichments/span_test.go @@ -824,7 +824,12 @@ func TestRootSpanAsDependencyEnrich(t *testing.T) { elasticattr.ProcessorEvent: "transaction", elasticattr.SpanType: "external", elasticattr.SpanSubtype: "http", + "destination.address": "localhost", + "destination.port": int64(8080), + "url.original": "http://localhost:8080", + elasticattr.SpanDestinationServiceName: "http://localhost:8080", elasticattr.SpanDestinationServiceResource: "localhost:8080", + elasticattr.SpanDestinationServiceType: "external", elasticattr.EventOutcome: outcomeSuccess, elasticattr.SuccessCount: int64(1), elasticattr.ServiceTargetName: "localhost:8080", @@ -861,7 +866,12 @@ func TestRootSpanAsDependencyEnrich(t *testing.T) { elasticattr.ProcessorEvent: "transaction", elasticattr.SpanType: "external", elasticattr.SpanSubtype: "http", + "destination.address": "localhost", + "destination.port": int64(8080), + "url.original": "http://localhost:8080", + elasticattr.SpanDestinationServiceName: "http://localhost:8080", elasticattr.SpanDestinationServiceResource: "localhost:8080", + elasticattr.SpanDestinationServiceType: "external", elasticattr.EventOutcome: outcomeSuccess, elasticattr.SuccessCount: int64(1), elasticattr.ServiceTargetName: "localhost:8080", @@ -900,7 +910,12 @@ func TestRootSpanAsDependencyEnrich(t *testing.T) { elasticattr.ProcessorEvent: "transaction", elasticattr.SpanType: "external", elasticattr.SpanSubtype: "http", + "destination.address": "localhost", + "destination.port": int64(8080), + "url.original": "http://localhost:8080", + elasticattr.SpanDestinationServiceName: "http://localhost:8080", elasticattr.SpanDestinationServiceResource: "localhost:8080", + elasticattr.SpanDestinationServiceType: "external", elasticattr.EventOutcome: outcomeSuccess, elasticattr.SuccessCount: int64(1), elasticattr.ServiceTargetName: "localhost:8080", @@ -1088,7 +1103,12 @@ func TestRootSpanAsDependencyEnrich(t *testing.T) { elasticattr.ProcessorEvent: "transaction", elasticattr.SpanType: "external", elasticattr.SpanSubtype: "http", + "destination.address": "localhost", + "destination.port": int64(8080), + "url.original": "http://localhost:8080", + elasticattr.SpanDestinationServiceName: "http://localhost:8080", elasticattr.SpanDestinationServiceResource: "localhost:8080", + elasticattr.SpanDestinationServiceType: "external", elasticattr.EventOutcome: outcomeSuccess, elasticattr.SuccessCount: int64(1), elasticattr.ServiceTargetName: "localhost:8080", @@ -1330,9 +1350,51 @@ func TestElasticSpanEnrich(t *testing.T) { elasticattr.SpanDurationUs: expectedDuration.Microseconds(), elasticattr.EventOutcome: "failure", elasticattr.SuccessCount: int64(0), + "destination.address": "www.foo.bar", + "destination.port": int64(443), elasticattr.ServiceTargetType: "http", elasticattr.ServiceTargetName: "www.foo.bar:443", - elasticattr.SpanDestinationServiceResource: "testsvc", + elasticattr.SpanDestinationServiceName: "https://www.foo.bar", + elasticattr.SpanDestinationServiceType: "external", + elasticattr.SpanDestinationServiceResource: "www.foo.bar:443", + "url.original": "https://www.foo.bar:443/search?q=OpenTelemetry#SemConv", + }, + }, + { + name: "http_span_implicit_default_port", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.Status().SetCode(ptrace.StatusCodeOk) + span.Attributes().PutStr(string(semconv25.PeerServiceKey), "testsvc") + span.Attributes().PutInt( + string(semconv25.HTTPResponseStatusCodeKey), + http.StatusOK, + ) + span.Attributes().PutStr( + string(semconv25.HTTPURLKey), + "https://api.example.com/users", + ) + return span + }(), + config: config.Enabled().Span, + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "external", + elasticattr.SpanSubtype: "http", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + "destination.address": "api.example.com", + "destination.port": int64(443), + elasticattr.ServiceTargetType: "http", + elasticattr.ServiceTargetName: "api.example.com:443", + elasticattr.SpanDestinationServiceName: "https://api.example.com", + elasticattr.SpanDestinationServiceType: "external", + elasticattr.SpanDestinationServiceResource: "api.example.com:443", + "url.original": "https://api.example.com/users", }, }, { @@ -1364,9 +1426,14 @@ func TestElasticSpanEnrich(t *testing.T) { elasticattr.SpanDurationUs: expectedDuration.Microseconds(), elasticattr.EventOutcome: "failure", elasticattr.SuccessCount: int64(0), + "destination.address": "www.foo.bar", + "destination.port": int64(443), elasticattr.ServiceTargetType: "http", elasticattr.ServiceTargetName: "www.foo.bar:443", - elasticattr.SpanDestinationServiceResource: "testsvc", + elasticattr.SpanDestinationServiceName: "https://www.foo.bar", + elasticattr.SpanDestinationServiceType: "external", + elasticattr.SpanDestinationServiceResource: "www.foo.bar:443", + "url.original": "https://www.foo.bar:443/search?q=OpenTelemetry#SemConv", }, }, { diff --git a/processor/elasticapmprocessor/processor_test.go b/processor/elasticapmprocessor/processor_test.go index 47626f806..72f38eddc 100644 --- a/processor/elasticapmprocessor/processor_test.go +++ b/processor/elasticapmprocessor/processor_test.go @@ -475,14 +475,34 @@ func TestConsumeTraces_ECSOTLPFallbacks(t *testing.T) { value, ok = actualSpanAttrs.Get("http.response.status_code") require.True(t, ok) assert.EqualValues(t, 404, value.Int()) - value, ok = actualSpanAttrs.Get("http.url") + value, ok = actualSpanAttrs.Get("url.original") require.True(t, ok) assert.Equal(t, "https://api.example.com/users", value.Str()) + value, ok = actualSpanAttrs.Get("destination.address") + require.True(t, ok) + assert.Equal(t, "api.example.com", value.Str()) + value, ok = actualSpanAttrs.Get("destination.port") + require.True(t, ok) + assert.EqualValues(t, 443, value.Int()) + value, ok = actualSpanAttrs.Get(elasticattr.ServiceTargetName) + require.True(t, ok) + assert.Equal(t, "api.example.com:443", value.Str()) + value, ok = actualSpanAttrs.Get(elasticattr.SpanDestinationServiceName) + require.True(t, ok) + assert.Equal(t, "https://api.example.com", value.Str()) + value, ok = actualSpanAttrs.Get(elasticattr.SpanDestinationServiceType) + require.True(t, ok) + assert.Equal(t, "external", value.Str()) + value, ok = actualSpanAttrs.Get(elasticattr.SpanDestinationServiceResource) + require.True(t, ok) + assert.Equal(t, "api.example.com:443", value.Str()) _, ok = actualSpanAttrs.Get("http.route") assert.False(t, ok) _, ok = actualSpanAttrs.Get("http.response_content_length") assert.False(t, ok) + _, ok = actualSpanAttrs.Get("http.url") + assert.False(t, ok) _, ok = actualSpanAttrs.Get("error.message") assert.False(t, ok) _, ok = actualSpanAttrs.Get("error.type") diff --git a/processor/elasticapmprocessor/testdata/ecs/elastic_hostname/spans_output.yaml b/processor/elasticapmprocessor/testdata/ecs/elastic_hostname/spans_output.yaml index 5cb14a756..0578790c5 100644 --- a/processor/elasticapmprocessor/testdata/ecs/elastic_hostname/spans_output.yaml +++ b/processor/elasticapmprocessor/testdata/ecs/elastic_hostname/spans_output.yaml @@ -35,7 +35,7 @@ resourceSpans: - scope: {} spans: - attributes: - - key: some.key + - key: labels.some_key value: stringValue: main - key: timestamp.us diff --git a/processor/elasticapmprocessor/testdata/elastic_span_http/output.yaml b/processor/elasticapmprocessor/testdata/elastic_span_http/output.yaml index 72910e37b..f11d38551 100644 --- a/processor/elasticapmprocessor/testdata/elastic_span_http/output.yaml +++ b/processor/elasticapmprocessor/testdata/elastic_span_http/output.yaml @@ -51,11 +51,26 @@ resourceSpans: value: stringValue: http - key: service.target.name + value: + stringValue: www.foo.bar:443 + - key: destination.address value: stringValue: www.foo.bar + - key: destination.port + value: + intValue: "443" + - key: url.original + value: + stringValue: https://www.foo.bar/search?q=OpenTelemetry#SemConv + - key: span.destination.service.name + value: + stringValue: https://www.foo.bar + - key: span.destination.service.type + value: + stringValue: external - key: span.destination.service.resource value: - stringValue: www.foo.bar + stringValue: www.foo.bar:443 - key: processor.event value: stringValue: span From 4bd206c4841dd0b4197c757d2a0c42903470afc2 Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Mon, 13 Apr 2026 11:22:30 -0400 Subject: [PATCH 09/15] feat: use semconv constants --- .../internal/ecs/ecs_translation.go | 124 ++++++++++-------- 1 file changed, 70 insertions(+), 54 deletions(-) diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go index 3207499d2..4f169bfe1 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go @@ -23,8 +23,11 @@ import ( "github.com/elastic/opentelemetry-collector-components/internal/elasticattr" "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/sanitize" "go.opentelemetry.io/collector/pdata/pcommon" + semconv25 "go.opentelemetry.io/otel/semconv/v1.25.0" semconv26 "go.opentelemetry.io/otel/semconv/v1.26.0" semconv "go.opentelemetry.io/otel/semconv/v1.27.0" + semconv37 "go.opentelemetry.io/otel/semconv/v1.37.0" + semconv39 "go.opentelemetry.io/otel/semconv/v1.39.0" ) // Supported ECS resource attributes @@ -200,65 +203,78 @@ func TranslateSpanAttributes(attributes pcommon.Map) { } switch k { + // data_stream.* case elasticattr.DataStreamDataset, elasticattr.DataStreamNamespace, elasticattr.DataStreamType, + // miscellaneous + elasticattr.SessionID, elasticattr.TransactionType, - "code.stacktrace", - "db.name", - "db.namespace", - "db.query.text", - "db.statement", - "db.system", - "db.system.name", - "db.user", - "gen_ai.provider.name", - "gen_ai.system", - "http.flavor", - "http.method", - "http.request.method", - "http.response.body.size", - "http.response.status_code", - "http.scheme", - "http.status_code", - "http.target", - "http.url", - "http.user_agent", - "messaging.destination.name", - "messaging.destination.temporary", - "messaging.operation", - "messaging.operation.name", - "messaging.system", - "net.host.name", - "net.peer.name", - "net.peer.port", - "network.carrier.icc", - "network.carrier.mcc", - "network.carrier.mnc", - "network.carrier.name", - "network.connection.subtype", - "network.connection.type", - "peer.service", - "rpc.grpc.status_code", - "rpc.method", - "rpc.response.status_code", - "rpc.service", - "rpc.system", - "rpc.system.name", - "server.address", - "server.port", - "service.peer.name", - "session.id", "type", - "url.domain", - "url.full", - "url.path", - "url.port", - "url.query", - "url.scheme", - "user_agent.name", - "user_agent.original", - "user_agent.version": + "code.stacktrace", + // db.* + string(semconv25.DBNameKey), + string(semconv37.DBNamespaceKey), + string(semconv37.DBQueryTextKey), + string(semconv25.DBStatementKey), + string(semconv25.DBSystemKey), + string(semconv37.DBSystemNameKey), + string(semconv25.DBUserKey), + // gen_ai.* + string(semconv37.GenAIProviderNameKey), + string(semconv.GenAISystemKey), + // http.* + string(semconv25.HTTPFlavorKey), + string(semconv25.HTTPMethodKey), + string(semconv25.HTTPRequestMethodKey), + string(semconv.HTTPResponseBodySizeKey), + string(semconv25.HTTPResponseStatusCodeKey), + string(semconv25.HTTPSchemeKey), + string(semconv25.HTTPStatusCodeKey), + string(semconv25.HTTPTargetKey), + string(semconv25.HTTPURLKey), + string(semconv25.HTTPUserAgentKey), + // messaging.* + string(semconv25.MessagingDestinationNameKey), + string(semconv25.MessagingDestinationTemporaryKey), + string(semconv25.MessagingOperationKey), + string(semconv37.MessagingOperationNameKey), + string(semconv25.MessagingSystemKey), + // net.* + string(semconv25.NetHostNameKey), + string(semconv25.NetPeerNameKey), + string(semconv25.NetPeerPortKey), + // network.* + string(semconv.NetworkCarrierIccKey), + string(semconv.NetworkCarrierMccKey), + string(semconv.NetworkCarrierMncKey), + string(semconv.NetworkCarrierNameKey), + string(semconv.NetworkConnectionSubtypeKey), + string(semconv.NetworkConnectionTypeKey), + // rpc.* + string(semconv25.PeerServiceKey), + string(semconv25.RPCGRPCStatusCodeKey), + string(semconv39.RPCMethodKey), + string(semconv39.RPCResponseStatusCodeKey), + string(semconv25.RPCServiceKey), + string(semconv25.RPCSystemKey), + string(semconv39.RPCSystemNameKey), + // server.* + string(semconv25.ServerAddressKey), + string(semconv25.ServerPortKey), + // service.* + string(semconv39.ServicePeerNameKey), + // url.* + string(semconv25.URLDomainKey), + string(semconv25.URLFullKey), + string(semconv25.URLPathKey), + string(semconv25.URLPortKey), + string(semconv25.URLQueryKey), + string(semconv25.URLSchemeKey), + // user_agent.* + string(semconv.UserAgentNameKey), + string(semconv.UserAgentOriginalKey), + string(semconv.UserAgentVersionKey): return true default: fallbackToLabelAttribute(attributes, k, v) From 795eded60601841c68351a73f85592c82d7678bd Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Mon, 13 Apr 2026 18:51:31 -0400 Subject: [PATCH 10/15] feat: improve peer.service handling --- .../internal/enrichments/span.go | 19 +++++++++++------- .../internal/enrichments/span_test.go | 20 +++++++++---------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/processor/elasticapmprocessor/internal/enrichments/span.go b/processor/elasticapmprocessor/internal/enrichments/span.go index cc18738f2..29626455c 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span.go +++ b/processor/elasticapmprocessor/internal/enrichments/span.go @@ -609,6 +609,7 @@ func (s *spanEnrichmentContext) setServiceTarget(span ptrace.Span) { } func (s *spanEnrichmentContext) setDestinationService(span ptrace.Span) { + attributes := span.Attributes() var destnResource string if s.peerService != "" { destnResource = s.peerService @@ -632,14 +633,18 @@ func (s *spanEnrichmentContext) setDestinationService(span ptrace.Span) { case s.isRPC, s.isHTTP: if s.isHTTP { if details, ok := s.httpDestinationDetails(); ok { - attribute.PutNonEmptyStr(span.Attributes(), "destination.address", details.destinationAddress) + attribute.PutNonEmptyStr(attributes, "destination.address", details.destinationAddress) if details.destinationPort > 0 { - attribute.PutInt(span.Attributes(), "destination.port", details.destinationPort) + attribute.PutInt(attributes, "destination.port", details.destinationPort) + } + attribute.PutNonEmptyStr(attributes, "url.original", details.urlOriginal) + if s.peerService != "" { + attribute.PutStr(attributes, elasticattr.SpanDestinationServiceName, s.peerService) + } else { + attribute.PutNonEmptyStr(attributes, elasticattr.SpanDestinationServiceName, details.spanDestinationServiceName) + destnResource = details.spanDestinationServiceResource } - attribute.PutNonEmptyStr(span.Attributes(), "url.original", details.urlOriginal) - attribute.PutNonEmptyStr(span.Attributes(), elasticattr.SpanDestinationServiceName, details.spanDestinationServiceName) - attribute.PutStr(span.Attributes(), elasticattr.SpanDestinationServiceType, "external") - destnResource = details.spanDestinationServiceResource + attribute.PutStr(attributes, elasticattr.SpanDestinationServiceType, "external") break } } @@ -657,7 +662,7 @@ func (s *spanEnrichmentContext) setDestinationService(span ptrace.Span) { } if destnResource != "" { - attribute.PutStr(span.Attributes(), elasticattr.SpanDestinationServiceResource, destnResource) + attribute.PutStr(attributes, elasticattr.SpanDestinationServiceResource, destnResource) } } diff --git a/processor/elasticapmprocessor/internal/enrichments/span_test.go b/processor/elasticapmprocessor/internal/enrichments/span_test.go index 6cec6fa60..85c413e83 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span_test.go +++ b/processor/elasticapmprocessor/internal/enrichments/span_test.go @@ -1327,8 +1327,8 @@ func TestElasticSpanEnrich(t *testing.T) { span := getElasticSpan() span.SetName("testspan") span.Status().SetCode(ptrace.StatusCodeError) - // peer.service should be ignored if more specific deductions - // can be made about the service target. + // peer.service should be ignored for service.target.*, but + // preserved for span.destination.service.*. span.Attributes().PutStr(string(semconv25.PeerServiceKey), "testsvc") span.Attributes().PutInt( string(semconv25.HTTPResponseStatusCodeKey), @@ -1354,9 +1354,9 @@ func TestElasticSpanEnrich(t *testing.T) { "destination.port": int64(443), elasticattr.ServiceTargetType: "http", elasticattr.ServiceTargetName: "www.foo.bar:443", - elasticattr.SpanDestinationServiceName: "https://www.foo.bar", + elasticattr.SpanDestinationServiceName: "testsvc", elasticattr.SpanDestinationServiceType: "external", - elasticattr.SpanDestinationServiceResource: "www.foo.bar:443", + elasticattr.SpanDestinationServiceResource: "testsvc", "url.original": "https://www.foo.bar:443/search?q=OpenTelemetry#SemConv", }, }, @@ -1391,9 +1391,9 @@ func TestElasticSpanEnrich(t *testing.T) { "destination.port": int64(443), elasticattr.ServiceTargetType: "http", elasticattr.ServiceTargetName: "api.example.com:443", - elasticattr.SpanDestinationServiceName: "https://api.example.com", + elasticattr.SpanDestinationServiceName: "testsvc", elasticattr.SpanDestinationServiceType: "external", - elasticattr.SpanDestinationServiceResource: "api.example.com:443", + elasticattr.SpanDestinationServiceResource: "testsvc", "url.original": "https://api.example.com/users", }, }, @@ -1402,8 +1402,8 @@ func TestElasticSpanEnrich(t *testing.T) { input: func() ptrace.Span { span := getElasticSpan() span.SetName("testspan") - // peer.service should be ignored if more specific deductions - // can be made about the service target. + // peer.service should be ignored for service.target.*, but + // preserved for span.destination.service.*. span.Attributes().PutStr(string(semconv25.PeerServiceKey), "testsvc") // No explicit span status code; HTTP 5xx triggers failure outcome span.Attributes().PutInt( @@ -1430,9 +1430,9 @@ func TestElasticSpanEnrich(t *testing.T) { "destination.port": int64(443), elasticattr.ServiceTargetType: "http", elasticattr.ServiceTargetName: "www.foo.bar:443", - elasticattr.SpanDestinationServiceName: "https://www.foo.bar", + elasticattr.SpanDestinationServiceName: "testsvc", elasticattr.SpanDestinationServiceType: "external", - elasticattr.SpanDestinationServiceResource: "www.foo.bar:443", + elasticattr.SpanDestinationServiceResource: "testsvc", "url.original": "https://www.foo.bar:443/search?q=OpenTelemetry#SemConv", }, }, From 7666734d642b0aef718928a20ffe939ee0700b07 Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Mon, 13 Apr 2026 18:54:12 -0400 Subject: [PATCH 11/15] feat: default span url scheme to http --- .../internal/enrichments/span.go | 3 ++ .../internal/enrichments/span_test.go | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/processor/elasticapmprocessor/internal/enrichments/span.go b/processor/elasticapmprocessor/internal/enrichments/span.go index 29626455c..ec1c005bd 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span.go +++ b/processor/elasticapmprocessor/internal/enrichments/span.go @@ -738,6 +738,9 @@ func (s *spanEnrichmentContext) buildURLFromComponents() *url.URL { } if u.Scheme == "" { u.Scheme = s.urlScheme + if u.Scheme == "" { + u.Scheme = "http" + } } if u.Host == "" { host := s.urlDomain diff --git a/processor/elasticapmprocessor/internal/enrichments/span_test.go b/processor/elasticapmprocessor/internal/enrichments/span_test.go index 85c413e83..e0af91db0 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span_test.go +++ b/processor/elasticapmprocessor/internal/enrichments/span_test.go @@ -1467,6 +1467,40 @@ func TestElasticSpanEnrich(t *testing.T) { elasticattr.SpanDestinationServiceResource: "testsvc", }, }, + { + name: "http_span_synthesized_url_defaults_scheme", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.Status().SetCode(ptrace.StatusCodeOk) + span.Attributes().PutInt( + string(semconv25.HTTPResponseStatusCodeKey), + http.StatusOK, + ) + span.Attributes().PutStr(string(semconv25.HTTPTargetKey), "/search?q=OpenTelemetry") + span.Attributes().PutStr(string(semconv25.ServerAddressKey), "api.example.com") + return span + }(), + config: config.Enabled().Span, + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "external", + elasticattr.SpanSubtype: "http", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + "destination.address": "api.example.com", + "destination.port": int64(80), + elasticattr.ServiceTargetType: "http", + elasticattr.ServiceTargetName: "api.example.com:80", + elasticattr.SpanDestinationServiceName: "http://api.example.com", + elasticattr.SpanDestinationServiceType: "external", + elasticattr.SpanDestinationServiceResource: "api.example.com:80", + "url.original": "http://api.example.com/search?q=OpenTelemetry", + }, + }, { name: "rpc_span_grpc", input: func() ptrace.Span { From 33cd39a57b34ab169dac657d9e0194c947098782 Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Mon, 13 Apr 2026 19:47:11 -0400 Subject: [PATCH 12/15] feat: address coderabbit feedback --- .../internal/ecs/ecs_translation.go | 4 + .../internal/ecs/ecs_translation_test.go | 4 + .../internal/enrichments/span.go | 66 ++++++--- .../internal/enrichments/span_test.go | 140 ++++++++++++++++++ .../elasticapmprocessor/processor_test.go | 54 +++++++ 5 files changed, 249 insertions(+), 19 deletions(-) diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go index a8f6e24ad..2993f2e8c 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go @@ -23,6 +23,7 @@ import ( "github.com/elastic/opentelemetry-collector-components/internal/elasticattr" "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/sanitize" "go.opentelemetry.io/collector/pdata/pcommon" + semconv12 "go.opentelemetry.io/otel/semconv/v1.12.0" semconv25 "go.opentelemetry.io/otel/semconv/v1.25.0" semconv26 "go.opentelemetry.io/otel/semconv/v1.26.0" semconv "go.opentelemetry.io/otel/semconv/v1.27.0" @@ -209,6 +210,8 @@ func TranslateSpanAttributes(attributes pcommon.Map) { elasticattr.DataStreamNamespace, elasticattr.DataStreamType, // miscellaneous + elasticattr.EventOutcome, + elasticattr.ProcessorEvent, elasticattr.SessionID, elasticattr.TransactionType, "type", @@ -233,6 +236,7 @@ func TranslateSpanAttributes(attributes pcommon.Map) { string(semconv25.HTTPSchemeKey), string(semconv25.HTTPStatusCodeKey), string(semconv25.HTTPTargetKey), + string(semconv12.HTTPHostKey), string(semconv25.HTTPURLKey), string(semconv25.HTTPUserAgentKey), // messaging.* diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go index 8d7610a95..61788f452 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go @@ -428,6 +428,8 @@ func TestTranslateSpanAttributes(t *testing.T) { attrs.PutInt("http.response.status_code", 200) attrs.PutStr("http.url", "https://api.example.com/users") attrs.PutStr("db.query.text", "SELECT * FROM users") + attrs.PutStr(elasticattr.EventOutcome, "unknown") + attrs.PutStr(elasticattr.ProcessorEvent, "transaction") attrs.PutStr("session.id", "session-123") attrs.PutStr(string(semconv.NetworkConnectionTypeKey), "wifi") attrs.PutStr(elasticattr.DataStreamDataset, "apm") @@ -437,6 +439,8 @@ func TestTranslateSpanAttributes(t *testing.T) { "http.response.status_code": int64(200), "http.url": "https://api.example.com/users", "db.query.text": "SELECT * FROM users", + elasticattr.EventOutcome: "unknown", + elasticattr.ProcessorEvent: "transaction", "session.id": "session-123", string(semconv.NetworkConnectionTypeKey): "wifi", elasticattr.DataStreamDataset: "apm", diff --git a/processor/elasticapmprocessor/internal/enrichments/span.go b/processor/elasticapmprocessor/internal/enrichments/span.go index ec1c005bd..8f84089a3 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span.go +++ b/processor/elasticapmprocessor/internal/enrichments/span.go @@ -32,6 +32,7 @@ import ( "github.com/ua-parser/uap-go/uaparser" "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/ptrace" + semconv12 "go.opentelemetry.io/otel/semconv/v1.12.0" semconv25 "go.opentelemetry.io/otel/semconv/v1.25.0" semconv27 "go.opentelemetry.io/otel/semconv/v1.27.0" semconv37 "go.opentelemetry.io/otel/semconv/v1.37.0" @@ -78,6 +79,7 @@ type spanEnrichmentContext struct { urlFull *url.URL peerService string + httpHost string serverAddress string httpTarget string urlScheme string @@ -98,6 +100,7 @@ type spanEnrichmentContext struct { // The inferred* attributes are derived from a base attribute userAgentOriginal string + userAgentOriginalSet bool userAgentName string userAgentVersion string inferredUserAgentName string @@ -136,6 +139,9 @@ func (s *spanEnrichmentContext) Enrich( switch k { case string(semconv25.PeerServiceKey), string(semconv39.ServicePeerNameKey): s.peerService = v.Str() + case string(semconv12.HTTPHostKey): + s.isHTTP = true + s.httpHost = v.Str() case string(semconv25.ServerAddressKey): s.serverAddress = v.Str() case string(semconv25.ServerPortKey): @@ -227,8 +233,13 @@ func (s *spanEnrichmentContext) Enrich( case string(semconv27.GenAISystemKey), string(semconv37.GenAIProviderNameKey): s.isGenAi = true s.genAiSystem = v.Str() + case string(semconv25.HTTPUserAgentKey): + if !s.userAgentOriginalSet { + s.userAgentOriginal = v.Str() + } case string(semconv27.UserAgentOriginalKey): s.userAgentOriginal = v.Str() + s.userAgentOriginalSet = true case string(semconv27.UserAgentNameKey): s.userAgentName = v.Str() case string(semconv27.UserAgentVersionKey): @@ -743,17 +754,21 @@ func (s *spanEnrichmentContext) buildURLFromComponents() *url.URL { } } if u.Host == "" { - host := s.urlDomain - port := s.urlPort - if host == "" { - host = s.serverAddress - port = s.serverPort - } - if host != "" { - if port > 0 { - u.Host = net.JoinHostPort(host, strconv.FormatInt(port, 10)) - } else { - u.Host = host + if s.httpHost != "" { + u.Host = s.httpHost + } else { + host := s.urlDomain + port := s.urlPort + if host == "" { + host = s.serverAddress + port = s.serverPort + } + if host != "" { + if port > 0 { + u.Host = net.JoinHostPort(host, strconv.FormatInt(port, 10)) + } else { + u.Host = host + } } } } @@ -762,14 +777,20 @@ func (s *spanEnrichmentContext) buildURLFromComponents() *url.URL { func (s *spanEnrichmentContext) removeHTTPSourceAttributes(span ptrace.Span) { attributes := span.Attributes() - attributes.Remove(string(semconv25.HTTPURLKey)) - attributes.Remove(string(semconv25.HTTPTargetKey)) - attributes.Remove(string(semconv25.URLFullKey)) - attributes.Remove(string(semconv25.URLSchemeKey)) - attributes.Remove(string(semconv25.URLDomainKey)) - attributes.Remove(string(semconv25.URLPortKey)) - attributes.Remove(string(semconv25.URLPathKey)) - attributes.Remove(string(semconv25.URLQueryKey)) + if _, ok := attributes.Get("url.original"); ok { + attributes.Remove(string(semconv12.HTTPHostKey)) + attributes.Remove(string(semconv25.HTTPURLKey)) + attributes.Remove(string(semconv25.HTTPTargetKey)) + attributes.Remove(string(semconv25.URLFullKey)) + attributes.Remove(string(semconv25.URLSchemeKey)) + attributes.Remove(string(semconv25.URLDomainKey)) + attributes.Remove(string(semconv25.URLPortKey)) + attributes.Remove(string(semconv25.URLPathKey)) + attributes.Remove(string(semconv25.URLQueryKey)) + } + if _, ok := attributes.Get(string(semconv27.UserAgentOriginalKey)); ok { + attributes.Remove(string(semconv25.HTTPUserAgentKey)) + } } func (s *spanEnrichmentContext) setInferredSpans(span ptrace.Span) { @@ -810,6 +831,13 @@ func (s *spanEnrichmentContext) setInferredSpans(span ptrace.Span) { } func (s *spanEnrichmentContext) setUserAgentIfRequired(span ptrace.Span) { + if s.userAgentOriginal != "" { + attribute.PutStr( + span.Attributes(), + string(semconv27.UserAgentOriginalKey), + s.userAgentOriginal, + ) + } if s.userAgentName == "" && s.inferredUserAgentName != "" { attribute.PutStr( span.Attributes(), diff --git a/processor/elasticapmprocessor/internal/enrichments/span_test.go b/processor/elasticapmprocessor/internal/enrichments/span_test.go index e0af91db0..840d10844 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span_test.go +++ b/processor/elasticapmprocessor/internal/enrichments/span_test.go @@ -30,6 +30,7 @@ import ( "github.com/ua-parser/uap-go/uaparser" "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/ptrace" + semconv12 "go.opentelemetry.io/otel/semconv/v1.12.0" semconv25 "go.opentelemetry.io/otel/semconv/v1.25.0" semconv27 "go.opentelemetry.io/otel/semconv/v1.27.0" semconv37 "go.opentelemetry.io/otel/semconv/v1.37.0" @@ -1171,6 +1172,7 @@ func TestElasticSpanEnrich(t *testing.T) { input ptrace.Span config config.ElasticSpanConfig enrichedAttrs map[string]any + removedAttrs []string expectedSpanLinks *ptrace.SpanLinkSlice }{ { @@ -1501,6 +1503,80 @@ func TestElasticSpanEnrich(t *testing.T) { "url.original": "http://api.example.com/search?q=OpenTelemetry", }, }, + { + name: "http_span_legacy_http_host", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.Status().SetCode(ptrace.StatusCodeOk) + span.Attributes().PutInt( + string(semconv25.HTTPResponseStatusCodeKey), + http.StatusOK, + ) + span.Attributes().PutStr(string(semconv12.HTTPHostKey), "api.example.com:444") + span.Attributes().PutStr(string(semconv25.HTTPTargetKey), "/search?q=OpenTelemetry") + return span + }(), + config: func() config.ElasticSpanConfig { + cfg := config.Enabled().Span + cfg.TranslateUnsupportedAttributes.Enabled = true + return cfg + }(), + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "external", + elasticattr.SpanSubtype: "http", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + "destination.address": "api.example.com", + "destination.port": int64(444), + elasticattr.ServiceTargetType: "http", + elasticattr.ServiceTargetName: "api.example.com:444", + elasticattr.SpanDestinationServiceName: "http://api.example.com:444", + elasticattr.SpanDestinationServiceType: "external", + elasticattr.SpanDestinationServiceResource: "api.example.com:444", + "url.original": "http://api.example.com:444/search?q=OpenTelemetry", + }, + removedAttrs: []string{ + string(semconv12.HTTPHostKey), + string(semconv25.HTTPTargetKey), + }, + }, + { + name: "http_source_attrs_retained_without_destination_service", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.Status().SetCode(ptrace.StatusCodeOk) + span.Attributes().PutInt( + string(semconv25.HTTPResponseStatusCodeKey), + http.StatusOK, + ) + span.Attributes().PutStr(string(semconv25.HTTPURLKey), "https://api.example.com/users") + return span + }(), + config: func() config.ElasticSpanConfig { + cfg := config.Enabled().Span + cfg.TranslateUnsupportedAttributes.Enabled = true + cfg.DestinationService.Enabled = false + return cfg + }(), + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "external", + elasticattr.SpanSubtype: "http", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + elasticattr.ServiceTargetType: "http", + elasticattr.ServiceTargetName: "api.example.com:443", + }, + }, { name: "rpc_span_grpc", input: func() ptrace.Span { @@ -2173,6 +2249,67 @@ func TestElasticSpanEnrich(t *testing.T) { string(semconv27.UserAgentVersionKey): "51.0.2704", }, }, + { + name: "http_user_agent_alias_promoted_and_removed", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.SetSpanID([8]byte{1}) + span.Attributes().PutStr(string(semconv25.HTTPRequestMethodKey), "GET") + span.Attributes().PutStr(string(semconv25.HTTPUserAgentKey), "Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1") + return span + }(), + config: func() config.ElasticSpanConfig { + cfg := config.Enabled().Span + cfg.TranslateUnsupportedAttributes.Enabled = true + return cfg + }(), + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "external", + elasticattr.SpanSubtype: "http", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + elasticattr.ServiceTargetType: "http", + elasticattr.ServiceTargetName: "", + string(semconv27.UserAgentOriginalKey): "Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", + string(semconv27.UserAgentNameKey): "Mobile Safari", + string(semconv27.UserAgentVersionKey): "13.1.1", + }, + removedAttrs: []string{string(semconv25.HTTPUserAgentKey)}, + }, + { + name: "http_user_agent_alias_retained_when_user_agent_disabled", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.SetSpanID([8]byte{1}) + span.Attributes().PutStr(string(semconv25.HTTPRequestMethodKey), "GET") + span.Attributes().PutStr(string(semconv25.HTTPUserAgentKey), "Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1") + return span + }(), + config: func() config.ElasticSpanConfig { + cfg := config.Enabled().Span + cfg.TranslateUnsupportedAttributes.Enabled = true + cfg.UserAgent.Enabled = false + return cfg + }(), + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "external", + elasticattr.SpanSubtype: "http", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + elasticattr.ServiceTargetType: "http", + elasticattr.ServiceTargetName: "", + }, + }, { name: "event_outcome_unknown_preserved_no_success_count", input: func() ptrace.Span { @@ -2328,6 +2465,9 @@ func TestElasticSpanEnrich(t *testing.T) { for k, v := range tc.enrichedAttrs { _ = expectedSpan.Attributes().PutEmpty(k).FromRaw(v) } + for _, key := range tc.removedAttrs { + expectedSpan.Attributes().Remove(key) + } enrichConfig := config.Config{ Span: tc.config, } diff --git a/processor/elasticapmprocessor/processor_test.go b/processor/elasticapmprocessor/processor_test.go index 72f38eddc..c0b6f62b6 100644 --- a/processor/elasticapmprocessor/processor_test.go +++ b/processor/elasticapmprocessor/processor_test.go @@ -515,6 +515,60 @@ func TestConsumeTraces_ECSOTLPFallbacks(t *testing.T) { assert.False(t, ok) } +func TestConsumeTraces_ECSOTLPPreservesProcessorEventAndUnknownOutcome(t *testing.T) { + ctx := client.NewContext(context.Background(), client.Info{ + Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), + }) + + factory := NewFactory() + settings := processortest.NewNopSettings(metadata.Type) + next := &consumertest.TracesSink{} + cfg := NewDefaultConfig().(*Config) + + tp, err := factory.CreateTraces(ctx, settings, cfg, next) + require.NoError(t, err) + + traces := ptrace.NewTraces() + resourceSpan := traces.ResourceSpans().AppendEmpty() + resource := resourceSpan.Resource() + resource.Attributes().PutStr("service.name", "otlp-service") + resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "opentelemetry") + + scopeSpans := resourceSpan.ScopeSpans().AppendEmpty() + span := scopeSpans.Spans().AppendEmpty() + span.SetName("otlp-transaction") + span.SetKind(ptrace.SpanKindInternal) + span.SetParentSpanID(pcommon.SpanID{1, 2, 3, 4, 5, 6, 7, 8}) + span.SetStartTimestamp(pcommon.Timestamp(1_000_000_000)) + span.SetEndTimestamp(pcommon.Timestamp(2_000_000_000)) + + attrs := span.Attributes() + attrs.PutStr(elasticattr.ProcessorEvent, "transaction") + attrs.PutStr(elasticattr.EventOutcome, "unknown") + attrs.PutStr("custom.attr", "preserved-as-label") + + require.NoError(t, tp.ConsumeTraces(ctx, traces)) + actual := next.AllTraces()[0] + + actualSpanAttrs := actual.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + value, ok := actualSpanAttrs.Get(elasticattr.ProcessorEvent) + require.True(t, ok) + assert.Equal(t, "transaction", value.Str()) + value, ok = actualSpanAttrs.Get(elasticattr.EventOutcome) + require.True(t, ok) + assert.Equal(t, "unknown", value.Str()) + value, ok = actualSpanAttrs.Get(elasticattr.TransactionDurationUs) + require.True(t, ok) + assert.EqualValues(t, 1_000_000, value.Int()) + _, ok = actualSpanAttrs.Get(elasticattr.SpanDurationUs) + assert.False(t, ok) + _, ok = actualSpanAttrs.Get(elasticattr.SuccessCount) + assert.False(t, ok) + value, ok = actualSpanAttrs.Get("labels.custom_attr") + require.True(t, ok) + assert.Equal(t, "preserved-as-label", value.Str()) +} + func TestConsumeTraces_ECSIntakeSkipsOTLPSpanFallbackTranslation(t *testing.T) { ctx := client.NewContext(context.Background(), client.Info{ Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), From c74d20bab7547cacdb8e3bd9cd124702aaf68bb2 Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Mon, 13 Apr 2026 20:42:56 -0400 Subject: [PATCH 13/15] refactor: span enrichment order --- .../internal/enrichments/span.go | 41 +++-- .../internal/enrichments/span_test.go | 73 +++++++- .../elasticapmprocessor/processor_test.go | 158 +++++------------- 3 files changed, 136 insertions(+), 136 deletions(-) diff --git a/processor/elasticapmprocessor/internal/enrichments/span.go b/processor/elasticapmprocessor/internal/enrichments/span.go index 8f84089a3..789cc8b0f 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span.go +++ b/processor/elasticapmprocessor/internal/enrichments/span.go @@ -78,6 +78,8 @@ func EnrichSpan( type spanEnrichmentContext struct { urlFull *url.URL + processorEvent string + eventOutcome string peerService string httpHost string serverAddress string @@ -127,16 +129,29 @@ func (s *spanEnrichmentContext) Enrich( cfg config.Config, userAgentParser *uaparser.Parser, ) { + s.capture(span) + s.normalizeAttributes(userAgentParser) + s.isTransaction = isElasticTransaction(span, s.processorEvent) if cfg.Span.TranslateUnsupportedAttributes.Enabled { ecs.TranslateSpanAttributes(span.Attributes()) } + s.enrich(span, cfg) + if cfg.Span.TranslateUnsupportedAttributes.Enabled && s.isHTTP { + s.removeHTTPSourceAttributes(span) + } + + s.enrichSpanEvents(span, cfg.SpanEvent) +} - // Extract top level span information. +func (s *spanEnrichmentContext) capture(span ptrace.Span) { s.spanStatusCode = span.Status().Code() - // Extract information from span attributes. span.Attributes().Range(func(k string, v pcommon.Value) bool { switch k { + case elasticattr.ProcessorEvent: + s.processorEvent = v.Str() + case elasticattr.EventOutcome: + s.eventOutcome = v.Str() case string(semconv25.PeerServiceKey), string(semconv39.ServicePeerNameKey): s.peerService = v.Str() case string(semconv12.HTTPHostKey): @@ -251,18 +266,13 @@ func (s *spanEnrichmentContext) Enrich( } return true }) +} - s.normalizeAttributes(userAgentParser) - s.isTransaction = isElasticTransaction(span) - s.enrich(span, cfg) - if cfg.Span.TranslateUnsupportedAttributes.Enabled && s.isHTTP { - s.removeHTTPSourceAttributes(span) - } - +func (s *spanEnrichmentContext) enrichSpanEvents(span ptrace.Span, cfg config.SpanEventConfig) { spanEvents := span.Events() for i := 0; i < spanEvents.Len(); i++ { var c spanEventEnrichmentContext - c.enrich(s, spanEvents.At(i), cfg.SpanEvent) + c.enrich(s, spanEvents.At(i), cfg) } } @@ -475,7 +485,7 @@ func (s *spanEnrichmentContext) setEventOutcome(span ptrace.Span) { // Exit early when event.outcome is already explicitly set to unknown (e.g. by // the intake receiver). This prevents success_count from being written when // the outcome is unknown. - if v, ok := span.Attributes().Get(elasticattr.EventOutcome); ok && v.Str() == outcomeUnknown { + if s.eventOutcome == outcomeUnknown { return } @@ -988,16 +998,11 @@ func isTraceRoot(span ptrace.Span) bool { return span.ParentSpanID().IsEmpty() } -func isElasticTransaction(span ptrace.Span) bool { +func isElasticTransaction(span ptrace.Span, processorEvent string) bool { flags := tracepb.SpanFlags(span.Flags()) - // Events may have already been defined as an elastic transaction. - // check the processor.event value to avoid incorrectly classifying - // a span. - processorEvent, _ := span.Attributes().Get(elasticattr.ProcessorEvent) - switch { - case processorEvent.Str() == "transaction": + case processorEvent == "transaction": return true case isTraceRoot(span): return true diff --git a/processor/elasticapmprocessor/internal/enrichments/span_test.go b/processor/elasticapmprocessor/internal/enrichments/span_test.go index 840d10844..b2c61ec14 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span_test.go +++ b/processor/elasticapmprocessor/internal/enrichments/span_test.go @@ -64,6 +64,7 @@ func TestElasticTransactionEnrich(t *testing.T) { name string input ptrace.Span config config.ElasticTransactionConfig + spanConfig config.ElasticSpanConfig enrichedAttrs map[string]any expectedSpanLinks *ptrace.SpanLinkSlice }{ @@ -281,6 +282,40 @@ func TestElasticTransactionEnrich(t *testing.T) { elasticattr.TransactionType: "request", }, }, + { + name: "processor_event_transaction_unknown_outcome_preserved_with_translation", + input: func() ptrace.Span { + span := ptrace.NewSpan() + span.SetSpanID([8]byte{1}) + span.SetParentSpanID([8]byte{8, 9, 10, 11, 12, 13, 14}) + span.SetKind(ptrace.SpanKindInternal) + span.SetStartTimestamp(startTs) + span.SetEndTimestamp(endTs) + span.SetName("testtxn") + span.Attributes().PutStr(elasticattr.ProcessorEvent, "transaction") + span.Attributes().PutStr(elasticattr.EventOutcome, outcomeUnknown) + return span + }(), + config: config.Enabled().Transaction, + spanConfig: func() config.ElasticSpanConfig { + cfg := config.Enabled().Span + cfg.TranslateUnsupportedAttributes.Enabled = true + return cfg + }(), + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.TransactionSampled: true, + elasticattr.TransactionRoot: false, + elasticattr.TransactionID: "0100000000000000", + elasticattr.TransactionName: "testtxn", + elasticattr.ProcessorEvent: "transaction", + elasticattr.TransactionRepresentativeCount: float64(1), + elasticattr.TransactionDurationUs: expectedDuration.Microseconds(), + elasticattr.TransactionResult: "Success", + elasticattr.TransactionType: "unknown", + elasticattr.EventOutcome: outcomeUnknown, + }, + }, { name: "http_status_1xx", input: func() ptrace.Span { @@ -741,6 +776,40 @@ func TestElasticTransactionEnrich(t *testing.T) { input: getElasticMobileTxn(), enrichedAttrs: map[string]any{}, }, + { + name: "processor_event_transaction_unknown_outcome_preserved_with_translation", + input: func() ptrace.Span { + span := ptrace.NewSpan() + span.SetSpanID([8]byte{1}) + span.SetParentSpanID([8]byte{8, 9, 10, 11, 12, 13, 14}) + span.SetKind(ptrace.SpanKindInternal) + span.SetStartTimestamp(startTs) + span.SetEndTimestamp(endTs) + span.SetName("testtxn") + span.Attributes().PutStr(elasticattr.ProcessorEvent, "transaction") + span.Attributes().PutStr(elasticattr.EventOutcome, outcomeUnknown) + return span + }(), + config: config.Enabled().Transaction, + spanConfig: func() config.ElasticSpanConfig { + cfg := config.Enabled().Span + cfg.TranslateUnsupportedAttributes.Enabled = true + return cfg + }(), + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.TransactionSampled: true, + elasticattr.TransactionRoot: false, + elasticattr.TransactionID: "0100000000000000", + elasticattr.TransactionName: "testtxn", + elasticattr.ProcessorEvent: "transaction", + elasticattr.TransactionRepresentativeCount: float64(1), + elasticattr.TransactionDurationUs: expectedDuration.Microseconds(), + elasticattr.TransactionResult: "Success", + elasticattr.TransactionType: "unknown", + elasticattr.EventOutcome: outcomeUnknown, + }, + }, { name: "http_status_ok_for_mobile", input: func() ptrace.Span { @@ -790,6 +859,7 @@ func TestElasticTransactionEnrich(t *testing.T) { EnrichSpan(tc.input, config.Config{ Transaction: tc.config, + Span: tc.spanConfig, }, uaparser.NewFromSaved()) assert.NoError(t, ptracetest.CompareSpan(expectedSpan, tc.input)) }) @@ -2715,7 +2785,8 @@ func TestIsElasticTransaction(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.isTxn, isElasticTransaction(tc.input)) + processorEvent, _ := tc.input.Attributes().Get(elasticattr.ProcessorEvent) + assert.Equal(t, tc.isTxn, isElasticTransaction(tc.input, processorEvent.Str())) }) } } diff --git a/processor/elasticapmprocessor/processor_test.go b/processor/elasticapmprocessor/processor_test.go index c0b6f62b6..1e45a40d3 100644 --- a/processor/elasticapmprocessor/processor_test.go +++ b/processor/elasticapmprocessor/processor_test.go @@ -335,7 +335,27 @@ func TestConsumeTraces_ECSIntakeSkipsOTLPFallbacks(t *testing.T) { require.NoError(t, err) traces := ptrace.NewTraces() - appendTraceResourceSpan(traces, "intake-service", "ElasticAPM", "linux", "intake-span") + resourceSpan := traces.ResourceSpans().AppendEmpty() + resource := resourceSpan.Resource() + resource.Attributes().PutStr("service.name", "intake-service") + resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "ElasticAPM") + resource.Attributes().PutStr(string(semconv.OSTypeKey), "linux") + + scopeSpans := resourceSpan.ScopeSpans().AppendEmpty() + span := scopeSpans.Spans().AppendEmpty() + span.SetName("intake-span") + span.SetKind(ptrace.SpanKindClient) + span.SetParentSpanID(pcommon.SpanID{1, 2, 3, 4, 5, 6, 7, 8}) + span.SetStartTimestamp(pcommon.Timestamp(1_000_000_000)) + span.SetEndTimestamp(pcommon.Timestamp(2_000_000_000)) + span.Status().SetCode(ptrace.StatusCodeError) + + attrs := span.Attributes() + attrs.PutStr("http.request.method", "GET") + attrs.PutStr("http.route", "/users") + attrs.PutInt("http.response_content_length", 1024) + attrs.PutStr("error.message", "Database connection failed") + attrs.PutStr("exception.message", "Database connection failed") require.NoError(t, tp.ConsumeTraces(ctx, traces)) actual := next.AllTraces()[0] @@ -350,6 +370,26 @@ func TestConsumeTraces_ECSIntakeSkipsOTLPFallbacks(t *testing.T) { actualSpanAttrs := actual.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() _, ok = actualSpanAttrs.Get(elasticattr.TransactionResult) assert.False(t, ok) + value, ok := actualSpanAttrs.Get("http.request.method") + require.True(t, ok) + assert.Equal(t, "GET", value.Str()) + value, ok = actualSpanAttrs.Get("http.route") + require.True(t, ok) + assert.Equal(t, "/users", value.Str()) + value, ok = actualSpanAttrs.Get("error.message") + require.True(t, ok) + assert.Equal(t, "Database connection failed", value.Str()) + value, ok = actualSpanAttrs.Get("exception.message") + require.True(t, ok) + assert.Equal(t, "Database connection failed", value.Str()) + _, ok = actualSpanAttrs.Get("labels.http_route") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("numeric_labels.http_response_content_length") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("labels.error_message") + assert.False(t, ok) + _, ok = actualSpanAttrs.Get("labels.exception_message") + assert.False(t, ok) } func TestConsumeTraces_ECSMixedOriginUsesPerResourceEnricher(t *testing.T) { @@ -515,122 +555,6 @@ func TestConsumeTraces_ECSOTLPFallbacks(t *testing.T) { assert.False(t, ok) } -func TestConsumeTraces_ECSOTLPPreservesProcessorEventAndUnknownOutcome(t *testing.T) { - ctx := client.NewContext(context.Background(), client.Info{ - Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), - }) - - factory := NewFactory() - settings := processortest.NewNopSettings(metadata.Type) - next := &consumertest.TracesSink{} - cfg := NewDefaultConfig().(*Config) - - tp, err := factory.CreateTraces(ctx, settings, cfg, next) - require.NoError(t, err) - - traces := ptrace.NewTraces() - resourceSpan := traces.ResourceSpans().AppendEmpty() - resource := resourceSpan.Resource() - resource.Attributes().PutStr("service.name", "otlp-service") - resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "opentelemetry") - - scopeSpans := resourceSpan.ScopeSpans().AppendEmpty() - span := scopeSpans.Spans().AppendEmpty() - span.SetName("otlp-transaction") - span.SetKind(ptrace.SpanKindInternal) - span.SetParentSpanID(pcommon.SpanID{1, 2, 3, 4, 5, 6, 7, 8}) - span.SetStartTimestamp(pcommon.Timestamp(1_000_000_000)) - span.SetEndTimestamp(pcommon.Timestamp(2_000_000_000)) - - attrs := span.Attributes() - attrs.PutStr(elasticattr.ProcessorEvent, "transaction") - attrs.PutStr(elasticattr.EventOutcome, "unknown") - attrs.PutStr("custom.attr", "preserved-as-label") - - require.NoError(t, tp.ConsumeTraces(ctx, traces)) - actual := next.AllTraces()[0] - - actualSpanAttrs := actual.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() - value, ok := actualSpanAttrs.Get(elasticattr.ProcessorEvent) - require.True(t, ok) - assert.Equal(t, "transaction", value.Str()) - value, ok = actualSpanAttrs.Get(elasticattr.EventOutcome) - require.True(t, ok) - assert.Equal(t, "unknown", value.Str()) - value, ok = actualSpanAttrs.Get(elasticattr.TransactionDurationUs) - require.True(t, ok) - assert.EqualValues(t, 1_000_000, value.Int()) - _, ok = actualSpanAttrs.Get(elasticattr.SpanDurationUs) - assert.False(t, ok) - _, ok = actualSpanAttrs.Get(elasticattr.SuccessCount) - assert.False(t, ok) - value, ok = actualSpanAttrs.Get("labels.custom_attr") - require.True(t, ok) - assert.Equal(t, "preserved-as-label", value.Str()) -} - -func TestConsumeTraces_ECSIntakeSkipsOTLPSpanFallbackTranslation(t *testing.T) { - ctx := client.NewContext(context.Background(), client.Info{ - Metadata: client.NewMetadata(map[string][]string{"x-elastic-mapping-mode": {"ecs"}}), - }) - - factory := NewFactory() - settings := processortest.NewNopSettings(metadata.Type) - next := &consumertest.TracesSink{} - cfg := NewDefaultConfig().(*Config) - - tp, err := factory.CreateTraces(ctx, settings, cfg, next) - require.NoError(t, err) - - traces := ptrace.NewTraces() - resourceSpan := traces.ResourceSpans().AppendEmpty() - resource := resourceSpan.Resource() - resource.Attributes().PutStr("service.name", "intake-service") - resource.Attributes().PutStr(string(semconv.TelemetrySDKNameKey), "ElasticAPM") - - scopeSpans := resourceSpan.ScopeSpans().AppendEmpty() - span := scopeSpans.Spans().AppendEmpty() - span.SetName("intake-span") - span.SetKind(ptrace.SpanKindClient) - span.SetParentSpanID(pcommon.SpanID{1, 2, 3, 4, 5, 6, 7, 8}) - span.SetStartTimestamp(pcommon.Timestamp(1_000_000_000)) - span.SetEndTimestamp(pcommon.Timestamp(2_000_000_000)) - span.Status().SetCode(ptrace.StatusCodeError) - - attrs := span.Attributes() - attrs.PutStr("http.request.method", "GET") - attrs.PutStr("http.route", "/users") - attrs.PutInt("http.response_content_length", 1024) - attrs.PutStr("error.message", "Database connection failed") - attrs.PutStr("exception.message", "Database connection failed") - - require.NoError(t, tp.ConsumeTraces(ctx, traces)) - actual := next.AllTraces()[0] - - actualSpanAttrs := actual.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() - value, ok := actualSpanAttrs.Get("http.request.method") - require.True(t, ok) - assert.Equal(t, "GET", value.Str()) - value, ok = actualSpanAttrs.Get("http.route") - require.True(t, ok) - assert.Equal(t, "/users", value.Str()) - value, ok = actualSpanAttrs.Get("error.message") - require.True(t, ok) - assert.Equal(t, "Database connection failed", value.Str()) - value, ok = actualSpanAttrs.Get("exception.message") - require.True(t, ok) - assert.Equal(t, "Database connection failed", value.Str()) - - _, ok = actualSpanAttrs.Get("labels.http_route") - assert.False(t, ok) - _, ok = actualSpanAttrs.Get("numeric_labels.http_response_content_length") - assert.False(t, ok) - _, ok = actualSpanAttrs.Get("labels.error_message") - assert.False(t, ok) - _, ok = actualSpanAttrs.Get("labels.exception_message") - assert.False(t, ok) -} - // TestSkipEnrichmentLogs tests that logs are only enriched when skipEnrichment is false or when mapping mode is ecs func TestSkipEnrichmentLogs(t *testing.T) { testCases := []struct { From 901a626097657d8b707df46d9f3b16b8234a8ccd Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Wed, 15 Apr 2026 13:04:46 -0400 Subject: [PATCH 14/15] fix: capture legacy attributes and match apm-data fallbacks --- .../internal/ecs/ecs_translation.go | 21 ++++++ .../internal/ecs/ecs_translation_test.go | 35 ++++++--- .../internal/enrichments/span.go | 72 +++++++++++++++++-- 3 files changed, 113 insertions(+), 15 deletions(-) diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go index 2993f2e8c..e8b282221 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go @@ -24,6 +24,7 @@ import ( "github.com/elastic/opentelemetry-collector-components/processor/elasticapmprocessor/internal/sanitize" "go.opentelemetry.io/collector/pdata/pcommon" semconv12 "go.opentelemetry.io/otel/semconv/v1.12.0" + semconv16 "go.opentelemetry.io/otel/semconv/v1.16.0" semconv25 "go.opentelemetry.io/otel/semconv/v1.25.0" semconv26 "go.opentelemetry.io/otel/semconv/v1.26.0" semconv "go.opentelemetry.io/otel/semconv/v1.27.0" @@ -209,6 +210,11 @@ func TranslateSpanAttributes(attributes pcommon.Map) { case elasticattr.DataStreamDataset, elasticattr.DataStreamNamespace, elasticattr.DataStreamType, + elasticattr.ServiceTargetName, + elasticattr.ServiceTargetType, + elasticattr.SpanDestinationServiceName, + elasticattr.SpanDestinationServiceType, + elasticattr.SpanDestinationServiceResource, // miscellaneous elasticattr.EventOutcome, elasticattr.ProcessorEvent, @@ -240,15 +246,20 @@ func TranslateSpanAttributes(attributes pcommon.Map) { string(semconv25.HTTPURLKey), string(semconv25.HTTPUserAgentKey), // messaging.* + string(semconv16.MessagingDestinationKey), string(semconv25.MessagingDestinationNameKey), string(semconv25.MessagingDestinationTemporaryKey), string(semconv25.MessagingOperationKey), + string(semconv.MessagingOperationTypeKey), string(semconv37.MessagingOperationNameKey), string(semconv25.MessagingSystemKey), // net.* string(semconv25.NetHostNameKey), string(semconv25.NetPeerNameKey), string(semconv25.NetPeerPortKey), + string(semconv12.NetPeerIPKey), + string(semconv25.NetSockPeerAddrKey), + string(semconv.NetworkPeerAddressKey), // network.* string(semconv.NetworkCarrierIccKey), string(semconv.NetworkCarrierMccKey), @@ -281,6 +292,16 @@ func TranslateSpanAttributes(attributes pcommon.Map) { string(semconv.UserAgentOriginalKey), string(semconv.UserAgentVersionKey): return true + case "span.kind", + "message_bus.destination", + "peer.address", + "peer.port", + "peer.hostname", + "peer.ipv4", + "peer.ipv6": + // remove redundant aliases + attributes.Remove(k) + return true default: fallbackToLabelAttribute(attributes, k, v) return true diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go index 61788f452..a8d0ef594 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation_test.go @@ -430,20 +430,30 @@ func TestTranslateSpanAttributes(t *testing.T) { attrs.PutStr("db.query.text", "SELECT * FROM users") attrs.PutStr(elasticattr.EventOutcome, "unknown") attrs.PutStr(elasticattr.ProcessorEvent, "transaction") + attrs.PutStr(elasticattr.ServiceTargetName, "api.example.com:443") + attrs.PutStr(elasticattr.ServiceTargetType, "http") + attrs.PutStr(elasticattr.SpanDestinationServiceName, "https://api.example.com") + attrs.PutStr(elasticattr.SpanDestinationServiceType, "external") + attrs.PutStr(elasticattr.SpanDestinationServiceResource, "api.example.com:443") attrs.PutStr("session.id", "session-123") attrs.PutStr(string(semconv.NetworkConnectionTypeKey), "wifi") attrs.PutStr(elasticattr.DataStreamDataset, "apm") }, want: map[string]any{ - "http.request.method": "GET", - "http.response.status_code": int64(200), - "http.url": "https://api.example.com/users", - "db.query.text": "SELECT * FROM users", - elasticattr.EventOutcome: "unknown", - elasticattr.ProcessorEvent: "transaction", - "session.id": "session-123", - string(semconv.NetworkConnectionTypeKey): "wifi", - elasticattr.DataStreamDataset: "apm", + "http.request.method": "GET", + "http.response.status_code": int64(200), + "http.url": "https://api.example.com/users", + "db.query.text": "SELECT * FROM users", + elasticattr.EventOutcome: "unknown", + elasticattr.ProcessorEvent: "transaction", + elasticattr.ServiceTargetName: "api.example.com:443", + elasticattr.ServiceTargetType: "http", + elasticattr.SpanDestinationServiceName: "https://api.example.com", + elasticattr.SpanDestinationServiceType: "external", + elasticattr.SpanDestinationServiceResource: "api.example.com:443", + "session.id": "session-123", + string(semconv.NetworkConnectionTypeKey): "wifi", + elasticattr.DataStreamDataset: "apm", }, }, { @@ -501,6 +511,13 @@ func TestTranslateSpanAttributes(t *testing.T) { }, wantAbsent: []string{"http.request"}, }, + { + name: "span kind dropped", + setAttrs: func(attrs pcommon.Map) { + attrs.PutStr("span.kind", "client") + }, + wantAbsent: []string{"span.kind"}, + }, } for _, tc := range tests { diff --git a/processor/elasticapmprocessor/internal/enrichments/span.go b/processor/elasticapmprocessor/internal/enrichments/span.go index 789cc8b0f..478ff2f44 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span.go +++ b/processor/elasticapmprocessor/internal/enrichments/span.go @@ -27,12 +27,14 @@ import ( "net/http" "net/url" "strconv" + "strings" "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling" "github.com/ua-parser/uap-go/uaparser" "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/ptrace" semconv12 "go.opentelemetry.io/otel/semconv/v1.12.0" + semconv16 "go.opentelemetry.io/otel/semconv/v1.16.0" semconv25 "go.opentelemetry.io/otel/semconv/v1.25.0" semconv27 "go.opentelemetry.io/otel/semconv/v1.27.0" semconv37 "go.opentelemetry.io/otel/semconv/v1.37.0" @@ -81,6 +83,8 @@ type spanEnrichmentContext struct { processorEvent string eventOutcome string peerService string + peerAddress string + netPeerIP string httpHost string serverAddress string httpTarget string @@ -146,6 +150,11 @@ func (s *spanEnrichmentContext) Enrich( func (s *spanEnrichmentContext) capture(span ptrace.Span) { s.spanStatusCode = span.Status().Code() + // Keep alias decoding aligned with apm-data's OTLP span translator so later + // enrichment decisions are driven by the same normalized inputs, especially + // for older db.*, peer.*, and messaging.* variants: + // https://github.com/elastic/apm-data/blob/26adeeef7f92ba5e01e59fb9e4c735fb8c31b58e/input/otlp/traces.go#L686-L712 + // https://github.com/elastic/apm-data/blob/26adeeef7f92ba5e01e59fb9e4c735fb8c31b58e/input/otlp/traces.go#L769-L785 span.Attributes().Range(func(k string, v pcommon.Value) bool { switch k { case elasticattr.ProcessorEvent: @@ -161,7 +170,7 @@ func (s *spanEnrichmentContext) capture(span ptrace.Span) { s.serverAddress = v.Str() case string(semconv25.ServerPortKey): s.serverPort = v.Int() - case string(semconv25.NetPeerNameKey): + case string(semconv25.NetPeerNameKey), "peer.hostname": if s.serverAddress == "" { // net.peer.name is deprecated, so has lower priority // only set when not already set with server.address @@ -175,17 +184,35 @@ func (s *spanEnrichmentContext) capture(span ptrace.Span) { // allowed to be overridden by server.port. s.serverPort = v.Int() } - case string(semconv25.MessagingDestinationNameKey): + case string(semconv12.NetPeerIPKey), + string(semconv25.NetSockPeerAddrKey), + string(semconv27.NetworkPeerAddressKey), + "peer.ipv4", + "peer.ipv6": + s.netPeerIP = v.Str() + case "peer.address": + s.peerAddress = v.Str() + case "peer.port": + // fallback if not already set + if s.serverPort == 0 { + s.serverPort = v.Int() + } + case string(semconv16.MessagingDestinationKey), + string(semconv25.MessagingDestinationNameKey), + "message_bus.destination": s.isMessaging = true s.messagingDestinationName = v.Str() case string(semconv25.MessagingOperationKey), - string(semconv37.MessagingOperationNameKey): + string(semconv27.MessagingOperationTypeKey): s.isMessaging = true s.messagingOperation = v.Str() + case string(semconv37.MessagingOperationNameKey): + s.isMessaging = true case string(semconv25.MessagingSystemKey): s.isMessaging = true s.messagingSystem = v.Str() - case string(semconv25.MessagingDestinationTemporaryKey): + case string(semconv16.MessagingTempDestinationKey), + string(semconv25.MessagingDestinationTemporaryKey): s.isMessaging = true s.messagingDestinationTemp = true case string(semconv25.HTTPStatusCodeKey), @@ -239,12 +266,23 @@ func (s *spanEnrichmentContext) capture(span ptrace.Span) { case string(semconv25.DBStatementKey), string(semconv25.DBUserKey), string(semconv37.DBQueryTextKey): s.isDB = true - case string(semconv25.DBNameKey), string(semconv37.DBNamespaceKey): + case string(semconv25.DBNameKey), + string(semconv37.DBNamespaceKey), + "db.instance", + "db.elasticsearch.cluster.name": s.isDB = true s.dbName = v.Str() - case string(semconv25.DBSystemKey), string(semconv37.DBSystemNameKey): + case string(semconv25.DBSystemKey), + string(semconv37.DBSystemNameKey), + "db.type": s.isDB = true s.dbSystem = v.Str() + case "sql.query": + s.isDB = true + // fallback if not already set + if s.dbSystem == "" { + s.dbSystem = "sql" + } case string(semconv27.GenAISystemKey), string(semconv37.GenAIProviderNameKey): s.isGenAi = true s.genAiSystem = v.Str() @@ -421,6 +459,17 @@ func (s *spanEnrichmentContext) normalizeAttributes(userAgentPraser *uaparser.Pa if s.rpcSystem == "" && s.grpcStatus != "" { s.rpcSystem = "grpc" } + if s.serverAddress == "" { + // apm-data treats peer.address as a hostname fallback only when it is not + // obviously a connection string / ip:port, then uses the normalized peer + // host and port when synthesizing HTTP URLs and destinations: + // https://github.com/elastic/apm-data/blob/26adeeef7f92ba5e01e59fb9e4c735fb8c31b58e/input/otlp/traces.go#L840-L878 + if s.netPeerIP != "" { + s.serverAddress = s.netPeerIP + } else if s.peerAddress != "" && (!strings.ContainsRune(s.peerAddress, ':') || net.ParseIP(s.peerAddress) != nil) { + s.serverAddress = s.peerAddress + } + } if s.urlFull == nil { s.urlFull = s.buildURLFromComponents() } @@ -536,10 +585,15 @@ func (s *spanEnrichmentContext) setMessageQueue(span ptrace.Span) { // This is another reason the attributes are deleted here to avoid special handling at export time. func (s *spanEnrichmentContext) removeMessagingAttrs(span ptrace.Span) { if s.isMessaging { + span.Attributes().Remove(string(semconv16.MessagingDestinationKey)) + span.Attributes().Remove("message_bus.destination") span.Attributes().Remove(string(semconv25.MessagingOperationKey)) + span.Attributes().Remove(string(semconv27.MessagingOperationTypeKey)) span.Attributes().Remove(string(semconv37.MessagingOperationNameKey)) span.Attributes().Remove(string(semconv25.MessagingSystemKey)) span.Attributes().Remove(string(semconv25.MessagingDestinationNameKey)) + span.Attributes().Remove(string(semconv16.MessagingTempDestinationKey)) + span.Attributes().Remove(string(semconv25.MessagingDestinationTemporaryKey)) } } @@ -612,6 +666,9 @@ func (s *spanEnrichmentContext) setServiceTarget(span ptrace.Span) { } case s.isHTTP: targetType = "http" + // For HTTP, apm-data derives service.target.name from the concrete URL/host, + // while span.destination.service.* may still preserve an explicit peer.service: + // https://github.com/elastic/apm-data/blob/26adeeef7f92ba5e01e59fb9e4c735fb8c31b58e/input/otlp/traces.go#L978-L1000 if details, ok := s.httpDestinationDetails(); ok && details.serviceTargetName != "" { targetName = details.serviceTargetName } else if resource := getHostPort( @@ -635,6 +692,9 @@ func (s *spanEnrichmentContext) setDestinationService(span ptrace.Span) { if s.peerService != "" { destnResource = s.peerService } + if s.peerAddress != "" { + destnResource = s.peerAddress + } switch { case s.isDB: From 2817f77ff81c06a9dd2d341dec415f5cf60c3dc3 Mon Sep 17 00:00:00 2001 From: Lanre Ade Date: Wed, 15 Apr 2026 13:36:36 -0400 Subject: [PATCH 15/15] fix: attribute remove, breaking test --- .../internal/ecs/ecs_translation.go | 6 +- .../internal/enrichments/span.go | 3 +- .../internal/enrichments/span_test.go | 161 ++++++++++++++++++ 3 files changed, 167 insertions(+), 3 deletions(-) diff --git a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go index e8b282221..47a3345a4 100644 --- a/processor/elasticapmprocessor/internal/ecs/ecs_translation.go +++ b/processor/elasticapmprocessor/internal/ecs/ecs_translation.go @@ -200,6 +200,7 @@ func TranslateLogRecordAttributes(attributes pcommon.Map) { // preserved, while unsupported attributes are moved to labels.* / // numeric_labels.* with a sanitized key. func TranslateSpanAttributes(attributes pcommon.Map) { + var attrsToDrop []string attributes.Range(func(k string, v pcommon.Value) bool { if sanitizeExistingLabelAttribute(attributes, k, v) { return true @@ -300,13 +301,16 @@ func TranslateSpanAttributes(attributes pcommon.Map) { "peer.ipv4", "peer.ipv6": // remove redundant aliases - attributes.Remove(k) + attrsToDrop = append(attrsToDrop, k) return true default: fallbackToLabelAttribute(attributes, k, v) return true } }) + for _, key := range attrsToDrop { + attributes.Remove(key) + } } // TranslateMetricDataPointAttributes applies the apm-data OTLP metric fallback diff --git a/processor/elasticapmprocessor/internal/enrichments/span.go b/processor/elasticapmprocessor/internal/enrichments/span.go index 478ff2f44..a1bb33d28 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span.go +++ b/processor/elasticapmprocessor/internal/enrichments/span.go @@ -203,11 +203,10 @@ func (s *spanEnrichmentContext) capture(span ptrace.Span) { s.isMessaging = true s.messagingDestinationName = v.Str() case string(semconv25.MessagingOperationKey), + string(semconv37.MessagingOperationNameKey), string(semconv27.MessagingOperationTypeKey): s.isMessaging = true s.messagingOperation = v.Str() - case string(semconv37.MessagingOperationNameKey): - s.isMessaging = true case string(semconv25.MessagingSystemKey): s.isMessaging = true s.messagingSystem = v.Str() diff --git a/processor/elasticapmprocessor/internal/enrichments/span_test.go b/processor/elasticapmprocessor/internal/enrichments/span_test.go index b2c61ec14..b405d3917 100644 --- a/processor/elasticapmprocessor/internal/enrichments/span_test.go +++ b/processor/elasticapmprocessor/internal/enrichments/span_test.go @@ -31,6 +31,7 @@ import ( "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/ptrace" semconv12 "go.opentelemetry.io/otel/semconv/v1.12.0" + semconv16 "go.opentelemetry.io/otel/semconv/v1.16.0" semconv25 "go.opentelemetry.io/otel/semconv/v1.25.0" semconv27 "go.opentelemetry.io/otel/semconv/v1.27.0" semconv37 "go.opentelemetry.io/otel/semconv/v1.37.0" @@ -845,10 +846,15 @@ func TestElasticTransactionEnrich(t *testing.T) { } // Remove messaging attributes if RemoveMessaging is enabled if tc.config.RemoveMessaging.Enabled { + expectedSpan.Attributes().Remove(string(semconv16.MessagingDestinationKey)) + expectedSpan.Attributes().Remove("message_bus.destination") expectedSpan.Attributes().Remove(string(semconv25.MessagingSystemKey)) expectedSpan.Attributes().Remove(string(semconv25.MessagingOperationKey)) + expectedSpan.Attributes().Remove(string(semconv27.MessagingOperationTypeKey)) expectedSpan.Attributes().Remove(string(semconv37.MessagingOperationNameKey)) expectedSpan.Attributes().Remove(string(semconv25.MessagingDestinationNameKey)) + expectedSpan.Attributes().Remove(string(semconv16.MessagingTempDestinationKey)) + expectedSpan.Attributes().Remove(string(semconv25.MessagingDestinationTemporaryKey)) } // Override span links if tc.expectedSpanLinks != nil { @@ -1573,6 +1579,46 @@ func TestElasticSpanEnrich(t *testing.T) { "url.original": "http://api.example.com/search?q=OpenTelemetry", }, }, + { + name: "http_span_peer_address_port_fallback", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.Status().SetCode(ptrace.StatusCodeOk) + span.Attributes().PutInt( + string(semconv25.HTTPResponseStatusCodeKey), + http.StatusOK, + ) + span.Attributes().PutStr(string(semconv25.HTTPTargetKey), "/search?q=OpenTelemetry") + span.Attributes().PutStr("peer.address", "api.example.com") + span.Attributes().PutInt("peer.port", 8080) + return span + }(), + config: func() config.ElasticSpanConfig { + cfg := config.Enabled().Span + cfg.TranslateUnsupportedAttributes.Enabled = true + return cfg + }(), + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "external", + elasticattr.SpanSubtype: "http", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + "destination.address": "api.example.com", + "destination.port": int64(8080), + elasticattr.ServiceTargetType: "http", + elasticattr.ServiceTargetName: "api.example.com:8080", + elasticattr.SpanDestinationServiceName: "http://api.example.com:8080", + elasticattr.SpanDestinationServiceType: "external", + elasticattr.SpanDestinationServiceResource: "api.example.com:8080", + "url.original": "http://api.example.com:8080/search?q=OpenTelemetry", + }, + removedAttrs: []string{"peer.address", "peer.port", string(semconv25.HTTPTargetKey)}, + }, { name: "http_span_legacy_http_host", input: func() ptrace.Span { @@ -1915,6 +1961,38 @@ func TestElasticSpanEnrich(t *testing.T) { elasticattr.SpanDestinationServiceResource: "testsvc", }, }, + { + name: "messaging_legacy_aliases", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.Attributes().PutStr(string(semconv25.MessagingSystemKey), "kafka") + span.Attributes().PutStr("message_bus.destination", "t1") + span.Attributes().PutStr(string(semconv27.MessagingOperationTypeKey), "publish") + return span + }(), + config: func() config.ElasticSpanConfig { + cfg := config.Enabled().Span + cfg.TranslateUnsupportedAttributes.Enabled = true + return cfg + }(), + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "messaging", + elasticattr.SpanSubtype: "kafka", + elasticattr.SpanAction: "publish", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + elasticattr.ServiceTargetType: "kafka", + elasticattr.ServiceTargetName: "t1", + elasticattr.SpanDestinationServiceResource: "kafka/t1", + elasticattr.SpanMessageQueueName: "t1", + }, + removedAttrs: []string{"message_bus.destination", string(semconv27.MessagingOperationTypeKey), string(semconv25.MessagingSystemKey)}, + }, { name: "messaging_with_existing_attributes_are_preserved", input: func() ptrace.Span { @@ -2001,6 +2079,33 @@ func TestElasticSpanEnrich(t *testing.T) { elasticattr.SpanMessageQueueName: "t1", }, }, + { + name: "messaging_legacy_temp_destination", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.Attributes().PutStr(string(semconv25.PeerServiceKey), "testsvc") + span.Attributes().PutBool(string(semconv16.MessagingTempDestinationKey), true) + span.Attributes().PutStr(string(semconv25.MessagingOperationKey), "receive") + span.Attributes().PutStr(string(semconv25.MessagingDestinationNameKey), "t1") + return span + }(), + config: config.Enabled().Span, + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "messaging", + elasticattr.SpanAction: "receive", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + elasticattr.ServiceTargetType: "messaging", + elasticattr.ServiceTargetName: "testsvc", + elasticattr.SpanDestinationServiceResource: "testsvc/t1", + elasticattr.SpanMessageQueueName: "t1", + }, + }, { name: "db_over_http", input: func() ptrace.Span { @@ -2505,6 +2610,57 @@ func TestElasticSpanEnrich(t *testing.T) { elasticattr.ServiceTargetName: "users", }, }, + { + name: "db_span_legacy_aliases", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("select users") + span.Attributes().PutStr("sql.query", "select * from users") + span.Attributes().PutStr("db.type", "postgresql") + span.Attributes().PutStr("db.instance", "users") + span.Attributes().PutStr("peer.hostname", "db.example.com") + span.Attributes().PutInt(string(semconv25.NetPeerPortKey), 5432) + return span + }(), + config: config.Enabled().Span, + enrichedAttrs: map[string]any{ + elasticattr.SpanDestinationServiceResource: "postgresql", + elasticattr.SpanType: "db", + elasticattr.SpanSubtype: "postgresql", + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.ProcessorEvent: "span", + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.ServiceTargetType: "postgresql", + elasticattr.ServiceTargetName: "users", + }, + }, + { + name: "db_span_elasticsearch_cluster_name_alias", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("select users") + span.Attributes().PutStr("db.elasticsearch.cluster.name", "cluster-a") + span.Attributes().PutStr("db.type", "elasticsearch") + return span + }(), + config: config.Enabled().Span, + enrichedAttrs: map[string]any{ + elasticattr.SpanDestinationServiceResource: "elasticsearch", + elasticattr.SpanType: "db", + elasticattr.SpanSubtype: "elasticsearch", + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.ProcessorEvent: "span", + elasticattr.EventOutcome: outcomeSuccess, + elasticattr.SuccessCount: int64(1), + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.ServiceTargetType: "elasticsearch", + elasticattr.ServiceTargetName: "cluster-a", + }, + }, { name: "db_span_after_db_stabilization_with_db_query_text", input: func() ptrace.Span { @@ -2549,10 +2705,15 @@ func TestElasticSpanEnrich(t *testing.T) { } // Remove messaging attributes if RemoveMessaging is enabled if enrichConfig.Transaction.RemoveMessaging.Enabled || enrichConfig.Span.RemoveMessaging.Enabled { + expectedSpan.Attributes().Remove(string(semconv16.MessagingDestinationKey)) + expectedSpan.Attributes().Remove("message_bus.destination") expectedSpan.Attributes().Remove(string(semconv25.MessagingSystemKey)) expectedSpan.Attributes().Remove(string(semconv25.MessagingOperationKey)) + expectedSpan.Attributes().Remove(string(semconv27.MessagingOperationTypeKey)) expectedSpan.Attributes().Remove(string(semconv37.MessagingOperationNameKey)) expectedSpan.Attributes().Remove(string(semconv25.MessagingDestinationNameKey)) + expectedSpan.Attributes().Remove(string(semconv16.MessagingTempDestinationKey)) + expectedSpan.Attributes().Remove(string(semconv25.MessagingDestinationTemporaryKey)) } // Override span links if tc.expectedSpanLinks != nil {