diff --git a/enrichments/trace/config/config.go b/enrichments/trace/config/config.go index bc18cbc..fb726f2 100644 --- a/enrichments/trace/config/config.go +++ b/enrichments/trace/config/config.go @@ -57,6 +57,7 @@ type ElasticTransactionConfig struct { Result AttributeConfig `mapstructure:"result"` EventOutcome AttributeConfig `mapstructure:"event_outcome"` InferredSpans AttributeConfig `mapstructure:"inferred_spans"` + UserAgent AttributeConfig `mapstructure:"user_agent"` } // ElasticSpanConfig configures the enrichment attributes for the spans @@ -75,6 +76,7 @@ type ElasticSpanConfig struct { ServiceTarget AttributeConfig `mapstructure:"service_target"` DestinationService AttributeConfig `mapstructure:"destination_service"` InferredSpans AttributeConfig `mapstructure:"inferred_spans"` + UserAgent AttributeConfig `mapstructure:"user_agent"` } // SpanEventConfig configures enrichment attributes for the span events. @@ -124,6 +126,7 @@ func Enabled() Config { EventOutcome: AttributeConfig{Enabled: true}, RepresentativeCount: AttributeConfig{Enabled: true}, InferredSpans: AttributeConfig{Enabled: true}, + UserAgent: AttributeConfig{Enabled: true}, }, Span: ElasticSpanConfig{ TimestampUs: AttributeConfig{Enabled: true}, @@ -136,6 +139,7 @@ func Enabled() Config { DestinationService: AttributeConfig{Enabled: true}, RepresentativeCount: AttributeConfig{Enabled: true}, InferredSpans: AttributeConfig{Enabled: true}, + UserAgent: AttributeConfig{Enabled: true}, }, SpanEvent: SpanEventConfig{ TimestampUs: AttributeConfig{Enabled: true}, diff --git a/enrichments/trace/internal/elastic/span.go b/enrichments/trace/internal/elastic/span.go index ca694f4..6d6f5f1 100644 --- a/enrichments/trace/internal/elastic/span.go +++ b/enrichments/trace/internal/elastic/span.go @@ -32,6 +32,7 @@ import ( "github.com/elastic/opentelemetry-lib/elasticattr" "github.com/elastic/opentelemetry-lib/enrichments/trace/config" + "github.com/ua-parser/uap-go/uaparser" "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/ptrace" semconv25 "go.opentelemetry.io/collector/semconv/v1.25.0" @@ -49,9 +50,13 @@ import ( // - Elastic spans, defined as all spans (including transactions). // However, for the enrichment logic spans are treated as a separate // entity i.e. all transactions are not enriched as spans and vice versa. -func EnrichSpan(span ptrace.Span, cfg config.Config) { +func EnrichSpan( + span ptrace.Span, + cfg config.Config, + userAgentParser *uaparser.Parser, +) { var c spanEnrichmentContext - c.Enrich(span, cfg) + c.Enrich(span, cfg, userAgentParser) } type spanEnrichmentContext struct { @@ -72,6 +77,13 @@ type spanEnrichmentContext struct { messagingDestinationName string genAiSystem string + // The inferred* attributes are derived from a base attribute + userAgentOriginal string + userAgentName string + userAgentVersion string + inferredUserAgentName string + inferredUserAgentVersion string + serverPort int64 urlPort int64 httpStatusCode int64 @@ -88,7 +100,11 @@ type spanEnrichmentContext struct { isGenAi bool } -func (s *spanEnrichmentContext) Enrich(span ptrace.Span, cfg config.Config) { +func (s *spanEnrichmentContext) Enrich( + span ptrace.Span, + cfg config.Config, + userAgentParser *uaparser.Parser, +) { // Extract top level span information. s.spanStatusCode = span.Status().Code() @@ -178,11 +194,17 @@ func (s *spanEnrichmentContext) Enrich(span ptrace.Span, cfg config.Config) { case semconv27.AttributeGenAiSystem: s.isGenAi = true s.genAiSystem = v.Str() + case semconv27.AttributeUserAgentOriginal: + s.userAgentOriginal = v.Str() + case semconv27.AttributeUserAgentName: + s.userAgentName = v.Str() + case semconv27.AttributeUserAgentVersion: + s.userAgentVersion = v.Str() } return true }) - s.normalizeAttributes() + s.normalizeAttributes(userAgentParser) s.isTransaction = isElasticTransaction(span) s.enrich(span, cfg) @@ -249,6 +271,9 @@ func (s *spanEnrichmentContext) enrichTransaction( if cfg.InferredSpans.Enabled { s.setInferredSpans(span) } + if cfg.UserAgent.Enabled { + s.setUserAgentIfRequired(span) + } } func (s *spanEnrichmentContext) enrichSpan( @@ -290,6 +315,9 @@ func (s *spanEnrichmentContext) enrichSpan( if cfg.ProcessorEvent.Enabled && !isExitRootSpan { span.Attributes().PutStr(elasticattr.ProcessorEvent, "span") } + if cfg.UserAgent.Enabled { + s.setUserAgentIfRequired(span) + } if isExitRootSpan && transactionTypeEnabled { if spanType != "" { @@ -304,10 +332,15 @@ func (s *spanEnrichmentContext) enrichSpan( // normalizeAttributes sets any dependent attributes that // might not have been explicitly set as an attribute. -func (s *spanEnrichmentContext) normalizeAttributes() { +func (s *spanEnrichmentContext) normalizeAttributes(userAgentPraser *uaparser.Parser) { if s.rpcSystem == "" && s.grpcStatus != "" { s.rpcSystem = "grpc" } + if s.userAgentOriginal != "" && userAgentPraser != nil { + ua := userAgentPraser.ParseUserAgent(s.userAgentOriginal) + s.inferredUserAgentName = ua.Family + s.inferredUserAgentVersion = ua.ToVersionString() + } } func (s *spanEnrichmentContext) getSampled() bool { @@ -520,6 +553,15 @@ func (s *spanEnrichmentContext) setInferredSpans(span ptrace.Span) { } } +func (s *spanEnrichmentContext) setUserAgentIfRequired(span ptrace.Span) { + if s.userAgentName == "" && s.inferredUserAgentName != "" { + span.Attributes().PutStr(semconv27.AttributeUserAgentName, s.inferredUserAgentName) + } + if s.userAgentVersion == "" && s.inferredUserAgentVersion != "" { + span.Attributes().PutStr(semconv27.AttributeUserAgentVersion, s.inferredUserAgentVersion) + } +} + type spanEventEnrichmentContext struct { exceptionType string exceptionMessage string diff --git a/enrichments/trace/internal/elastic/span_test.go b/enrichments/trace/internal/elastic/span_test.go index 1b03335..6e538fe 100644 --- a/enrichments/trace/internal/elastic/span_test.go +++ b/enrichments/trace/internal/elastic/span_test.go @@ -29,6 +29,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest/ptracetest" "github.com/stretchr/testify/assert" + "github.com/ua-parser/uap-go/uaparser" "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/ptrace" semconv25 "go.opentelemetry.io/collector/semconv/v1.25.0" @@ -364,6 +365,63 @@ func TestElasticTransactionEnrich(t *testing.T) { return &spanLinks }(), }, + { + name: "user_agent_parse_name_version", + input: func() ptrace.Span { + span := getElasticTxn() + span.SetName("testtxn") + span.Attributes().PutStr(semconv27.AttributeUserAgentOriginal, "Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0") + return span + }(), + config: config.Enabled().Transaction, + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.TransactionSampled: true, + elasticattr.TransactionRoot: true, + elasticattr.TransactionID: "0100000000000000", + elasticattr.TransactionName: "testtxn", + elasticattr.ProcessorEvent: "transaction", + elasticattr.TransactionRepresentativeCount: float64(1), + elasticattr.TransactionDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: "success", + elasticattr.SuccessCount: int64(1), + elasticattr.TransactionResult: "Success", + elasticattr.TransactionType: "unknown", + semconv27.AttributeUserAgentName: "Firefox", + semconv27.AttributeUserAgentVersion: "126.0", + }, + }, + { + name: "user_agent_no_override", + input: func() ptrace.Span { + span := getElasticTxn() + span.SetName("testtxn") + span.Attributes().PutStr(semconv27.AttributeUserAgentOriginal, "Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0") + // In practical situations the user_agent.{name, version} should be derived from the + // original user agent, however, for testing we are setting different values. + span.Attributes().PutStr(semconv27.AttributeUserAgentName, "Chrome") + span.Attributes().PutStr(semconv27.AttributeUserAgentVersion, "51.0.2704") + return span + }(), + config: config.Enabled().Transaction, + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.TransactionSampled: true, + elasticattr.TransactionRoot: true, + elasticattr.TransactionID: "0100000000000000", + elasticattr.TransactionName: "testtxn", + elasticattr.ProcessorEvent: "transaction", + elasticattr.TransactionRepresentativeCount: float64(1), + elasticattr.TransactionDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: "success", + elasticattr.SuccessCount: int64(1), + elasticattr.TransactionResult: "Success", + elasticattr.TransactionType: "unknown", + // If user_agent.{name, version} are already set then don't override them. + semconv27.AttributeUserAgentName: "Chrome", + semconv27.AttributeUserAgentVersion: "51.0.2704", + }, + }, } { t.Run(tc.name, func(t *testing.T) { expectedSpan := ptrace.NewSpan() @@ -382,7 +440,7 @@ func TestElasticTransactionEnrich(t *testing.T) { EnrichSpan(tc.input, config.Config{ Transaction: tc.config, - }) + }, uaparser.NewFromSaved()) assert.NoError(t, ptracetest.CompareSpan(expectedSpan, tc.input)) }) } @@ -528,7 +586,7 @@ func TestRootSpanAsDependencyEnrich(t *testing.T) { expectedSpan.Links().RemoveIf(func(_ ptrace.SpanLink) bool { return true }) } - EnrichSpan(tc.input, tc.config) + EnrichSpan(tc.input, tc.config, uaparser.NewFromSaved()) assert.NoError(t, ptracetest.CompareSpan(expectedSpan, tc.input)) }) } @@ -1111,6 +1169,57 @@ func TestElasticSpanEnrich(t *testing.T) { elasticattr.SpanDestinationServiceResource: "myService", }, }, + { + name: "user_agent_parse_name_version", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.SetSpanID([8]byte{1}) + span.Attributes().PutStr(semconv27.AttributeUserAgentOriginal, "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: config.Enabled().Span, + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.SpanName: "testspan", + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "unknown", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: "success", + elasticattr.SuccessCount: int64(1), + semconv27.AttributeUserAgentName: "Mobile Safari", + semconv27.AttributeUserAgentVersion: "13.1.1", + }, + }, + { + name: "user_agent_no_override", + input: func() ptrace.Span { + span := getElasticSpan() + span.SetName("testspan") + span.SetSpanID([8]byte{1}) + span.Attributes().PutStr(semconv27.AttributeUserAgentOriginal, "Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0") + // In practical situations the user_agent.{name, version} should be derived from the + // original user agent, however, for testing we are setting different values. + span.Attributes().PutStr(semconv27.AttributeUserAgentName, "Chrome") + span.Attributes().PutStr(semconv27.AttributeUserAgentVersion, "51.0.2704") + return span + }(), + config: config.Enabled().Span, + enrichedAttrs: map[string]any{ + elasticattr.TimestampUs: startTs.AsTime().UnixMicro(), + elasticattr.SpanName: "testspan", + elasticattr.ProcessorEvent: "span", + elasticattr.SpanRepresentativeCount: float64(1), + elasticattr.SpanType: "unknown", + elasticattr.SpanDurationUs: expectedDuration.Microseconds(), + elasticattr.EventOutcome: "success", + elasticattr.SuccessCount: int64(1), + // If user_agent.{name, version} are already set then don't override them. + semconv27.AttributeUserAgentName: "Chrome", + semconv27.AttributeUserAgentVersion: "51.0.2704", + }, + }, } { t.Run(tc.name, func(t *testing.T) { expectedSpan := ptrace.NewSpan() @@ -1129,7 +1238,7 @@ func TestElasticSpanEnrich(t *testing.T) { EnrichSpan(tc.input, config.Config{ Span: tc.config, - }) + }, uaparser.NewFromSaved()) assert.NoError(t, ptracetest.CompareSpan(expectedSpan, tc.input)) }) } @@ -1235,7 +1344,7 @@ func TestSpanEventEnrich(t *testing.T) { tc.input.MoveTo(tc.parent.Events().AppendEmpty()) EnrichSpan(tc.parent, config.Config{ SpanEvent: tc.config, - }) + }, uaparser.NewFromSaved()) actual := tc.parent.Events().At(0).Attributes() errorID, ok := actual.Get(elasticattr.ErrorID) diff --git a/enrichments/trace/trace.go b/enrichments/trace/trace.go index f9271da..af0e858 100644 --- a/enrichments/trace/trace.go +++ b/enrichments/trace/trace.go @@ -20,19 +20,25 @@ package trace import ( "github.com/elastic/opentelemetry-lib/enrichments/trace/config" "github.com/elastic/opentelemetry-lib/enrichments/trace/internal/elastic" + "github.com/ua-parser/uap-go/uaparser" "go.opentelemetry.io/collector/pdata/ptrace" ) // Enricher enriches the OTel traces with attributes required to power // functionalities in the Elastic UI. type Enricher struct { + // If there are more than one parser in the future we should consider + // abstracting the parsers in a separate internal package. + userAgentParser *uaparser.Parser + Config config.Config } // NewEnricher creates a new instance of Enricher. func NewEnricher(cfg config.Config) *Enricher { return &Enricher{ - Config: cfg, + Config: cfg, + userAgentParser: uaparser.NewFromSaved(), } } @@ -51,7 +57,7 @@ func (e *Enricher) Enrich(pt ptrace.Traces) { elastic.EnrichScope(scopeSpan.Scope(), e.Config) spans := scopeSpan.Spans() for k := 0; k < spans.Len(); k++ { - elastic.EnrichSpan(spans.At(k), e.Config) + elastic.EnrichSpan(spans.At(k), e.Config, e.userAgentParser) } } } diff --git a/go.mod b/go.mod index cd66bf3..6a650d6 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/open-telemetry/opentelemetry-collector-contrib/pkg/golden v0.121.0 github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest v0.121.0 github.com/stretchr/testify v1.10.0 + github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 go.opentelemetry.io/collector/component v1.27.0 go.opentelemetry.io/collector/component/componenttest v0.121.0 go.opentelemetry.io/collector/config/configcompression v1.27.0 @@ -35,6 +36,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect @@ -66,5 +68,6 @@ require ( golang.org/x/text v0.22.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/yaml.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index abbdf8c..733ddcc 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -74,6 +76,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 h1:rB0J+hLNltG1Qv+UF+MkdFz89XMps5BOAFJN4xWjc+s= +github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90/go.mod h1:BUbeWZiieNxAuuADTBNb3/aeje6on3DhU3rpWsQSB1E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -170,5 +174,7 @@ google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojt gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=