From f44316ded5e1dcebae1b4409c5fbcc15d1b744e1 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Wed, 20 May 2026 11:28:27 +0100 Subject: [PATCH 1/4] Bug fixes and optimisations for the otel bridge The main fix is ensuring we dedupe certain libraries when the instrumentation library is present in the application. --- .gitignore | 5 +- src/Elastic.Apm/AgentComponents.cs | 4 +- .../DiagnosticListeners/KnownListeners.cs | 2 +- .../OpenTelemetry/ElasticActivityListener.cs | 594 ++++++++---------- .../OpenTelemetry/OTelActivityMapper.cs | 242 +++++++ .../OpenTelemetry/SemanticConventions.cs | 3 + .../OpenTelemetryTests.cs | 315 ++++++++++ 7 files changed, 831 insertions(+), 334 deletions(-) create mode 100644 src/Elastic.Apm/OpenTelemetry/OTelActivityMapper.cs diff --git a/.gitignore b/.gitignore index e7a9cbc81..76dc73cfa 100644 --- a/.gitignore +++ b/.gitignore @@ -358,4 +358,7 @@ test/Elastic.Apm.Feature.Tests/SpecFlow/userid # Azurite __azurite_* -__blobstorage__ \ No newline at end of file +__blobstorage__ + +# C# Dev Kit +*.lscache diff --git a/src/Elastic.Apm/AgentComponents.cs b/src/Elastic.Apm/AgentComponents.cs index 65526201e..cdeac9627 100644 --- a/src/Elastic.Apm/AgentComponents.cs +++ b/src/Elastic.Apm/AgentComponents.cs @@ -65,7 +65,7 @@ internal AgentComponents( // Initialize early because ServerInfoCallback requires it and might execute // before EnsureElasticActivityStarted runs #if NET || NETSTANDARD2_1 - ElasticActivityListener = new ElasticActivityListener(this, HttpTraceConfiguration); + ElasticActivityListener = new ElasticActivityListener(this); // Ensure we have a listener so that transaction activities are created when the OTel bridge is disabled if (!Configuration.OpenTelemetryBridgeEnabled && !Transaction.ElasticApmActivitySource.HasListeners()) @@ -251,7 +251,7 @@ internal static IApmLogger GetGlobalLogger(IApmLogger fallbackLogger, LogLevel a } #if NET || NETSTANDARD2_1 - private ElasticActivityListener ElasticActivityListener { get; } + internal ElasticActivityListener ElasticActivityListener { get; } #endif internal ICentralConfigurationFetcher CentralConfigurationFetcher { get; } diff --git a/src/Elastic.Apm/DiagnosticListeners/KnownListeners.cs b/src/Elastic.Apm/DiagnosticListeners/KnownListeners.cs index 3711ed0c7..0038b4d62 100644 --- a/src/Elastic.Apm/DiagnosticListeners/KnownListeners.cs +++ b/src/Elastic.Apm/DiagnosticListeners/KnownListeners.cs @@ -15,7 +15,7 @@ internal static class KnownListeners public const string SystemNetHttpDesktopHttpRequestOut = "System.Net.Http.Desktop.HttpRequestOut"; public const string ApmTransactionActivityName = "ElasticApm.Transaction"; - public static HashSet SkippedActivityNamesSet => + public static readonly HashSet SkippedActivityNamesSet = [ MicrosoftAspNetCoreHostingHttpRequestIn, SystemNetHttpHttpRequestOut, diff --git a/src/Elastic.Apm/OpenTelemetry/ElasticActivityListener.cs b/src/Elastic.Apm/OpenTelemetry/ElasticActivityListener.cs index c0081933d..25dcb0452 100644 --- a/src/Elastic.Apm/OpenTelemetry/ElasticActivityListener.cs +++ b/src/Elastic.Apm/OpenTelemetry/ElasticActivityListener.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Runtime.CompilerServices; using Elastic.Apm.Api; using Elastic.Apm.DiagnosticListeners; @@ -20,110 +19,236 @@ namespace Elastic.Apm.OpenTelemetry { public class ElasticActivityListener : IDisposable { - private static readonly string[] ServerPortAttributeKeys = [SemanticConventions.ServerPort, SemanticConventions.NetPeerPort]; - - private static readonly string[] ServerAddressAttributeKeys = - [SemanticConventions.ServerAddress, SemanticConventions.NetPeerName, SemanticConventions.NetPeerIp]; - - private static readonly string[] HttpAttributeKeys = - [SemanticConventions.UrlFull, SemanticConventions.HttpUrl, SemanticConventions.HttpScheme]; - - private static readonly string[] HttpUrlAttributeKeys = [SemanticConventions.UrlFull, SemanticConventions.HttpUrl]; - private readonly ConditionalWeakTable _activeSpans = new(); private readonly ConditionalWeakTable _activeTransactions = new(); - - internal ElasticActivityListener(IApmAgent agent, HttpTraceConfiguration httpTraceConfiguration) => (_logger, _httpTraceConfiguration) = - (agent.Logger?.Scoped(nameof(ElasticActivityListener)), httpTraceConfiguration); - - private static readonly bool HasServiceBusInstrumentation = - AppDomain.CurrentDomain.GetAssemblies().SingleOrDefault(assembly => - assembly.GetName().Name == "Elastic.Apm.Azure.ServiceBus") != null; - - private static readonly bool HasStorageInstrumentation = - AppDomain.CurrentDomain.GetAssemblies().SingleOrDefault(assembly => - assembly.GetName().Name == "Elastic.Apm.Azure.Storage") != null; - + private readonly IApmAgent _agent; private readonly IApmLogger _logger; - private Tracer _tracer; - private readonly HttpTraceConfiguration _httpTraceConfiguration; + private volatile Tracer _tracer; + private ActivityListener _listener; + + private volatile bool _hasServiceBusInstrumentation; + private volatile bool _hasStorageInstrumentation; + private volatile bool _hasCosmosDbInstrumentation; + private volatile bool _hasMongoDbInstrumentation; + private volatile bool _hasGrpcClientInstrumentation; private bool _disposed; + internal ElasticActivityListener(IApmAgent agent) + { + _agent = agent; + _logger = agent.Logger?.Scoped(nameof(ElasticActivityListener)); + } + internal void Start(Tracer tracerInternal) { _tracer = tracerInternal; - Listener = new ActivityListener + if (_listener != null) + return; + + // Subscribe before scanning to avoid missing a load that races with the initial scan + AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoad; + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + CheckAssembly(assembly.GetName().Name); + + _logger?.Debug()?.Log( + "ElasticActivityListener started. Detected instrumentation packages: ServiceBus={ServiceBus}, Storage={Storage}, " + + "CosmosDb={CosmosDb}, MongoDb={MongoDb}, GrpcClient={GrpcClient}", + _hasServiceBusInstrumentation, _hasStorageInstrumentation, _hasCosmosDbInstrumentation, + _hasMongoDbInstrumentation, _hasGrpcClientInstrumentation); + + _listener = new ActivityListener { - ActivityStarted = ActivityStarted, - ActivityStopped = ActivityStopped, + ActivityStarted = OnActivityStarted, + ActivityStopped = OnActivityStopped, ShouldListenTo = _ => true, Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData }; - ActivitySource.AddActivityListener(Listener); + ActivitySource.AddActivityListener(_listener); } - private ActivityListener Listener { get; set; } + private void OnAssemblyLoad(object sender, AssemblyLoadEventArgs args) => + CheckAssembly(args.LoadedAssembly.GetName().Name); - private Action ActivityStarted => - activity => + internal void CheckAssembly(string name) + { + if (name == "Elastic.Apm.Azure.ServiceBus") { - // Prevent recording of Azure Functions activities which are quite broken at the moment - // See https://github.com/Azure/azure-functions-dotnet-worker/issues/2733 - // See https://github.com/Azure/azure-functions-dotnet-worker/issues/2875 - // See https://github.com/Azure/azure-functions-host/issues/10641 - // See https://github.com/Azure/azure-functions-dotnet-worker/issues/2810 - if ((activity.Source.Name == "" && activity.DisplayName == "InvokeFunctionAsync") - || (activity.Source.Name == "Microsoft.Azure.Functions.Worker")) - { - return; - } + _hasServiceBusInstrumentation = true; + _logger?.Debug()?.Log("Detected 'Elastic.Apm.Azure.ServiceBus' — 'Microsoft.ServiceBus' activities will be skipped by the OTel bridge."); + } + else if (name == "Elastic.Apm.Azure.Storage") + { + _hasStorageInstrumentation = true; + _logger?.Debug()?.Log("Detected 'Elastic.Apm.Azure.Storage' — 'Microsoft.Storage' activities will be skipped by the OTel bridge."); + } + else if (name == "Elastic.Apm.Azure.CosmosDb") + { + _hasCosmosDbInstrumentation = true; + _logger?.Debug()?.Log("Detected 'Elastic.Apm.Azure.CosmosDb' — 'Microsoft.DocumentDB' activities will be skipped by the OTel bridge."); + } + else if (name == "Elastic.Apm.MongoDb") + { + _hasMongoDbInstrumentation = true; + _logger?.Debug()?.Log("Detected 'Elastic.Apm.MongoDb' — 'MongoDB.Driver' activities will be skipped by the OTel bridge."); + } + else if (name == "Elastic.Apm.GrpcClient") + { + _hasGrpcClientInstrumentation = true; + _logger?.Debug()?.Log("Detected 'Elastic.Apm.GrpcClient' — 'Grpc.Net.Client' activities will be skipped by the OTel bridge."); + } + } - // If the Elastic instrumentation for ServiceBus is present, we skip duplicating the instrumentation through the OTel bridge. - // Without this, we end up with some redundant spans in the trace with subtle differences. - if (HasServiceBusInstrumentation && activity.Tags.Any(kvp => - kvp.Key.Equals("az.namespace", StringComparison.Ordinal) && kvp.Value.Equals("Microsoft.ServiceBus", StringComparison.Ordinal))) - { - _logger?.Debug()?.Log("ActivityStarted: name:{DisplayName} id:{ActivityId} traceId:{TraceId} skipped 'Microsoft.ServiceBus' " + - "activity because 'Elastic.Apm.Azure.ServiceBus' is present in the application.", - activity.DisplayName, activity.Id, activity.TraceId); + private void OnActivityStarted(Activity activity) + { + if (_tracer == null) + return; - return; - } + var config = _agent.Configuration; + if (!config.Enabled || !config.Recording) + return; - if (HasStorageInstrumentation && activity.Tags.Any(kvp => - kvp.Key.Equals("az.namespace", StringComparison.Ordinal) && kvp.Value.Equals("Microsoft.Storage", StringComparison.Ordinal))) - { - _logger?.Debug()?.Log("ActivityStarted: name:{DisplayName} id:{ActivityId} traceId:{TraceId} skipped 'Microsoft.Storage' " + - "activity because 'Elastic.Apm.Azure.Storage' is present in the application.", - activity.DisplayName, activity.Id, activity.TraceId); + if (ShouldSkipActivity(activity, out var skipReason)) + { + LogSkippedActivity(activity, skipReason, onStart: true); + return; + } - return; - } + _logger?.Trace()?.Log("ActivityStarted: name:{DisplayName} id:{ActivityId} traceId:{TraceId}", + activity.DisplayName, activity.Id, activity.TraceId); + + List spanLinks = null; + foreach (var link in activity.Links) + { + spanLinks ??= []; + spanLinks.Add(new SpanLink(link.Context.SpanId.ToString(), link.Context.TraceId.ToString())); + } + + var timestamp = TimeUtils.ToTimestamp(activity.StartTimeUtc); + if (!CreateTransactionForActivity(activity, timestamp, spanLinks)) + CreateSpanForActivity(activity, timestamp, spanLinks); + } + + /// + /// Central policy for activities the OTel bridge must not capture (dedup, known listeners, broken upstream sources). + /// Used by both and so start/stop stay symmetric. + /// + private bool ShouldSkipActivity(Activity activity, out ActivitySkipReason skipReason) + { + skipReason = ActivitySkipReason.None; + + // Prevent recording of Azure Functions activities which are quite broken at the moment + // See https://github.com/Azure/azure-functions-dotnet-worker/issues/2733 + // See https://github.com/Azure/azure-functions-dotnet-worker/issues/2875 + // See https://github.com/Azure/azure-functions-host/issues/10641 + // See https://github.com/Azure/azure-functions-dotnet-worker/issues/2810 + if ((activity.Source.Name == "" && activity.DisplayName == "InvokeFunctionAsync") + || activity.Source.Name == "Microsoft.Azure.Functions.Worker") + { + skipReason = ActivitySkipReason.AzureFunctions; + return true; + } - if (KnownListeners.SkippedActivityNamesSet.Contains(activity.OperationName)) + if (KnownListeners.SkippedActivityNamesSet.Contains(activity.OperationName)) + { + skipReason = ActivitySkipReason.KnownListener; + return true; + } + + // If the Elastic instrumentation for an Azure service is present, skip duplicating through the OTel bridge. + if (_hasServiceBusInstrumentation || _hasStorageInstrumentation || _hasCosmosDbInstrumentation) + { + OTelActivityMapper.TryGetStringValue(activity, SemanticConventions.AzNamespace, out var azNamespace); + + if (_hasServiceBusInstrumentation && azNamespace == "Microsoft.ServiceBus") { - _logger?.Trace()?.Log("ActivityStarted: name:{DisplayName} id:{ActivityId} traceId:{TraceId} skipped because it matched " + - "a skipped activity name defined in KnownListeners.", activity.DisplayName, activity.Id, activity.TraceId); - return; + skipReason = ActivitySkipReason.ServiceBusDedup; + return true; } - _logger?.Trace()?.Log("ActivityStarted: name:{DisplayName} id:{ActivityId} traceId:{TraceId}", - activity.DisplayName, activity.Id, activity.TraceId); + if (_hasStorageInstrumentation && azNamespace == "Microsoft.Storage") + { + skipReason = ActivitySkipReason.StorageDedup; + return true; + } - var spanLinks = new List(activity.Links.Count()); - if (activity.Links.Any()) + if (_hasCosmosDbInstrumentation && azNamespace == "Microsoft.DocumentDB") { - foreach (var link in activity.Links) - spanLinks.Add(new SpanLink(link.Context.SpanId.ToString(), link.Context.TraceId.ToString())); + skipReason = ActivitySkipReason.CosmosDbDedup; + return true; } + } - var timestamp = TimeUtils.ToTimestamp(activity.StartTimeUtc); - if (!CreateTransactionForActivity(activity, timestamp, spanLinks)) - CreateSpanForActivity(activity, timestamp, spanLinks); - }; + if (_hasMongoDbInstrumentation && activity.Source.Name == "MongoDB.Driver") + { + skipReason = ActivitySkipReason.MongoDbDedup; + return true; + } + + if (_hasGrpcClientInstrumentation && activity.Source.Name == "Grpc.Net.Client") + { + skipReason = ActivitySkipReason.GrpcClientDedup; + return true; + } + + return false; + } + + private void LogSkippedActivity(Activity activity, ActivitySkipReason skipReason, bool onStart) + { + var phase = onStart ? "ActivityStarted" : "ActivityStopped"; + + switch (skipReason) + { + case ActivitySkipReason.AzureFunctions: + _logger?.Trace()?.Log("{Phase}: name:{DisplayName} id:{ActivityId} skipped Azure Functions activity " + + "(source='{SourceName}') due to known upstream issues.", phase, activity.DisplayName, activity.Id, activity.Source.Name); + break; + case ActivitySkipReason.ServiceBusDedup: + _logger?.Debug()?.Log("{Phase}: name:{DisplayName} id:{ActivityId} traceId:{TraceId} skipped 'Microsoft.ServiceBus' " + + "activity because 'Elastic.Apm.Azure.ServiceBus' is present in the application.", + phase, activity.DisplayName, activity.Id, activity.TraceId); + break; + case ActivitySkipReason.StorageDedup: + _logger?.Debug()?.Log("{Phase}: name:{DisplayName} id:{ActivityId} traceId:{TraceId} skipped 'Microsoft.Storage' " + + "activity because 'Elastic.Apm.Azure.Storage' is present in the application.", + phase, activity.DisplayName, activity.Id, activity.TraceId); + break; + case ActivitySkipReason.CosmosDbDedup: + _logger?.Debug()?.Log("{Phase}: name:{DisplayName} id:{ActivityId} traceId:{TraceId} skipped 'Microsoft.DocumentDB' " + + "activity because 'Elastic.Apm.Azure.CosmosDb' is present in the application.", + phase, activity.DisplayName, activity.Id, activity.TraceId); + break; + case ActivitySkipReason.MongoDbDedup: + _logger?.Debug()?.Log("{Phase}: name:{DisplayName} id:{ActivityId} traceId:{TraceId} skipped 'MongoDB.Driver' " + + "activity because 'Elastic.Apm.MongoDb' is present in the application.", + phase, activity.DisplayName, activity.Id, activity.TraceId); + break; + case ActivitySkipReason.GrpcClientDedup: + _logger?.Debug()?.Log("{Phase}: name:{DisplayName} id:{ActivityId} traceId:{TraceId} skipped 'Grpc.Net.Client' " + + "activity because 'Elastic.Apm.GrpcClient' is present in the application.", + phase, activity.DisplayName, activity.Id, activity.TraceId); + break; + case ActivitySkipReason.KnownListener: + _logger?.Trace()?.Log("{Phase}: name:{DisplayName} id:{ActivityId} traceId:{TraceId} skipped because it matched " + + "a skipped activity name defined in KnownListeners.", phase, activity.DisplayName, activity.Id, activity.TraceId); + break; + } + } + + private enum ActivitySkipReason + { + None, + AzureFunctions, + ServiceBusDedup, + StorageDedup, + CosmosDbDedup, + MongoDbDedup, + GrpcClientDedup, + KnownListener + } private bool CreateTransactionForActivity(Activity activity, long timestamp, List spanLinks) { @@ -134,13 +259,13 @@ private bool CreateTransactionForActivity(Activity activity, long timestamp, Lis transaction = _tracer.StartTransactionInternal(activity.DisplayName, "unknown", timestamp, true, activity.SpanId.ToString(), - distributedTracingData: dt, links: spanLinks.Count > 0 ? spanLinks : null, current: activity); + distributedTracingData: dt, links: spanLinks?.Count > 0 ? spanLinks : null, current: activity); } - else if (activity.ParentId == null) + else if (activity.ParentId == null && _tracer.CurrentTransaction == null) { transaction = _tracer.StartTransactionInternal(activity.DisplayName, "unknown", timestamp, true, activity.SpanId.ToString(), - activity.TraceId.ToString(), links: spanLinks.Count > 0 ? spanLinks : null, current: activity); + activity.TraceId.ToString(), links: spanLinks?.Count > 0 ? spanLinks : null, current: activity); } if (transaction == null) @@ -148,8 +273,10 @@ private bool CreateTransactionForActivity(Activity activity, long timestamp, Lis transaction.Otel = new OTel { SpanKind = activity.Kind.ToString() }; - if (activity.Id != null) - _activeTransactions.AddOrUpdate(activity, transaction); + _activeTransactions.AddOrUpdate(activity, transaction); + + _logger?.Trace()?.Log("Created transaction id:{TransactionId} name:{Name} for activity id:{ActivityId}", + transaction.Id, transaction.Name, activity.Id); return true; } @@ -160,16 +287,20 @@ private void CreateSpanForActivity(Activity activity, long timestamp, List 0 ? spanLinks : null, current: activity); + timestamp: timestamp, id: activity.SpanId.ToString(), links: spanLinks?.Count > 0 ? spanLinks : null, current: activity); } else { newSpan = (_tracer.CurrentSpan as Span)?.StartSpanInternal(activity.DisplayName, "unknown", - timestamp: timestamp, id: activity.SpanId.ToString(), links: spanLinks.Count > 0 ? spanLinks : null, current: activity); + timestamp: timestamp, id: activity.SpanId.ToString(), links: spanLinks?.Count > 0 ? spanLinks : null, current: activity); } if (newSpan == null) + { + _logger?.Trace()?.Log("ActivityStarted: name:{DisplayName} id:{ActivityId} — could not create span (parent transaction/span is absent or non-recording). Activity will not be captured.", + activity.DisplayName, activity.Id); return; + } newSpan.Otel = new OTel { SpanKind = activity.Kind.ToString() }; @@ -179,84 +310,56 @@ private void CreateSpanForActivity(Activity activity, long timestamp, List ActivityStopped => - activity => + private void OnActivityStopped(Activity activity) + { + if (activity == null) { - if (activity == null) - { - _logger?.Trace()?.Log("ActivityStopped called with `null` activity. Ignoring `null` activity."); - return; - } - activity.Stop(); - - _logger?.Trace()?.Log("ActivityStopped: name:{DisplayName} id:{ActivityId} traceId:{TraceId}", - activity.DisplayName, activity.Id, activity.TraceId); + _logger?.Trace()?.Log("ActivityStopped called with `null` activity. Ignoring `null` activity."); + return; + } - if (KnownListeners.SkippedActivityNamesSet.Contains(activity.OperationName)) - return; + if (_tracer == null) + return; - if (activity.Id == null) - return; + var config = _agent.Configuration; + if (!config.Enabled || !config.Recording) + return; - if (_activeTransactions.TryGetValue(activity, out var transaction)) - { - _activeTransactions.Remove(activity); - transaction.Duration = activity.Duration.TotalMilliseconds; + if (ShouldSkipActivity(activity, out _)) + return; - UpdateOTelAttributes(activity, transaction.Otel); + _logger?.Trace()?.Log("ActivityStopped: name:{DisplayName} id:{ActivityId} traceId:{TraceId}", + activity.DisplayName, activity.Id, activity.TraceId); - InferTransactionType(transaction, activity); + if (_activeTransactions.TryGetValue(activity, out var transaction)) + { + _activeTransactions.Remove(activity); + transaction.Duration = activity.Duration.TotalMilliseconds; - // By default we set unknown outcome - transaction.Outcome = Outcome.Unknown; + OTelActivityMapper.UpdateOTelAttributes(activity, transaction.Otel); + OTelActivityMapper.InferTransactionType(transaction, activity); + transaction.Outcome = Outcome.Unknown; #if NET // Not available in netstandard2.1 - switch (activity.Status) - { - case ActivityStatusCode.Unset: - transaction.Outcome = Outcome.Unknown; - break; - case ActivityStatusCode.Ok: - transaction.Outcome = Outcome.Success; - break; - case ActivityStatusCode.Error: - transaction.Outcome = Outcome.Failure; - break; - } + transaction.Outcome = ActivityStatusToOutcome(activity.Status); #endif - - transaction.End(); - } - else if (_activeSpans.TryGetValue(activity, out var span)) - { - _activeSpans.Remove(activity); - UpdateSpan(activity, span); - } - }; - - private static void UpdateOTelAttributes(Activity activity, OTel otel) - { - if (!activity.TagObjects.Any()) - return; - - // https://opentelemetry.io/docs/specs/otel/common/#attribute-limits - // copy max 128 keys and truncate values to 10k chars (the current maximum for e.g. statement.db). - var i = 0; - otel.Attributes ??= []; - foreach (var tagObject in activity.TagObjects) + transaction.End(); + } + else if (_activeSpans.TryGetValue(activity, out var span)) { - if (i >= 128) - break; - - if (tagObject.Value is string s) - otel.Attributes[tagObject.Key] = s.Truncate(10_000); - else - otel.Attributes[tagObject.Key] = tagObject.Value; - i++; + _activeSpans.Remove(activity); + UpdateSpan(activity, span); + } + else + { + _logger?.Trace()?.Log("ActivityStopped: name:{DisplayName} id:{ActivityId} — activity was not tracked (no matching span or transaction).", + activity.DisplayName, activity.Id); } } @@ -264,28 +367,13 @@ private static void UpdateSpan(Activity activity, Span span) { span.Duration = activity.Duration.TotalMilliseconds; - UpdateOTelAttributes(activity, span.Otel); - - InferSpanTypeAndSubType(span, activity); + OTelActivityMapper.UpdateOTelAttributes(activity, span.Otel); + OTelActivityMapper.InferSpanTypeAndSubType(span, activity); - // By default we set unknown outcome span.Outcome = Outcome.Unknown; - #if NET // Not available in netstandard2.1 - switch (activity.Status) - { - case ActivityStatusCode.Unset: - span.Outcome = Outcome.Unknown; - break; - case ActivityStatusCode.Ok: - span.Outcome = Outcome.Success; - break; - case ActivityStatusCode.Error: - span.Outcome = Outcome.Failure; - break; - } + span.Outcome = ActivityStatusToOutcome(activity.Status); #endif - span.End(); } @@ -294,180 +382,26 @@ private static void UpdateSpan(Activity activity, Span span) /// internal static void UpdateSpanBenchmark(Activity activity, Span span) => UpdateSpan(activity, span); - private static void InferTransactionType(Transaction transaction, Activity activity) - { - if (activity.Kind == ActivityKind.Server && (TryGetStringValue(activity, SemanticConventions.RpcSystem, out _) - || TryGetStringValue(activity, HttpAttributeKeys, out _))) - transaction.Type = ApiConstants.TypeRequest; - else if (activity.Kind == ActivityKind.Consumer && TryGetStringValue(activity, SemanticConventions.MessagingSystem, out _)) - transaction.Type = ApiConstants.TypeMessaging; - else - transaction.Type = "unknown"; - } - - private static void InferSpanTypeAndSubType(Span span, Activity activity) - { - static string HttpPortFromScheme(string scheme) - { - return scheme switch - { - "http" => "80", - "https" => "443", - _ => string.Empty - }; - } - - // extracts 'host' or 'host:port' from URL - static string ParseNetName(string url) - { - try - { - var u = new Uri(url); // https://developer.mozilla.org/en-US/docs/Web/API/URL - return u.Host + ':' + u.Port; - } - catch - { - return string.Empty; - } - } - - static string ToResourceName(string type, string name) - { - return string.IsNullOrEmpty(name) ? type : $"{type}/{name}"; - } - - var peerPort = string.Empty; - var netName = string.Empty; - - if (TryGetStringValue(activity, ServerPortAttributeKeys, out var netPortValue)) - peerPort = netPortValue; - - if (TryGetStringValue(activity, ServerAddressAttributeKeys, out var netNameValue)) - netName = netNameValue; - - if (netName.Length > 0 && peerPort.Length > 0) - { - netName += ':'; - netName += peerPort; - } - - string serviceTargetType = null; - string serviceTargetName = null; - string resource = null; - - if (TryGetStringValue(activity, SemanticConventions.DbSystem, out var dbSystem)) - { - span.Type = ApiConstants.TypeDb; - span.Subtype = dbSystem; - serviceTargetType = span.Subtype; - serviceTargetName = TryGetStringValue(activity, SemanticConventions.DbName, out var dbName) ? dbName : null; - resource = ToResourceName(span.Subtype, serviceTargetName); - } - else if (TryGetStringValue(activity, SemanticConventions.MessagingSystem, out var messagingSystem)) - { - span.Type = ApiConstants.TypeMessaging; - span.Subtype = messagingSystem; - serviceTargetType = span.Subtype; - serviceTargetName = TryGetStringValue(activity, SemanticConventions.MessagingDestination, out var messagingDestination) - ? messagingDestination - : null; - resource = ToResourceName(span.Subtype, serviceTargetName); - } - else if (TryGetStringValue(activity, SemanticConventions.RpcSystem, out var rpcSystem)) - { - span.Type = ApiConstants.TypeExternal; - span.Subtype = rpcSystem; - serviceTargetType = span.Subtype; - serviceTargetName = !string.IsNullOrEmpty(netName) - ? netName - : TryGetStringValue(activity, SemanticConventions.RpcService, out var rpcService) - ? rpcService - : null; - resource = serviceTargetName ?? span.Subtype; - } - else if (activity.TagObjects.Any(n => - n.Key == SemanticConventions.HttpUrl || n.Key == SemanticConventions.UrlFull || n.Key == SemanticConventions.HttpScheme)) - { - var hasHttpHost = TryGetStringValue(activity, SemanticConventions.HttpHost, out var httpHost); - var hasHttpScheme = TryGetStringValue(activity, SemanticConventions.HttpScheme, out var httpScheme); - span.Type = ApiConstants.TypeExternal; - span.Subtype = ApiConstants.SubtypeHttp; - serviceTargetType = span.Subtype; - if (hasHttpHost && hasHttpScheme) - serviceTargetName = $"{httpHost}:{HttpPortFromScheme(httpScheme)}"; - else if (TryGetStringValue(activity, HttpUrlAttributeKeys, out var httpUrl)) - serviceTargetName = ParseNetName(httpUrl); - else - serviceTargetName = netName; - resource = serviceTargetName; - } - - if (serviceTargetType == null) - { - if (activity.Kind == ActivityKind.Internal) - { - span.Type = ApiConstants.TypeApp; - span.Subtype = ApiConstants.SubTypeInternal; - } - else - span.Type = ApiConstants.TypeUnknown; - } - - span.Context.Service = new SpanService(new Target(serviceTargetType, serviceTargetName)); - if (resource != null) - { - span.Context.Destination ??= new Destination(); - span.Context.Destination.Service = new Destination.DestinationService { Resource = resource }; - } - } - - private static bool TryGetStringValue(Activity activity, string key, out string value) - { - value = null; - #if NET - var attribute = activity.GetTagItem(key); -#else - var attribute = activity.TagObjects.FirstOrDefault(kvp => kvp.Key == key).Value; -#endif - - if (attribute is string stringValue) - { - value = stringValue; - return true; - } - - if (attribute is int intValue) - { - value = intValue.ToString(); - return true; - } - - return false; - } - - private static bool TryGetStringValue(Activity activity, string[] keys, out string value) + private static Outcome ActivityStatusToOutcome(ActivityStatusCode status) => status switch { - value = null; - - foreach (var key in keys) - { - if (TryGetStringValue(activity, key, out var attributeValue)) - { - value = attributeValue; - return true; - } - } - - return false; - } + ActivityStatusCode.Ok => Outcome.Success, + ActivityStatusCode.Error => Outcome.Failure, + _ => Outcome.Unknown + }; +#endif protected virtual void Dispose(bool disposing) { if (!_disposed) { if (disposing) - Listener?.Dispose(); + { + _logger?.Debug()?.Log("ElasticActivityListener disposing."); + AppDomain.CurrentDomain.AssemblyLoad -= OnAssemblyLoad; + _listener?.Dispose(); + _listener = null; + } _disposed = true; } diff --git a/src/Elastic.Apm/OpenTelemetry/OTelActivityMapper.cs b/src/Elastic.Apm/OpenTelemetry/OTelActivityMapper.cs new file mode 100644 index 000000000..93934b6a5 --- /dev/null +++ b/src/Elastic.Apm/OpenTelemetry/OTelActivityMapper.cs @@ -0,0 +1,242 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +#if NET || NETSTANDARD2_1 +using System; +using System.Diagnostics; +using System.Linq; +using Elastic.Apm.Api; +using Elastic.Apm.Helpers; +using Elastic.Apm.Model; + +namespace Elastic.Apm.OpenTelemetry +{ + /// + /// Translates OpenTelemetry attributes into Elastic APM model fields. + /// + internal static class OTelActivityMapper + { + internal static readonly string[] ServerPortAttributeKeys = + [SemanticConventions.ServerPort, SemanticConventions.NetPeerPort]; + + internal static readonly string[] ServerAddressAttributeKeys = + [SemanticConventions.ServerAddress, SemanticConventions.NetPeerName, SemanticConventions.NetPeerIp]; + + // Canonical URL/host-presence keys used both as "is this HTTP?" and for URL parsing. + internal static readonly string[] HttpAttributeKeys = + [SemanticConventions.UrlFull, SemanticConventions.HttpUrl]; + + internal static void UpdateOTelAttributes(Activity activity, OTel otel) + { + var i = 0; + foreach (var tagObject in activity.TagObjects) + { + if (i >= 128) + { + // https://opentelemetry.io/docs/specs/otel/common/#attribute-limits + // copy max 128 keys and truncate values to 10k chars (the current maximum for e.g. statement.db). + break; + } + + otel.Attributes ??= []; + + if (tagObject.Value is string s) + otel.Attributes[tagObject.Key] = s.Truncate(10_000); + else + otel.Attributes[tagObject.Key] = tagObject.Value; + i++; + } + } + + internal static void InferTransactionType(Transaction transaction, Activity activity) + { + if (activity.Kind == ActivityKind.Server && (TryGetStringValue(activity, SemanticConventions.RpcSystem, out _) + || TryGetStringValue(activity, HttpAttributeKeys, out _))) + transaction.Type = ApiConstants.TypeRequest; + else if (activity.Kind == ActivityKind.Consumer && TryGetStringValue(activity, SemanticConventions.MessagingSystem, out _)) + transaction.Type = ApiConstants.TypeMessaging; + else + transaction.Type = "unknown"; + } + + internal static void InferSpanTypeAndSubType(Span span, Activity activity) + { + var peerPort = string.Empty; + var netName = string.Empty; + + if (TryGetStringValue(activity, ServerPortAttributeKeys, out var netPortValue)) + peerPort = netPortValue; + + if (TryGetStringValue(activity, ServerAddressAttributeKeys, out var netNameValue)) + netName = netNameValue; + + if (netName.Length > 0 && peerPort.Length > 0) + { + netName += ':'; + netName += peerPort; + } + + string serviceTargetType = null; + string serviceTargetName = null; + string resource = null; + + if (TryGetStringValue(activity, SemanticConventions.DbSystem, out var dbSystem)) + { + span.Type = ApiConstants.TypeDb; + span.Subtype = dbSystem; + serviceTargetType = span.Subtype; + serviceTargetName = TryGetStringValue(activity, SemanticConventions.DbName, out var dbName) ? dbName : null; + resource = ToResourceName(span.Subtype, serviceTargetName); + } + else if (TryGetStringValue(activity, SemanticConventions.MessagingSystem, out var messagingSystem)) + { + span.Type = ApiConstants.TypeMessaging; + span.Subtype = messagingSystem; + serviceTargetType = span.Subtype; + serviceTargetName = TryGetStringValue(activity, SemanticConventions.MessagingDestination, out var messagingDestination) + ? messagingDestination + : null; + resource = ToResourceName(span.Subtype, serviceTargetName); + } + else if (TryGetStringValue(activity, SemanticConventions.RpcSystem, out var rpcSystem)) + { + span.Type = ApiConstants.TypeExternal; + span.Subtype = rpcSystem; + serviceTargetType = span.Subtype; + serviceTargetName = !string.IsNullOrEmpty(netName) + ? netName + : TryGetStringValue(activity, SemanticConventions.RpcService, out var rpcService) + ? rpcService + : null; + resource = serviceTargetName ?? span.Subtype; + } + else if (TryGetStringValue(activity, HttpAttributeKeys, out var httpUrl)) + { + var hasHttpHost = TryGetStringValue(activity, SemanticConventions.HttpHost, out var httpHost); + var hasHttpScheme = TryGetStringValue(activity, SemanticConventions.HttpScheme, out var httpScheme); + span.Type = ApiConstants.TypeExternal; + span.Subtype = ApiConstants.SubtypeHttp; + serviceTargetType = span.Subtype; + if (hasHttpHost && hasHttpScheme) + { + var httpPort = HttpPortFromScheme(httpScheme); + serviceTargetName = string.IsNullOrEmpty(httpPort) ? httpHost : $"{httpHost}:{httpPort}"; + } + else if (!string.IsNullOrEmpty(httpUrl)) + { + var parsedNetName = ParseNetName(httpUrl); + serviceTargetName = string.IsNullOrEmpty(parsedNetName) ? null : parsedNetName; + } + else + serviceTargetName = string.IsNullOrEmpty(netName) ? null : netName; + + resource = string.IsNullOrEmpty(serviceTargetName) ? null : serviceTargetName; + } + + if (serviceTargetType == null) + { + if (activity.Kind == ActivityKind.Internal) + { + span.Type = ApiConstants.TypeApp; + span.Subtype = ApiConstants.SubTypeInternal; + } + else + span.Type = ApiConstants.TypeUnknown; + } + + span.Context.Service = new SpanService(new Target(serviceTargetType, serviceTargetName)); + if (resource != null) + { + span.Context.Destination ??= new Destination(); + span.Context.Destination.Service = new Destination.DestinationService { Resource = resource }; + } + } + + /// + /// Reads an activity tag as a string, coercing and numeric + /// values (which OTel instrumentation libraries commonly use for port numbers). + /// Returns false for absent keys or values of any other type. + /// + internal static bool TryGetStringValue(Activity activity, string key, out string value) + { + value = null; + +#if NET + var attribute = activity.GetTagItem(key); +#else + var attribute = activity.TagObjects.FirstOrDefault(kvp => kvp.Key == key).Value; +#endif + + if (attribute is string stringValue) + { + value = stringValue; + return true; + } + + if (attribute is int intValue) + { + value = intValue.ToString(); + return true; + } + + if (attribute is long longValue) + { + value = longValue.ToString(); + return true; + } + + return false; + } + + internal static bool TryGetStringValue(Activity activity, string[] keys, out string value) + { + value = null; + + foreach (var key in keys) + { + if (TryGetStringValue(activity, key, out var attributeValue)) + { + value = attributeValue; + return true; + } + } + + return false; + } + + /// + /// Returns the well-known default port string for http / https schemes, + /// or null for any other scheme (preventing a trailing-colon resource name). + /// + private static string HttpPortFromScheme(string scheme) => scheme switch + { + "http" => "80", + "https" => "443", + _ => null + }; + + /// + /// Extracts host or host:port from a URL string. + /// Returns an empty string if the URL cannot be parsed. + /// + private static string ParseNetName(string url) + { + try + { + var u = new Uri(url); // https://developer.mozilla.org/en-US/docs/Web/API/URL + // Uri.Port returns -1 when no port is present and the scheme has no default. + return u.Port > 0 ? u.Host + ':' + u.Port : u.Host; + } + catch (UriFormatException) + { + return string.Empty; + } + } + + private static string ToResourceName(string type, string name) => + string.IsNullOrEmpty(name) ? type : $"{type}/{name}"; + } +} +#endif diff --git a/src/Elastic.Apm/OpenTelemetry/SemanticConventions.cs b/src/Elastic.Apm/OpenTelemetry/SemanticConventions.cs index 60c32155c..b6ba88473 100644 --- a/src/Elastic.Apm/OpenTelemetry/SemanticConventions.cs +++ b/src/Elastic.Apm/OpenTelemetry/SemanticConventions.cs @@ -40,4 +40,7 @@ internal static class SemanticConventions // RPC SYSTEM public const string RpcSystem = "rpc.system"; public const string RpcService = "rpc.service"; + + // AZURE + public const string AzNamespace = "az.namespace"; } diff --git a/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs b/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs index 1537eb25d..869d4dd03 100644 --- a/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs +++ b/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs @@ -214,4 +214,319 @@ public void ResourceIsRequiredWhenSpanDestinationServiceIsNotNull() && span.Context.Destination.Service.Resource == null, "Resource is required in Destination Service"); } + + [Fact] + public void AzureFunctionsWorkerActivitiesAreNotCaptured() + { + var payloadSender = new MockPayloadSender(); + using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + var src = new ActivitySource("Microsoft.Azure.Functions.Worker"); + using (src.StartActivity("SomeFunction")) { } + } + payloadSender.Transactions.Should().BeEmpty(); + payloadSender.Spans.Should().BeEmpty(); + } + + [Fact] + public void AzureFunctionsInvokeFunctionAsyncWithEmptySourceIsNotCaptured() + { + var payloadSender = new MockPayloadSender(); + using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + var src = new ActivitySource(string.Empty); + using (src.StartActivity("InvokeFunctionAsync")) { } + } + payloadSender.Transactions.Should().BeEmpty(); + payloadSender.Spans.Should().BeEmpty(); + } + + [Fact] + public void OtelAttributesAreCappedAt128() + { + var payloadSender = new MockPayloadSender(); + using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + var src = new ActivitySource("Test.AttributeCap"); + using (var activity = src.StartActivity("root", ActivityKind.Server)) + { + for (var i = 0; i < 130; i++) + activity?.SetTag($"key{i}", $"value{i}"); + } + } + payloadSender.WaitForTransactions(); + payloadSender.FirstTransaction.Otel.Attributes.Should().HaveCount(128); + } + + [Fact] + public void OtelAttributeStringValuesAreTruncatedAt10000Chars() + { + var payloadSender = new MockPayloadSender(); + var longValue = new string('x', 15_000); + using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + var src = new ActivitySource("Test.Truncation"); + using (var activity = src.StartActivity("root", ActivityKind.Server)) + activity?.SetTag("long-key", longValue); + } + payloadSender.WaitForTransactions(); + payloadSender.FirstTransaction.Otel.Attributes.Should().ContainKey("long-key"); + ((string)payloadSender.FirstTransaction.Otel.Attributes["long-key"]).Should().HaveLength(10_000); + } + + /// + /// Baseline: activities from the given source ARE captured when no deduplication flag is set, + /// confirming the test setup is valid before testing the dedup path. + /// + [Theory] + [InlineData("MongoDB.Driver")] + [InlineData("Grpc.Net.Client")] + public void ActivitiesAreCapturedWithoutDeduplicationFlag(string sourceName) + { + var payloadSender = new MockPayloadSender(); + using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + var src = new ActivitySource(sourceName); + using (src.StartActivity("operation", ActivityKind.Client)) { } + } + payloadSender.WaitForTransactions(); + payloadSender.Transactions.Should().HaveCount(1); + } + + [Theory] + [InlineData("MongoDB.Driver", "Elastic.Apm.MongoDb")] + [InlineData("Grpc.Net.Client", "Elastic.Apm.GrpcClient")] + public void ActivitiesAreSkippedWhenSourceNameMatchesInstrumentationPackage(string sourceName, string packageAssemblyName) + { + var payloadSender = new MockPayloadSender(); + var components = new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716); + using (new ApmAgent(components)) + { + components.ElasticActivityListener.CheckAssembly(packageAssemblyName); + var src = new ActivitySource(sourceName); + using (src.StartActivity("operation", ActivityKind.Client)) { } + } + payloadSender.Transactions.Should().BeEmpty(); + payloadSender.Spans.Should().BeEmpty(); + } + + /// + /// Baseline: Azure service activities tagged with az.namespace ARE captured when no dedup flag is set. + /// + [Theory] + [InlineData("Microsoft.ServiceBus")] + [InlineData("Microsoft.Storage")] + [InlineData("Microsoft.DocumentDB")] + public void AzureServiceActivitiesAreCapturedWithoutDeduplicationFlag(string azNamespace) + { + var payloadSender = new MockPayloadSender(); + using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + var src = new ActivitySource("Azure.Test.Source"); + var tags = new ActivityTagsCollection { ["az.namespace"] = azNamespace }; + using (src.StartActivity("operation", ActivityKind.Client, default(ActivityContext), tags)) { } + } + payloadSender.WaitForTransactions(); + payloadSender.Transactions.Should().HaveCount(1); + } + + [Theory] + [InlineData("Microsoft.ServiceBus", "Elastic.Apm.Azure.ServiceBus")] + [InlineData("Microsoft.Storage", "Elastic.Apm.Azure.Storage")] + [InlineData("Microsoft.DocumentDB", "Elastic.Apm.Azure.CosmosDb")] + public void AzureServiceActivitiesAreSkippedWhenInstrumentationPackageIsDetected(string azNamespace, string packageAssemblyName) + { + var payloadSender = new MockPayloadSender(); + var components = new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716); + using (new ApmAgent(components)) + { + components.ElasticActivityListener.CheckAssembly(packageAssemblyName); + var src = new ActivitySource("Azure.Test.Source"); + var tags = new ActivityTagsCollection { ["az.namespace"] = azNamespace }; + using (src.StartActivity("operation", ActivityKind.Client, default(ActivityContext), tags)) { } + } + payloadSender.Transactions.Should().BeEmpty(); + payloadSender.Spans.Should().BeEmpty(); + } + + /// + /// Activities whose OperationName matches a known internal listener (ASP.NET Core, System.Net.Http, Elastic APM's own + /// transaction activity) must be silently dropped to prevent double-capturing. + /// + [Theory] + [InlineData("Microsoft.AspNetCore.Hosting.HttpRequestIn")] + [InlineData("System.Net.Http.HttpRequestOut")] + [InlineData("System.Net.Http.Desktop.HttpRequestOut")] + [InlineData("ElasticApm.Transaction")] + public void KnownInternalActivitiesAreSkipped(string operationName) + { + var payloadSender = new MockPayloadSender(); + using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + var src = new ActivitySource("Test.KnownListeners"); + using (src.StartActivity(operationName)) { } + } + payloadSender.Transactions.Should().BeEmpty(); + payloadSender.Spans.Should().BeEmpty(); + } + + /// + /// Instrumentation libraries (e.g. gRPC) emit integer-valued tags for port numbers. + /// Verifies those are converted to strings correctly so the span resource is populated. + /// + [Fact] + public void IntValuedTagIsUsedInSpanResource() + { + var payloadSender = new MockPayloadSender(); + using (var agent = new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + agent.Tracer.CaptureTransaction("parent", "test", () => + { + var src = new ActivitySource("Test.IntTag"); + using (var activity = src.StartActivity("rpc.call", ActivityKind.Client)) + { + activity?.SetTag("rpc.system", "grpc"); + activity?.SetTag("net.peer.name", "myhost"); + activity?.SetTag("net.peer.port", 50051); // int, not string + } + }); + } + payloadSender.WaitForTransactions(); + payloadSender.WaitForSpans(); + var rpcSpan = payloadSender.Spans.Should().ContainSingle().Subject; + rpcSpan.Context.Destination.Service.Resource.Should().Be("myhost:50051"); + } + + [Fact] + public void ActivitiesAreNotCapturedAfterDispose() + { + var payloadSender = new MockPayloadSender(); + var agent = new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716)); + agent.Dispose(); + + var src = new ActivitySource("Test.PostDispose"); + using (src.StartActivity("root", ActivityKind.Server)) { } + + payloadSender.Transactions.Should().BeEmpty(); + payloadSender.Spans.Should().BeEmpty(); + } + + [Fact] + public void LongValuedPortTagIsUsedInSpanResource() + { + var payloadSender = new MockPayloadSender(); + using (var agent = new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + agent.Tracer.CaptureTransaction("parent", "test", () => + { + var src = new ActivitySource("Test.LongTag"); + using (var activity = src.StartActivity("rpc.call", ActivityKind.Client)) + { + activity?.SetTag("rpc.system", "grpc"); + activity?.SetTag("net.peer.name", "myhost"); + activity?.SetTag("net.peer.port", 50051L); // long, not int or string + } + }); + } + + payloadSender.WaitForTransactions(); + payloadSender.WaitForSpans(); + + var rpcSpan = payloadSender.Spans.Should().ContainSingle().Subject; + rpcSpan.Context.Destination.Service.Resource.Should().Be("myhost:50051"); + } + + [Fact] + public void HttpUrlWithNoExplicitPortAndNonStandardSchemeProducesHostOnlyResource() + { + // Verifies that ParseNetName does not emit "host:-1" when Uri.Port returns -1 + // (i.e. no explicit port and the scheme has no registered default). + var payloadSender = new MockPayloadSender(); + using (var agent = new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + agent.Tracer.CaptureTransaction("parent", "test", () => + { + var src = new ActivitySource("Test.NoPort"); + using (var activity = src.StartActivity("http-call", ActivityKind.Client)) + activity?.SetTag(SemanticConventions.UrlFull, "custom://api.example.com/path"); + }); + } + + payloadSender.WaitForTransactions(); + payloadSender.WaitForSpans(); + + var span = payloadSender.Spans.Should().ContainSingle().Subject; + span.Type.Should().Be(ApiConstants.TypeExternal); + span.Subtype.Should().Be(ApiConstants.SubtypeHttp); + span.Context.Destination.Service.Resource.Should().Be("api.example.com"); + span.Context.Destination.Service.Resource.Should().NotContain(":-1"); + } + + [Fact] + public void HttpSchemeOnlyDoesNotClassifySpanAsHttp() + { + var payloadSender = new MockPayloadSender(); + using (var agent = new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + agent.Tracer.CaptureTransaction("parent", "test", () => + { + var src = new ActivitySource("Test.SchemeOnly"); + using (var activity = src.StartActivity("scheme-only", ActivityKind.Client)) + activity?.SetTag(SemanticConventions.HttpScheme, "https"); + }); + } + + payloadSender.WaitForTransactions(); + payloadSender.WaitForSpans(); + + var span = payloadSender.Spans.Should().ContainSingle().Subject; + span.Type.Should().Be(ApiConstants.TypeUnknown); + span.Subtype.Should().BeNull(); + } + + /// + /// http.scheme alone is not sufficient to classify a root Server activity as an HTTP request transaction; + /// url.full or http.url is required (aligned with span HTTP detection). + /// + [Fact] + public void HttpSchemeOnlyDoesNotClassifyServerTransactionAsRequest() + { + var payloadSender = new MockPayloadSender(); + using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + var src = new ActivitySource("Test.SchemeOnlyServer"); + using (var activity = src.StartActivity("scheme-only-server", ActivityKind.Server)) + activity?.SetTag(SemanticConventions.HttpScheme, "https"); + } + + payloadSender.WaitForTransactions(); + payloadSender.FirstTransaction.Type.Should().Be("unknown"); + } + + [Fact] + public void HttpHostWithUnknownSchemeDoesNotProduceTrailingColonInResource() + { + var payloadSender = new MockPayloadSender(); + using (var agent = new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) + { + agent.Tracer.CaptureTransaction("parent", "test", () => + { + var src = new ActivitySource("Test.UnknownSchemeHost"); + using (var activity = src.StartActivity("http-call", ActivityKind.Client)) + { + activity?.SetTag(SemanticConventions.HttpHost, "api.example.com"); + activity?.SetTag(SemanticConventions.HttpScheme, "custom"); + activity?.SetTag(SemanticConventions.UrlFull, "custom://api.example.com/path"); + } + }); + } + + payloadSender.WaitForTransactions(); + payloadSender.WaitForSpans(); + + var span = payloadSender.Spans.Should().ContainSingle().Subject; + span.Type.Should().Be(ApiConstants.TypeExternal); + span.Subtype.Should().Be(ApiConstants.SubtypeHttp); + span.Context.Destination.Service.Resource.Should().Be("api.example.com"); + } } From d4f967dd666df4c5b7ecc093b4425efb0c03aa89 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Wed, 20 May 2026 12:10:23 +0100 Subject: [PATCH 2/4] Fix formatting --- .../OpenTelemetryTests.cs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs b/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs index 869d4dd03..924b7d9b7 100644 --- a/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs +++ b/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs @@ -222,7 +222,8 @@ public void AzureFunctionsWorkerActivitiesAreNotCaptured() using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) { var src = new ActivitySource("Microsoft.Azure.Functions.Worker"); - using (src.StartActivity("SomeFunction")) { } + using (src.StartActivity("SomeFunction")) + { } } payloadSender.Transactions.Should().BeEmpty(); payloadSender.Spans.Should().BeEmpty(); @@ -235,7 +236,8 @@ public void AzureFunctionsInvokeFunctionAsyncWithEmptySourceIsNotCaptured() using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) { var src = new ActivitySource(string.Empty); - using (src.StartActivity("InvokeFunctionAsync")) { } + using (src.StartActivity("InvokeFunctionAsync")) + { } } payloadSender.Transactions.Should().BeEmpty(); payloadSender.Spans.Should().BeEmpty(); @@ -287,7 +289,8 @@ public void ActivitiesAreCapturedWithoutDeduplicationFlag(string sourceName) using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) { var src = new ActivitySource(sourceName); - using (src.StartActivity("operation", ActivityKind.Client)) { } + using (src.StartActivity("operation", ActivityKind.Client)) + { } } payloadSender.WaitForTransactions(); payloadSender.Transactions.Should().HaveCount(1); @@ -304,7 +307,8 @@ public void ActivitiesAreSkippedWhenSourceNameMatchesInstrumentationPackage(stri { components.ElasticActivityListener.CheckAssembly(packageAssemblyName); var src = new ActivitySource(sourceName); - using (src.StartActivity("operation", ActivityKind.Client)) { } + using (src.StartActivity("operation", ActivityKind.Client)) + { } } payloadSender.Transactions.Should().BeEmpty(); payloadSender.Spans.Should().BeEmpty(); @@ -324,7 +328,8 @@ public void AzureServiceActivitiesAreCapturedWithoutDeduplicationFlag(string azN { var src = new ActivitySource("Azure.Test.Source"); var tags = new ActivityTagsCollection { ["az.namespace"] = azNamespace }; - using (src.StartActivity("operation", ActivityKind.Client, default(ActivityContext), tags)) { } + using (src.StartActivity("operation", ActivityKind.Client, default(ActivityContext), tags)) + { } } payloadSender.WaitForTransactions(); payloadSender.Transactions.Should().HaveCount(1); @@ -343,7 +348,8 @@ public void AzureServiceActivitiesAreSkippedWhenInstrumentationPackageIsDetected components.ElasticActivityListener.CheckAssembly(packageAssemblyName); var src = new ActivitySource("Azure.Test.Source"); var tags = new ActivityTagsCollection { ["az.namespace"] = azNamespace }; - using (src.StartActivity("operation", ActivityKind.Client, default(ActivityContext), tags)) { } + using (src.StartActivity("operation", ActivityKind.Client, default(ActivityContext), tags)) + { } } payloadSender.Transactions.Should().BeEmpty(); payloadSender.Spans.Should().BeEmpty(); @@ -364,7 +370,8 @@ public void KnownInternalActivitiesAreSkipped(string operationName) using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) { var src = new ActivitySource("Test.KnownListeners"); - using (src.StartActivity(operationName)) { } + using (src.StartActivity(operationName)) + { } } payloadSender.Transactions.Should().BeEmpty(); payloadSender.Spans.Should().BeEmpty(); @@ -405,7 +412,8 @@ public void ActivitiesAreNotCapturedAfterDispose() agent.Dispose(); var src = new ActivitySource("Test.PostDispose"); - using (src.StartActivity("root", ActivityKind.Server)) { } + using (src.StartActivity("root", ActivityKind.Server)) + { } payloadSender.Transactions.Should().BeEmpty(); payloadSender.Spans.Should().BeEmpty(); From e72d17b42a2c837639d11fed2ec2fc2a95531379 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Wed, 20 May 2026 13:04:56 +0100 Subject: [PATCH 3/4] Spec fixes --- src/Elastic.Apm/OpenTelemetry/ElasticActivityListener.cs | 5 ++++- src/Elastic.Apm/OpenTelemetry/OTelActivityMapper.cs | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Apm/OpenTelemetry/ElasticActivityListener.cs b/src/Elastic.Apm/OpenTelemetry/ElasticActivityListener.cs index 25dcb0452..5e2575992 100644 --- a/src/Elastic.Apm/OpenTelemetry/ElasticActivityListener.cs +++ b/src/Elastic.Apm/OpenTelemetry/ElasticActivityListener.cs @@ -158,7 +158,10 @@ private bool ShouldSkipActivity(Activity activity, out ActivitySkipReason skipRe } // If the Elastic instrumentation for an Azure service is present, skip duplicating through the OTel bridge. - if (_hasServiceBusInstrumentation || _hasStorageInstrumentation || _hasCosmosDbInstrumentation) + // Guard with a source name prefix check to avoid the tag lookup on non-Azure activities. + if ((_hasServiceBusInstrumentation || _hasStorageInstrumentation || _hasCosmosDbInstrumentation) + && (activity.Source.Name.StartsWith("Azure.", StringComparison.Ordinal) + || activity.Source.Name.StartsWith("Microsoft.Azure.", StringComparison.Ordinal))) { OTelActivityMapper.TryGetStringValue(activity, SemanticConventions.AzNamespace, out var azNamespace); diff --git a/src/Elastic.Apm/OpenTelemetry/OTelActivityMapper.cs b/src/Elastic.Apm/OpenTelemetry/OTelActivityMapper.cs index 93934b6a5..ba888af34 100644 --- a/src/Elastic.Apm/OpenTelemetry/OTelActivityMapper.cs +++ b/src/Elastic.Apm/OpenTelemetry/OTelActivityMapper.cs @@ -53,7 +53,8 @@ internal static void UpdateOTelAttributes(Activity activity, OTel otel) internal static void InferTransactionType(Transaction transaction, Activity activity) { if (activity.Kind == ActivityKind.Server && (TryGetStringValue(activity, SemanticConventions.RpcSystem, out _) - || TryGetStringValue(activity, HttpAttributeKeys, out _))) + || TryGetStringValue(activity, HttpAttributeKeys, out _) + || TryGetStringValue(activity, SemanticConventions.HttpScheme, out _))) transaction.Type = ApiConstants.TypeRequest; else if (activity.Kind == ActivityKind.Consumer && TryGetStringValue(activity, SemanticConventions.MessagingSystem, out _)) transaction.Type = ApiConstants.TypeMessaging; @@ -112,7 +113,8 @@ internal static void InferSpanTypeAndSubType(Span span, Activity activity) : null; resource = serviceTargetName ?? span.Subtype; } - else if (TryGetStringValue(activity, HttpAttributeKeys, out var httpUrl)) + else if (TryGetStringValue(activity, HttpAttributeKeys, out var httpUrl) + || TryGetStringValue(activity, SemanticConventions.HttpScheme, out _)) { var hasHttpHost = TryGetStringValue(activity, SemanticConventions.HttpHost, out var httpHost); var hasHttpScheme = TryGetStringValue(activity, SemanticConventions.HttpScheme, out var httpScheme); From e4795801c59721e4acc0b0e8a5cb06df8b8b0248 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Wed, 20 May 2026 13:24:44 +0100 Subject: [PATCH 4/4] Fix tests --- .../OpenTelemetryTests.cs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs b/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs index 924b7d9b7..c1e40065c 100644 --- a/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs +++ b/test/opentelemetry/Elastic.Apm.OpenTelemetry.Tests/OpenTelemetryTests.cs @@ -471,7 +471,7 @@ public void HttpUrlWithNoExplicitPortAndNonStandardSchemeProducesHostOnlyResourc } [Fact] - public void HttpSchemeOnlyDoesNotClassifySpanAsHttp() + public void HttpSchemeAloneClassifiesClientSpanAsHttp() { var payloadSender = new MockPayloadSender(); using (var agent = new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) @@ -488,16 +488,12 @@ public void HttpSchemeOnlyDoesNotClassifySpanAsHttp() payloadSender.WaitForSpans(); var span = payloadSender.Spans.Should().ContainSingle().Subject; - span.Type.Should().Be(ApiConstants.TypeUnknown); - span.Subtype.Should().BeNull(); + span.Type.Should().Be(ApiConstants.TypeExternal); + span.Subtype.Should().Be(ApiConstants.SubtypeHttp); } - /// - /// http.scheme alone is not sufficient to classify a root Server activity as an HTTP request transaction; - /// url.full or http.url is required (aligned with span HTTP detection). - /// [Fact] - public void HttpSchemeOnlyDoesNotClassifyServerTransactionAsRequest() + public void HttpSchemeAloneClassifiesServerActivityAsRequest() { var payloadSender = new MockPayloadSender(); using (new ApmAgent(new TestAgentComponents(payloadSender: payloadSender, apmServerInfo: MockApmServerInfo.Version716))) @@ -508,7 +504,7 @@ public void HttpSchemeOnlyDoesNotClassifyServerTransactionAsRequest() } payloadSender.WaitForTransactions(); - payloadSender.FirstTransaction.Type.Should().Be("unknown"); + payloadSender.FirstTransaction.Type.Should().Be(ApiConstants.TypeRequest); } [Fact]